Skip to content

Commit 7ba40b3

Browse files
committed
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.
1 parent 2a84c10 commit 7ba40b3

13 files changed

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

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

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@ const PRESS = {
2222

2323
function pressOptions(element: HTMLElement) {
2424
const ref = <T>(current: T) => ({ current });
25-
const selection = { element, capabilities: { canApplyManualOffset: true } };
25+
const capabilities = { canApplyManualOffset: true, canApplyManualSize: true };
26+
const selection = { element, capabilities };
2627
return {
2728
selectionRef: ref(selection as unknown as DomEditSelection),
2829
overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
29-
boxRef: ref(null),
30+
boxRef: ref(document.createElement("div")),
3031
overlayRef: ref(null),
3132
iframeRef: ref(null),
3233
gestureRef: ref<GestureState | null>(null),
@@ -36,13 +37,14 @@ function pressOptions(element: HTMLElement) {
3637
};
3738
}
3839

39-
describe("a drag press on a page that loads GSAP", () => {
40+
describe("a drag or resize press on a page that loads GSAP", () => {
4041
it.each([
41-
["GSAP animates nothing", false],
42-
["GSAP animates only its parent", true],
43-
])(
44-
"%s: the press never asks GSAP about the element, so its translate stays CSS",
45-
(_, parentTween) => {
42+
["drag", "GSAP animates nothing", false],
43+
["drag", "GSAP animates only its parent", true],
44+
["resize", "GSAP animates nothing", false],
45+
] as const)(
46+
"%s, %s: the press never asks GSAP about the element, so its translate stays CSS",
47+
(kind, _, parentTween) => {
4648
const getProperty = vi.fn(() => 0);
4749
const set = vi.fn();
4850
const parent = document.createElement("div");
@@ -55,7 +57,7 @@ describe("a drag press on a page that loads GSAP", () => {
5557
Object.assign(window, { gsap: { getProperty, set }, __timelines: timelines });
5658
const opts = pressOptions(element);
5759
const handlers = createDomEditOverlayGestureHandlers(opts as never);
58-
expect(handlers.startGesture("drag", PRESS as never)).toBe(true);
60+
expect(handlers.startGesture(kind, PRESS as never)).toBe(true);
5961

6062
expect(opts.gestureRef.current?.pathOffsetMember?.plainTranslate).toBe(true);
6163
expect(getProperty).not.toHaveBeenCalled();

‎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/manualOffsetDrag.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,3 +622,41 @@ describe("a move of an element GSAP does not position", () => {
622622
expect(member().plainTranslate).toBe(false);
623623
});
624624
});
625+
626+
describe("a resize's anchor member", () => {
627+
function anchorMember(tweenVars?: Record<string, number>) {
628+
const window = new Window();
629+
const element = window.document.createElement("div");
630+
element.id = "box";
631+
window.document.body.append(element);
632+
const tweens = tweenVars ? [{ vars: tweenVars, targets: () => [element] }] : [];
633+
Object.assign(window, { __timelines: { main: { getChildren: () => tweens } } });
634+
element.getBoundingClientRect = () => {
635+
const [x = 0, y = 0] = element.style
636+
.getPropertyValue("translate")
637+
.split(" ")
638+
.map((v) => Number.parseFloat(v));
639+
return new window.DOMRect(10 + (x || 0), 20 + (y || 0), 100, 50);
640+
};
641+
const result = createManualOffsetDragMember({
642+
key: "box",
643+
selection: { element } as never,
644+
element,
645+
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
646+
gesture: "resize",
647+
});
648+
if (!result.ok) throw new Error("member");
649+
return result.member;
650+
}
651+
652+
it("keeps the centre with the element's own plain translate when GSAP does not own the box", () => {
653+
const member = anchorMember();
654+
expect(member.plainTranslate).toBe(true);
655+
applyManualOffsetDragDraft(member, -10.25, 3.5);
656+
expect(member.element.style.getPropertyValue("translate")).toBe("-10.25px 3.5px");
657+
});
658+
659+
it("leaves a box whose size GSAP tweens to the GSAP writer", () => {
660+
expect(anchorMember({ width: 300 }).plainTranslate).toBe(false);
661+
});
662+
});

‎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, 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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,11 @@ export function gsapWritesPosition(el: Element): boolean {
440440
return !!cache?.renderTransform || gsapWritesChannels(el, MOVE_CHANNELS);
441441
}
442442

443+
/** GSAP owns this element's box: its position, or its width or height. Else a resize writes CSS. */
444+
export function gsapWritesBox(el: Element): boolean {
445+
return gsapWritesPosition(el) || gsapWritesChannels(el, ["width", "height"]);
446+
}
447+
443448
/** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */
444449
export function elementHasNonHoldTween(el: Element, channels?: string[]): boolean {
445450
const win = el.ownerDocument.defaultView as {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,8 @@ describe("useDomGeometryCommit, from the package entry", () => {
216216
const showToast = vi.fn();
217217
const restore = vi.fn();
218218
const { element, recordEdit, hook, unmount } = renderHost({ showToast });
219+
// GSAP renders the card's transform, so its resize reads animations first.
220+
Object.assign(element, { _gsap: { renderTransform: () => undefined } });
219221

220222
await expect(
221223
hook().commitBoxSize(

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

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import { useGsapCacheVersion } from "./useGsapTweenCache";
1313
import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
1414
import { useDomEditPersist } from "./useDomEditPersist";
1515
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
16+
import { useDomGeometryCommits } from "./useDomGeometryCommits";
1617
import { useMountEffect } from "./useMountEffect";
17-
import { stageElementOffset } from "./elementOffsetStager";
1818

1919
/**
2020
* Studio's `Player` must show the project, with `beginTimelineSession(projectId)` run before it
@@ -47,8 +47,6 @@ export interface DomGeometryCommits {
4747

4848
const noop = () => {};
4949
const NO_SELECTED_ANIMATIONS: GsapAnimation[] = [];
50-
// ponytail: unreachable, the GSAP writer always exists so a resize never takes the DOM route.
51-
const noDomBoxSizeRoute = () => Promise.reject(new Error("Resize has no DOM route here"));
5250

5351
/**
5452
* Saves canvas moves, resizes and rotations through Studio's own GSAP-aware commits, for a host
@@ -107,16 +105,12 @@ export function useDomGeometryCommit({
107105
},
108106
[commitPositionPatchToHtml, queue],
109107
);
110-
const stageElementPositionOffset = useCallback(
111-
(selection: DomEditSelection, next: { x: number; y: number }, coalesceKey?: string) =>
112-
stageElementOffset(
113-
{ commitPositionPatchToHtml: commitWithFreshQueue, showToast },
114-
selection,
115-
next,
116-
coalesceKey,
117-
),
118-
[commitWithFreshQueue, showToast],
119-
);
108+
const { stageElementPositionOffset, handleDomBoxSizeCommit } = useDomGeometryCommits({
109+
previewIframeRef: iframeRef,
110+
showToast,
111+
commitPositionPatchToHtml: commitWithFreshQueue,
112+
readOnlyPreview: false,
113+
});
120114
const makeFetchFallback = useGsapAnimationFetchFallback(projectId);
121115
const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast);
122116
const {
@@ -135,7 +129,7 @@ export function useDomGeometryCommit({
135129
makeFetchFallback,
136130
trackGsapInteractionFailure,
137131
stageElementPositionOffset,
138-
handleDomBoxSizeCommit: noDomBoxSizeRoute,
132+
handleDomBoxSizeCommit,
139133
commitPositionPatchToHtml: commitWithFreshQueue,
140134
addGsapAnimation: gsap.addGsapAnimation,
141135
convertToKeyframes: gsap.convertToKeyframes,

0 commit comments

Comments
 (0)