diff --git a/packages/studio/src/components/editor/manualOffsetDrag.test.ts b/packages/studio/src/components/editor/manualOffsetDrag.test.ts index 5af32996a0..724bea797a 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.test.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.test.ts @@ -2,6 +2,7 @@ import { Window } from "happy-dom"; import { describe, expect, it } from "vitest"; import { applyManualOffsetDragCommit, + resumeGsapTimelines, applyManualOffsetDragDraft, applyManualOffsetDragMatrix, createManualOffsetDragMember, @@ -360,3 +361,51 @@ describe("GSAP-element drag — dot-a flies regressions", () => { expect(element.style.getPropertyValue("transform")).toMatch(/translate\(/); }); }); + +describe("resumeGsapTimelines", () => { + it("unpauses exactly the timelines the drag start paused, then re-seeks the player", () => { + const window = new Window(); + const element = window.document.createElement("div"); + element.setAttribute("data-hf-drag-paused-timelines", "figma-demo-unlock,figma-demo-stagger"); + window.document.body.append(element); + + const pausedState: Record = { + "figma-demo-unlock": true, + "figma-demo-stagger": true, + main: true, + }; + const makeTl = (id: string) => ({ + paused: (value?: boolean) => { + if (value !== undefined) pausedState[id] = value; + return pausedState[id]!; + }, + }); + const seeks: number[] = []; + const win = element.ownerDocument.defaultView as unknown as { + __timelines?: Record; + __player?: { seek: (t: number) => void; getTime: () => number }; + }; + win.__timelines = { + "figma-demo-unlock": makeTl("figma-demo-unlock"), + "figma-demo-stagger": makeTl("figma-demo-stagger"), + main: makeTl("main"), + }; + win.__player = { seek: (t: number) => seeks.push(t), getTime: () => 3.5 }; + + resumeGsapTimelines(element); + + expect(pausedState["figma-demo-unlock"]).toBe(false); + expect(pausedState["figma-demo-stagger"]).toBe(false); + // main was NOT paused by the drag — leave its state alone + expect(pausedState["main"]).toBe(true); + expect(seeks).toEqual([3.5]); + expect(element.hasAttribute("data-hf-drag-paused-timelines")).toBe(false); + }); + + it("is a no-op without the paused-timelines attribute", () => { + const window = new Window(); + const element = window.document.createElement("div"); + window.document.body.append(element); + expect(() => resumeGsapTimelines(element)).not.toThrow(); + }); +}); diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 6151a09790..2af0550a00 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -513,11 +513,23 @@ export function resumeGsapTimelines(element: HTMLElement): void { if (!ids) return; const win = element.ownerDocument.defaultView as | (Window & { - __timelines?: Record void }>; + __timelines?: Record boolean }>; __player?: { seek?: (t: number) => void; getTime?: () => number }; }) | null; if (!win) return; + // Unpause exactly the timelines the drag start paused. The player seek below + // repositions them, but a seek alone leaves play-state-driven sub-composition + // timelines paused forever — the "selecting a piece kills its animation" bug: + // main survives (seek-driven every frame) while every scene timeline freezes, + // and deselecting can't recover because nothing else ever resumes them. + for (const id of ids.split(",")) { + try { + win.__timelines?.[id]?.paused?.(false); + } catch { + /* cross-origin guard */ + } + } const t = win.__player?.getTime?.() ?? 0; win.__player?.seek?.(t); }