@hyperframes/studio 0.7.52 → 0.7.54
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-DKcziwpY.js → index-CMHYjEZ5.js} +1 -1
- package/dist/assets/index-pRhCpGPz.js +423 -0
- package/dist/assets/{index-B27HFK8R.js → index-uBY329wb.js} +1 -1
- package/dist/{chunk-BA66NM4L.js → chunk-SOTCF4DF.js} +4 -2
- package/dist/chunk-SOTCF4DF.js.map +1 -0
- package/dist/{domEditingLayers-H7LDFIJ7.js → domEditingLayers-2ECJK24D.js} +2 -2
- package/dist/index.html +1 -1
- package/dist/index.js +669 -338
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/DomEditCropHandles.test.tsx +86 -0
- package/src/components/editor/DomEditCropHandles.tsx +96 -58
- package/src/components/editor/DomEditOverlay.test.ts +41 -0
- package/src/components/editor/DomEditOverlay.tsx +17 -1
- package/src/components/editor/domEditOverlayCrop.test.ts +137 -0
- package/src/components/editor/domEditOverlayCrop.ts +103 -7
- package/src/components/editor/domEditOverlayGestures.ts +35 -6
- package/src/components/editor/domEditOverlayStartGesture.ts +35 -2
- package/src/components/editor/domEditingDom.ts +1 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +48 -7
- package/src/hooks/gsapResizeIntercept.test.ts +129 -0
- package/src/hooks/gsapResizeIntercept.ts +385 -0
- package/src/hooks/gsapRuntimeBridge.ts +3 -226
- package/src/hooks/gsapRuntimePatch.test.ts +63 -0
- package/src/hooks/gsapRuntimePatch.ts +35 -1
- package/src/hooks/gsapRuntimeReaders.test.ts +111 -0
- package/src/hooks/gsapRuntimeReaders.ts +39 -14
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +66 -5
- package/src/hooks/useAnimatedPropertyCommit.ts +85 -17
- package/src/hooks/useGsapAwareEditing.ts +2 -5
- package/src/hooks/useGsapScriptCommits.test.tsx +86 -66
- package/src/hooks/useGsapScriptCommits.ts +27 -4
- package/src/utils/authoredOpacity.ts +35 -0
- package/src/utils/elementGsap.ts +31 -0
- package/src/utils/gsapSoftReload.test.ts +86 -2
- package/src/utils/gsapSoftReload.ts +69 -7
- package/dist/assets/index-v3DXednk.js +0 -423
- package/dist/chunk-BA66NM4L.js.map +0 -1
- /package/dist/{domEditingLayers-H7LDFIJ7.js.map → domEditingLayers-2ECJK24D.js.map} +0 -0
|
@@ -38,6 +38,27 @@ function result(over: Partial<MutationResult> = {}): MutationResult {
|
|
|
38
38
|
return { ok: true, scriptText: "tl.set('#a',{})", ...over };
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** The canonical drag commit options every path-decision test drives with. */
|
|
42
|
+
function dragOptions() {
|
|
43
|
+
return {
|
|
44
|
+
label: "drag",
|
|
45
|
+
softReload: true,
|
|
46
|
+
instantPatch: { selector: "#a", change: { kind: "set" as const, props: { x: 10 } } },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function syncDragPreview(res: MutationResult, reloadPreview: () => void) {
|
|
51
|
+
applyPreviewSync(FAKE_IFRAME, res, dragOptions(), reloadPreview);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function expectSoftReloadedWith(onAsyncFailure: unknown, authoredHtml: string | undefined) {
|
|
55
|
+
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", {
|
|
56
|
+
onAsyncFailure,
|
|
57
|
+
currentTimeOverride: 0,
|
|
58
|
+
authoredHtml,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
41
62
|
describe("applyPreviewSync", () => {
|
|
42
63
|
beforeEach(() => {
|
|
43
64
|
patchRuntimeTweenInPlace.mockReset();
|
|
@@ -49,16 +70,7 @@ describe("applyPreviewSync", () => {
|
|
|
49
70
|
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
50
71
|
const reloadPreview = vi.fn();
|
|
51
72
|
|
|
52
|
-
|
|
53
|
-
FAKE_IFRAME,
|
|
54
|
-
result(),
|
|
55
|
-
{
|
|
56
|
-
label: "drag",
|
|
57
|
-
softReload: true,
|
|
58
|
-
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
|
59
|
-
},
|
|
60
|
-
reloadPreview,
|
|
61
|
-
);
|
|
73
|
+
syncDragPreview(result(), reloadPreview);
|
|
62
74
|
|
|
63
75
|
expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(FAKE_IFRAME, "#a", {
|
|
64
76
|
kind: "set",
|
|
@@ -73,20 +85,11 @@ describe("applyPreviewSync", () => {
|
|
|
73
85
|
applySoftReload.mockReturnValue("applied");
|
|
74
86
|
const reloadPreview = vi.fn();
|
|
75
87
|
|
|
76
|
-
|
|
77
|
-
FAKE_IFRAME,
|
|
78
|
-
result({ scriptText: "SCRIPT" }),
|
|
79
|
-
{
|
|
80
|
-
label: "drag",
|
|
81
|
-
softReload: true,
|
|
82
|
-
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
|
83
|
-
},
|
|
84
|
-
reloadPreview,
|
|
85
|
-
);
|
|
88
|
+
syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview);
|
|
86
89
|
|
|
87
90
|
// reloadPreview is wired as onAsyncFailure (3rd arg) so a MotionPath-plugin
|
|
88
91
|
// CDN load failure escalates to a full reload — but it is NOT called eagerly.
|
|
89
|
-
|
|
92
|
+
expectSoftReloadedWith(reloadPreview, undefined);
|
|
90
93
|
expect(reloadPreview).not.toHaveBeenCalled();
|
|
91
94
|
// A successful instant patch is the fast path; here it missed → fallback event.
|
|
92
95
|
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
@@ -100,20 +103,11 @@ describe("applyPreviewSync", () => {
|
|
|
100
103
|
applySoftReload.mockReturnValue("verify-failed");
|
|
101
104
|
const reloadPreview = vi.fn();
|
|
102
105
|
|
|
103
|
-
|
|
104
|
-
FAKE_IFRAME,
|
|
105
|
-
result({ scriptText: "SCRIPT" }),
|
|
106
|
-
{
|
|
107
|
-
label: "drag",
|
|
108
|
-
softReload: true,
|
|
109
|
-
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
|
110
|
-
},
|
|
111
|
-
reloadPreview,
|
|
112
|
-
);
|
|
106
|
+
syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview);
|
|
113
107
|
|
|
114
108
|
// U4: "verify-failed" is the TRANSIENT empty-timeline window — the live state
|
|
115
109
|
// is correct, so we must NOT escalate to a full reload.
|
|
116
|
-
|
|
110
|
+
expectSoftReloadedWith(reloadPreview, undefined);
|
|
117
111
|
expect(reloadPreview).not.toHaveBeenCalled();
|
|
118
112
|
// Telemetry records the suppressed transient (escalated: false).
|
|
119
113
|
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
@@ -131,19 +125,10 @@ describe("applyPreviewSync", () => {
|
|
|
131
125
|
applySoftReload.mockReturnValue("cannot-soft-reload");
|
|
132
126
|
const reloadPreview = vi.fn();
|
|
133
127
|
|
|
134
|
-
|
|
135
|
-
FAKE_IFRAME,
|
|
136
|
-
result({ scriptText: "SCRIPT" }),
|
|
137
|
-
{
|
|
138
|
-
label: "drag",
|
|
139
|
-
softReload: true,
|
|
140
|
-
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
|
141
|
-
},
|
|
142
|
-
reloadPreview,
|
|
143
|
-
);
|
|
128
|
+
syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview);
|
|
144
129
|
|
|
145
130
|
// Structural failure: the preview is genuinely stale/broken → full reload.
|
|
146
|
-
|
|
131
|
+
expectSoftReloadedWith(reloadPreview, undefined);
|
|
147
132
|
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
148
133
|
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
149
134
|
"gsap_soft_reload_outcome",
|
|
@@ -167,7 +152,7 @@ describe("applyPreviewSync", () => {
|
|
|
167
152
|
);
|
|
168
153
|
|
|
169
154
|
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
|
|
170
|
-
|
|
155
|
+
expectSoftReloadedWith(reloadPreview, undefined);
|
|
171
156
|
expect(reloadPreview).not.toHaveBeenCalled();
|
|
172
157
|
// "applied" emits no telemetry (only the failure paths do).
|
|
173
158
|
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
@@ -185,7 +170,7 @@ describe("applyPreviewSync", () => {
|
|
|
185
170
|
);
|
|
186
171
|
|
|
187
172
|
// onAsyncFailure is wired, but the transient result does not trigger it.
|
|
188
|
-
|
|
173
|
+
expectSoftReloadedWith(reloadPreview, undefined);
|
|
189
174
|
expect(reloadPreview).not.toHaveBeenCalled();
|
|
190
175
|
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
191
176
|
"gsap_soft_reload_outcome",
|
|
@@ -204,7 +189,7 @@ describe("applyPreviewSync", () => {
|
|
|
204
189
|
reloadPreview,
|
|
205
190
|
);
|
|
206
191
|
|
|
207
|
-
|
|
192
|
+
expectSoftReloadedWith(reloadPreview, undefined);
|
|
208
193
|
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
209
194
|
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
210
195
|
"gsap_soft_reload_outcome",
|
|
@@ -291,6 +276,57 @@ function mockFetchResult(over: Partial<MutationResult> = {}): void {
|
|
|
291
276
|
}
|
|
292
277
|
|
|
293
278
|
describe("runCommit — instantPatch wiring", () => {
|
|
279
|
+
it("no-op commit with an instantPatch still patches the runtime (paired x/y commits)", async () => {
|
|
280
|
+
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
281
|
+
mockFetchResult({ changed: false });
|
|
282
|
+
const deps = renderCommitHook();
|
|
283
|
+
|
|
284
|
+
await act(async () => {
|
|
285
|
+
await deps.api.commitMutation(
|
|
286
|
+
selection,
|
|
287
|
+
{ type: "update-property", property: "y", value: 311 },
|
|
288
|
+
{
|
|
289
|
+
label: "Move layer",
|
|
290
|
+
softReload: true,
|
|
291
|
+
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 485, y: 311 } } },
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// The file already matched (changed:false) but the runtime patch deferred
|
|
297
|
+
// from the paired first commit must still land.
|
|
298
|
+
expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(FAKE_IFRAME, "#a", {
|
|
299
|
+
kind: "set",
|
|
300
|
+
props: { x: 485, y: 311 },
|
|
301
|
+
});
|
|
302
|
+
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("no-op commit whose instant patch MISSES soft-reloads (never full-reloads)", async () => {
|
|
306
|
+
// Server contract: gsap-mutations returns scriptText on EVERY response,
|
|
307
|
+
// including changed:false — so the fallback re-runs the identical script
|
|
308
|
+
// ("applied") instead of escalating a genuine no-op to a full reload.
|
|
309
|
+
patchRuntimeTweenInPlace.mockReturnValue(false);
|
|
310
|
+
applySoftReload.mockReturnValue("applied");
|
|
311
|
+
mockFetchResult({ changed: false });
|
|
312
|
+
const deps = renderCommitHook();
|
|
313
|
+
|
|
314
|
+
await act(async () => {
|
|
315
|
+
await deps.api.commitMutation(
|
|
316
|
+
selection,
|
|
317
|
+
{ type: "update-property", property: "y", value: 311 },
|
|
318
|
+
{
|
|
319
|
+
label: "Move layer",
|
|
320
|
+
softReload: true,
|
|
321
|
+
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 485, y: 311 } } },
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
expectSoftReloadedWith(deps.reloadPreview, "AFTER");
|
|
327
|
+
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
328
|
+
});
|
|
329
|
+
|
|
294
330
|
beforeEach(() => {
|
|
295
331
|
patchRuntimeTweenInPlace.mockReset();
|
|
296
332
|
applySoftReload.mockReset();
|
|
@@ -308,15 +344,7 @@ describe("runCommit — instantPatch wiring", () => {
|
|
|
308
344
|
const deps = renderCommitHook();
|
|
309
345
|
|
|
310
346
|
await act(async () => {
|
|
311
|
-
await deps.api.commitMutation(
|
|
312
|
-
selection,
|
|
313
|
-
{ x: 10 },
|
|
314
|
-
{
|
|
315
|
-
label: "drag",
|
|
316
|
-
softReload: true,
|
|
317
|
-
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
|
318
|
-
},
|
|
319
|
-
);
|
|
347
|
+
await deps.api.commitMutation(selection, { x: 10 }, dragOptions());
|
|
320
348
|
});
|
|
321
349
|
|
|
322
350
|
expect(fetch).toHaveBeenCalledTimes(1); // source mutation persisted
|
|
@@ -333,19 +361,11 @@ describe("runCommit — instantPatch wiring", () => {
|
|
|
333
361
|
const deps = renderCommitHook();
|
|
334
362
|
|
|
335
363
|
await act(async () => {
|
|
336
|
-
await deps.api.commitMutation(
|
|
337
|
-
selection,
|
|
338
|
-
{ x: 10 },
|
|
339
|
-
{
|
|
340
|
-
label: "drag",
|
|
341
|
-
softReload: true,
|
|
342
|
-
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
|
343
|
-
},
|
|
344
|
-
);
|
|
364
|
+
await deps.api.commitMutation(selection, { x: 10 }, dragOptions());
|
|
345
365
|
});
|
|
346
366
|
|
|
347
367
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
348
|
-
|
|
368
|
+
expectSoftReloadedWith(deps.reloadPreview, "AFTER");
|
|
349
369
|
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
350
370
|
expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
|
|
351
371
|
});
|
|
@@ -360,7 +380,7 @@ describe("runCommit — instantPatch wiring", () => {
|
|
|
360
380
|
});
|
|
361
381
|
|
|
362
382
|
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
|
|
363
|
-
|
|
383
|
+
expectSoftReloadedWith(deps.reloadPreview, "AFTER");
|
|
364
384
|
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
365
385
|
});
|
|
366
386
|
});
|
|
@@ -66,12 +66,17 @@ function softReloadOrEscalate(
|
|
|
66
66
|
scriptText: string,
|
|
67
67
|
reloadPreview: () => void,
|
|
68
68
|
origin: "preview_sync" | "sdk_refresh",
|
|
69
|
+
authoredHtml?: string,
|
|
69
70
|
): void {
|
|
70
71
|
// Seek the rebuilt timeline to the studio's own authoritative scrub position,
|
|
71
72
|
// not the iframe's raw `__player.getTime()` — see the comment in
|
|
72
73
|
// applySoftReload for why the two can desync after a keyframe-node drag.
|
|
73
74
|
const currentTime = usePlayerStore.getState().currentTime;
|
|
74
|
-
const result: SoftReloadResult = applySoftReload(iframe, scriptText,
|
|
75
|
+
const result: SoftReloadResult = applySoftReload(iframe, scriptText, {
|
|
76
|
+
onAsyncFailure: reloadPreview,
|
|
77
|
+
currentTimeOverride: currentTime,
|
|
78
|
+
authoredHtml,
|
|
79
|
+
});
|
|
75
80
|
if (result === "applied") return;
|
|
76
81
|
trackStudioEvent("gsap_soft_reload_outcome", {
|
|
77
82
|
origin,
|
|
@@ -116,7 +121,13 @@ export function applyPreviewSync(
|
|
|
116
121
|
// already correct on screen, and a remount re-flashes the WebGL context AND
|
|
117
122
|
// re-inlines subcomps (reverting their keyframes). The async MotionPath-plugin
|
|
118
123
|
// load failure escalates separately via `onAsyncFailure`.
|
|
119
|
-
softReloadOrEscalate(
|
|
124
|
+
softReloadOrEscalate(
|
|
125
|
+
iframe,
|
|
126
|
+
result.scriptText,
|
|
127
|
+
reloadPreview,
|
|
128
|
+
"preview_sync",
|
|
129
|
+
result.after ?? undefined,
|
|
130
|
+
);
|
|
120
131
|
} else {
|
|
121
132
|
reloadPreview();
|
|
122
133
|
}
|
|
@@ -149,7 +160,19 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
149
160
|
if (options.skipReload) return;
|
|
150
161
|
throw error;
|
|
151
162
|
}
|
|
152
|
-
if (result.changed === false)
|
|
163
|
+
if (result.changed === false) {
|
|
164
|
+
// The FILE already matched, but a deferred instant patch may still be
|
|
165
|
+
// owed to the RUNTIME: paired commits (x with skipReload, then y carrying
|
|
166
|
+
// the patch for both) rely on the SECOND commit to sync the preview — if
|
|
167
|
+
// that half happens to be a no-op (a purely-horizontal drag or resize
|
|
168
|
+
// compensation), returning here would leave the runtime showing the old
|
|
169
|
+
// value while the file holds the new one. Patching in place is idempotent
|
|
170
|
+
// when the values truly match everywhere.
|
|
171
|
+
if (!options.skipReload && options.instantPatch) {
|
|
172
|
+
applyPreviewSync(previewIframeRef.current, result, options, reloadPreview);
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
153
176
|
domEditSaveTimestampRef.current = Date.now();
|
|
154
177
|
if (result.before != null && result.after != null) {
|
|
155
178
|
await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
|
|
@@ -196,7 +219,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
196
219
|
// plugin-CDN load error genuinely breaks the iframe → full reload. Per U4, a
|
|
197
220
|
// synchronous "verify-failed" (transient empty __timelines) does NOT escalate,
|
|
198
221
|
// but a "cannot-soft-reload" (structural failure) does.
|
|
199
|
-
softReloadOrEscalate(previewIframeRef.current, script, reloadPreview, "sdk_refresh");
|
|
222
|
+
softReloadOrEscalate(previewIframeRef.current, script, reloadPreview, "sdk_refresh", after);
|
|
200
223
|
} else {
|
|
201
224
|
reloadPreview();
|
|
202
225
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authored-opacity contract, studio side. The runtime stamps every graded
|
|
3
|
+
* element's authored inline opacity at document parse time (see
|
|
4
|
+
* installAuthoredOpacityCapture in @hyperframes/core); studio code that makes
|
|
5
|
+
* GSAP re-initialize tweens (soft reload, in-place patches) restores it so
|
|
6
|
+
* re-captures never bake a runtime transient in as a tween bound.
|
|
7
|
+
*/
|
|
8
|
+
import { COLOR_GRADING_AUTHORED_OPACITY_ATTR } from "@hyperframes/core/color-grading";
|
|
9
|
+
|
|
10
|
+
interface AttributeReader {
|
|
11
|
+
getAttribute(name: string): string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The stamped authored inline opacity. Three-state:
|
|
16
|
+
* "0.98" — the authored value; "" — captured, authored none;
|
|
17
|
+
* null — never captured (unknown).
|
|
18
|
+
* Duck-typed so iframe-realm elements (no shared HTMLElement) work.
|
|
19
|
+
*/
|
|
20
|
+
export function readStampedAuthoredOpacity(element: AttributeReader): string | null {
|
|
21
|
+
return element.getAttribute(COLOR_GRADING_AUTHORED_OPACITY_ATTR);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Write an authored inline opacity back: "" removes the property, a value sets
|
|
26
|
+
* it. Priority-lossy by design: the capture reads `style.opacity` (value only)
|
|
27
|
+
* and the write sets no priority, so an authored `opacity: X !important`
|
|
28
|
+
* round-trips as `opacity: X`. The only `!important` opacity in the pipeline
|
|
29
|
+
* is the color-grading runtime hide — a transient this contract exists to
|
|
30
|
+
* discard — and authored compositions don't `!important` their opacity.
|
|
31
|
+
*/
|
|
32
|
+
export function applyAuthoredInlineOpacity(style: CSSStyleDeclaration, authored: string): void {
|
|
33
|
+
if (authored === "") style.removeProperty("opacity");
|
|
34
|
+
else style.setProperty("opacity", authored);
|
|
35
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSAP access through an ELEMENT'S OWN window (the preview iframe's runtime),
|
|
3
|
+
* not the studio window. This is the single way studio gesture code touches an
|
|
4
|
+
* iframe element's GSAP position outside the commit pipeline — the resize
|
|
5
|
+
* anchor pin (apply + restore) and the post-commit live correction. The commit
|
|
6
|
+
* pipeline itself stays the owner of persisted values.
|
|
7
|
+
*/
|
|
8
|
+
type ElementGsapWindow = Window & {
|
|
9
|
+
gsap?: {
|
|
10
|
+
set?: (target: Element, vars: Record<string, number>) => void;
|
|
11
|
+
getProperty?: (target: Element, prop: string) => unknown;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function gsapOf(element: HTMLElement): ElementGsapWindow["gsap"] | undefined {
|
|
16
|
+
return (element.ownerDocument.defaultView as ElementGsapWindow | null)?.gsap;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Set the element's GSAP x/y. Returns false when no runtime is reachable. */
|
|
20
|
+
export function setElementGsapPosition(element: HTMLElement, x: number, y: number): boolean {
|
|
21
|
+
const gsap = gsapOf(element);
|
|
22
|
+
if (!gsap?.set) return false;
|
|
23
|
+
gsap.set(element, { x, y });
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The element's GSAP numeric property, or null when unreadable. */
|
|
28
|
+
export function readElementGsapNumber(element: HTMLElement, prop: string): number | null {
|
|
29
|
+
const value = Number(gsapOf(element)?.getProperty?.(element, prop));
|
|
30
|
+
return Number.isFinite(value) ? value : null;
|
|
31
|
+
}
|
|
@@ -107,7 +107,7 @@ describe("applySoftReload", () => {
|
|
|
107
107
|
// async commit resolves. The rebuilt timeline must re-seek to the caller's
|
|
108
108
|
// value, not the iframe's possibly-stale one.
|
|
109
109
|
const { iframe, contentWindow } = buildMockIframe();
|
|
110
|
-
const result = applySoftReload(iframe, SCRIPT_TEXT,
|
|
110
|
+
const result = applySoftReload(iframe, SCRIPT_TEXT, { currentTimeOverride: 0 });
|
|
111
111
|
expect(result).toBe("applied");
|
|
112
112
|
expect(contentWindow.__player.seek).toHaveBeenCalledWith(0);
|
|
113
113
|
});
|
|
@@ -244,7 +244,7 @@ describe("applySoftReload", () => {
|
|
|
244
244
|
(iframe.contentDocument as unknown as { head: unknown }).head = head;
|
|
245
245
|
|
|
246
246
|
const onAsyncFailure = vi.fn();
|
|
247
|
-
const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, onAsyncFailure);
|
|
247
|
+
const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, { onAsyncFailure });
|
|
248
248
|
|
|
249
249
|
// Optimistically "applied" (script will run once the plugin loads) — and the
|
|
250
250
|
// script has NOT executed yet, so the timeline isn't rebound synchronously.
|
|
@@ -363,3 +363,87 @@ describe("ensureMotionPathPluginLoaded", () => {
|
|
|
363
363
|
expect(appendedScripts).toHaveLength(2);
|
|
364
364
|
});
|
|
365
365
|
});
|
|
366
|
+
|
|
367
|
+
// The authored-opacity restore: before the script re-runs (and its tweens
|
|
368
|
+
// re-capture bounds), every animated element's inline opacity must be put back
|
|
369
|
+
// to its AUTHORED value — from the after-write file HTML when provided, else
|
|
370
|
+
// from the parse-time stamp. Otherwise a runtime transient (the color-grading
|
|
371
|
+
// hide's 0, a mid-flight tween value) becomes a permanent tween bound.
|
|
372
|
+
describe("applySoftReload authored-opacity restore", () => {
|
|
373
|
+
function buildIframeWithTarget(el: HTMLElement, overrides: Record<string, unknown> = {}) {
|
|
374
|
+
const scriptEl = document.createElement("script");
|
|
375
|
+
scriptEl.textContent =
|
|
376
|
+
'const tl = gsap.timeline({ paused: true }); tl.to("#box", { opacity: 0.5 });';
|
|
377
|
+
const tl = {
|
|
378
|
+
kill: vi.fn(),
|
|
379
|
+
pause: vi.fn(),
|
|
380
|
+
getChildren: () => [{ targets: () => [el] }],
|
|
381
|
+
};
|
|
382
|
+
const contentWindow = {
|
|
383
|
+
gsap: { timeline: vi.fn(), set: vi.fn() },
|
|
384
|
+
__hfForceTimelineRebind: vi.fn(),
|
|
385
|
+
__timelines: { root: tl } as Record<string, unknown>,
|
|
386
|
+
__player: { getTime: () => 2.0, seek: vi.fn() },
|
|
387
|
+
__hfStudioManualEditsApply: vi.fn(),
|
|
388
|
+
...overrides,
|
|
389
|
+
};
|
|
390
|
+
const container = document.createElement("div");
|
|
391
|
+
container.appendChild(scriptEl);
|
|
392
|
+
// Intercept only POST-SETUP appends: simulate the re-run script
|
|
393
|
+
// repopulating __timelines (as in buildMockIframe).
|
|
394
|
+
const realAppendChild = container.appendChild.bind(container);
|
|
395
|
+
container.appendChild = <T extends Node>(node: T): T => {
|
|
396
|
+
const result = realAppendChild(node);
|
|
397
|
+
if (node instanceof HTMLScriptElement && node.textContent?.includes("gsap.timeline")) {
|
|
398
|
+
contentWindow.__timelines.root = { kill: vi.fn(), pause: vi.fn() };
|
|
399
|
+
}
|
|
400
|
+
return result;
|
|
401
|
+
};
|
|
402
|
+
const contentDocument = {
|
|
403
|
+
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
|
|
404
|
+
createElement: (tag: string) => document.createElement(tag),
|
|
405
|
+
body: container,
|
|
406
|
+
head: document.createElement("div"),
|
|
407
|
+
};
|
|
408
|
+
return { iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Run one restore cycle over `el` and return the final inline opacity. */
|
|
412
|
+
function restoreOpacity(el: HTMLElement, authoredHtml?: string): string {
|
|
413
|
+
const { iframe } = buildIframeWithTarget(el);
|
|
414
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT, authoredHtml ? { authoredHtml } : {})).toBe(
|
|
415
|
+
"applied",
|
|
416
|
+
);
|
|
417
|
+
return el.style.getPropertyValue("opacity");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
it("restores opacity from the after-write HTML (matched by data-hf-id)", () => {
|
|
421
|
+
const el = document.createElement("img");
|
|
422
|
+
el.setAttribute("data-hf-id", "hf-1");
|
|
423
|
+
el.style.setProperty("opacity", "0", "important"); // the grading hide
|
|
424
|
+
|
|
425
|
+
const opacity = restoreOpacity(
|
|
426
|
+
el,
|
|
427
|
+
'<html><body><img data-hf-id="hf-1" style="opacity: 0.98"></body></html>',
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
expect(opacity).toBe("0.98");
|
|
431
|
+
expect(el.style.getPropertyPriority("opacity")).toBe("");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("falls back to the parse-time stamp when no after-write HTML is given", () => {
|
|
435
|
+
const el = document.createElement("img");
|
|
436
|
+
el.setAttribute("data-hf-authored-opacity", "0.75");
|
|
437
|
+
el.style.opacity = "0.123"; // mid-flight tween transient
|
|
438
|
+
|
|
439
|
+
expect(restoreOpacity(el)).toBe("0.75");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("an empty stamp (authored none) removes the inline opacity", () => {
|
|
443
|
+
const el = document.createElement("img");
|
|
444
|
+
el.setAttribute("data-hf-authored-opacity", "");
|
|
445
|
+
el.style.opacity = "0";
|
|
446
|
+
|
|
447
|
+
expect(restoreOpacity(el)).toBe("");
|
|
448
|
+
});
|
|
449
|
+
});
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
|
|
2
|
+
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
|
|
3
|
+
|
|
1
4
|
type IframeWindow = Window & {
|
|
2
5
|
__timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
|
|
3
6
|
__player?: { getTime?: () => number; seek?: (t: number) => void };
|
|
@@ -171,12 +174,21 @@ export type SoftReloadResult = "applied" | "verify-failed" | "cannot-soft-reload
|
|
|
171
174
|
* caller should perform a full reload to recover. It never fires on the
|
|
172
175
|
* synchronous paths.
|
|
173
176
|
*/
|
|
177
|
+
export interface SoftReloadOptions {
|
|
178
|
+
/** Escalation for async plugin-load failures (e.g. MotionPath CDN error). */
|
|
179
|
+
onAsyncFailure?: () => void;
|
|
180
|
+
/** Seek target for the rebuilt timeline; defaults to the iframe player time. */
|
|
181
|
+
currentTimeOverride?: number;
|
|
182
|
+
/** After-write file HTML — the primary source for authored-opacity restore. */
|
|
183
|
+
authoredHtml?: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
174
186
|
export function applySoftReload(
|
|
175
187
|
iframe: HTMLIFrameElement | null,
|
|
176
188
|
scriptText: string,
|
|
177
|
-
|
|
178
|
-
currentTimeOverride?: number,
|
|
189
|
+
options: SoftReloadOptions = {},
|
|
179
190
|
): SoftReloadResult {
|
|
191
|
+
const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
|
|
180
192
|
if (!iframe || !scriptText) return "cannot-soft-reload";
|
|
181
193
|
|
|
182
194
|
const win = iframe.contentWindow as IframeWindow | null;
|
|
@@ -227,6 +239,36 @@ export function applySoftReload(
|
|
|
227
239
|
// full iframe reload that destroys the very WebGL context we're preserving.
|
|
228
240
|
let deferredToAsync = false;
|
|
229
241
|
|
|
242
|
+
// Authored-opacity resolution for the restore loop below. Three-state:
|
|
243
|
+
// "0.98" — the element's authored inline opacity
|
|
244
|
+
// "" — resolved, and the element has NO authored inline opacity
|
|
245
|
+
// null — unknown (no authored HTML supplied, element not found in it,
|
|
246
|
+
// and no runtime parse-time stamp)
|
|
247
|
+
// The just-written file (`authoredHtml`) is the current truth; the runtime's
|
|
248
|
+
// parse-time stamp (data-hf-authored-opacity, installAuthoredOpacityCapture)
|
|
249
|
+
// covers elements the file lookup can't resolve. Parsed lazily, at most once.
|
|
250
|
+
let authoredDoc: Document | null | undefined;
|
|
251
|
+
const findAuthoredSource = (el: HTMLElement): Element | null => {
|
|
252
|
+
if (authoredDoc === undefined) {
|
|
253
|
+
try {
|
|
254
|
+
authoredDoc = authoredHtml
|
|
255
|
+
? new DOMParser().parseFromString(authoredHtml, "text/html")
|
|
256
|
+
: null;
|
|
257
|
+
} catch {
|
|
258
|
+
authoredDoc = null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (!authoredDoc) return null;
|
|
262
|
+
const hfId = el.getAttribute("data-hf-id");
|
|
263
|
+
if (hfId) return authoredDoc.querySelector(`[data-hf-id="${hfId}"]`);
|
|
264
|
+
return el.id ? authoredDoc.getElementById(el.id) : null;
|
|
265
|
+
};
|
|
266
|
+
const readAuthoredOpacity = (el: HTMLElement): string | null => {
|
|
267
|
+
const source = findAuthoredSource(el);
|
|
268
|
+
if (source instanceof HTMLElement) return source.style.opacity;
|
|
269
|
+
return readStampedAuthoredOpacity(el);
|
|
270
|
+
};
|
|
271
|
+
|
|
230
272
|
// fallow-ignore-next-line complexity
|
|
231
273
|
const doReload = () => {
|
|
232
274
|
const timelines = win.__timelines;
|
|
@@ -283,19 +325,39 @@ export function applySoftReload(
|
|
|
283
325
|
// nukes the element's CSS base (position, width, height, etc.) from the
|
|
284
326
|
// HTML `style=""` attribute. Save → clear → restore → strip `transform`.
|
|
285
327
|
if (allTargets.length > 0 && win.gsap?.set) {
|
|
286
|
-
const saved: Array<[
|
|
328
|
+
const saved: Array<[HTMLElement, string]> = [];
|
|
287
329
|
for (const el of allTargets) {
|
|
288
|
-
|
|
289
|
-
|
|
330
|
+
// Iframe-realm node: instanceof HTMLElement fails across realms, and
|
|
331
|
+
// gsap targets() only yields elements here — style access is duck-typed.
|
|
332
|
+
const styled = el as HTMLElement;
|
|
333
|
+
if (styled.style?.cssText != null) saved.push([styled, styled.style.cssText]);
|
|
290
334
|
}
|
|
291
335
|
try {
|
|
292
336
|
win.gsap.set(allTargets, { clearProps: "all" });
|
|
293
337
|
} catch {}
|
|
294
338
|
for (const [el, css] of saved) {
|
|
295
|
-
const s =
|
|
296
|
-
if (!s) continue;
|
|
339
|
+
const s = el.style;
|
|
297
340
|
s.cssText = css;
|
|
298
341
|
s.removeProperty("transform");
|
|
342
|
+
// The restored cssText carries RUNTIME opacity, not authored opacity:
|
|
343
|
+
// a mid-flight tween's interpolated value, or the color-grading hide
|
|
344
|
+
// (`opacity: 0 !important`). The re-run script's tweens re-initialize
|
|
345
|
+
// against it — a from() captures it as its END, a to() as its START —
|
|
346
|
+
// turning the transient into the tween's permanent bound (dimmed or
|
|
347
|
+
// invisible elements). Put the AUTHORED inline opacity back; the seek
|
|
348
|
+
// below re-renders the correct animated value either way.
|
|
349
|
+
const authored = readAuthoredOpacity(el);
|
|
350
|
+
if (authored !== null) {
|
|
351
|
+
applyAuthoredInlineOpacity(s, authored);
|
|
352
|
+
} else if (
|
|
353
|
+
el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR) &&
|
|
354
|
+
s.getPropertyValue("opacity") === "0" &&
|
|
355
|
+
s.getPropertyPriority("opacity") === "important"
|
|
356
|
+
) {
|
|
357
|
+
// Authored value unknown, but this is definitely the grading hide —
|
|
358
|
+
// never let a from() capture 0; fall back to the CSS cascade.
|
|
359
|
+
s.removeProperty("opacity");
|
|
360
|
+
}
|
|
299
361
|
}
|
|
300
362
|
}
|
|
301
363
|
|