@hyperframes/studio 0.8.133 → 0.8.135

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/assets/{hyperframes-player-Cjcwveg0.js → hyperframes-player-DFv9heE6.js} +1 -1
  2. package/dist/assets/{index-CwgWmfgx.js → index-Buy06mZy.js} +1 -1
  3. package/dist/assets/{index-ME-Z5jh6.js → index-CTp4loy9.js} +1 -1
  4. package/dist/assets/{index-Dj21zXWA.js → index-N_4Ij4X6.js} +184 -183
  5. package/dist/assets/{index-D11atkBt.css → index-syMK0_9C.css} +1 -1
  6. package/dist/{chunk-5VSXRGIX.js → chunk-NM7BXNZ7.js} +1 -1
  7. package/dist/chunk-NM7BXNZ7.js.map +1 -0
  8. package/dist/{chunk-FOEID72M.js → chunk-QRLKWENQ.js} +2 -2
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +911 -691
  12. package/dist/index.js.map +1 -1
  13. package/dist/player.js +2 -2
  14. package/dist/styles.css +1 -1
  15. package/dist/ui.d.ts +3 -2
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/components/editor/AnimationCard.test.tsx +60 -0
  19. package/src/components/editor/AnimationCard.tsx +16 -6
  20. package/src/components/editor/KeyframeEaseList.tsx +4 -1
  21. package/src/components/editor/LayersPanel.tsx +39 -47
  22. package/src/components/editor/domEditInlineText.ts +1 -1
  23. package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
  24. package/src/components/editor/gsapAnimationHelpers.ts +34 -0
  25. package/src/components/editor/layerKind.test.ts +38 -0
  26. package/src/components/editor/layerKind.ts +19 -0
  27. package/src/components/editor/manualEditsDom.ts +5 -1
  28. package/src/components/editor/plainTranslate.test.ts +21 -2
  29. package/src/components/editor/plainTranslate.ts +7 -0
  30. package/src/components/editor/useDomEditNudge.test.tsx +48 -27
  31. package/src/components/ui/cn.ts +2 -1
  32. package/src/contexts/DomEditContext.tsx +3 -1
  33. package/src/hooks/draggedGsapPosition.test.ts +34 -0
  34. package/src/hooks/draggedGsapPosition.ts +40 -15
  35. package/src/hooks/gsapDragCommit.ts +17 -10
  36. package/src/hooks/gsapDragPositionCommit.ts +8 -9
  37. package/src/hooks/gsapPlaybackTestHarness.ts +28 -0
  38. package/src/hooks/gsapResizeIntercept.test.ts +72 -1
  39. package/src/hooks/gsapResizeIntercept.ts +84 -17
  40. package/src/hooks/gsapRuntimeBridge.ts +4 -1
  41. package/src/hooks/gsapRuntimePatch.ts +4 -0
  42. package/src/hooks/gsapShared.test.ts +11 -0
  43. package/src/hooks/gsapShared.ts +1 -1
  44. package/src/hooks/gsapTrailingKeyframes.realGsap.test.ts +44 -0
  45. package/src/hooks/keyframeEase.realGsap.test.ts +37 -0
  46. package/src/hooks/useAnimatedPropertyCommit.test.tsx +33 -0
  47. package/src/hooks/useAnimatedPropertyCommit.ts +3 -1
  48. package/src/hooks/useDomGeometryCommits.test.tsx +21 -0
  49. package/src/hooks/useDomGeometryCommits.ts +6 -1
  50. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  51. package/src/hooks/useEditHistoryActions.ts +3 -0
  52. package/src/hooks/useEnableKeyframes.ts +32 -37
  53. package/src/hooks/useGestureCommit.test.tsx +89 -0
  54. package/src/hooks/useGestureCommit.ts +26 -9
  55. package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
  56. package/src/hooks/useGsapAwareEditing.ts +10 -2
  57. package/src/icons/SystemIcons.tsx +6 -0
  58. package/src/layersPanelExport.test.tsx +45 -4
  59. package/src/utils/elementGsap.ts +6 -5
  60. package/src/utils/gsapKeyframeEases.ts +2 -0
  61. package/src/utils/gsapLiveRetime.cache.test.ts +39 -0
  62. package/src/utils/gsapLiveRetime.realGsap.test.ts +39 -15
  63. package/src/utils/gsapLiveRetime.ts +39 -31
  64. package/src/utils/gsapSoftReload.test.ts +98 -14
  65. package/src/utils/gsapSoftReload.ts +14 -2
  66. package/src/utils/gsapUndoRestore.test.ts +43 -2
  67. package/src/utils/gsapUndoRestore.ts +4 -2
  68. package/src/utils/softReloadTargets.ts +56 -0
  69. package/src/utils/studioPendingEdits.test.ts +123 -0
  70. package/src/utils/studioPendingEdits.ts +39 -3
  71. package/dist/chunk-5VSXRGIX.js.map +0 -1
  72. /package/dist/{chunk-FOEID72M.js.map → chunk-QRLKWENQ.js.map} +0 -0
