@hyperframes/studio 0.6.121 → 0.7.0

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 (98) hide show
  1. package/dist/assets/index-CbNg8Ghm.js +374 -0
  2. package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
  3. package/dist/assets/{index-CLsc2FW3.js → index-DsxCdV8b.js} +1 -1
  4. package/dist/assets/index-DzWIinxk.css +1 -0
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.html +3 -3
  7. package/dist/index.js +3757 -2232
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/App.tsx +1 -0
  11. package/src/components/StudioHeader.tsx +2 -3
  12. package/src/components/StudioPreviewArea.tsx +12 -0
  13. package/src/components/TimelineToolbar.tsx +29 -16
  14. package/src/components/editor/DomEditOverlay.test.ts +0 -13
  15. package/src/components/editor/DomEditOverlay.tsx +3 -10
  16. package/src/components/editor/KeyframeNavigation.test.ts +36 -0
  17. package/src/components/editor/KeyframeNavigation.tsx +34 -4
  18. package/src/components/editor/MotionPathNode.tsx +98 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +481 -0
  20. package/src/components/editor/SnapToolbar.tsx +28 -3
  21. package/src/components/editor/SourceEditor.tsx +21 -9
  22. package/src/components/editor/domEditOverlayGeometry.ts +12 -0
  23. package/src/components/editor/domEditOverlayGestures.ts +5 -1
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
  25. package/src/components/editor/domEditingDom.ts +24 -9
  26. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  27. package/src/components/editor/manualEditingAvailability.ts +1 -11
  28. package/src/components/editor/manualEdits.ts +1 -0
  29. package/src/components/editor/manualEditsDom.ts +68 -39
  30. package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
  31. package/src/components/editor/manualOffsetDrag.test.ts +12 -0
  32. package/src/components/editor/manualOffsetDrag.ts +79 -4
  33. package/src/components/editor/motionPathCommit.test.ts +130 -0
  34. package/src/components/editor/motionPathCommit.ts +83 -0
  35. package/src/components/editor/motionPathGeometry.test.ts +127 -0
  36. package/src/components/editor/motionPathGeometry.ts +116 -0
  37. package/src/components/editor/motionPathSelection.ts +33 -0
  38. package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
  39. package/src/components/editor/useDomEditOverlayRects.ts +6 -0
  40. package/src/components/editor/useMotionPathData.ts +135 -0
  41. package/src/components/nle/NLELayout.tsx +17 -6
  42. package/src/components/panels/SlideshowPanel.tsx +2 -1
  43. package/src/components/panels/SlideshowSubPanels.tsx +2 -1
  44. package/src/components/renders/RenderQueue.tsx +1 -1
  45. package/src/components/renders/useRenderQueue.ts +3 -2
  46. package/src/hooks/draggedGsapPosition.ts +47 -0
  47. package/src/hooks/gsapDragCommit.test.ts +435 -0
  48. package/src/hooks/gsapDragCommit.ts +278 -290
  49. package/src/hooks/gsapDragPositionCommit.ts +410 -0
  50. package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
  51. package/src/hooks/gsapRuntimeBridge.ts +86 -69
  52. package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
  53. package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
  54. package/src/hooks/gsapRuntimePatch.test.ts +462 -0
  55. package/src/hooks/gsapRuntimePatch.ts +159 -0
  56. package/src/hooks/gsapScriptCommitTypes.ts +11 -1
  57. package/src/hooks/gsapShared.test.ts +58 -0
  58. package/src/hooks/gsapShared.ts +28 -10
  59. package/src/hooks/gsapTargetCache.ts +0 -3
  60. package/src/hooks/useDomEditSession.ts +0 -4
  61. package/src/hooks/useDomSelection.ts +12 -5
  62. package/src/hooks/useEnableKeyframes.test.ts +130 -0
  63. package/src/hooks/useEnableKeyframes.ts +298 -45
  64. package/src/hooks/useGestureCommit.ts +121 -53
  65. package/src/hooks/useGestureRecording.ts +61 -42
  66. package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
  67. package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
  68. package/src/hooks/useGsapAnimationOps.ts +1 -1
  69. package/src/hooks/useGsapAwareEditing.ts +38 -40
  70. package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
  71. package/src/hooks/useGsapScriptCommits.ts +102 -6
  72. package/src/hooks/useGsapTweenCache.ts +7 -1
  73. package/src/hooks/useRazorSplit.ts +21 -2
  74. package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
  75. package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
  76. package/src/hooks/useServerConnection.ts +4 -1
  77. package/src/hooks/useStudioContextValue.ts +3 -2
  78. package/src/hooks/useStudioUrlState.ts +97 -37
  79. package/src/player/components/ShortcutsPanel.tsx +8 -0
  80. package/src/player/components/TimelineClipDiamonds.tsx +10 -12
  81. package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
  82. package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
  83. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  84. package/src/player/lib/mediaProbe.ts +7 -1
  85. package/src/player/store/playerStore.ts +38 -1
  86. package/src/telemetry/config.ts +3 -2
  87. package/src/utils/editDebugLog.ts +16 -0
  88. package/src/utils/generateId.ts +7 -0
  89. package/src/utils/gsapSoftReload.test.ts +217 -13
  90. package/src/utils/gsapSoftReload.ts +228 -50
  91. package/src/utils/studioHelpers.test.ts +31 -1
  92. package/src/utils/studioHelpers.ts +24 -0
  93. package/src/utils/studioPreviewHelpers.test.ts +26 -1
  94. package/src/utils/studioPreviewHelpers.ts +30 -6
  95. package/src/utils/studioTelemetry.ts +3 -1
  96. package/dist/assets/index-20PAVS4k.js +0 -269
  97. package/dist/assets/index-BvEhf7SS.css +0 -1
  98. package/src/player/components/TimelinePropertyRows.tsx +0 -126
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { describe, it, expect, vi } from "vitest";
4
- import { applySoftReload } from "./gsapSoftReload";
4
+ import { applySoftReload, ensureMotionPathPluginLoaded } from "./gsapSoftReload";
5
5
 
