Skip to content

Commit 3e011b7

Browse files
fix(studio): resize an element without GSAP by its own width, height and translate (#4805)
* fix(studio): resize an element without GSAP by its own width, height and translate A corner resize of an element GSAP does not size or position now saves plain CSS: its inline width and height, the scaled crop, and the translate that keeps its centre where it was, in one save and one undo step, with no animation read and no preview reload. It used to write a GSAP script into a file that had none, and the box snapped back to its old size on release. The anchor that keeps the centre planted is the same plain px translate a move writes, kept to a thousandth of a pixel, and a percent translate is held as px from the press so a growing box cannot drag it along on the first frame. * fix(studio): a resize leaves a box to GSAP when GSAP tweens anything the CSS box writer sets gsapWritesBox now reads its channels from BOX_SIZE_STYLE_PROPS, the writer's own property set, instead of width and height alone. A host-level test pins the GSAP-free resize route through useDomGeometryCommit. * fix(studio): a GSAP scaleX or scaleY tween also keeps a resize on the GSAP route They are GSAP's names for parts of the one CSS scale the box writer sets. * test(studio): bank the edit accuracy cases a plain-CSS resize now passes baseline.json is the edit-accuracy-gate artifact of the CI run at 5ae51f1, copied byte for byte: 494 to 530 passing, the 36 resize cases without GSAP, none regressed.
1 parent 86a7e8c commit 3e011b7

15 files changed

Lines changed: 937 additions & 763 deletions

‎packages/studio/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts‎

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,49 @@ const ORIGIN_CENTER = {
1616

1717
// Consistent geometry stub: model the physical truth the real DOM would report.
1818
// A CSS width/height change grows the box from its top-left, so the rendered
19-
// center drifts by half the size delta; the manual offset the gesture applies
20-
// (read back from the element's studio vars) pulls it back. `elementCornerOverlayPoints`
19+
// center drifts by half the size delta; the element's own translate, which the gesture
20+
// writes in plain px, pulls it back. `elementCornerOverlayPoints`
2121
// returns the four corners of that drifted box; `overlayCornersCentroid` (kept
2222
// real) averages them so the anchor loop can measure the true center each frame.
23+
// An authored `translate: 25% 25%` follows the box size until the gesture writes px over it.
24+
const authored = vi.hoisted(() => ({ percent: 0 }));
25+
function inlineTranslate(element: HTMLElement): { x: number; y: number } | null {
26+
const value = element.style.getPropertyValue("translate");
27+
if (!value) return null;
28+
const [x = 0, y = 0] = value.split(" ").map((v) => Number.parseFloat(v) || 0);
29+
return { x, y };
30+
}
31+
function renderedTranslate(element: HTMLElement, width: number, height: number) {
32+
return inlineTranslate(element) ?? { x: authored.percent * width, y: authored.percent * height };
33+
}
34+
35+
vi.mock("./plainTranslate", async () => {
36+
const actual = await vi.importActual<typeof import("./plainTranslate")>("./plainTranslate");
37+
return {
38+
...actual,
39+
readTranslatePx: (element: HTMLElement) =>
40+
renderedTranslate(
41+
element,
42+
element.offsetWidth || ORIGIN.width,
43+
element.offsetHeight || ORIGIN.height,
44+
),
45+
};
46+
});
47+
2348
vi.mock("./domEditOverlayGeometry", async () => {
2449
const actual = await vi.importActual<typeof import("./domEditOverlayGeometry")>(
2550
"./domEditOverlayGeometry",
2651
);
27-
const { readStudioBoxSize, readStudioPathOffset } = await import("./manualEditsDom");
52+
const { readStudioBoxSize } = await import("./manualEditsDom");
2853
const physicalCenter = (element: HTMLElement) => {
2954
const size = readStudioBoxSize(element);
3055
const width = size.width > 0 ? size.width : ORIGIN.width;
3156
const height = size.height > 0 ? size.height : ORIGIN.height;
32-
const offset = readStudioPathOffset(element);
57+
const offset = renderedTranslate(element, width, height);
58+
const start = { x: authored.percent * ORIGIN.width, y: authored.percent * ORIGIN.height };
3359
return {
34-
x: ORIGIN_CENTER.x + (width - ORIGIN.width) / 2 + offset.x,
35-
y: ORIGIN_CENTER.y + (height - ORIGIN.height) / 2 + offset.y,
60+
x: ORIGIN_CENTER.x + (width - ORIGIN.width) / 2 + offset.x - start.x,
61+
y: ORIGIN_CENTER.y + (height - ORIGIN.height) / 2 + offset.y - start.y,
3662
width,
3763
height,
3864
};
@@ -189,6 +215,7 @@ async function finishResize(handlers: ReturnType<typeof createDomEditOverlayGest
189215

190216
afterEach(() => {
191217
document.body.innerHTML = "";
218+
authored.percent = 0;
192219
});
193220

194221
describe("anchored corner resize — the release commit feeds the center-pin offset", () => {
@@ -227,6 +254,19 @@ describe("anchored corner resize — the release commit feeds the center-pin off
227254
expect(offset.y).toBeCloseTo(-(size.height - ORIGIN.height) / 2, 0);
228255
});
229256

257+
it("keeps the centre on the first frame when the authored translate is a percent", async () => {
258+
const { orientedOverlayRect } = await import("./domEditOverlayGeometry");
259+
authored.percent = 0.25;
260+
const { handlers, selection } = buildHarness();
261+
handlers.startGesture("resize", evt(ORIGIN_CENTER.x + 100, ORIGIN_CENTER.y), {
262+
resizeHandle: "se",
263+
});
264+
handlers.onPointerMove(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
265+
const rect = orientedOverlayRect(null as never, null as never, selection.element)!;
266+
expect(rect.left + rect.width / 2).toBeCloseTo(ORIGIN_CENTER.x, 1);
267+
expect(rect.top + rect.height / 2).toBeCloseTo(ORIGIN_CENTER.y, 1);
268+
});
269+
230270
it("does not log a paused save queue as an ordinary resize failure", async () => {
231271
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
232272
const { handlers } = buildHarness(() => Promise.reject(new DomEditSaveQueueOpenError()));

‎packages/studio/src/components/editor/domEditOverlayGestures.ts‎

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -70,20 +70,6 @@ export interface GestureState {
7070
// follows the cursor instead of overshooting by the live scale.
7171
contentScaleX: number;
7272
contentScaleY: number;
73-
// Resize anchor pinning: with a live scale transform, growing the CSS box
74-
// shifts the rendered box (scaling happens around the element center), so the
75-
// un-dragged corner creeps during the draft. The move handler measures the
76-
// gesture-start top-left drift each frame and counters it through the GSAP
77-
// position channel; the pin accumulates so the correction converges.
78-
// Present only on resize gestures.
79-
resizeAnchor?: {
80-
anchorX: number;
81-
anchorY: number;
82-
baseGsapX: number;
83-
baseGsapY: number;
84-
pinX: number;
85-
pinY: number;
86-
};
8773
manualEditDragToken?: string;
8874
snapContext?: SnapContext;
8975
lastSnappedDx?: number;

‎packages/studio/src/components/editor/domEditOverlayStartGesture.test.ts‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,23 @@ const PRESS = {
2020
currentTarget: { setPointerCapture() {} },
2121
};
2222

23-
/** Presses a drag on the element and returns the gesture it started. */
24-
function pressDrag(element: HTMLElement): GestureState | null {
23+
/** Presses a drag or resize on the element and returns the gesture it started. */
24+
function pressGesture(element: HTMLElement, kind: "drag" | "resize" = "drag"): GestureState | null {
2525
const opts = pressOptions(element);
2626
expect(
27-
createDomEditOverlayGestureHandlers(opts as never).startGesture("drag", PRESS as never),
27+
createDomEditOverlayGestureHandlers(opts as never).startGesture(kind, PRESS as never),
2828
).toBe(true);
2929
return opts.gestureRef.current;
3030
}
3131

3232
function pressOptions(element: HTMLElement) {
3333
const ref = <T>(current: T) => ({ current });
34-
const selection = { element, capabilities: { canApplyManualOffset: true } };
34+
const capabilities = { canApplyManualOffset: true, canApplyManualSize: true };
35+
const selection = { element, capabilities };
3536
return {
3637
selectionRef: ref(selection as unknown as DomEditSelection),
3738
overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
38-
boxRef: ref(null),
39+
boxRef: ref(document.createElement("div")),
3940
overlayRef: ref(null),
4041
iframeRef: ref(null),
4142
gestureRef: ref<GestureState | null>(null),
@@ -45,13 +46,14 @@ function pressOptions(element: HTMLElement) {
4546
};
4647
}
4748

48-
describe("a drag press on a page that loads GSAP", () => {
49+
describe("a drag or resize press on a page that loads GSAP", () => {
4950
it.each([
50-
["GSAP animates nothing", false],
51-
["GSAP animates only its parent", true],
52-
])(
53-
"%s: the press never asks GSAP about the element, so its translate stays CSS",
54-
(_, parentTween) => {
51+
["drag", "GSAP animates nothing", false],
52+
["drag", "GSAP animates only its parent", true],
53+
["resize", "GSAP animates nothing", false],
54+
] as const)(
55+
"%s, %s: the press never asks GSAP about the element, so its translate stays CSS",
56+
(kind, _, parentTween) => {
5557
const getProperty = vi.fn(() => 0);
5658
const set = vi.fn();
5759
const parent = document.createElement("div");
@@ -62,7 +64,7 @@ describe("a drag press on a page that loads GSAP", () => {
6264
const tween = { targets: () => [parent], vars: { x: 100 }, duration: () => 2 };
6365
const timelines = { main: { getChildren: () => (parentTween ? [tween] : []) } };
6466
Object.assign(window, { gsap: { getProperty, set }, __timelines: timelines });
65-
expect(pressDrag(element)?.pathOffsetMember?.plainTranslate).toBe(true);
67+
expect(pressGesture(element, kind)?.pathOffsetMember?.plainTranslate).toBe(true);
6668
expect(getProperty).not.toHaveBeenCalled();
6769
expect(set).not.toHaveBeenCalled();
6870
expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
@@ -77,7 +79,7 @@ describe("a drag press on a centred element without GSAP", () => {
7779
"position: absolute; left: 50%; top: 50%; width: 240px; height: 160px; translate: -50% -50%";
7880
document.body.append(element);
7981
const style = element.getAttribute("style");
80-
expect(pressDrag(element)?.pathOffsetMember?.initialOffset).toEqual({ x: -120, y: -80 });
82+
expect(pressGesture(element)?.pathOffsetMember?.initialOffset).toEqual({ x: -120, y: -80 });
8183
expect(element.getAttribute("style")).toBe(style);
8284
});
8385
});

‎packages/studio/src/components/editor/domEditOverlayStartGesture.ts‎

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
* Gesture-begin functions: startGroupDrag and startGesture.
33
* These are pure "start a new gesture" operations — no draft rect updates.
44
*/
5-
import { readElementGsapNumber } from "../../utils/elementGsap";
65
import { type DomEditSelection } from "./domEditing";
76
import {
7+
applyManualOffsetDragDraft,
88
createManualOffsetDragMember,
99
readGsapRotation,
1010
restoreManualOffsetDragMembers,
@@ -171,18 +171,6 @@ export function startGesture(
171171
Number.isFinite(rawContentScaleX) && rawContentScaleX > 0 ? rawContentScaleX : 1;
172172
const contentScaleY =
173173
Number.isFinite(rawContentScaleY) && rawContentScaleY > 0 ? rawContentScaleY : 1;
174-
let resizeAnchor: GestureState["resizeAnchor"];
175-
if (kind === "resize") {
176-
const startBcr = sel.element.getBoundingClientRect();
177-
resizeAnchor = {
178-
anchorX: startBcr.x,
179-
anchorY: startBcr.y,
180-
baseGsapX: readElementGsapNumber(sel.element, "x") ?? 0,
181-
baseGsapY: readElementGsapNumber(sel.element, "y") ?? 0,
182-
pinX: 0,
183-
pinY: 0,
184-
};
185-
}
186174
let initialPathOffset = captureStudioPathOffset(sel.element);
187175
let manualEditDragToken: string | undefined;
188176
let pathOffsetMember: ManualOffsetDragMember | undefined;
@@ -205,23 +193,24 @@ export function startGesture(
205193
initialPathOffset = result.member.initialPathOffset;
206194
manualEditDragToken = result.member.gestureToken;
207195
} else {
208-
// Center-anchored corner resize (CapCut model): the element scales about its
209-
// CENTER, which stays planted. All four corners behave identically, so EVERY
210-
// corner needs the manual-offset member that translates the element to re-pin
211-
// its center per frame (the memberless else-branch is only a defensive fallback
212-
// if member creation fails, e.g. the element can't take a manual offset).
196+
// Center-anchored corner resize (CapCut model): the element scales about its planted CENTER,
197+
// so every corner needs the member that re-pins the center per frame (the memberless
198+
// branch is only a fallback for an element that can't take a manual offset).
213199
const needsAnchorOffset = kind === "resize" && sel.capabilities.canApplyManualOffset;
214200
if (needsAnchorOffset) {
215201
const result = createManualOffsetDragMember({
216202
key: selectionCacheKey(sel),
217203
selection: sel,
218204
element: sel.element,
219205
rect,
206+
gesture: "resize",
220207
});
221208
if (result.ok) {
222209
pathOffsetMember = result.member;
223210
initialPathOffset = result.member.initialPathOffset;
224211
manualEditDragToken = result.member.gestureToken;
212+
// Hold a % translate as the same px now, so a growing box can't drag it along mid-frame.
213+
if (result.member.plainTranslate) applyManualOffsetDragDraft(result.member, 0, 0);
225214
} else {
226215
manualEditDragToken = beginStudioManualEditGesture(sel.element);
227216
}
@@ -285,7 +274,6 @@ export function startGesture(
285274
editScaleY: rect.editScaleY,
286275
contentScaleX,
287276
contentScaleY,
288-
resizeAnchor,
289277
manualEditDragToken,
290278
snapContext,
291279
resizeHandle: kind === "resize" ? (options?.resizeHandle ?? "se") : undefined,

‎packages/studio/src/components/editor/manualEditsDomPatches.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export function buildClearPathOffsetPatches(element: HTMLElement): PatchOperatio
118118

119119
/* ── Box size patches ────────────────────────────────────────────── */
120120

121-
const BOX_SIZE_STYLE_PROPS = [
121+
export const BOX_SIZE_STYLE_PROPS = [
122122
"width",
123123
"height",
124124
"min-width",

‎packages/studio/src/components/editor/manualOffsetDrag.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,3 +652,44 @@ describe("a move of an element GSAP does not position", () => {
652652
expect(member().plainTranslate).toBe(false);
653653
});
654654
});
655+
656+
describe("a resize's anchor member", () => {
657+
function anchorMember(tweenVars?: Record<string, number>) {
658+
const window = new Window();
659+
const element = window.document.createElement("div");
660+
element.id = "box";
661+
window.document.body.append(element);
662+
const tweens = tweenVars ? [{ vars: tweenVars, targets: () => [element] }] : [];
663+
Object.assign(window, { __timelines: { main: { getChildren: () => tweens } } });
664+
element.getBoundingClientRect = () => {
665+
const [x = 0, y = 0] = element.style
666+
.getPropertyValue("translate")
667+
.split(" ")
668+
.map((v) => Number.parseFloat(v));
669+
return new window.DOMRect(10 + (x || 0), 20 + (y || 0), 100, 50);
670+
};
671+
const result = createManualOffsetDragMember({
672+
key: "box",
673+
selection: { element } as never,
674+
element,
675+
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
676+
gesture: "resize",
677+
});
678+
if (!result.ok) throw new Error("member");
679+
return result.member;
680+
}
681+
682+
it("keeps the centre with the element's own plain translate when GSAP does not own the box", () => {
683+
const member = anchorMember();
684+
expect(member.plainTranslate).toBe(true);
685+
applyManualOffsetDragDraft(member, -10.25, 3.5);
686+
expect(member.element.style.getPropertyValue("translate")).toBe("-10.25px 3.5px");
687+
});
688+
689+
it.each(["width", "maxWidth", "minHeight", "flexBasis", "scale", "scaleX"])(
690+
"leaves a box whose %s GSAP tweens, which the CSS box writer also sets, to the GSAP writer",
691+
(channel) => {
692+
expect(anchorMember({ [channel]: 300 }).plainTranslate).toBe(false);
693+
},
694+
);
695+
});

‎packages/studio/src/components/editor/manualOffsetDrag.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
type StudioPathOffsetSnapshot,
1212
} from "./manualEdits";
1313
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
14-
import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
14+
import { gsapWritesBox, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
1515
import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
1616

1717
interface OffsetDragGsap {
@@ -337,11 +337,11 @@ export function createManualOffsetDragMember(input: {
337337
selection: DomEditSelection;
338338
element: HTMLElement;
339339
rect: ManualOffsetDragRect;
340-
gesture?: "drag" | "nudge"; // a resize's anchor member keeps the legacy offset channel
340+
gesture?: "drag" | "nudge" | "resize"; // resize: the anchor that keeps its centre planted
341341
}): ManualOffsetDragMemberResult {
342-
const plainTranslate = !!input.gesture && !gsapWritesPosition(input.element);
343-
// Base the drag on the offset ACTUALLY applied, never a dormant var (readAppliedStudioPathOffset),
344-
// so a stale offset can't fling the element off-screen.
342+
const gsapOwns = input.gesture === "resize" ? gsapWritesBox : gsapWritesPosition;
343+
const plainTranslate = !!input.gesture && !gsapOwns(input.element);
344+
// The APPLIED offset, never a dormant var, so a stale one can't fling the element off-screen.
345345
const initialOffset = plainTranslate
346346
? readTranslatePx(input.element)
347347
: readAppliedStudioPathOffset(input.element);

‎packages/studio/src/hooks/gsapRuntimeKeyframes.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
1212
import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
1313
import { roundTo3 } from "../utils/rounding";
14+
import { BOX_SIZE_STYLE_PROPS } from "../components/editor/manualEditsDomPatches";
1415

1516
/**
1617
* A GSAP tween's `vars` object — intentionally open: it mixes channel values
@@ -446,6 +447,19 @@ export function gsapWritesPosition(el: Element): boolean {
446447
return !!cache?.renderTransform || gsapWritesChannels(el, MOVE_CHANNELS);
447448
}
448449

450+
const BOX_CHANNELS = [
451+
...BOX_SIZE_STYLE_PROPS.map((prop) =>
452+
prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()),
453+
),
454+
"scaleX",
455+
"scaleY",
456+
];
457+
458+
/** GSAP owns this element's box: its position, or any property the CSS box writer sets. Else a resize writes CSS. */
459+
export function gsapWritesBox(el: Element): boolean {
460+
return gsapWritesPosition(el) || gsapWritesChannels(el, BOX_CHANNELS);
461+
}
462+
449463
/** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */
450464
export function elementHasNonHoldTween(el: Element, channels?: string[]): boolean {
451465
const win = el.ownerDocument.defaultView as {

‎packages/studio/src/hooks/useDomGeometryCommit.test.tsx‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,28 @@ describe("useDomGeometryCommit, from the package entry", () => {
176176
unmount();
177177
});
178178

179+
it("saves a GSAP-free resize as its inline size, with no GSAP script and no animation read", async () => {
180+
const calls = stubPatchServer();
181+
const { element, recordEdit, hook, unmount } = renderHost();
182+
183+
await expect(
184+
hook().commitBoxSize(makeSelection("card", element), { width: 300, height: 90 }),
185+
).resolves.toEqual({ ok: true });
186+
187+
expect(element.style.getPropertyValue("width")).toBe("300px");
188+
expect(calls.patches).toEqual([
189+
expect.objectContaining({
190+
operations: expect.arrayContaining([
191+
{ type: "inline-style", property: "width", value: "300px" },
192+
{ type: "inline-style", property: "height", value: "90px" },
193+
]),
194+
}),
195+
]);
196+
expect(calls.urls.filter((url) => url.includes("gsap"))).toEqual([]);
197+
expect(recordEdit).toHaveBeenCalledTimes(1);
198+
unmount();
199+
});
200+
179201
it("puts a GSAP-free element's translate back when its move cannot be saved", async () => {
180202
stubPatchServer(500);
181203
const { element, hook, unmount } = renderHost();
@@ -216,6 +238,8 @@ describe("useDomGeometryCommit, from the package entry", () => {
216238
const showToast = vi.fn();
217239
const restore = vi.fn();
218240
const { element, recordEdit, hook, unmount } = renderHost({ showToast });
241+
// GSAP renders the card's transform, so its resize reads animations first.
242+
Object.assign(element, { _gsap: { renderTransform: () => undefined } });
219243

220244
await expect(
221245
hook().commitBoxSize(

0 commit comments

Comments
 (0)