@@ -10,6 +10,7 @@ import { mountReactHarness } from "./domSelectionTestHarness";
10
10
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
11
11
  import { useGsapAwareEditing } from "./useGsapAwareEditing";
12
12
  import { useGestureCommit } from "./useGestureCommit";
13
+ import { xAtTime } from "./gsapPlaybackTestHarness";
13
14
 
14
15
  vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
15
16
 
@@ -243,6 +244,94 @@ it.each([
243
244
  },
244
245
  );
245
246
 
247
+ const FROM_0_TO_100 = [
248
+ { percentage: 0, properties: { x: 0 } },
249
+ { percentage: 100, properties: { x: 100 } },
250
+ ];
251
+
252
+ function positionTween(
253
+ duration: number,
254
+ eases: { ease?: string; easeEach?: string } = {},
255
+ keyframes = FROM_0_TO_100,
256
+ ) {
257
+ return {
258
+ id: "card-position",
259
+ targetSelector: "#card",
260
+ propertyGroup: "position",
261
+ method: "to",
262
+ properties: { x: 0, y: 0 },
263
+ resolvedStart: 0,
264
+ position: 0,
265
+ duration,
266
+ ...(eases.ease ? { ease: eases.ease } : {}),
267
+ keyframes: {
268
+ ...(eases.easeEach ? { easeEach: eases.easeEach } : {}),
269
+ keyframes,
270
+ },
271
+ } as unknown as GsapAnimation;
272
+ }
273
+
274
+ /** Records the mocked 1 s gesture (x 0, 50, 100) from 0 s over `tween`; resolves to the merge mutation. */
275
+ async function recordMergeInto(tween: GsapAnimation) {
276
+ const writer = vi.fn(
277
+ async (_mutation: Record<string, unknown>, options: CommitMutationOptions) => {
278
+ options.onResult?.({ ok: true, changed: true });
279
+ },
280
+ );
281
+ const hook = mountRecording(writer, [tween]);
282
+ act(() => hook().handleToggleRecording("button"));
283
+ await act(async () => {
284
+ hook().handleToggleRecording();
285
+ await vi.waitFor(() => expect(writer).toHaveBeenCalled());
286
+ });
287
+ expect(writer.mock.calls[0]![1]).toMatchObject({ label: "Gesture recording (merge)" });
288
+ return writer.mock.calls[0]![0] as unknown as Parameters<typeof xAtTime>[0] & {
289
+ type: string;
290
+ ease?: string;
291
+ easeEach?: string;
292
+ };
293
+ }
294
+
295
+ it("keeps the tween's eases when a recording merges into it", async () => {
296
+ const merge = await recordMergeInto(
297
+ positionTween(2, { ease: "back.out", easeEach: "power2.out" }),
298
+ );
299
+ expect(merge).toMatchObject({
300
+ type: "replace-with-keyframes",
301
+ ease: "back.out",
302
+ easeEach: "power2.out",
303
+ });
304
+ // Recorded segments the fitter left unset stay constant speed instead of taking easeEach.
305
+ expect(merge.keyframes.map((kf) => kf.ease)).toEqual([undefined, "none", "none", undefined]);
306
+ });
307
+
308
+ it("plays a recording merged into an eased tween at the times it was recorded", async () => {
309
+ const merge = await recordMergeInto(positionTween(3, { ease: "power2.out" }));
310
+ expect(xAtTime(merge, 0.5)).toBeCloseTo(50, 0);
311
+ expect(xAtTime(merge, 1)).toBeCloseTo(100, 0);
312
+ });
313
+
314
+ it("keeps recorded keyframes apart over the tail of a power4.out tween", async () => {
315
+ usePlayerStore.setState({ currentTime: 9 });
316
+ const merge = await recordMergeInto(positionTween(10, { ease: "power4.out" }));
317
+ expect(xAtTime(merge, 9.5)).toBeCloseTo(50, 0);
318
+ });
319
+
320
+ it("keeps a keyframe playing at 8.5 s when a recording covers 9-10 s of a power2.out tween", async () => {
321
+ usePlayerStore.setState({ currentTime: 9 });
322
+ const at8_5s = { percentage: 99.6625, properties: { x: 80 } };
323
+ const merge = await recordMergeInto(
324
+ positionTween(10, { ease: "power2.out" }, [FROM_0_TO_100[0]!, at8_5s, FROM_0_TO_100[1]!]),
325
+ );
326
+ expect(merge.keyframes).toContainEqual(at8_5s);
327
+ expect(xAtTime(merge, 8.5)).toBeCloseTo(80, 0);
328
+ });
329
+
330
+ it("writes merged keyframe percentages rounded, not as float noise", async () => {
331
+ const merge = await recordMergeInto(positionTween(3));
332
+ expect(merge.keyframes.map((kf) => kf.percentage)).toEqual([0, 16.667, 33.333, 100]);
333
+ });
334
+
246
335
  it("keeps a pending recording isolated until its writer settles", async () => {
247
336
  let release!: () => void;
248
337
  const pending = new Promise<void>((resolve) => {
@@ -16,8 +16,15 @@ import { trackPreviewEditResult } from "../utils/previewFeatureUsage";
16
16
  import { observeGsapGesture } from "./gsapGestureOutcome";
17
17
  import { roundTo3 } from "../utils/rounding";
18
18
  import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
19
- import { isInstantHold, idSelector, writeTargetSelector, tweenTargetsElement } from "./gsapShared";
19
+ import {
20
+ isInstantHold,
21
+ idSelector,
22
+ keyframeEases,
23
+ writeTargetSelector,
24
+ tweenTargetsElement,
25
+ } from "./gsapShared";
20
26
  import { useStableHandlers } from "./useStableHandlers";
27
+ import { progressAtTime, runEaseOf, timeAtProgress, warpsTime } from "../utils/gsapKeyframeEases";
21
28
 
22
29
  type RecordedKeyframe = {
23
30
  percentage: number;
@@ -269,25 +276,34 @@ export function useGestureCommit({
269
276
 
270
277
  if (overlaps) {
271
278
  const existingKfs = existingPositionTween.keyframes?.keyframes ?? [];
272
- const rangeStartPct =
279
+ const startTimePct =
273
280
  tweenDur > 0 ? Math.max(0, ((recStart - tweenStart) / tweenDur) * 100) : 0;
274
- const rangeEndPct =
281
+ const endTimePct =
275
282
  tweenDur > 0 ? Math.min(100, ((recEnd - tweenStart) / tweenDur) * 100) : 100;
283
+ const runEase = runEaseOf(existingPositionTween);
284
+ const roundPct = warpsTime(runEase) ? (pct: number) => pct : roundTo3;
276
285
 
277
286
  const preserved = existingKfs
278
- .filter(
279
- (kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5,
280
- )
287
+ .filter((kf) => {
288
+ const playsAt = timeAtProgress(runEase, kf.percentage);
289
+ return playsAt < startTimePct - 0.5 || playsAt > endTimePct + 0.5;
290
+ })
281
291
  .map((kf) => ({
282
292
  percentage: kf.percentage,
283
293
  properties: kf.properties,
284
294
  ...(kf.ease ? { ease: kf.ease } : {}),
285
295
  }));
286
296
 
287
- const mapped = keyframes.map((kf) => ({
288
- percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct),
297
+ const constantSpeed = { ease: "none" };
298
+ const mapped = keyframes.map((kf, index) => ({
299
+ percentage: roundPct(
300
+ progressAtTime(
301
+ runEase,
302
+ startTimePct + (kf.percentage / 100) * (endTimePct - startTimePct),
303
+ ),
304
+ ),
289
305
  properties: kf.properties,
290
- ...(kf.ease ? { ease: kf.ease } : {}),
306
+ ...(kf.ease ? { ease: kf.ease } : index > 0 ? constantSpeed : {}),
291
307
  }));
292
308
 
293
309
  const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
@@ -303,6 +319,7 @@ export function useGestureCommit({
303
319
  : tweenStart,
304
320
  duration: tweenDur,
305
321
  keyframes: merged,
322
+ ...keyframeEases(existingPositionTween),
306
323
  },
307
324
  { label: "Gesture recording (merge)", softReload: true, keyframeAction: "add" },
308
325
  );
@@ -424,6 +424,7 @@ describe("useGsapAwareEditing anchored resize", () => {
424
424
  expect.any(Function),
425
425
  undefined,
426
426
  expect.any(Function),
427
+ expect.objectContaining({ frozen: true }),
427
428
  );
428
429
  act(() => h.root.unmount());
429
430
  });
@@ -16,7 +16,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
16
16
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
17
17
  import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
18
18
  import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
19
- import { computeDraggedGsapPosition } from "./draggedGsapPosition";
19
+ import { computeDraggedGsapPosition, freezeDragStamp } from "./draggedGsapPosition";
20
20
  import { readGsapPositionFromIframe } from "./gsapPositionDetection";
21
21
  import { selectorFromSelection } from "./gsapShared";
22
22
  import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
@@ -209,6 +209,7 @@ export function useGsapAwareEditing({
209
209
  restore: () => void = () => undefined,
210
210
  route?: { plainTranslate: boolean },
211
211
  ) => {
212
+ const stamp = freezeDragStamp(selection.element);
212
213
  const writes = observeGsapGesture(gsapCommitMutation);
213
214
  if (route?.plainTranslate ?? editsPlainCss(selection.element, "resize")) {
214
215
  const result = await handleDomBoxSizeCommit(selection, next, offset, restore);
@@ -256,6 +257,7 @@ export function useGsapAwareEditing({
256
257
  previewIframeRef.current,
257
258
  commitMutation,
258
259
  makeFetchFallback(selection),
260
+ { stamp },
259
261
  );
260
262
  // Saved after the size, under its undo key, so the two are one step.
261
263
  await saveMove(dragOutcome, async () => {
@@ -279,7 +281,12 @@ export function useGsapAwareEditing({
279
281
  x: 0,
280
282
  y: 0,
281
283
  };
282
- const { newX, newY } = computeDraggedGsapPosition(selection.element, offset, gsapPos);
284
+ const { newX, newY } = computeDraggedGsapPosition(
285
+ selection.element,
286
+ offset,
287
+ gsapPos,
288
+ stamp,
289
+ );
283
290
  logResize("sync-settle", { gsapPos, offset, newX, newY });
284
291
  setElementGsapPosition(selection.element, newX, newY);
285
292
  });
@@ -297,6 +304,7 @@ export function useGsapAwareEditing({
297
304
  makeFetchFallback(selection),
298
305
  offset,
299
306
  writes.drawKeepingUndone,
307
+ stamp,
300
308
  );
301
309
  assertGsapEditPersisted(outcome);
302
310
  // Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
@@ -24,6 +24,9 @@ import {
24
24
  Link as PhLink,
25
25
  Eyedropper as PhEyedropper,
26
26
  Trash as PhTrash,
27
+ Image as PhImage,
28
+ BezierCurve,
29
+ FolderSimple,
27
30
  } from "@phosphor-icons/react";
28
31
  import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
29
32
 
@@ -75,3 +78,6 @@ export const Scissors = makeIcon(PhScissors);
75
78
  export const Link = makeIcon(PhLink);
76
79
  export const Eyedropper = makeIcon(PhEyedropper);
77
80
  export const Trash = makeIcon(PhTrash);
81
+ export const ImageIcon = makeIcon(PhImage);
82
+ export const Vector = makeIcon(BezierCurve);
83
+ export const Folder = makeIcon(FolderSimple);
@@ -29,12 +29,17 @@ afterEach(() => {
29
29
  });
30
30
 
31
31
  /** The host's preview (title in front of bg, rows to match) and its edit session, mounted around the panel. */
32
- async function mountPanel(wrap: (panel: ReactNode) => ReactNode = (panel) => panel) {
32
+ const TWO_BOXES =
33
+ '<div data-composition-id="main"><div id="bg" style="z-index:1"></div><div id="title" style="z-index:2"></div></div>';
34
+
35
+ async function mountPanel(
36
+ wrap: (panel: ReactNode) => ReactNode = (panel) => panel,
37
+ picture = TWO_BOXES,
38
+ ) {
33
39
  const iframe = document.createElement("iframe");
34
40
  document.body.appendChild(iframe);
35
41
  const doc = iframe.contentDocument!;
36
- doc.body.innerHTML =
37
- '<div data-composition-id="main"><div id="bg" style="z-index:1"></div><div id="title" style="z-index:2"></div></div>';
42
+ doc.body.innerHTML = picture;
38
43
  usePlayerStore.setState({ elements: [clip("title", 0), clip("bg", 1)] });
39
44
  const session = {
40
45
  domEditSelection: null,
@@ -84,7 +89,7 @@ async function dragBackRowToFront() {
84
89
  it("lists, selects and reorders the host's preview layers through the host's session", async () => {
85
90
  const { doc, session, root } = await mountPanel();
86
91
 
87
- expect(rowLabels()).toEqual(["DiTitle", "DiBg"]);
92
+ expect(rowLabels()).toEqual(["Title", "Bg"]);
88
93
 
89
94
  const rows = document.querySelectorAll<HTMLElement>("[data-layer-index]");
90
95
  await act(async () => rows[0]!.click());
@@ -113,3 +118,39 @@ it("mirrors the reorder into the timeline rows through the host's move handler",
113
118
  expect(bgTrack()).not.toBe(1);
114
119
  await act(async () => root.unmount());
115
120
  });
121
+
122
+ it("puts a caret only on a group's row and starts each child's icon where its parent's label starts", async () => {
123
+ const { root } = await mountPanel(
124
+ undefined,
125
+ '<div data-composition-id="main"><h1 id="title" style="z-index:2">Hi</h1>' +
126
+ '<div id="intro" data-hf-group="Intro" style="z-index:1"><img id="logo">' +
127
+ '<div id="inner" data-hf-group="Inner"><img id="mark"></div></div>' +
128
+ '<div id="wrap" style="z-index:0"><div id="box"></div></div></div>',
129
+ );
130
+ const rows = [...document.querySelectorAll<HTMLElement>("[data-layer-index]")];
131
+ const byLabel = (label: string) => rows.find((row) => row.textContent?.startsWith(label))!;
132
+ const kind = (label: string) =>
133
+ byLabel(label).querySelector("[data-layer-kind]")?.getAttribute("data-layer-kind");
134
+ expect(["Title", "Intro", "Logo", "Inner", "Mark"].map(kind)).toEqual([
135
+ "text",
136
+ "group",
137
+ "image",
138
+ "group",
139
+ "image",
140
+ ]);
141
+ expect([kind("Wrap"), kind("Box")]).toEqual(["group", "shape"]);
142
+ expect(byLabel("Wrap").firstElementChild?.getAttribute("aria-label")).toBe("Collapse children");
143
+ const icons = ["Title", "Intro", "Logo", "Box"].map(
144
+ (l) => byLabel(l).querySelector("[data-layer-kind]")!.innerHTML,
145
+ );
146
+ expect(new Set(icons).size).toBe(4);
147
+ for (const row of rows.filter((r) => r.title)) {
148
+ expect(row.querySelector<HTMLElement>("[data-layer-kind]")!.title).toContain(row.title);
149
+ }
150
+ expect(byLabel("Title").firstElementChild?.hasAttribute("data-layer-kind")).toBe(true);
151
+ expect(byLabel("Intro").firstElementChild?.getAttribute("aria-label")).toBe("Collapse children");
152
+ expect(
153
+ ["Title", "Intro", "Logo", "Inner", "Mark"].map((l) => byLabel(l).style.paddingLeft),
154
+ ).toEqual(["8px", "8px", "56px", "56px", "104px"]);
155
+ await act(async () => root.unmount());
156
+ });
@@ -8,7 +8,6 @@
8
8
  type ElementGsapWindow = Window & {
9
9
  gsap?: {
10
10
  set?: (target: Element, vars: Record<string, number>) => void;
11
- getProperty?: (target: Element, prop: string) => unknown;
12
11
  };
13
12
  };
14
13
 
@@ -38,10 +37,12 @@ export function setElementGsapScale(element: HTMLElement, x: number, y: number):
38
37
  return true;
39
38
  }
40
39
 
41
- /** The element's GSAP numeric property, or null when unreadable. */
42
- export function readElementGsapNumber(element: HTMLElement, prop: string): number | null {
43
- const value = Number(gsapOf(element)?.getProperty?.(element, prop));
44
- return Number.isFinite(value) ? value : null;
40
+ /** Set the element's GSAP width/height. Returns false when no runtime is reachable. */
41
+ export function setElementGsapSize(element: HTMLElement, width: number, height: number): boolean {
42
+ const gsap = gsapOf(element);
43
+ if (!gsap?.set) return false;
44
+ gsap.set(element, { width, height });
45
+ return true;
45
46
  }
46
47
 
47
48
  /** The targets CSSPlugin styles: not plain objects (the runtime's filler) or XML-namespace elements. */
@@ -50,6 +50,8 @@ function timingEase(runEase: string | undefined): ((progress: number) => number)
50
50
  return timingEases.get(runEase) ?? null;
51
51
  }
52
52
 
53
+ export const warpsTime = (runEase: string | undefined) => timingEase(runEase) !== null;
54
+
53
55
  /** The keyframe progress (0-100) GSAP shows at `timePercentage` of a tween with this run ease. */
54
56
  export function progressAtTime(runEase: string | undefined, timePercentage: number): number {
55
57
  const run = timingEase(runEase);
@@ -0,0 +1,39 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const reads = vi.hoisted(() => ({ shape: [] as string[] }));
4
+
5
+ vi.mock("@hyperframes/parsers/gsap-parser-acorn", async (importOriginal) => {
6
+ const real = await importOriginal<typeof import("@hyperframes/parsers/gsap-parser-acorn")>();
7
+ return {
8
+ ...real,
9
+ scriptShape: (code: string, mask?: boolean) => (
10
+ reads.shape.push(code), real.scriptShape(code, mask)
11
+ ),
12
+ };
13
+ });
14
+
15
+ const { planLiveRetime } = await import("./gsapLiveRetime");
16
+
17
+ const at = (start: number) =>
18
+ `var tl = gsap.timeline({ paused: true });\ntl.to("#a", { x: 1, duration: 1 }, ${start});\nwindow.__timelines["t"] = tl;`;
19
+
20
+ describe("planLiveRetime", () => {
21
+ beforeEach(() => {
22
+ reads.shape.length = 0;
23
+ });
24
+
25
+ it("reads each script's shape once across two drags in a row", () => {
26
+ expect(planLiveRetime(at(0), at(1)).kind).toBe("retime");
27
+ expect(planLiveRetime(at(1), at(2)).kind).toBe("retime");
28
+
29
+ expect(reads.shape).toEqual([at(0), at(1), at(2)]);
30
+ });
31
+
32
+ it("keeps the live script's shape while saves land before the preview catches up", () => {
33
+ planLiveRetime(at(10), at(11));
34
+ planLiveRetime(at(10), at(12));
35
+ planLiveRetime(at(10), at(13));
36
+
37
+ expect(reads.shape).toEqual([at(10), at(11), at(12), at(13)]);
38
+ });
39
+ });
@@ -17,9 +17,11 @@ const BEFORE = [
17
17
  const SAMPLES = Array.from({ length: 25 }, (_, i) => i * 0.25);
18
18
 
19
19
  /** Runs a composition script and binds its timeline at `at`, as the preview's runtime does. */
20
- function play(script: string, at = 0, nested = "") {
20
+ function play(script: string, at = 0, nested = "", registered = "") {
21
21
  const win = { __timelines: {} as Record<string, gsap.core.Timeline> };
22
22
  new Function("gsap", "window", script)(gsap, win);
23
+ // Another composition's script, which registers its own timeline beside this one.
24
+ new Function("gsap", "window", registered)(gsap, win);
23
25
  const timeline = win.__timelines.t!;
24
26
  // The runtime nests each sub-composition's timeline into its host's; the script does not know them...
25
27
  const sub = gsap.timeline().to({}, { duration: 6.7 });
@@ -52,12 +54,12 @@ const shownNow = () =>
52
54
  );
53
55
 
54
56
  /** A preview iframe whose live script is `script`, built and bound. */
55
- function preview(script: string, nested = "") {
57
+ function preview(script: string, nested = "", registered = "") {
56
58
  const tag = document.createElement("script");
57
59
  tag.type = "text/plain"; // the runtime already ran it; happy-dom must not run it again
58
60
  tag.textContent = script;
59
61
  document.body.appendChild(tag);
60
- const { win, timeline } = play(script, 0, nested);
62
+ const { win, timeline } = play(script, 0, nested, registered);
61
63
  const rebind = vi.fn();
62
64
  // The runtime hooks a rebind needs; a soft reload would also need `gsap`, which this preview lacks.
63
65
  Object.assign(win, { __hfForceTimelineRebind: rebind, __player: { seek: vi.fn() } });
@@ -245,6 +247,14 @@ it.each([
245
247
  after: script('tl.to("#a", { x: 100, duration: 1 }, 3);'),
246
248
  nested: 'sub.to("#a", { x: 50, duration: 1 }, 2);',
247
249
  },
250
+ {
251
+ name: "another composition's timeline on the moved tween's element",
252
+ path: "rerun",
253
+ before: script('tl.to("#a", { x: 100, duration: 1 }, 0);'),
254
+ after: script('tl.to("#a", { x: 100, duration: 1 }, 3);'),
255
+ registered:
256
+ 'window.__timelines.scene = gsap.timeline({ paused: true }).to("#a", { x: 50, duration: 1 }, 2);',
257
+ },
248
258
  {
249
259
  name: "a delayed tween that GSAP orders after the moved one",
250
260
  path: "rerun",
@@ -300,7 +310,7 @@ it.each([
300
310
  },
301
311
  {
302
312
  name: "a move of a sub-composition's host",
303
- path: "rerun",
313
+ path: "retime",
304
314
  before: script('tl.to("#a", { y: 40, duration: 1 }, 0);'),
305
315
  after: script('tl.to("#a", { y: 40, duration: 1 }, 2);'),
306
316
  setup: () => {
@@ -311,17 +321,10 @@ it.each([
311
321
  },
312
322
  {
313
323
  name: "a tween on an element inside a sub-composition's host",
314
- path: "rerun",
315
- before: script('tl.to("#a", { y: 40, duration: 1 }, 0);'),
316
- after: script('tl.to("#a", { y: 40, duration: 1 }, 2);'),
317
- setup: () => inComposition("t", inComposition("scene", document.getElementById("a")!)),
318
- },
319
- {
320
- name: "a tween on an element of the timeline's own composition",
321
324
  path: "retime",
322
325
  before: script('tl.to("#a", { y: 40, duration: 1 }, 0);'),
323
326
  after: script('tl.to("#a", { y: 40, duration: 1 }, 2);'),
324
- setup: () => inComposition("t", document.getElementById("a")!),
327
+ setup: () => inComposition("t", inComposition("scene", document.getElementById("a")!)),
325
328
  },
326
329
  {
327
330
  name: "a set and a tween on one element that round to the same start",
@@ -358,10 +361,10 @@ it.each([
358
361
  },
359
362
  ])(
360
363
  "after a drop over $name, the live preview equals a fresh load or the script re-runs",
361
- async ({ before, after, nested = "", at = 0, path, setup, unplayed }) => {
364
+ async ({ before, after, nested = "", registered = "", at = 0, path, setup, unplayed }) => {
362
365
  setup?.();
363
366
  const error = vi.spyOn(console, "error");
364
- const live = preview(before, nested);
367
+ const live = preview(before, nested, registered);
365
368
  // Played through once, then parked: every tween has recorded its start values.
366
369
  if (!unplayed) live.timeline.totalTime(live.timeline.duration(), false);
367
370
  live.timeline.totalTime(at, false);
@@ -371,7 +374,7 @@ it.each([
371
374
  const shown = shownNow();
372
375
  const got = observe(live.timeline);
373
376
  live.timeline.revert();
374
- const fresh = play(after, at, nested).timeline;
377
+ const fresh = play(after, at, nested, registered).timeline;
375
378
  expect(shown).toEqual(shownNow());
376
379
  expect(got).toEqual(observe(fresh));
377
380
  }
@@ -433,6 +436,18 @@ it("reloads the preview, and says so, when moving the live tweens throws", async
433
436
  expect(error).toHaveBeenCalledTimes(1);
434
437
  });
435
438
 
439
+ it("re-runs a script that does not parse as a classic script, such as one with an import", () => {
440
+ const before = script('import "./setup.js";', 'tl.to("#a", { x: 1, duration: 1 }, 0);');
441
+ const after = script('import "./setup.js";', 'tl.to("#a", { x: 1, duration: 1 }, 1);');
442
+ expect(planLiveRetime(before, after).kind).toBe("rerun");
443
+ });
444
+
445
+ it("re-runs the script when the edit also flips an operator in a tween's value", () => {
446
+ const before = script('tl.to("#a", { x: window.innerWidth - 40, duration: 1 }, 0);');
447
+ const after = script('tl.to("#a", { x: window.innerWidth + 40, duration: 1 }, 1);');
448
+ expect(planLiveRetime(before, after).kind).toBe("rerun");
449
+ });
450
+
436
451
  it("re-runs the script when the edit also joins two words of code", () => {
437
452
  const before = script('tl.to("#a", { x: 1, duration: 1 }, 0);', "var b = typeof a;");
438
453
  const after = script('tl.to("#a", { x: 1, duration: 1 }, 1);', "var b = typeofa;");
@@ -514,6 +529,15 @@ it("syncs a timeline move by moving the live tweens and rebinding, without re-ru
514
529
  expect(document.querySelectorAll("script")).toHaveLength(1);
515
530
  });
516
531
 
532
+ it("moves the live tweens when the preview runs the saved script re-printed without its comments", async () => {
533
+ const live = preview(BEFORE);
534
+ const saved = `// Seams between sections.\n${moveA().replaceAll('"', "'")}\n/* end */`;
535
+
536
+ const reloadPreview = await dropInto(live.iframe, saved);
537
+
538
+ expect(reloadPreview).not.toHaveBeenCalled();
539
+ });
540
+
517
541
  it("moves the live tweens when the preview runs a reformatted copy of the saved script", async () => {
518
542
  const live = preview(BEFORE);
519
543
  live.tag.textContent = reformatted(BEFORE);
@@ -1,5 +1,5 @@
1
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
- import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
2
+ import { parseGsapScriptAcorn, scriptShape } from "@hyperframes/parsers/gsap-parser-acorn";
3
3
  import { hasExplicitTime } from "@hyperframes/parsers/gsap-writer-acorn";
4
4
  import { RUNTIME_FILLER } from "@hyperframes/core/runtime/protocol";
5
5
  import {
@@ -30,19 +30,14 @@ export type LiveRetimePlan =
30
30
 
31
31
  const RERUN = { kind: "rerun" } as const;
32
32
 
33
- /** The preview runs a reformatted copy of the script: compare code, not its layout. */
34
- const withoutLayout = (code: string) =>
35
- code
36
- .replace(/\s+/g, " ")
37
- .replace(/ ?([^\w$ ]) ?/g, "$1")
38
- .trim();
39
-
40
33
  function untimedShape(animation: GsapAnimation): string {
41
34
  const { id: _id, position: _p, resolvedStart: _s, duration: _d, ...rest } = animation;
42
35
  // An offset into the text, which moves when the preview reformats its copy of the script.
43
36
  const provenance = rest.provenance && { ...rest.provenance, sourceRange: undefined };
44
37
  return JSON.stringify({ ...rest, provenance }, (_key, value) =>
45
- typeof value === "string" && value.startsWith("__raw:") ? withoutLayout(value) : value,
38
+ typeof value === "string" && value.startsWith("__raw:")
39
+ ? (scriptShape(`(${value.slice(6)}\n)`) ?? value)
40
+ : value,
46
41
  );
47
42
  }
48
43
 
@@ -55,13 +50,22 @@ const placedAlone = (animation: GsapAnimation) =>
55
50
  const lengthOf = (animation: GsapAnimation) =>
56
51
  animation.method === "set" ? 0 : animation.durationUnresolved ? undefined : animation.duration;
57
52
 
58
- /** A timing edit rewrites numbers only; anything else may be code the parser does not read. */
59
- const withoutNumbers = (script: string) =>
60
- withoutLayout(script.replace(/(?<![\w$#.])\d*\.?\d+/g, "0"));
53
+ /** The last two script shapes, by text: a repeat drag starts from the script the last one saved. */
54
+ const shapes = new Map<string, string | null>();
55
+
56
+ function shapeOf(code: string): string | null {
57
+ const shape = shapes.has(code) ? (shapes.get(code) as string | null) : scriptShape(code, true);
58
+ shapes.delete(code);
59
+ shapes.set(code, shape);
60
+ if (shapes.size > 2) shapes.delete(shapes.keys().next().value!);
61
+ return shape;
62
+ }
61
63
 
62
64
  /** Decides, from the two scripts alone, whether a saved edit moved tweens and nothing else. */
63
65
  export function planLiveRetime(before: string, after: string): LiveRetimePlan {
64
- if (withoutNumbers(before) !== withoutNumbers(after)) return RERUN;
66
+ // The preview runs the script re-printed by the bundler; a timing edit rewrites numbers only.
67
+ const shape = shapeOf(before);
68
+ if (shape === null || shape !== shapeOf(after)) return RERUN;
65
69
  const keys = timelineKeys(after);
66
70
  if (keys.length !== 1 || timelineKeys(before).join() !== keys.join()) return RERUN;
67
71
  const was = parseGsapScriptAcorn(before).animations.filter(onTimeline);
@@ -180,16 +184,6 @@ function sharesOneStretch(tweens: TweenTiming[]): boolean {
180
184
  return stretch > 0 && fits;
181
185
  }
182
186
 
183
- /** The runtime places a sub-composition's timeline at its host's start once; moving the host leaves it there. */
184
- function inOrHostsSubComposition(target: unknown, ownId: string): boolean {
185
- const element = target as Element;
186
- const owner = element.closest?.("[data-composition-id]");
187
- return (
188
- (owner != null && owner.getAttribute("data-composition-id") !== ownId) ||
189
- element.querySelector?.("[data-composition-id]") != null
190
- );
191
- }
192
-
193
187
  /** Tweens on one element still start in the order they did, an equal start in script order. */
194
188
  const keepsOrder = (tweens: TweenTiming[]) =>
195
189
  tweens.every(
@@ -204,6 +198,7 @@ function movesKeepEachElementsHistory(
204
198
  timeline: LiveTimeline,
205
199
  plan: LiveRetime,
206
200
  children: LiveTween[],
201
+ registry: unknown[],
207
202
  ): boolean {
208
203
  const byElement = new Map<unknown, number[]>();
209
204
  children.forEach((child, i) => {
@@ -216,7 +211,6 @@ function movesKeepEachElementsHistory(
216
211
  const moved = new Set<unknown>();
217
212
  for (const [target, indexes] of byElement) {
218
213
  if (!indexes.some((i) => plan.tweens[i]!.moved)) continue;
219
- if (inOrHostsSubComposition(target, plan.key)) return false;
220
214
  const tweens = indexes.map((i) => plan.tweens[i]!);
221
215
  // GSAP adds delays to the schedule unstretched; a shift keeps every length.
222
216
  const shifted = tweens.every((t) => t.duration === t.wasDuration);
@@ -224,13 +218,26 @@ function movesKeepEachElementsHistory(
224
218
  if (!keepsOrder(tweens) || !sharesOneStretch(tweens) || !(shifted || undelayed)) return false;
225
219
  moved.add(target);
226
220
  }
227
- return !timeline
228
- .getChildren(false, false, true)
229
- .some((sub) =>
230
- sub
231
- .getChildren(true, true, false)
232
- .some((tween) => tween.targets?.().some((t) => moved.has(t))),
221
+ return !animatedElsewhere(timeline, registry, moved);
222
+ }
223
+
224
+ /** Whether a timeline other than `own` (nested in it, or another registered one) animates any of `targets`. */
225
+ function animatedElsewhere(own: LiveTimeline, registry: unknown[], targets: Set<unknown>): boolean {
226
+ const seen = new Set<unknown>([own]);
227
+ const visit = (timeline: LiveTimeline): boolean => {
228
+ if (seen.has(timeline)) return false;
229
+ seen.add(timeline);
230
+ return (
231
+ timeline
232
+ .getChildren(false, true, false)
233
+ .some((tween) => tween.targets?.().some((target) => targets.has(target))) ||
234
+ timeline.getChildren(false, false, true).some(visit)
233
235
  );
236
+ };
237
+ const timelines = registry.filter(
238
+ (entry): entry is LiveTimeline => typeof (entry as LiveTimeline)?.getChildren === "function",
239
+ );
240
+ return own.getChildren(false, false, true).some(visit) || timelines.some(visit);
234
241
  }
235
242
 
236
243
  /**
@@ -245,7 +252,8 @@ export function applyLiveRetime(iframe: HTMLIFrameElement | null, plan: LiveReti
245
252
  const children = pairedLiveTweens(timeline, plan);
246
253
  if (!children) return false;
247
254
  if (plan.tweens.some((t) => t.moved)) {
248
- if (!movesKeepEachElementsHistory(timeline, plan, children)) return false;
255
+ const registry = Object.values(win?.__timelines ?? {});
256
+ if (!movesKeepEachElementsHistory(timeline, plan, children, registry)) return false;
249
257
  // Re-added in script order: GSAP orders equal starts by when they were added, as a fresh run does.
250
258
  const bySource = plan.tweens
251
259
  .map((t, i) => ({ ...t, tween: children[i]! }))