6
6
  const SCRIPT_TEXT = `
7
7
  window.__timelines = window.__timelines || {};
@@ -10,6 +10,13 @@ tl.to("#box", { opacity: 0.8 });
10
10
  window.__timelines["root"] = tl;
11
11
  `;
12
12
 
13
+ const MOTION_PATH_SCRIPT_TEXT = `
14
+ window.__timelines = window.__timelines || {};
15
+ const tl = gsap.timeline({ paused: true });
16
+ tl.to("#box", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 50 }] } });
17
+ window.__timelines["root"] = tl;
18
+ `;
19
+
13
20
  function buildMockIframe(overrides: Record<string, unknown> = {}) {
14
21
  const scriptEl = document.createElement("script");
15
22
  scriptEl.textContent =
@@ -58,29 +65,35 @@ function buildMockIframe(overrides: Record<string, unknown> = {}) {
58
65
  }
59
66
 
60
67
  describe("applySoftReload", () => {
61
- it("returns false when iframe is null", () => {
62
- expect(applySoftReload(null, SCRIPT_TEXT)).toBe(false);
68
+ it('returns "cannot-soft-reload" when iframe is null', () => {
69
+ expect(applySoftReload(null, SCRIPT_TEXT)).toBe("cannot-soft-reload");
63
70
  });
64
71
 
65
- it("returns false when scriptText is empty", () => {
72
+ it('returns "cannot-soft-reload" when scriptText is empty', () => {
66
73
  const { iframe } = buildMockIframe();
67
- expect(applySoftReload(iframe, "")).toBe(false);
74
+ expect(applySoftReload(iframe, "")).toBe("cannot-soft-reload");
68
75
  });
69
76
 
70
- it("returns false when gsap is not on iframe window", () => {
77
+ it('returns "cannot-soft-reload" when gsap is not on iframe window', () => {
71
78
  const { iframe } = buildMockIframe({ gsap: undefined });
72
- expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe(false);
79
+ expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
73
80
  });
74
81
 
75
- it("returns false when __hfForceTimelineRebind is missing", () => {
82
+ it('returns "cannot-soft-reload" when __hfForceTimelineRebind is missing', () => {
76
83
  const { iframe } = buildMockIframe({ __hfForceTimelineRebind: undefined });
77
- expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe(false);
84
+ expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
85
+ });
86
+
87
+ it('returns "cannot-soft-reload" when the script registers no scopable key', () => {
88
+ // No __timelines["key"] pattern → targetKeys is empty → can't scope safely.
89
+ const { iframe } = buildMockIframe();
90
+ expect(applySoftReload(iframe, 'gsap.to("#box", { x: 1 });')).toBe("cannot-soft-reload");
78
91
  });
79
92
 
80
93
  it("kills existing timelines, rebinds, and re-seeks on success", () => {
81
94
  const { iframe, contentWindow, mockTimeline } = buildMockIframe();
82
95
  const result = applySoftReload(iframe, SCRIPT_TEXT);
83
- expect(result).toBe(true);
96
+ expect(result).toBe("applied");
84
97
  expect(mockTimeline.kill).toHaveBeenCalled();
85
98
  expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalled();
86
99
  expect(contentWindow.__player.seek).toHaveBeenCalledWith(2.0);
@@ -96,11 +109,119 @@ describe("applySoftReload", () => {
96
109
  },
97
110
  });
98
111
  const result = applySoftReload(iframe, SCRIPT_TEXT);
99
- expect(result).toBe(true);
112
+ expect(result).toBe("applied");
100
113
  expect(suppressionCalled).toBe(true);
101
114
  });
102
115
 
103
- it("returns false when multiple GSAP scripts exist (ambiguous)", () => {
116
+ it('returns "applied" when the re-run re-registers the script\'s expected key', () => {
117
+ // SCRIPT_TEXT registers __timelines["root"]; buildMockIframe's appendChild
118
+ // shim repopulates `root` on execution. The hardened verify checks the
119
+ // expected target key is present (not merely "some key"), so a correct re-run
120
+ // reliably reports "applied" — it doesn't spuriously hit the transient window.
121
+ const { iframe, contentWindow } = buildMockIframe();
122
+ expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("applied");
123
+ expect(contentWindow.__timelines.root).toBeDefined();
124
+ });
125
+
126
+ it('returns "verify-failed" (transient) when the re-run leaves the key empty', () => {
127
+ // No appendChild shim repopulation: the body container has no shim, so the
128
+ // re-run kills __timelines["root"] and the new script doesn't re-register it.
129
+ // That is the TRANSIENT post-run window — surfaced as "verify-failed" so
130
+ // callers know NOT to escalate (the live gsap.set already shows the value).
131
+ const scriptEl = document.createElement("script");
132
+ scriptEl.textContent = 'window.__timelines["root"] = gsap.timeline();';
133
+ const container = document.createElement("div"); // no appendChild shim
134
+ container.appendChild(scriptEl);
135
+ const { iframe } = buildMockIframe();
136
+ (iframe as unknown as { contentDocument: unknown }).contentDocument = {
137
+ querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
138
+ createElement: (tag: string) => document.createElement(tag),
139
+ body: container,
140
+ head: document.createElement("div"),
141
+ };
142
+ expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("verify-failed");
143
+ });
144
+
145
+ it("editing composition A leaves composition B's timeline intact (scoped kill)", () => {
146
+ // Two comps live side by side; the soft reload only re-runs comp "root".
147
+ // Comp "subscene" must survive untouched — the regression the full remount
148
+ // (re-inline) used to cause.
149
+ const subsceneTimeline = { kill: vi.fn(), pause: vi.fn() };
150
+ const { iframe, contentWindow, mockTimeline } = buildMockIframe({
151
+ __timelines: {
152
+ root: { kill: vi.fn(), pause: vi.fn() },
153
+ subscene: subsceneTimeline,
154
+ } as Record<string, { kill: ReturnType<typeof vi.fn>; pause: ReturnType<typeof vi.fn> }>,
155
+ });
156
+ void mockTimeline;
157
+
158
+ expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("applied");
159
+ // Comp B was never killed and is still registered.
160
+ expect(subsceneTimeline.kill).not.toHaveBeenCalled();
161
+ expect(contentWindow.__timelines.subscene).toBe(subsceneTimeline);
162
+ });
163
+
164
+ it("runs synchronously (no async plugin load) when MotionPathPlugin is already present", () => {
165
+ // The preview bootstrap pre-loads MotionPathPlugin, so win.MotionPathPlugin
166
+ // is set before any motion-path edit. The soft reload must then execute the
167
+ // script inline — no CDN <script> appended to <head>, the timeline is
168
+ // repopulated synchronously, and verifyTimelinesPopulated reports the real
169
+ // result (not the optimistic-true async path).
170
+ const headAppends: Node[] = [];
171
+ const head = document.createElement("div");
172
+ const realHeadAppend = head.appendChild.bind(head);
173
+ head.appendChild = <T extends Node>(node: T): T => {
174
+ headAppends.push(node);
175
+ return realHeadAppend(node);
176
+ };
177
+ const { iframe, contentWindow } = buildMockIframe({ MotionPathPlugin: {} });
178
+ (iframe.contentDocument as unknown as { head: unknown }).head = head;
179
+
180
+ const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT);
181
+
182
+ expect(result).toBe("applied");
183
+ // No CDN plugin <script> was appended to <head> — ran inline.
184
+ expect(headAppends.filter((n) => n instanceof HTMLScriptElement)).toHaveLength(0);
185
+ expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalled();
186
+ expect(contentWindow.__player.seek).toHaveBeenCalledWith(2.0);
187
+ expect(contentWindow.__timelines.root).toBeDefined();
188
+ });
189
+
190
+ it("falls back to the async plugin load when MotionPathPlugin is genuinely absent", () => {
191
+ const head = document.createElement("div");
192
+ const appendedScripts: HTMLScriptElement[] = [];
193
+ const realHeadAppend = head.appendChild.bind(head);
194
+ head.appendChild = <T extends Node>(node: T): T => {
195
+ if (node instanceof HTMLScriptElement) appendedScripts.push(node);
196
+ return realHeadAppend(node);
197
+ };
198
+ // gsap present but MotionPathPlugin unset → async load path.
199
+ const { iframe, contentWindow } = buildMockIframe({
200
+ MotionPathPlugin: undefined,
201
+ gsap: { timeline: vi.fn(), registerPlugin: vi.fn() },
202
+ });
203
+ (iframe.contentDocument as unknown as { head: unknown }).head = head;
204
+
205
+ const onAsyncFailure = vi.fn();
206
+ const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, onAsyncFailure);
207
+
208
+ // Optimistically "applied" (script will run once the plugin loads) — and the
209
+ // script has NOT executed yet, so the timeline isn't rebound synchronously.
210
+ expect(result).toBe("applied");
211
+ expect(appendedScripts).toHaveLength(1);
212
+ expect(appendedScripts[0]!.src).toContain("MotionPathPlugin");
213
+ expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
214
+
215
+ // onerror must NOT run the script (that would reference a missing plugin) —
216
+ // it escalates via onAsyncFailure so the caller can full-reload to recover,
217
+ // and clears the in-flight loading flag.
218
+ appendedScripts[0]!.onerror?.(new Event("error"));
219
+ expect(onAsyncFailure).toHaveBeenCalledTimes(1);
220
+ expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
221
+ expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
222
+ });
223
+
224
+ it('returns "cannot-soft-reload" when multiple GSAP scripts exist (ambiguous)', () => {
104
225
  const script1 = document.createElement("script");
105
226
  script1.textContent = "const tl = gsap.timeline({ paused: true });";
106
227
  const script2 = document.createElement("script");
@@ -115,6 +236,89 @@ describe("applySoftReload", () => {
115
236
  createElement: (tag: string) => document.createElement(tag),
116
237
  body: container,
117
238
  };
118
- expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe(false);
239
+ // Multiple scripts, none registering "root" → can't identify what to replace
240
+ // → structural failure that genuinely needs a full reload.
241
+ expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
242
+ });
243
+ });
244
+
245
+ function buildBootstrapIframe(overrides: Record<string, unknown> = {}) {
246
+ const head = document.createElement("div");
247
+ const appendedScripts: HTMLScriptElement[] = [];
248
+ const realHeadAppend = head.appendChild.bind(head);
249
+ head.appendChild = <T extends Node>(node: T): T => {
250
+ if (node instanceof HTMLScriptElement) appendedScripts.push(node);
251
+ return realHeadAppend(node);
252
+ };
253
+
254
+ const registerPlugin = vi.fn();
255
+ const contentWindow = {
256
+ gsap: { registerPlugin } as Record<string, unknown> | undefined,
257
+ MotionPathPlugin: undefined as unknown,
258
+ __hfMotionPathPluginLoading: undefined as boolean | undefined,
259
+ ...overrides,
260
+ };
261
+ const contentDocument = {
262
+ createElement: (tag: string) => document.createElement(tag),
263
+ head,
264
+ };
265
+ return {
266
+ iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement,
267
+ contentWindow,
268
+ appendedScripts,
269
+ registerPlugin,
270
+ };
271
+ }
272
+
273
+ describe("ensureMotionPathPluginLoaded", () => {
274
+ it("no-ops when the iframe is null", () => {
275
+ expect(() => ensureMotionPathPluginLoaded(null)).not.toThrow();
276
+ });
277
+
278
+ it("no-ops when gsap is unavailable", () => {
279
+ const { iframe, appendedScripts } = buildBootstrapIframe({ gsap: undefined });
280
+ ensureMotionPathPluginLoaded(iframe);
281
+ expect(appendedScripts).toHaveLength(0);
282
+ });
283
+
284
+ it("appends the plugin script once and registers it on load", () => {
285
+ const { iframe, contentWindow, appendedScripts, registerPlugin } = buildBootstrapIframe();
286
+ ensureMotionPathPluginLoaded(iframe);
287
+ expect(appendedScripts).toHaveLength(1);
288
+ expect(appendedScripts[0]!.src).toContain("MotionPathPlugin");
289
+ expect(contentWindow.__hfMotionPathPluginLoading).toBe(true);
290
+
291
+ // Simulate the CDN load completing; the plugin is now present.
292
+ contentWindow.MotionPathPlugin = {};
293
+ appendedScripts[0]!.onload?.(new Event("load"));
294
+ expect(registerPlugin).toHaveBeenCalledWith(contentWindow.MotionPathPlugin);
295
+ expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
296
+ });
297
+
298
+ it("is idempotent: a second call while loading does not append a second script", () => {
299
+ const { iframe, appendedScripts } = buildBootstrapIframe();
300
+ ensureMotionPathPluginLoaded(iframe);
301
+ ensureMotionPathPluginLoaded(iframe);
302
+ expect(appendedScripts).toHaveLength(1);
303
+ });
304
+
305
+ it("registers an already-present plugin without appending a script", () => {
306
+ const plugin = {};
307
+ const { iframe, appendedScripts, registerPlugin } = buildBootstrapIframe({
308
+ MotionPathPlugin: plugin,
309
+ });
310
+ ensureMotionPathPluginLoaded(iframe);
311
+ expect(appendedScripts).toHaveLength(0);
312
+ expect(registerPlugin).toHaveBeenCalledWith(plugin);
313
+ });
314
+
315
+ it("clears the loading flag and still resolves when the CDN load errors", () => {
316
+ const { iframe, contentWindow, appendedScripts } = buildBootstrapIframe();
317
+ ensureMotionPathPluginLoaded(iframe);
318
+ appendedScripts[0]!.onerror?.(new Event("error"));
319
+ expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
320
+ // A subsequent call can retry (plugin still absent, flag cleared).
321
+ ensureMotionPathPluginLoaded(iframe);
322
+ expect(appendedScripts).toHaveLength(2);
119
323
  });
120
324
  });
@@ -4,6 +4,10 @@ type IframeWindow = Window & {
4
4
  __hfForceTimelineRebind?: () => void;
5
5
  __hfSuppressSceneMutations?: <T>(fn: () => T) => T;
6
6
  __hfStudioManualEditsApply?: () => void;
7
+ // Set while a MotionPathPlugin <script> is being fetched, so overlapping soft
8
+ // reloads (each needing the plugin) don't queue duplicate plugin scripts that
9
+ // re-flash the iframe. Cleared once the plugin loads or errors.
10
+ __hfMotionPathPluginLoading?: boolean;
7
11
  gsap?: {
8
12
  timeline?: (...args: unknown[]) => unknown;
9
13
  registerPlugin?: (...plugins: unknown[]) => unknown;
@@ -13,6 +17,67 @@ type IframeWindow = Window & {
13
17
  MotionPathPlugin?: unknown;
14
18
  };
15
19
 
20
+ /**
21
+ * CDN URL for the GSAP MotionPathPlugin. Shared between the one-time preview
22
+ * bootstrap (ensureMotionPathPluginLoaded) and the soft-reload fallback so the
23
+ * version is pinned in a single place.
24
+ */
25
+ const MOTION_PATH_PLUGIN_CDN =
26
+ "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
27
+
28
+ /**
29
+ * Pre-load + register MotionPathPlugin ONCE in the preview iframe so
30
+ * `win.MotionPathPlugin` is reliably set before any studio edit. Called from the
31
+ * preview bootstrap (NLELayout's onIframeLoad) on every iframe load.
32
+ *
33
+ * Why: when a user ADDS a motion path to a composition that never used one, the
34
+ * plugin isn't loaded, so the first soft reload takes the async `<script src>`
35
+ * load path — the timeline is killed/cleared while the CDN load is pending,
36
+ * producing a visible flash. Loading it eagerly here means the soft reload runs
37
+ * synchronously and `needsMotionPath && !win.MotionPathPlugin` never fires for
38
+ * studio edits.
39
+ *
40
+ * Idempotent (no-ops once the plugin is present or already loading) and
41
+ * defensive: no-ops without gsap/registerPlugin and tolerates a CDN failure
42
+ * (the soft-reload async fallback in applySoftReload still covers that case).
43
+ */
44
+ export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null): void {
45
+ if (!iframe?.contentWindow || !iframe.contentDocument) return;
46
+ const win = iframe.contentWindow as IframeWindow;
47
+ const doc = iframe.contentDocument;
48
+
49
+ // Already registered (composition shipped its own plugin, or a prior bootstrap
50
+ // ran) — register it on gsap to be safe, then bail.
51
+ if (win.MotionPathPlugin) {
52
+ try {
53
+ if (win.gsap?.registerPlugin) win.gsap.registerPlugin(win.MotionPathPlugin);
54
+ } catch {}
55
+ return;
56
+ }
57
+ if (!win.gsap?.registerPlugin) return;
58
+ // A load is already in flight for this iframe — don't queue a second script.
59
+ if (win.__hfMotionPathPluginLoading) return;
60
+
61
+ try {
62
+ win.__hfMotionPathPluginLoading = true;
63
+ const pluginScript = doc.createElement("script");
64
+ pluginScript.src = MOTION_PATH_PLUGIN_CDN;
65
+ const finalize = () => {
66
+ win.__hfMotionPathPluginLoading = false;
67
+ try {
68
+ if (win.MotionPathPlugin && win.gsap?.registerPlugin) {
69
+ win.gsap.registerPlugin(win.MotionPathPlugin);
70
+ }
71
+ } catch {}
72
+ };
73
+ pluginScript.onload = finalize;
74
+ pluginScript.onerror = finalize;
75
+ doc.head.appendChild(pluginScript);
76
+ } catch {
77
+ win.__hfMotionPathPluginLoading = false;
78
+ }
79
+ }
80
+
16
81
  function isGsapScript(text: string): boolean {
17
82
  return (
18
83
  text.includes("gsap.timeline") ||
@@ -44,14 +109,44 @@ export function extractGsapScriptText(html: string): string | null {
44
109
  return scripts[0].textContent || null;
45
110
  }
46
111
 
47
- /** Check that the new script repopulated __timelines with at least one entry. */
48
- function verifyTimelinesPopulated(win: IframeWindow): boolean {
49
- const tlKeys = win.__timelines
50
- ? Object.keys(win.__timelines).filter((k) => k !== "__proxied")
51
- : [];
52
- return tlKeys.length > 0;
112
+ /**
113
+ * Confirm the re-run repopulated the timeline(s) this script owns. We check the
114
+ * EXPECTED keys (the ones the script re-registers), not merely "any key": a
115
+ * scoped soft reload only re-runs ONE composition, so the right success signal is
116
+ * "my target keys are back", not "the global map is non-empty". Checking the
117
+ * exact keys avoids the transient false where the global map momentarily looks
118
+ * empty right after the re-run — the spurious trigger of the full-remount fallback.
119
+ */
120
+ function verifyTimelinesPopulated(win: IframeWindow, targetKeys: string[]): boolean {
121
+ const timelines = win.__timelines;
122
+ if (!timelines) return false;
123
+ if (targetKeys.length > 0) {
124
+ return targetKeys.every((key) => timelines[key] != null);
125
+ }
126
+ return Object.keys(timelines).filter((k) => k !== "__proxied").length > 0;
53
127
  }
54
128
 
129
+ /**
130
+ * Outcome of a soft-reload attempt. Callers must distinguish PERMANENT failures
131
+ * (the preview genuinely can't be soft-updated — escalate to a full reload) from
132
+ * the TRANSIENT post-run empty-timeline window (the live `gsap.set` already shows
133
+ * the correct value — do NOT escalate; a remount would re-flash the WebGL context
134
+ * and revert subcomposition keyframes):
135
+ *
136
+ * - `"applied"` — the script ran (or is deferred to the async plugin
137
+ * load and WILL run). The preview is/will be correct.
138
+ * - `"verify-failed"` — TRANSIENT: the re-run happened but `__timelines`
139
+ * momentarily read empty. Live state is correct → do
140
+ * NOT escalate. (Was a bare `false` before.)
141
+ * - `"cannot-soft-reload"` — PERMANENT/STRUCTURAL: no gsap runtime, no rebind
142
+ * hook, no scopable target key, or no script element
143
+ * to replace. The preview is stale/broken → escalate.
144
+ *
145
+ * The async MotionPath-plugin load failure is still surfaced via
146
+ * `onAsyncFailure` (it fires after this returned `"applied"` optimistically).
147
+ */
148
+ export type SoftReloadResult = "applied" | "verify-failed" | "cannot-soft-reload";
149
+
55
150
  /**
56
151
  * Replace the GSAP script in the live iframe without reloading. This preserves
57
152
  * the WebGL context and shader transition cache.
@@ -60,22 +155,60 @@ function verifyTimelinesPopulated(win: IframeWindow): boolean {
60
155
  * elements (sub-compositions) are not visible to `querySelectorAll` and will
61
156
  * fall back to a full iframe reload.
62
157
  *
63
- * Returns false (triggering a full reload fallback) when:
158
+ * Returns `"cannot-soft-reload"` (caller should full-reload) when:
64
159
  * - The iframe or GSAP runtime isn't available
65
- * - Multiple GSAP scripts are found (ambiguous which to replace)
66
- * - No matching GSAP script element exists in the live DOM
160
+ * - The rebind hook isn't installed
161
+ * - The script registers no scopable `__timelines` key
162
+ * - No GSAP script element exists in the live DOM
163
+ * - The synchronous re-run threw
164
+ *
165
+ * Returns `"verify-failed"` when the re-run executed but the target timeline
166
+ * keys read empty in the transient post-run window (live state is still correct).
167
+ *
168
+ * `onAsyncFailure` is invoked when the soft reload was deferred to load the
169
+ * MotionPath plugin (so this returned `"applied"` optimistically) but the plugin
170
+ * `<script>` then failed to load — the iframe is left without the plugin and the
171
+ * caller should perform a full reload to recover. It never fires on the
172
+ * synchronous paths.
67
173
  */
68
- export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: string): boolean {
69
- if (!iframe || !scriptText) return false;
174
+ export function applySoftReload(
175
+ iframe: HTMLIFrameElement | null,
176
+ scriptText: string,
177
+ onAsyncFailure?: () => void,
178
+ ): SoftReloadResult {
179
+ if (!iframe || !scriptText) return "cannot-soft-reload";
70
180
 
71
181
  const win = iframe.contentWindow as IframeWindow | null;
72
182
  const doc = iframe.contentDocument;
73
- if (!win || !doc) return false;
74
- if (!win.gsap || !win.__hfForceTimelineRebind) return false;
183
+ if (!win || !doc) return "cannot-soft-reload";
184
+ if (!win.gsap || !win.__hfForceTimelineRebind) return "cannot-soft-reload";
75
185
 
186
+ // Which composition(s) does this script rebuild? A soft reload re-runs ONE
187
+ // composition's GSAP script, which re-registers its own window.__timelines[key].
188
+ // In a multi-composition preview (top-level + inlined subcompositions) each
189
+ // composition owns a separate timeline keyed by its id, and they're all children
190
+ // of the global timeline — so tearing down ALL of them (or the global timeline's
191
+ // children) and re-running a single script wipes every OTHER composition,
192
+ // reverting its edits. Scope the teardown to the keys THIS script re-registers.
193
+ const targetKeys = [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)]
194
+ .map((m) => m[1]!)
195
+ .filter((key) => key !== "__proxied");
196
+ if (targetKeys.length === 0) return "cannot-soft-reload"; // can't scope safely → full reload
76
197
  const gsapScripts = findGsapScriptElements(doc);
77
- if (gsapScripts.length !== 1) return false;
78
- const oldScriptEl = gsapScripts[0]!;
198
+ if (gsapScripts.length === 0) return "cannot-soft-reload";
199
+ // Remove only the stale script element(s) that registered a target key; one we
200
+ // can't match in the doc is left alone (re-running appends a fresh element).
201
+ const staleScripts = gsapScripts.filter((script) =>
202
+ targetKeys.some((key) => {
203
+ const text = script.textContent || "";
204
+ return text.includes(`__timelines["${key}"]`) || text.includes(`__timelines['${key}']`);
205
+ }),
206
+ );
207
+ // Multiple GSAP scripts exist but none registers a key this script owns — we
208
+ // can't identify which element to replace (ambiguous, matching
209
+ // extractGsapScriptText's single-script requirement). Escalate to a full reload
210
+ // rather than killing the target timeline and appending an orphan script.
211
+ if (gsapScripts.length > 1 && staleScripts.length === 0) return "cannot-soft-reload";
79
212
 
80
213
  const currentTime = win.__player?.getTime?.() ?? 0;
81
214
 
@@ -91,47 +224,55 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
91
224
  const timelines = win.__timelines;
92
225
  const allTargets: Element[] = [];
93
226
 
227
+ // Kill ONLY the target composition's timeline(s) — leaving every other
228
+ // composition's timeline (and its children on the global timeline) intact.
94
229
  if (timelines) {
95
- for (const key of Object.keys(timelines)) {
96
- if (key === "__proxied") continue;
97
- try {
98
- const tl = timelines[key] as {
99
- kill?: () => void;
100
- getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
101
- };
102
- if (tl?.getChildren) {
103
- try {
104
- for (const child of tl.getChildren(true)) {
105
- if (typeof child.targets === "function") {
106
- for (const t of child.targets()) allTargets.push(t);
107
- }
230
+ for (const key of targetKeys) {
231
+ const tl = timelines[key] as
232
+ | {
233
+ kill?: () => void;
234
+ getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
235
+ }
236
+ | undefined;
237
+ if (!tl) continue;
238
+ if (tl.getChildren) {
239
+ try {
240
+ for (const child of tl.getChildren(true)) {
241
+ if (typeof child.targets === "function") {
242
+ for (const t of child.targets()) allTargets.push(t);
108
243
  }
109
- } catch {}
110
- }
111
- tl?.kill?.();
244
+ }
245
+ } catch {}
246
+ }
247
+ try {
248
+ tl.kill?.();
112
249
  } catch {}
113
250
  delete timelines[key];
114
251
  }
115
252
  }
116
253
 
117
- // Kill bare gsap.to/from tweens not registered on __timelines
118
- if (win.gsap?.globalTimeline?.getChildren) {
119
- try {
120
- for (const child of win.gsap.globalTimeline.getChildren(false)) {
121
- child.kill?.();
122
- }
123
- } catch {}
124
- }
125
-
126
- // Clear residual inline transforms left by killed tweens so from() tweens
127
- // don't read stale end values from the DOM on re-execution
254
+ // Reset GSAP's internal transform cache so from() tweens don't read stale
255
+ // end values. `clearProps: "all"` is needed to flush the cache, but it also
256
+ // nukes the element's CSS base (position, width, height, etc.) from the
257
+ // HTML `style=""` attribute. Save → clear → restore → strip `transform`.
128
258
  if (allTargets.length > 0 && win.gsap?.set) {
259
+ const saved: Array<[Element, string]> = [];
260
+ for (const el of allTargets) {
261
+ const s = (el as HTMLElement).style;
262
+ if (s?.cssText != null) saved.push([el, s.cssText]);
263
+ }
129
264
  try {
130
265
  win.gsap.set(allTargets, { clearProps: "all" });
131
266
  } catch {}
267
+ for (const [el, css] of saved) {
268
+ const s = (el as HTMLElement).style;
269
+ if (!s) continue;
270
+ s.cssText = css;
271
+ s.removeProperty("transform");
272
+ }
132
273
  }
133
274
 
134
- oldScriptEl.remove();
275
+ for (const script of staleScripts) script.remove();
135
276
 
136
277
  const executeScript = () => {
137
278
  if (win.MotionPathPlugin && win.gsap?.registerPlugin) {
@@ -148,10 +289,41 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
148
289
  const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
149
290
  if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
150
291
  deferredToAsync = true;
292
+ // A prior soft reload is already fetching the plugin — don't queue a second
293
+ // <script> (it re-flashes the iframe). Defer THIS script's execution until
294
+ // the in-flight load settles via a one-shot poll. The bootstrap guard is
295
+ // the single source of truth for "plugin fetch in progress".
296
+ if (win.__hfMotionPathPluginLoading) {
297
+ const started = Date.now();
298
+ const poll = win.setInterval(() => {
299
+ if (win.MotionPathPlugin) {
300
+ win.clearInterval(poll);
301
+ executeScript();
302
+ } else if (!win.__hfMotionPathPluginLoading || Date.now() - started > 10000) {
303
+ // The in-flight load finished without registering the plugin (errored)
304
+ // or we timed out — recover with a full reload instead of running a
305
+ // script that references a missing plugin.
306
+ win.clearInterval(poll);
307
+ onAsyncFailure?.();
308
+ }
309
+ }, 50);
310
+ return;
311
+ }
312
+ win.__hfMotionPathPluginLoading = true;
151
313
  const pluginScript = doc.createElement("script");
152
- pluginScript.src = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
153
- pluginScript.onload = () => executeScript();
154
- pluginScript.onerror = () => executeScript();
314
+ pluginScript.src = MOTION_PATH_PLUGIN_CDN;
315
+ pluginScript.onload = () => {
316
+ win.__hfMotionPathPluginLoading = false;
317
+ executeScript();
318
+ };
319
+ pluginScript.onerror = () => {
320
+ // The plugin failed to load. Running executeScript() now would leave the
321
+ // iframe with a motionPath tween referencing a missing plugin while the
322
+ // caller already thinks the soft reload succeeded. Signal failure so the
323
+ // caller can full-reload (which fetches the plugin fresh) instead.
324
+ win.__hfMotionPathPluginLoading = false;
325
+ onAsyncFailure?.();
326
+ };
155
327
  doc.head.appendChild(pluginScript);
156
328
  return;
157
329
  }
@@ -166,10 +338,16 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
166
338
  doReload();
167
339
  }
168
340
  // When MotionPath needs async loading, the script hasn't executed yet —
169
- // skip the __timelines check and return true optimistically.
170
- if (deferredToAsync) return true;
171
- return verifyTimelinesPopulated(win);
341
+ // skip the __timelines check and report success optimistically (the script
342
+ // WILL run on plugin load; onAsyncFailure covers the CDN-error case).
343
+ if (deferredToAsync) return "applied";
344
+ // The re-run executed. If the target keys read back, we're done; otherwise
345
+ // it's the TRANSIENT empty-timeline window (live state is correct) — surfaced
346
+ // as "verify-failed" so callers know NOT to escalate.
347
+ return verifyTimelinesPopulated(win, targetKeys) ? "applied" : "verify-failed";
172
348
  } catch {
173
- return false;
349
+ // The synchronous re-run threw — the preview is now genuinely broken (target
350
+ // timeline killed, script not re-registered). Escalate to a full reload.
351
+ return "cannot-soft-reload";
174
352
  }
175
353
  }
@@ -1,5 +1,11 @@
1
+ // @vitest-environment happy-dom
2
+
1
3
  import { describe, expect, it } from "vitest";
2
- import { findMatchingTimelineElementId, resolveTimelineSelectionSeekTime } from "./studioHelpers";
4
+ import {
5
+ findMatchingTimelineElementId,
6
+ findTimelineIdByAncestor,
7
+ resolveTimelineSelectionSeekTime,
8
+ } from "./studioHelpers";
3
9
 
4
10
  describe("resolveTimelineSelectionSeekTime", () => {
5
11
  it("keeps the current time when it is already inside the clip range", () => {
@@ -42,3 +48,27 @@ describe("findMatchingTimelineElementId", () => {
42
48
  expect(findMatchingTimelineElementId({ id: "ghost", sourceFile: "index.html" }, [])).toBe(null);
43
49
  });
44
50
  });
51
+
52
+ describe("findTimelineIdByAncestor", () => {
53
+ const el = (over: Record<string, unknown>) =>
54
+ ({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
55
+
56
+ it("resolves a static descendant (.num) to its nearest clip ancestor", () => {
57
+ // #stat1 (a clip) > .num (selected, not a clip)
58
+ const stat1 = document.createElement("div");
59
+ stat1.id = "stat1";
60
+ const num = document.createElement("div");
61
+ num.className = "num";
62
+ stat1.appendChild(num);
63
+
64
+ const els = [el({ id: "stat1", domId: "stat1", key: "index.html#stat1" })];
65
+ expect(findTimelineIdByAncestor(num, els, "index.html")).toBe("index.html#stat1");
66
+ });
67
+
68
+ it("returns null when no ancestor is a clip", () => {
69
+ const wrap = document.createElement("div");
70
+ const child = document.createElement("span");
71
+ wrap.appendChild(child);
72
+ expect(findTimelineIdByAncestor(child, [], "index.html")).toBe(null);
73
+ });
74
+ });