@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.
- package/dist/assets/index-CbNg8Ghm.js +374 -0
- package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
- package/dist/assets/{index-CLsc2FW3.js → index-DsxCdV8b.js} +1 -1
- package/dist/assets/index-DzWIinxk.css +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.html +3 -3
- package/dist/index.js +3757 -2232
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/App.tsx +1 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioPreviewArea.tsx +12 -0
- package/src/components/TimelineToolbar.tsx +29 -16
- package/src/components/editor/DomEditOverlay.test.ts +0 -13
- package/src/components/editor/DomEditOverlay.tsx +3 -10
- package/src/components/editor/KeyframeNavigation.test.ts +36 -0
- package/src/components/editor/KeyframeNavigation.tsx +34 -4
- package/src/components/editor/MotionPathNode.tsx +98 -0
- package/src/components/editor/MotionPathOverlay.tsx +481 -0
- package/src/components/editor/SnapToolbar.tsx +28 -3
- package/src/components/editor/SourceEditor.tsx +21 -9
- package/src/components/editor/domEditOverlayGeometry.ts +12 -0
- package/src/components/editor/domEditOverlayGestures.ts +5 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
- package/src/components/editor/domEditingDom.ts +24 -9
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +1 -11
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +68 -39
- package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
- package/src/components/editor/manualOffsetDrag.test.ts +12 -0
- package/src/components/editor/manualOffsetDrag.ts +79 -4
- package/src/components/editor/motionPathCommit.test.ts +130 -0
- package/src/components/editor/motionPathCommit.ts +83 -0
- package/src/components/editor/motionPathGeometry.test.ts +127 -0
- package/src/components/editor/motionPathGeometry.ts +116 -0
- package/src/components/editor/motionPathSelection.ts +33 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
- package/src/components/editor/useDomEditOverlayRects.ts +6 -0
- package/src/components/editor/useMotionPathData.ts +135 -0
- package/src/components/nle/NLELayout.tsx +17 -6
- package/src/components/panels/SlideshowPanel.tsx +2 -1
- package/src/components/panels/SlideshowSubPanels.tsx +2 -1
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/hooks/draggedGsapPosition.ts +47 -0
- package/src/hooks/gsapDragCommit.test.ts +435 -0
- package/src/hooks/gsapDragCommit.ts +278 -290
- package/src/hooks/gsapDragPositionCommit.ts +410 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
- package/src/hooks/gsapRuntimeBridge.ts +86 -69
- package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
- package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
- package/src/hooks/gsapRuntimePatch.test.ts +462 -0
- package/src/hooks/gsapRuntimePatch.ts +159 -0
- package/src/hooks/gsapScriptCommitTypes.ts +11 -1
- package/src/hooks/gsapShared.test.ts +58 -0
- package/src/hooks/gsapShared.ts +28 -10
- package/src/hooks/gsapTargetCache.ts +0 -3
- package/src/hooks/useDomEditSession.ts +0 -4
- package/src/hooks/useDomSelection.ts +12 -5
- package/src/hooks/useEnableKeyframes.test.ts +130 -0
- package/src/hooks/useEnableKeyframes.ts +298 -45
- package/src/hooks/useGestureCommit.ts +121 -53
- package/src/hooks/useGestureRecording.ts +61 -42
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
- package/src/hooks/useGsapAnimationOps.ts +1 -1
- package/src/hooks/useGsapAwareEditing.ts +38 -40
- package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
- package/src/hooks/useGsapScriptCommits.ts +102 -6
- package/src/hooks/useGsapTweenCache.ts +7 -1
- package/src/hooks/useRazorSplit.ts +21 -2
- package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
- package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
- package/src/hooks/useServerConnection.ts +4 -1
- package/src/hooks/useStudioContextValue.ts +3 -2
- package/src/hooks/useStudioUrlState.ts +97 -37
- package/src/player/components/ShortcutsPanel.tsx +8 -0
- package/src/player/components/TimelineClipDiamonds.tsx +10 -12
- package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/lib/mediaProbe.ts +7 -1
- package/src/player/store/playerStore.ts +38 -1
- package/src/telemetry/config.ts +3 -2
- package/src/utils/editDebugLog.ts +16 -0
- package/src/utils/generateId.ts +7 -0
- package/src/utils/gsapSoftReload.test.ts +217 -13
- package/src/utils/gsapSoftReload.ts +228 -50
- package/src/utils/studioHelpers.test.ts +31 -1
- package/src/utils/studioHelpers.ts +24 -0
- package/src/utils/studioPreviewHelpers.test.ts +26 -1
- package/src/utils/studioPreviewHelpers.ts +30 -6
- package/src/utils/studioTelemetry.ts +3 -1
- package/dist/assets/index-20PAVS4k.js +0 -269
- package/dist/assets/index-BvEhf7SS.css +0 -1
- 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(
|
|
62
|
-
expect(applySoftReload(null, SCRIPT_TEXT)).toBe(
|
|
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(
|
|
72
|
+
it('returns "cannot-soft-reload" when scriptText is empty', () => {
|
|
66
73
|
const { iframe } = buildMockIframe();
|
|
67
|
-
expect(applySoftReload(iframe, "")).toBe(
|
|
74
|
+
expect(applySoftReload(iframe, "")).toBe("cannot-soft-reload");
|
|
68
75
|
});
|
|
69
76
|
|
|
70
|
-
it(
|
|
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(
|
|
79
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
|
|
73
80
|
});
|
|
74
81
|
|
|
75
|
-
it(
|
|
82
|
+
it('returns "cannot-soft-reload" when __hfForceTimelineRebind is missing', () => {
|
|
76
83
|
const { iframe } = buildMockIframe({ __hfForceTimelineRebind: undefined });
|
|
77
|
-
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe(
|
|
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(
|
|
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(
|
|
112
|
+
expect(result).toBe("applied");
|
|
100
113
|
expect(suppressionCalled).toBe(true);
|
|
101
114
|
});
|
|
102
115
|
|
|
103
|
-
it(
|
|
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
|
-
|
|
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
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
158
|
+
* Returns `"cannot-soft-reload"` (caller should full-reload) when:
|
|
64
159
|
* - The iframe or GSAP runtime isn't available
|
|
65
|
-
* -
|
|
66
|
-
* -
|
|
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(
|
|
69
|
-
|
|
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
|
|
74
|
-
if (!win.gsap || !win.__hfForceTimelineRebind) return
|
|
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
|
|
78
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
244
|
+
}
|
|
245
|
+
} catch {}
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
tl.kill?.();
|
|
112
249
|
} catch {}
|
|
113
250
|
delete timelines[key];
|
|
114
251
|
}
|
|
115
252
|
}
|
|
116
253
|
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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 =
|
|
153
|
-
pluginScript.onload = () =>
|
|
154
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
+
});
|