Skip to content

Commit e83e3e4

Browse files
committed
fix(studio): route a GSAP-free move through the on-element writer and meet the file checks
1 parent 1615653 commit e83e3e4

9 files changed

Lines changed: 99 additions & 135 deletions

‎packages/studio/src/components/editor/PropertyPanel.tsx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,6 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
210210
manualSize.height > 0
211211
? manualSize.height
212212
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
213-
214213
const manualRotation = readStudioRotation(element.element);
215214

216215
const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -337,13 +337,11 @@ export function createManualOffsetDragMember(input: {
337337
selection: DomEditSelection;
338338
element: HTMLElement;
339339
rect: ManualOffsetDragRect;
340-
/** A move or nudge; a resize's anchor member keeps the legacy offset channel. */
341-
gesture?: "drag" | "nudge";
340+
gesture?: "drag" | "nudge"; // a resize's anchor member keeps the legacy offset channel
342341
}): ManualOffsetDragMemberResult {
343342
const plainTranslate = !!input.gesture && !gsapWritesPosition(input.element);
344-
// Base the drag on the offset ACTUALLY applied, never the raw (possibly dormant)
345-
// var — see readAppliedStudioPathOffset. This keeps the commit purely relative
346-
// (applied + delta) so a stale offset can't fling the element off-screen.
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.
347345
const initialOffset = plainTranslate
348346
? readTranslatePx(input.element)
349347
: readAppliedStudioPathOffset(input.element);
@@ -479,7 +477,8 @@ function applyManualOffsetCommitValue(
479477
member: ManualOffsetDragMember,
480478
offset: { x: number; y: number },
481479
): { x: number; y: number } {
482-
if (!member.plainTranslate) stampGestureBase(member.element, member.initialOffset, member.baseGsap);
480+
if (!member.plainTranslate)
481+
stampGestureBase(member.element, member.initialOffset, member.baseGsap);
483482
drawMemberOffset(member, offset, true);
484483
return offset;
485484
}

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,10 @@ export function readTranslatePx(el: HTMLElement): Point {
3131
}
3232

3333
/** Plain px only: GSAP's CSSPlugin splits `translate` on spaces and drops a calc(). */
34-
export function formatTranslatePx(p: Point): string {
34+
function formatTranslatePx(p: Point): string {
3535
return `${roundTo3(p.x)}px ${roundTo3(p.y)}px`;
3636
}
3737

38-
/** The source patch that saves a move: the same literal the live element shows. */
3938
export function translatePatch(p: Point): PatchOperation & { value: string } {
4039
return { type: "inline-style", property: "translate", value: formatTranslatePx(p) };
4140
}

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

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
} from "../components/editor/elementPositionOffset";
66
import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../player/lib/timelineElementHelpers";
77
import type { PatchOperation } from "../utils/sourcePatcher";
8+
import { translatePatch } from "../components/editor/plainTranslate";
9+
import { gsapWritesPosition } from "./gsapRuntimeKeyframes";
810

911
const ELEMENT_OFFSET_REFUSED: Record<ElementOffsetRefusal, string> = {
1012
anchored: "This layer is anchored from its right or bottom edge. Move it in the Code tab.",
@@ -30,8 +32,34 @@ function settleGsapDraftAtGestureStart(el: HTMLElement): void {
3032
if (gsap && Number.isFinite(x) && Number.isFinite(y)) gsap.set(el, { x, y });
3133
}
3234

33-
/** Applies a shared-tween element's move live now; `save` persists it as left/top on that
34-
* element, `rollback` takes the live move back. Throws, after a toast, when it cannot. */
35+
let plainMoveCounter = 0;
36+
37+
/** GSAP does not position the element: `next` is its whole translate, live now and saved as drawn. */
38+
function stagePlainTranslate(
39+
commitPositionPatchToHtml: ElementOffsetStagerDeps["commitPositionPatchToHtml"],
40+
selection: DomEditSelection,
41+
next: { x: number; y: number },
42+
coalesceKey?: string,
43+
): { save: () => Promise<void>; rollback: () => void } {
44+
const style = selection.element.style;
45+
const before = style.getPropertyValue("translate");
46+
const patch = translatePatch(next);
47+
style.setProperty("translate", patch.value);
48+
const rollback = () => {
49+
if (style.getPropertyValue("translate") === patch.value) style.setProperty("translate", before);
50+
};
51+
const key = coalesceKey ?? `move:${++plainMoveCounter}`;
52+
const options = { label: "Move layer", coalesceKey: key, coalesceMs: Number.POSITIVE_INFINITY };
53+
const save = () =>
54+
commitPositionPatchToHtml(selection, [patch], options).catch((error) => {
55+
rollback();
56+
throw error;
57+
});
58+
return { save, rollback };
59+
}
60+
61+
/** Applies a move on the element itself live now: its translate when GSAP does not position it,
62+
* else left/top for a shared-tween element. Throws, after a toast, when it cannot. */
3563
export function stageElementOffset(
3664
{ commitPositionPatchToHtml, showToast, readOnlyPreview }: ElementOffsetStagerDeps,
3765
selection: DomEditSelection,
@@ -40,6 +68,9 @@ export function stageElementOffset(
4068
): { save: () => Promise<void>; rollback: () => void } {
4169
const el = selection.element;
4270
if (readOnlyPreview) return { save: () => Promise.resolve(), rollback: () => undefined };
71+
if (!gsapWritesPosition(el)) {
72+
return stagePlainTranslate(commitPositionPatchToHtml, selection, next, coalesceKey);
73+
}
4374
const previous = { position: el.style.position, left: el.style.left, top: el.style.top };
4475
const liftMarker = el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
4576
const result = applyElementPositionOffset(el, next);

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

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -405,33 +405,30 @@ export function hasNonHoldTweenForElement(
405405
return !!targetEl && hasNonHoldTween(timelinesOf(iframe), targetEl, channels, compositionId);
406406
}
407407

408-
// Position channels — used to scope the "has a live position tween?" check so a
409-
// sibling rotation/scale animation never forces a static position hold into the
410-
// keyframe branch (which corrupts it into a frozen duration-0 keyframed tween).
408+
// A sibling rotation/scale tween must never push a static position hold into the keyframe branch.
411409
export const POSITION_CHANNELS: string[] = [
412410
"x",
413411
"y",
414412
"xPercent",
415413
"yPercent",
416414
"left",
417415
"top",
418-
// GSAP normalizes translateX/Y to x/y at play time, but readTween reads the
419-
// AUTHORED shape — include them so a hand-authored translateX/Y position tween
420-
// still counts as a live position tween.
416+
// readTween reads the authored translateX/Y; GSAP normalizes them to x/y only at play time.
421417
"translateX",
422418
"translateY",
423419
];
424420
const MOVE_CHANNELS = [...POSITION_CHANNELS, "motionPath"];
425421

426422
/** Whether a live timeline tween or hold writes any of `channels` on `el`. Sync, no fetch. */
427-
export function gsapWritesChannels(el: Element, channels: string[]): boolean {
423+
function gsapWritesChannels(el: Element, channels: string[]): boolean {
428424
const win = el.ownerDocument.defaultView as { __timelines?: Record<string, RuntimeTimeline> };
429425
return Object.values(win?.__timelines ?? {}).some((tl) =>
430426
(tl?.getChildren?.(true) ?? []).some(
431427
(tween) =>
432428
!!tween.vars &&
433429
matchesElement(tween, el) &&
434-
(channels.some((ch) => ch in tween.vars!) || keyframeVarsCarryChannel(tween.vars, channels)),
430+
(channels.some((ch) => ch in tween.vars!) ||
431+
keyframeVarsCarryChannel(tween.vars, channels)),
435432
),
436433
);
437434
}

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

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -138,37 +138,56 @@ describe("useDomGeometryCommit, from the package entry", () => {
138138
unmount();
139139
});
140140

141-
it("saves a GSAP-free move as its inline translate, with no GSAP script and no animation read", async () => {
142-
const urls: string[] = [];
143-
const patches: unknown[] = [];
141+
function stubPatchServer(patchStatus = 200) {
142+
const calls = { urls: [] as string[], patches: [] as unknown[] };
144143
vi.stubGlobal(
145144
"fetch",
146145
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
147146
const url = String(input instanceof Request ? input.url : input);
148-
urls.push(url);
149-
if (url.includes("/file-mutations/patch-element/")) patches.push(JSON.parse(String(init?.body)));
150-
const body = url.includes("/files/")
151-
? { content: SOURCE }
152-
: { ok: true, changed: true, matched: true, content: "AFTER", version: "v2" };
153-
return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } });
147+
calls.urls.push(url);
148+
if (url.includes("/patch-element/")) calls.patches.push(JSON.parse(String(init?.body)));
149+
const saved = { ok: true, changed: true, matched: true, content: "AFTER", version: "v2" };
150+
const body = url.includes("/files/") ? { content: SOURCE } : saved;
151+
const status = url.includes("/patch-element/") ? patchStatus : 200;
152+
return new Response(JSON.stringify(body), {
153+
status,
154+
headers: { "content-type": "application/json" },
155+
});
154156
}),
155157
);
158+
return calls;
159+
}
160+
161+
it("saves a GSAP-free move as its inline translate, with no GSAP script and no animation read", async () => {
162+
const calls = stubPatchServer();
156163
const { element, recordEdit, hook, unmount } = renderHost();
157164

158165
const card = makeSelection("card", element);
159166
await expect(hook().commitPathOffset(card, { x: 130.5, y: 90 })).resolves.toEqual({ ok: true });
160167

161168
expect(element.style.getPropertyValue("translate")).toBe("130.5px 90px");
162-
expect(patches).toEqual([
169+
expect(calls.patches).toEqual([
163170
expect.objectContaining({
164171
operations: [{ type: "inline-style", property: "translate", value: "130.5px 90px" }],
165172
}),
166173
]);
167-
expect(urls.filter((url) => url.includes("gsap"))).toEqual([]);
174+
expect(calls.urls.filter((url) => url.includes("gsap"))).toEqual([]);
168175
expect(recordEdit).toHaveBeenCalledTimes(1);
169176
unmount();
170177
});
171178

179+
it("puts a GSAP-free element's translate back when its move cannot be saved", async () => {
180+
stubPatchServer(500);
181+
const { element, hook, unmount } = renderHost();
182+
element.style.setProperty("translate", "40px 30px");
183+
184+
await expect(
185+
hook().commitPathOffset(makeSelection("card", element), { x: 1, y: 2 }),
186+
).rejects.toThrow();
187+
expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
188+
unmount();
189+
});
190+
172191
it("keeps the same commits across renders, so the overlay's handlers stay put", () => {
173192
stubServer();
174193
const { hook, rerender, unmount } = renderHost();

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,10 @@ describe("useDomGeometryCommits read-only preview", () => {
147147
});
148148

149149
describe("useDomGeometryCommits element position offset", () => {
150-
it("persists left/top on the element and no translate offset", async () => {
151-
const element = document.createElement("span");
150+
it("persists left/top on a word a shared tween positions, and no translate offset", async () => {
151+
const element = Object.assign(document.createElement("span"), {
152+
_gsap: { renderTransform: () => {} },
153+
});
152154
Object.defineProperties(element, {
153155
offsetLeft: { get: () => 100 + (Number.parseFloat(element.style.left) || 0) },
154156
offsetTop: { get: () => 200 + (Number.parseFloat(element.style.top) || 0) },

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

Lines changed: 15 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -117,14 +117,8 @@ function mountGroupHandler({
117117
makeFetchFallback,
118118
trackGsapInteractionFailure = vi.fn(),
119119
stageElementPositionOffset = vi.fn(),
120-
commitPositionPatchToHtml = vi.fn(),
121120
}: Pick<AwareEditingParams, "gsapCommitMutation" | "makeFetchFallback"> &
122-
Partial<
123-
Pick<
124-
AwareEditingParams,
125-
"trackGsapInteractionFailure" | "stageElementPositionOffset" | "commitPositionPatchToHtml"
126-
>
127-
>) {
121+
Partial<Pick<AwareEditingParams, "trackGsapInteractionFailure" | "stageElementPositionOffset">>) {
128122
let groupCommit!: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void>;
129123
let pathOffsetCommit!: (
130124
selection: DomEditSelection,
@@ -142,7 +136,7 @@ function mountGroupHandler({
142136
trackGsapInteractionFailure,
143137
stageElementPositionOffset,
144138
handleDomBoxSizeCommit: vi.fn(),
145-
commitPositionPatchToHtml,
139+
commitPositionPatchToHtml: vi.fn(),
146140
addGsapAnimation: vi.fn(),
147141
convertToKeyframes: vi.fn(),
148142
setArcPath: vi.fn(),
@@ -162,72 +156,25 @@ function mountGroupHandler({
162156
}
163157

164158
describe("useGsapAwareEditing moves of an element GSAP does not position", () => {
165-
const translatePatch = (value: string) => [{ type: "inline-style", property: "translate", value }];
166-
167-
it("writes its inline translate as drawn, with no GSAP write and no animation read", async () => {
168-
const commitPositionPatchToHtml = vi.fn().mockResolvedValue(undefined);
159+
it("saves the move on the element itself, with no GSAP write and no animation read", async () => {
160+
const save = vi.fn().mockResolvedValue(undefined);
161+
const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
169162
const gsapCommitMutation = vi.fn();
170163
const makeFetchFallback = vi.fn();
171164
const { pathOffsetCommit, root } = mountGroupHandler({
172165
gsapCommitMutation,
173166
makeFetchFallback,
174-
commitPositionPatchToHtml,
167+
stageElementPositionOffset,
175168
});
176169
const box = { element: document.createElement("div"), id: "box", selector: "#box" };
177170
await act(() => pathOffsetCommit(box as unknown as DomEditSelection, { x: 130.25, y: 90 }));
178-
expect(box.element.style.getPropertyValue("translate")).toBe("130.25px 90px");
179-
expect(commitPositionPatchToHtml).toHaveBeenCalledWith(
180-
box,
181-
translatePatch("130.25px 90px"),
182-
expect.objectContaining({ label: "Move layer" }),
183-
);
171+
expect(stageElementPositionOffset).toHaveBeenCalledWith(box, { x: 130.25, y: 90 });
172+
expect(save).toHaveBeenCalledTimes(1);
184173
expect(gsapCommitMutation).not.toHaveBeenCalled();
185174
expect(makeFetchFallback).not.toHaveBeenCalled();
186175
expect(mocks.drag).not.toHaveBeenCalled();
187176
act(() => root.unmount());
188177
});
189-
190-
it("puts the live translate back when the save fails", async () => {
191-
const commitPositionPatchToHtml = vi.fn().mockRejectedValue(new Error("offline"));
192-
const gsapCommitMutation = vi.fn();
193-
const { pathOffsetCommit, root } = mountGroupHandler({
194-
gsapCommitMutation,
195-
makeFetchFallback: vi.fn(),
196-
commitPositionPatchToHtml,
197-
});
198-
const box = { element: document.createElement("div"), id: "box", selector: "#box" };
199-
box.element.style.setProperty("translate", "40px 30px");
200-
await expect(pathOffsetCommit(box as unknown as DomEditSelection, { x: 1, y: 2 })).rejects.toThrow(
201-
"offline",
202-
);
203-
expect(box.element.style.getPropertyValue("translate")).toBe("40px 30px");
204-
act(() => root.unmount());
205-
});
206-
207-
it("writes a GSAP-free group member by translate under the group key, the rest through GSAP", async () => {
208-
mocks.drag.mockResolvedValue({ status: "persisted" });
209-
const commitPositionPatchToHtml = vi.fn().mockResolvedValue(undefined);
210-
const { groupCommit, root } = mountGroupHandler({
211-
gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
212-
makeFetchFallback: () => vi.fn().mockResolvedValue([]),
213-
commitPositionPatchToHtml,
214-
});
215-
const plain = { element: document.createElement("div"), id: "plain", selector: "#plain" };
216-
const animated = { element: gsapPositioned("div"), id: "anim", selector: "#anim" };
217-
await act(() =>
218-
groupCommit([
219-
{ selection: plain, next: { x: 10, y: 20 } },
220-
{ selection: animated, next: { x: 10, y: 20 } },
221-
] as unknown as DomEditGroupPathOffsetCommit[]),
222-
);
223-
expect(commitPositionPatchToHtml).toHaveBeenCalledWith(
224-
plain,
225-
translatePatch("10px 20px"),
226-
expect.objectContaining({ coalesceKey: expect.stringMatching(/^group-drag:\d+$/) }),
227-
);
228-
expect(mocks.drag.mock.calls.map((call) => call[0])).toEqual([animated, animated]);
229-
act(() => root.unmount());
230-
});
231178
});
232179

233180
describe("useGsapAwareEditing shared-tween moves", () => {
@@ -249,7 +196,7 @@ describe("useGsapAwareEditing shared-tween moves", () => {
249196
act(() => root.unmount());
250197
});
251198

252-
it("saves a group member that shares a tween through its own offset, under the group key", async () => {
199+
it("saves shared-tween and GSAP-free group members on themselves, under the group key", async () => {
253200
mocks.drag.mockImplementation(async (selection, _next, _a, _i, commit, _f, options) => {
254201
if (selection.hfId === "w0") return { status: "element-offset" };
255202
if (!options?.preflightOnly) await commit(selection, { type: "move" }, { label: "Move" });
@@ -264,17 +211,18 @@ describe("useGsapAwareEditing shared-tween moves", () => {
264211
});
265212
const word = { element: gsapPositioned("span"), hfId: "w0", selector: ".w" };
266213
const box = { element: gsapPositioned("div"), id: "box", selector: "#box" };
214+
const plain = { element: document.createElement("div"), id: "plain", selector: "#plain" };
267215
await act(() =>
268216
groupCommit([
269217
{ selection: word, next: { x: 40, y: 20 } },
270218
{ selection: box, next: { x: 40, y: 20 } },
219+
{ selection: plain, next: { x: 40, y: 20 } },
271220
] as unknown as DomEditGroupPathOffsetCommit[]),
272221
);
273-
expect(stageElementPositionOffset).toHaveBeenCalledWith(
274-
word,
275-
{ x: 40, y: 20 },
276-
expect.stringMatching(/^group-drag:\d+$/),
277-
);
222+
const groupKey = expect.stringMatching(/^group-drag:\d+$/);
223+
expect(stageElementPositionOffset).toHaveBeenCalledWith(word, { x: 40, y: 20 }, groupKey);
224+
expect(stageElementPositionOffset).toHaveBeenCalledWith(plain, { x: 40, y: 20 }, groupKey);
225+
expect(mocks.drag.mock.calls.some((call) => call[0] === plain)).toBe(false);
278226
act(() => root.unmount());
279227
});
280228
});

0 commit comments

Comments
 (0)