fix(studio): a resized corner stays on the pointer when the size rounds to whole pixels (#5213)

* fix(studio): a resized corner stays on the pointer when the size rounds to whole pixels

Resized sizes stay on whole CSS pixels (GSAP centres a -50% translate from the
integer offset size), so the centre-anchored resize left the grabbed corner up to
a third of a pixel times the element's scale off the pointer: 0.53 px on a 1.5x
scaled layer. The centre pin now takes half the rounding remainder along the
element's rendered edges, so the grabbed corner lands where the wanted size puts
it while the written size stays whole.

* fix(studio): a scale resize saves the box exactly where it was dropped

finalizeScaleResizeCommit skipped its position correction whenever the box sat
within 0.5 px of the drop point on each axis, so a sub-pixel drop (now the
norm, with the grabbed corner on the pointer) saved up to half a pixel off.
It now skips only when the correction rounds to nothing at the precision the
file is written in.

* fix(studio): a scale resize lands centred on the box that was dropped

finalizeScaleResizeCommit lined the committed box up with the drop by its
top-left corner. When one scale cannot reproduce the dragged width and height
(the draft rounds each to whole pixels), that put the whole gap on the far
edges, up to half a pixel. The resize is centre-anchored, so it now lines up
centres and the gap splits evenly.

* chore(studio): keep the complexity note on the resize intercept, not the new helper

* test(studio): bank the two scaled resize cases that now pass the edit accuracy bench

* fix(studio): a scale resize leaves animated positions alone and mirrored corners track

* test(studio): bank only the two newly passing scaled resize cases

* fix(studio): a mirrored or parent-rotated layer's resize corner tracks the pointer

* fix(studio): a resize corner on a skewed layer picks its side from the box's own edges

* fix(studio): resizing a layer that renders with no area moves nothing
This commit is contained in:
Miguel Ángel
2026-10-09 19:20:01 +00:00
committed by GitHub
parent 0241078560
commit 6b341b0d84
10 changed files with 223 additions and 50 deletions
@@ -259,6 +259,28 @@ describe("anchored corner resize — the release commit feeds the center-pin off
expect(offset.y).toBeCloseTo(-(size.height - ORIGIN.height) / 2, 0);
});
it("puts the grabbed corner on the pointer when the size rounds to whole px", () => {
const { handlers, commits } = buildHarness();
const grabbed = { x: ORIGIN_CENTER.x + 100, y: ORIGIN_CENTER.y + 50 };
handlers.startGesture("resize", evt(grabbed.x, grabbed.y), {
resizeHandle: "se",
resizeCorner: grabbed,
});
// Radial scale 1.503: the pointer asks for 300.6 x 150.3, Studio writes 301 x 150.
for (let i = 0; i < 5; i++)
handlers.onPointerMove(evt(ORIGIN_CENTER.x + 150.3, ORIGIN_CENTER.y + 75.15));
handlers.onPointerUp(evt(ORIGIN_CENTER.x + 150.3, ORIGIN_CENTER.y + 75.15));
const { size, offset } = commits[0]!;
expect(size).toEqual({ width: 301, height: 150 });
const corner = {
x: ORIGIN_CENTER.x + (size.width - ORIGIN.width) / 2 + offset!.x + size.width / 2,
y: ORIGIN_CENTER.y + (size.height - ORIGIN.height) / 2 + offset!.y + size.height / 2,
};
expect(corner.x).toBeCloseTo(ORIGIN_CENTER.x + 300.6 / 2, 6);
expect(corner.y).toBeCloseTo(ORIGIN_CENTER.y + 150.3 / 2, 6);
});
it("moves the grabbed corner with the cursor when the press lands beside it", () => {
const { handlers, commits } = buildHarness();
handlers.startGesture("resize", evt(ORIGIN_CENTER.x + 106, ORIGIN_CENTER.y), {
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
resizeRemainderShift,
resolveCenterResizeScale,
resolveCenterResizeSize,
resolveRotatedResizeCursor,
@@ -139,3 +140,72 @@ describe("resolveRotatedResizeCursor", () => {
expect(resolveRotatedResizeCursor("se", -90)).toBe("nesw-resize");
});
});
describe("resizeRemainderShift", () => {
const written = { width: 301, height: 150 };
const wanted = { width: 300.6, height: 150.3 };
const centre = { x: 400, y: 300 };
// Local (u, v) px map to overlay px through a 1.5x scale, a rotation and an optional x mirror.
const box = (angle: number, mirror: 1 | -1) => {
const at = (u: number, v: number) => ({
x: centre.x + 1.5 * (mirror * u * Math.cos(angle) - v * Math.sin(angle)),
y: centre.y + 1.5 * (mirror * u * Math.sin(angle) + v * Math.cos(angle)),
});
const [w, h] = [written.width / 2, written.height / 2];
return { at, corners: { nw: at(-w, -h), ne: at(w, -h), sw: at(-w, h), se: at(w, h) } };
};
// A rotated parent composes into the same map, and a mirror moves each handle onto another local corner.
it.each([
[30, 1, -1, -1],
[30, 1, 1, -1],
[30, 1, -1, 1],
[30, 1, 1, 1],
[-120, 1, 1, 1],
[90, 1, 1, -1],
[0, -1, -1, 1],
[150, -1, 1, 1],
[180, -1, -1, -1],
] as const)("at %i deg, mirror %i, lands local corner (%i, %i)", (deg, mirror, su, sv) => {
const { at, corners } = box((deg * Math.PI) / 180, mirror);
const grabbed = at((su * written.width) / 2, (sv * written.height) / 2);
const grab = { x: grabbed.x - centre.x, y: grabbed.y - centre.y };
const shift = resizeRemainderShift(corners, grab, { wanted, written });
const target = at((su * wanted.width) / 2, (sv * wanted.height) / 2);
expect(grabbed.x + shift.x).toBeCloseTo(target.x, 9);
expect(grabbed.y + shift.y).toBeCloseTo(target.y, 9);
});
});
describe("resizeRemainderShift on a skewed box", () => {
// A 30 deg child of a parent scaled 2 x 1 renders as a parallelogram; a 10 x 200 bar flips a projected sign.
const angle = Math.PI / 6;
const at = (u: number, v: number) => ({
x: 400 + 2 * (u * Math.cos(angle) - v * Math.sin(angle)),
y: 300 + (u * Math.sin(angle) + v * Math.cos(angle)),
});
const written = { width: 10, height: 200 };
const wanted = { width: 10.4, height: 199.7 };
const corners = { nw: at(-5, -100), ne: at(5, -100), sw: at(-5, 100), se: at(5, 100) };
it.each([
[1, 1],
[-1, -1],
[1, -1],
[-1, 1],
] as const)("lands local corner (%i, %i) on the wanted size", (su, sv) => {
const grabbed = at((su * written.width) / 2, (sv * written.height) / 2);
const grab = { x: grabbed.x - 400, y: grabbed.y - 300 };
const shift = resizeRemainderShift(corners, grab, { wanted, written });
const target = at((su * wanted.width) / 2, (sv * wanted.height) / 2);
expect(grabbed.x + shift.x).toBeCloseTo(target.x, 9);
expect(grabbed.y + shift.y).toBeCloseTo(target.y, 9);
});
});
it("shifts nothing for a box that renders with no area", () => {
const p = { x: 400, y: 300 };
const corners = { nw: p, ne: p, sw: p, se: p };
const sizes = { wanted: { width: 300.6, height: 150.3 }, written: { width: 301, height: 150 } };
expect(resizeRemainderShift(corners, { x: 1, y: 1 }, sizes)).toEqual({ x: 0, y: 0 });
});
@@ -14,6 +14,7 @@
* rendered center, feeding the center-pin translate through the manual-offset
* channel) lives in useDomEditOverlayGestures.ts.
*/
import type { FixedCorner } from "./domEditOverlayGeometry";
import type { ResizeHandle } from "./domEditOverlayGestures";
/** Minimum element edge in LOCAL px — mirrors the old MIN_RESIZE_EDGE_PX clamp
@@ -112,6 +113,29 @@ export function resolveRotatedResizeCursor(handle: ResizeHandle, rotationDeg: nu
return CURSORS_8[bucket]!;
}
type Size = { width: number; height: number };
/** The size the pointer asks for and the whole-px size written for it. */
export type ResizeDraftSizes = { wanted: Size; written: Size };
/** Overlay shift of the pinned center that puts the grabbed corner where the wanted size would: the
* written size is whole px, so half the remainder moves along the element's own rendered edges. */
export function resizeRemainderShift(
corners: Record<FixedCorner, { x: number; y: number }>,
grab: { x: number; y: number },
{ wanted, written }: ResizeDraftSizes,
): { x: number; y: number } {
const u = { x: corners.ne.x - corners.nw.x, y: corners.ne.y - corners.nw.y };
const v = { x: corners.sw.x - corners.nw.x, y: corners.sw.y - corners.nw.y };
// `grab` in edge coordinates: which side of each edge the grabbed corner is, even on a skewed box.
const det = u.x * v.y - u.y * v.x;
if (!det) return { x: 0, y: 0 };
const su = Math.sign((grab.x * v.y - grab.y * v.x) / det);
const sv = Math.sign((u.x * grab.y - u.y * grab.x) / det);
const kw = (su * (wanted.width - written.width)) / (2 * written.width);
const kh = (sv * (wanted.height - written.height)) / (2 * written.height);
return { x: u.x * kw + v.x * kh, y: u.y * kw + v.y * kh };
}
/** Per-frame anchored-resize center accumulator: ADD the residual center correction
* (fixedStart − fixedNow) onto the previous anchor so the pin CONVERGES instead of
* oscillating (fa4f39168). Pure; exported for the release-shift characterization tests. */
@@ -36,7 +36,7 @@ import {
} from "./manualEditsTypes";
import { gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes";
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
import { roundTo3, roundToLayoutPx } from "../../utils/rounding";
import { roundTo3, roundToLayoutSize } from "../../utils/rounding";
import { BOX_SIZE_STYLE_PROPS } from "./manualEditsDomPatches";
/* ── Gesture tracking ─────────────────────────────────────────────── */
@@ -402,7 +402,7 @@ function readParentFlexBasisPixels(
if (display !== "flex" && display !== "inline-flex") return null;
const direction = readStyleOrComputed(parent, "flex-direction").trim();
return roundToLayoutPx(Math.max(1, direction.startsWith("column") ? size.height : size.width));
return direction.startsWith("column") ? size.height : size.width;
}
function restoreStaleStudioScaleResize(element: HTMLElement): void {
@@ -477,22 +477,21 @@ function writeStudioBoxSizeVars(
}
element.setAttribute(STUDIO_BOX_SIZE_ATTR, "true");
element.style.setProperty(STUDIO_WIDTH_PROP, `${roundToLayoutPx(Math.max(1, size.width))}px`);
element.style.setProperty(STUDIO_HEIGHT_PROP, `${roundToLayoutPx(Math.max(1, size.height))}px`);
element.style.setProperty(STUDIO_WIDTH_PROP, `${size.width}px`);
element.style.setProperty(STUDIO_HEIGHT_PROP, `${size.height}px`);
}
function applyStudioBoxSizeDimensions(
element: HTMLElement,
size: { width: number; height: number },
): void {
wantedSize: { width: number; height: number },
): { width: number; height: number } {
const size = roundToLayoutSize(wantedSize);
writeStudioBoxSizeVars(element, size);
restoreStaleStudioScaleResize(element);
const width = roundToLayoutPx(Math.max(1, size.width));
const height = roundToLayoutPx(Math.max(1, size.height));
element.style.setProperty("box-sizing", "border-box");
element.style.setProperty("width", `${width}px`);
element.style.setProperty("height", `${height}px`);
element.style.setProperty("width", `${size.width}px`);
element.style.setProperty("height", `${size.height}px`);
element.style.setProperty("min-width", "0px");
element.style.setProperty("min-height", "0px");
element.style.setProperty("max-width", "none");
@@ -507,6 +506,7 @@ function applyStudioBoxSizeDimensions(
if (computedDisplay === "inline") {
element.style.setProperty("display", "inline-block");
}
return size;
}
export function applyStudioBoxSize(
@@ -533,12 +533,12 @@ export function forgetStudioBoxSizeDraftBase(element: HTMLElement): void {
export function applyStudioBoxSizeDraft(
element: HTMLElement,
size: { width: number; height: number },
): void {
): { width: number; height: number } {
if (!boxSizeDraftBases.has(element)) {
boxSizeDraftBases.set(element, { width: element.offsetWidth, height: element.offsetHeight });
}
promoteInlineForTransform(element);
applyStudioBoxSizeDimensions(element, size);
return applyStudioBoxSizeDimensions(element, size);
}
/* ── Rotation apply ───────────────────────────────────────────────── */
@@ -6,14 +6,15 @@ import {
elementCornerOverlayPoints,
overlayCornersCentroid,
} from "./domEditOverlayGeometry";
import { computeNextResizeAnchor } from "./domEditResizeLocal";
import { computeNextResizeAnchor, resizeRemainderShift } from "./domEditResizeLocal";
import type { ResizeDraftSizes } from "./domEditResizeLocal";
import { applyManualOffsetDragDraft } from "./manualOffsetDrag";
type Corners = ReturnType<typeof elementCornerOverlayPoints>;
/**
* The residual center-pin offset for this frame. With measurable corners and a
* fixed-center start, accumulate `fixedStart - centerNow` onto the previous
* fixed-center start, accumulate `fixedStart + shift - centerNow` onto the previous
* anchor so it CONVERGES rather than oscillating: `applyManualOffsetDragDraft`
* treats its argument as the absolute offset, and `centerNow` (measured on the
* live element) already carries the previous frame's offset, so the difference
@@ -25,10 +26,15 @@ function resolveResizeAnchor(
g: GestureState,
corners: Corners | null,
measureOrientedRect: () => OverlayRect | null,
sizes: ResizeDraftSizes,
): { dx: number; dy: number } {
const fixedStart = g.resizeFixedCenterStart;
if (corners && fixedStart) {
return computeNextResizeAnchor(g.lastResizeAnchor, fixedStart, overlayCornersCentroid(corners));
const press = g.resizePressFromCorner ?? { x: 0, y: 0 };
const grab = { x: g.startX - press.x - g.centerX, y: g.startY - press.y - g.centerY };
const shift = resizeRemainderShift(corners, grab, sizes);
const target = { x: fixedStart.x + shift.x, y: fixedStart.y + shift.y };
return computeNextResizeAnchor(g.lastResizeAnchor, target, overlayCornersCentroid(corners));
}
const fallbackRect = measureOrientedRect();
return resolveResizeCenterAnchorOffset({
@@ -52,11 +58,12 @@ function resolveAnchoredResizeDraft(
overlayEl: HTMLDivElement | null,
iframe: HTMLIFrameElement | null,
measureOrientedRect: () => OverlayRect | null,
sizes: ResizeDraftSizes,
): OverlayRect {
// Measure real corners ONCE — reused for the anchor and the fallback size.
const corners =
overlayEl && iframe ? elementCornerOverlayPoints(overlayEl, iframe, element) : null;
const anchor = resolveResizeAnchor(g, corners, measureOrientedRect);
const anchor = resolveResizeAnchor(g, corners, measureOrientedRect, sizes);
g.lastResizeAnchor = anchor;
applyManualOffsetDragDraft(member, anchor.dx, anchor.dy);
// Re-measure AFTER the anchor translate so it hugs the element every frame.
@@ -80,6 +87,7 @@ export function resolveResizeDraftRect(
overlayEl: HTMLDivElement | null,
iframe: HTMLIFrameElement | null,
measureOrientedRect: () => OverlayRect | null,
sizes: ResizeDraftSizes,
): OverlayRect {
if (g.pathOffsetMember) {
return resolveAnchoredResizeDraft(
@@ -89,6 +97,7 @@ export function resolveResizeDraftRect(
overlayEl,
iframe,
measureOrientedRect,
sizes,
);
}
// Re-measure the element's oriented box AFTER the size write. The size draft
@@ -258,7 +258,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
pointerStart: { x: g.startX - grab.x, y: g.startY - grab.y },
centerStart: { x: g.centerX, y: g.centerY },
});
applyStudioBoxSizeDraft(sel.element, nextSize);
const writtenSize = applyStudioBoxSizeDraft(sel.element, nextSize);
const overlayEl = opts.overlayRef.current;
const iframe = opts.iframeRef.current;
@@ -271,6 +271,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
overlayEl,
iframe,
measureOrientedRect,
{ wanted: nextSize, written: writtenSize },
);
logResizeMove({
pointer: { x: e.clientX, y: e.clientY },
@@ -455,10 +455,31 @@ it("does not mix the scale shorthand into a tween that speaks longhands", async
* The fixture models the geometry the browser reported: a 630x252 element
* dragged from x=432 to x=587 with its box drafted down to 320x128, dropped at
* a committed scale of 0.837. Scaling about the centre puts it back on the drop
* point at its pre-gesture position, so the correct persisted correction is
* NONE.
* point at its pre-gesture position, so the persisted correction is only the
* drop's sub-pixel offset.
*/
it("does not move a statically positioned element when a scale resize lands", async () => {
it.each([
{
name: "does not move a statically positioned element when a scale resize lands",
shift: { x: 0, y: 0 },
},
{
name: "puts a statically positioned element on a sub-pixel drop point",
shift: { x: 0.3, y: -0.2 },
writes: true,
},
{
name: "leaves a static position alone when the drop is off by less than the file's precision",
shift: { x: 0.0003, y: 0 },
writes: false,
},
{
name: "leaves an animated position alone for a sub-pixel drop point",
shift: { x: 0.3, y: -0.2 },
animated: true,
writes: false,
},
])("$name", async ({ shift, animated, writes }) => {
document.body.innerHTML = "";
const el = document.createElement("div");
el.id = "clip";
@@ -476,7 +497,7 @@ it("does not move a statically positioned element when a scale resize lands", as
el.style.height = "128px";
document.body.append(el);
const pos = { x: 587, y: 235 };
const pos = { x: 587 + shift.x, y: 235 + shift.y };
const scale = { x: 1.648, y: 1.648 };
const [LEFT, TOP] = [120, 520];
el.getBoundingClientRect = () => {
@@ -517,6 +538,15 @@ it("does not move a statically positioned element when a scale resize lands", as
duration: 0,
global: true,
} as unknown as GsapAnimation;
const positionTween = {
...positionHold,
id: "#clip-to-0-position",
method: "to",
properties: { x: 500 },
duration: 4,
global: false,
} as unknown as GsapAnimation;
const position = animated ? positionTween : positionHold;
const selection = { id: "clip", selector: "#clip", element: el } as DomEditSelection;
usePlayerStore.setState({ currentTime: 0.5 });
const commitMutation = vi.fn();
@@ -524,22 +554,27 @@ it("does not move a statically positioned element when a scale resize lands", as
await tryGsapResizeIntercept(
selection,
{ width: 320, height: 128 },
[keyframedScaleFixture(), positionHold],
[keyframedScaleFixture(), position],
iframe,
commitMutation,
async () => [keyframedScaleFixture(), positionHold],
async () => [keyframedScaleFixture(), position],
);
const positionWrites = commitMutation.mock.calls
.map((call) => call[1] as { properties?: Record<string, number> })
.filter((mutation) => mutation.properties?.x != null || mutation.properties?.y != null);
// Either it left the position alone, or it rewrote the same value.
// No position write, or one that moves the hold by exactly the drop's sub-pixel offset.
if (writes === false) expect(positionWrites).toEqual([]);
if (writes) expect(positionWrites.length).toBeGreaterThan(0);
for (const write of positionWrites) {
expect(write.properties?.x).toBe(432);
expect(write.properties?.y).toBe(173);
expect(write.properties?.x).toBeCloseTo(432 + shift.x, 3);
expect(write.properties?.y).toBeCloseTo(173 + shift.y, 3);
}
// And the live element ends on the drop point, not a drag away from it.
expect(el.getBoundingClientRect().x).toBeCloseTo(603.3, 0);
// And the live box is centred where it was dropped (left 120 + 587 + 160, top 520 + 235 + 64).
const landed = animated ? { x: 0, y: 0 } : shift;
const box = el.getBoundingClientRect();
expect(box.x + box.width / 2).toBeCloseTo(867 + landed.x, 3);
expect(box.y + box.height / 2).toBeCloseTo(819 + landed.y, 3);
});
function titleSelection(): DomEditSelection {
@@ -170,6 +170,11 @@ function tweenUsesScaleLonghands(anim: GsapAnimation | null): boolean {
// ── Resize intercept ──────────────────────────────────────────────────────
// The resize is centre-anchored, so a scale that cannot reproduce the dragged size splits the gap evenly.
function rectCentre(rect: DOMRect): { x: number; y: number } {
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
}
// fallow-ignore-next-line complexity
export async function tryGsapResizeIntercept(
selection: DomEditSelection,
@@ -371,15 +376,10 @@ export async function tryGsapResizeIntercept(
committedScale = useScaleLonghands
? { x: newScaleX, y: newScaleY }
: { x: newScaleX, y: newScaleX };
// Where the user DROPPED the box: the draft (anchor-pinned to the
// gesture-start top-left) is still applied here, so this rect is exactly
// what the preview showed at release. The committed scale renders around
// the element CENTER instead — the finalize step below measures that
// difference and compensates, so release matches the drop pixel-for-pixel
// regardless of live scale or repeat resizes.
// Where the user DROPPED the box: the draft is still applied, so this is what the preview showed
// at release. The finalize step below moves the committed scale's box onto it.
if (el) {
const dropRect = draw(() => el.getBoundingClientRect());
scaleDraftDropPoint = { x: dropRect.x, y: dropRect.y };
scaleDraftDropPoint = draw(() => rectCentre(el.getBoundingClientRect()));
}
} else {
resizeProps = {
@@ -396,8 +396,8 @@ export async function tryGsapResizeIntercept(
// position (a `tl.set` hold or none) — a keyframed position path has no
// single anchor to preserve, so it keeps the plain center-scale behavior.
// The size route hands its draft to GSAP itself (handOverDraftSize).
// ponytail: for a 3D-rotated element the rects are AABBs, so the anchor is
// approximate rather than corner-exact.
// ponytail: the centre of an AABB is exact for any 2D transform; under
// perspective it is approximate.
// fallow-ignore-next-line complexity
const finalizeScaleResizeCommit = async (): Promise<boolean> => {
// Only the scale route captures the element, so a null draft means this
@@ -428,15 +428,9 @@ export async function tryGsapResizeIntercept(
);
const base = { x: baseGsapX, y: baseGsapY };
setElementGsapPosition(draftEl, base.x, base.y);
const post = draftEl.getBoundingClientRect();
const post = rectCentre(draftEl.getBoundingClientRect());
const residual = { x: dropPoint.x - post.x, y: dropPoint.y - post.y };
if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return null;
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
// Settled, with nothing to write. Still ours: forwarding the drag offset
// on top would move the box off the point it is already sitting on.
return "settled" as const;
}
// The ONE corrected position — rounded once so the live runtime and the
// persisted file agree exactly (commitStaticGsapPosition composes the same
// rounded value from this delta).
@@ -444,9 +438,15 @@ export async function tryGsapResizeIntercept(
x: roundTo3(base.x + residual.x),
y: roundTo3(base.y + residual.y),
};
if (corrected.x === roundTo3(base.x) && corrected.y === roundTo3(base.y)) {
logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
// Settled, with nothing to write. Still ours: forwarding the drag offset
// on top would move the box off the point it is already sitting on.
return "settled" as const;
}
logResize("scale-finalize", {
dropPoint,
post: { x: post.x, y: post.y },
post,
residual,
gsapPos,
base,
@@ -455,11 +455,11 @@ export async function tryGsapResizeIntercept(
// Correct the live box in the same task as the measurement, so no frame shows it off the drop
// point while the position write is in flight.
setElementGsapPosition(draftEl, corrected.x, corrected.y);
return { base, corrected };
return { base, corrected, residual };
});
if (measured === null) return false;
if (measured === "settled") return true;
const { base, corrected } = measured;
const { base, corrected, residual } = measured;
// Re-fetch: the scale commit above just rewrote the script, so the caller's
// animation list (and its ids) may be stale for the position lookup.
const currentAnimations = fetchFallbackAnimations
@@ -482,6 +482,11 @@ export async function tryGsapResizeIntercept(
moment.time,
);
if (positionTween) {
// A sub-pixel correction is not worth a keyframe in an authored tween.
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
draw(() => setElementGsapPosition(draftEl, base.x, base.y));
return true;
}
logResize("scale-finalize", { route: "position-keyframe", tweenId: positionTween.id });
assertGsapEditPersisted(
await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, {
+7
View File
@@ -8,6 +8,13 @@ export function roundToLayoutPx(val: number): number {
return Math.round(val);
}
export function roundToLayoutSize(size: { width: number; height: number }) {
return {
width: roundToLayoutPx(Math.max(1, size.width)),
height: roundToLayoutPx(Math.max(1, size.height)),
};
}
/** Round to 2 decimal places (centisecond precision for timeline values). */
export function roundToCenti(val: number): number {
return Math.round(val * 100) / 100;
@@ -1462,7 +1462,7 @@
"resize-scale-px-r30-nested-z200-after": {"smooth":false,"tracking":0.45,"pressJump":0,"teleport":true,"teleportPx":0.44,"keys":true,"css":true,"renderKey":0.11,"drop":0.06,"reload":0.07,"render":0.08,"undo":true,"dropped":0,"controlDropped":0,"workMax":8.32,"frameP95":20},
"resize-scale-px-r30-nested-z200-mid": {"smooth":true,"tracking":0.35,"pressJump":0,"teleport":true,"teleportPx":0.34,"keys":true,"css":true,"renderKey":0.11,"drop":0.2,"reload":0.21,"render":0.13,"undo":true,"dropped":0,"controlDropped":0,"workMax":7.35,"frameP95":19.2},
"resize-scale-px-r30-nested-z200-on": {"smooth":true,"tracking":0.25,"pressJump":0,"teleport":true,"teleportPx":0.27,"keys":true,"css":true,"renderKey":0.11,"drop":0.24,"reload":0.24,"render":0.06,"undo":true,"dropped":0,"controlDropped":0,"workMax":6.31,"frameP95":19.31},
"resize-scale-px-r30-nested-z50-after": {"smooth":true,"tracking":0.52,"pressJump":0,"teleport":true,"teleportPx":0.43,"keys":true,"css":true,"renderKey":0.11,"drop":0.1,"reload":0.15,"render":0.08,"undo":true,"dropped":0,"controlDropped":0,"workMax":4.7,"frameP95":18.5},
"resize-scale-px-r30-nested-z50-after": {"smooth":true,"tracking":0.18,"pressJump":0,"teleport":true,"teleportPx":0.07,"keys":true,"css":true,"renderKey":0.06,"drop":0.13,"reload":0.17,"render":0.07,"undo":true,"dropped":0,"controlDropped":0,"workMax":6.26,"frameP95":18.81},
"resize-scale-px-r30-nested-z50-mid": {"smooth":true,"tracking":0.46,"pressJump":0,"teleport":true,"teleportPx":0.34,"keys":true,"css":true,"renderKey":0.11,"drop":0.21,"reload":0.2,"render":0.13,"undo":true,"dropped":0,"controlDropped":0,"workMax":4.7,"frameP95":18.5},
"resize-scale-px-r30-nested-z50-on": {"smooth":true,"tracking":0.39,"pressJump":0,"teleport":true,"teleportPx":0.28,"keys":true,"css":true,"renderKey":0.11,"drop":0.26,"reload":0.28,"render":0.06,"undo":true,"dropped":0,"controlDropped":0,"workMax":6.69,"frameP95":18.31},
"resize-scale-px-r30-root-z100-after": {"smooth":true,"tracking":0.44,"pressJump":0,"teleport":true,"teleportPx":0.44,"keys":true,"css":true,"renderKey":0.08,"drop":0.07,"reload":0.07,"render":0.08,"undo":true,"dropped":0,"controlDropped":0,"workMax":7.88,"frameP95":20.31},
@@ -1471,7 +1471,7 @@
"resize-scale-px-r30-root-z200-after": {"smooth":true,"tracking":0.45,"pressJump":0,"teleport":true,"teleportPx":0.44,"keys":true,"css":true,"renderKey":0.08,"drop":0.05,"reload":0.06,"render":0.08,"undo":true,"dropped":0,"controlDropped":0,"workMax":5.35,"frameP95":17.91},
"resize-scale-px-r30-root-z200-mid": {"smooth":true,"tracking":0.36,"pressJump":0,"teleport":true,"teleportPx":0.34,"keys":true,"css":true,"renderKey":0.08,"drop":0.18,"reload":0.23,"render":0.12,"undo":true,"dropped":0,"controlDropped":0,"workMax":6.6,"frameP95":19.21},
"resize-scale-px-r30-root-z200-on": {"smooth":true,"tracking":0.28,"pressJump":0,"teleport":true,"teleportPx":0.27,"keys":true,"css":true,"renderKey":0.08,"drop":0.24,"reload":0.24,"render":0.02,"undo":true,"dropped":0,"controlDropped":0,"workMax":5.22,"frameP95":18.71},
"resize-scale-px-r30-root-z50-after": {"smooth":true,"tracking":0.53,"pressJump":0,"teleport":true,"teleportPx":0.44,"keys":true,"css":true,"renderKey":0.08,"drop":0.1,"reload":0.07,"render":0.08,"undo":true,"dropped":0,"controlDropped":0,"workMax":6.11,"frameP95":19},
"resize-scale-px-r30-root-z50-after": {"smooth":true,"tracking":0.15,"pressJump":0,"teleport":true,"teleportPx":0.06,"keys":true,"css":true,"renderKey":0.03,"drop":0.1,"reload":0.07,"render":0.07,"undo":true,"dropped":0,"controlDropped":0,"workMax":7.13,"frameP95":18.81},
"resize-scale-px-r30-root-z50-mid": {"smooth":true,"tracking":0.39,"pressJump":0,"teleport":true,"teleportPx":0.34,"keys":true,"css":true,"renderKey":0.08,"drop":0.21,"reload":0.23,"render":0.12,"undo":true,"dropped":0,"controlDropped":0,"workMax":7.56,"frameP95":19.31},
"resize-scale-px-r30-root-z50-on": {"smooth":true,"tracking":0.3,"pressJump":0,"teleport":true,"teleportPx":0.28,"keys":true,"css":true,"renderKey":0.08,"drop":0.29,"reload":0.28,"render":0.02,"undo":true,"dropped":0,"controlDropped":0,"workMax":7.73,"frameP95":18.81},
"resize-size-px-r0-nested-z100-after": {"smooth":false,"tracking":0.21,"pressJump":0,"teleport":true,"teleportPx":0.23,"keys":true,"css":true,"renderKey":0.02,"drop":0,"reload":0,"render":0.02,"undo":true,"dropped":1,"controlDropped":0,"workMax":6.29,"frameP95":18},