@hyperframes/studio 0.6.110 → 0.6.111
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-B7S86vK1.js +370 -0
- package/dist/assets/index-DP8pPIk2.css +1 -0
- package/dist/assets/{index-CfiyaR7G.js → index-_IV-vm9l.js} +1 -1
- package/dist/assets/{index-CXy_mWnP.js → index-x0c2-zQN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -5
- package/src/App.tsx +133 -141
- package/src/components/StudioHeader.tsx +44 -0
- package/src/components/StudioOverlays.tsx +70 -0
- package/src/components/editor/SourceEditor.tsx +19 -16
- package/src/components/editor/manualEditingAvailability.ts +25 -7
- package/src/components/storyboard/FramePoster.tsx +56 -0
- package/src/components/storyboard/StoryboardDirection.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
- package/src/components/storyboard/StoryboardGrid.tsx +33 -0
- package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
- package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
- package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
- package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
- package/src/components/storyboard/StoryboardView.tsx +78 -0
- package/src/components/storyboard/frameStatus.ts +36 -0
- package/src/contexts/ViewModeContext.tsx +98 -0
- package/src/hooks/gsapScriptCommitTypes.ts +4 -7
- package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
- package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -0
- package/src/hooks/useAppHotkeys.ts +20 -0
- package/src/hooks/useDomEditCommits.ts +43 -14
- package/src/hooks/useDomEditSession.test.ts +41 -0
- package/src/hooks/useDomEditSession.ts +38 -6
- package/src/hooks/useDomGeometryCommits.ts +5 -9
- package/src/hooks/useElementLifecycleOps.ts +30 -0
- package/src/hooks/useGsapAnimationOps.ts +78 -51
- package/src/hooks/useGsapKeyframeOps.ts +127 -43
- package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
- package/src/hooks/useGsapPropertyDebounce.ts +201 -23
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
- package/src/hooks/useGsapScriptCommits.ts +95 -40
- package/src/hooks/useSdkSession.test.ts +12 -0
- package/src/hooks/useSdkSession.ts +91 -25
- package/src/hooks/useStoryboard.ts +80 -0
- package/src/hooks/useTimelineEditing.ts +163 -68
- package/src/utils/gsapSoftReload.ts +14 -0
- package/src/utils/sdkCutover.gate.test.ts +61 -0
- package/src/utils/sdkCutover.test.ts +839 -0
- package/src/utils/sdkCutover.ts +465 -0
- package/src/utils/sdkOpMapping.ts +43 -0
- package/src/utils/sdkResolverShadow.test.ts +366 -0
- package/src/utils/sdkResolverShadow.ts +313 -0
- package/dist/assets/index-1C8oFiPi.css +0 -1
- package/dist/assets/index-D-3sGz65.js +0 -296
- package/src/utils/sdkShadow.test.ts +0 -606
- package/src/utils/sdkShadow.ts +0 -517
- package/src/utils/sdkShadowGsapFidelity.ts +0 -296
- package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
- package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
- package/src/utils/sdkShadowNumeric.ts +0 -11
|
@@ -1,11 +1,56 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
3
|
+
import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
|
|
2
4
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
5
|
+
import {
|
|
6
|
+
sdkGsapTweenPersist,
|
|
7
|
+
sdkGsapRemovePropertyPersist,
|
|
8
|
+
type CutoverDeps,
|
|
9
|
+
} from "../utils/sdkCutover";
|
|
10
|
+
import { extractGsapScriptText } from "../utils/gsapSoftReload";
|
|
3
11
|
import { PROPERTY_DEFAULTS } from "./gsapScriptCommitHelpers";
|
|
4
12
|
import type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes";
|
|
5
13
|
|
|
6
14
|
const DEBOUNCE_MS = 150;
|
|
7
15
|
|
|
8
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The SDK `setGsapTween` 'set' path REPLACES a tween's editable property set
|
|
18
|
+
* (engine `handleSetGsapTween` → `updateAnimationInScript`), so sending only the
|
|
19
|
+
* single edited key would silently drop the tween's other animated props. Mirror
|
|
20
|
+
* the legacy server path (`{ ...anim.properties, [property]: val }`): read the
|
|
21
|
+
* tween's CURRENT properties from the in-memory SDK doc and merge the one edit in,
|
|
22
|
+
* so REPLACE semantics preserve siblings. Returns the single-key map unchanged
|
|
23
|
+
* when the tween/script can't be found (best-effort; before===after then falls
|
|
24
|
+
* back to the server path).
|
|
25
|
+
*/
|
|
26
|
+
export function mergeTweenProperties(
|
|
27
|
+
sdkSession: Composition,
|
|
28
|
+
animationId: string,
|
|
29
|
+
edited: Record<string, number | string>,
|
|
30
|
+
kind: "to" | "from",
|
|
31
|
+
): Record<string, number | string> {
|
|
32
|
+
try {
|
|
33
|
+
const script = extractGsapScriptText(sdkSession.serialize());
|
|
34
|
+
if (!script) return { ...edited };
|
|
35
|
+
const anim = parseGsapScriptAcorn(script).animations.find((a) => a.id === animationId);
|
|
36
|
+
if (!anim) return { ...edited };
|
|
37
|
+
const existing = kind === "from" ? (anim.fromProperties ?? {}) : anim.properties;
|
|
38
|
+
return { ...existing, ...edited };
|
|
39
|
+
} catch {
|
|
40
|
+
return { ...edited };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface SdkPropertyDeps {
|
|
45
|
+
sdkSession?: Composition | null;
|
|
46
|
+
sdkDeps?: CutoverDeps | null;
|
|
47
|
+
activeCompPath?: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useGsapPropertyDebounce(
|
|
51
|
+
commitMutationSafely: SafeGsapCommitMutation,
|
|
52
|
+
sdk?: SdkPropertyDeps,
|
|
53
|
+
) {
|
|
9
54
|
const pendingPropertyEditRef = useRef<{
|
|
10
55
|
selection: DomEditSelection;
|
|
11
56
|
animationId: string;
|
|
@@ -14,11 +59,38 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
|
|
|
14
59
|
} | null>(null);
|
|
15
60
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
16
61
|
|
|
17
|
-
|
|
62
|
+
// The caller passes `sdk` as a fresh object literal every render. Keying any
|
|
63
|
+
// callback's deps on it (esp. flushPendingPropertyEdit, whose identity drives
|
|
64
|
+
// the unmount-flush cleanup effect) re-fires the cleanup on EVERY parent
|
|
65
|
+
// re-render — so a playhead tick mid-slider-drag would flush + record an undo
|
|
66
|
+
// entry per render. Hold the latest value in a ref instead so every callback
|
|
67
|
+
// reads current deps without re-subscribing on identity churn.
|
|
68
|
+
const sdkRef = useRef(sdk);
|
|
69
|
+
sdkRef.current = sdk;
|
|
70
|
+
|
|
71
|
+
const flushPendingPropertyEdit = useCallback(async () => {
|
|
18
72
|
const pending = pendingPropertyEditRef.current;
|
|
19
73
|
if (!pending) return;
|
|
20
74
|
pendingPropertyEditRef.current = null;
|
|
21
75
|
const { selection, animationId, property, value } = pending;
|
|
76
|
+
const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
|
|
77
|
+
if (sdkSession && sdkDeps) {
|
|
78
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
79
|
+
const handled = await sdkGsapTweenPersist(
|
|
80
|
+
targetPath,
|
|
81
|
+
{
|
|
82
|
+
kind: "set",
|
|
83
|
+
animationId,
|
|
84
|
+
properties: {
|
|
85
|
+
properties: mergeTweenProperties(sdkSession, animationId, { [property]: value }, "to"),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
sdkSession,
|
|
89
|
+
sdkDeps,
|
|
90
|
+
{ label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` },
|
|
91
|
+
);
|
|
92
|
+
if (handled) return;
|
|
93
|
+
}
|
|
22
94
|
commitMutationSafely(
|
|
23
95
|
selection,
|
|
24
96
|
{ type: "update-property", animationId, property, value },
|
|
@@ -39,7 +111,9 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
|
|
|
39
111
|
) => {
|
|
40
112
|
pendingPropertyEditRef.current = { selection, animationId, property, value };
|
|
41
113
|
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
|
42
|
-
debounceTimerRef.current = setTimeout(
|
|
114
|
+
debounceTimerRef.current = setTimeout(() => {
|
|
115
|
+
void flushPendingPropertyEdit();
|
|
116
|
+
}, DEBOUNCE_MS);
|
|
43
117
|
},
|
|
44
118
|
[flushPendingPropertyEdit],
|
|
45
119
|
);
|
|
@@ -47,12 +121,14 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
|
|
|
47
121
|
useEffect(() => {
|
|
48
122
|
return () => {
|
|
49
123
|
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
|
50
|
-
flushPendingPropertyEdit();
|
|
124
|
+
void flushPendingPropertyEdit();
|
|
51
125
|
};
|
|
52
126
|
}, [flushPendingPropertyEdit]);
|
|
53
127
|
|
|
128
|
+
// fallow-ignore-next-line complexity
|
|
54
129
|
const addGsapProperty = useCallback(
|
|
55
|
-
|
|
130
|
+
// fallow-ignore-next-line complexity
|
|
131
|
+
async (selection: DomEditSelection, animationId: string, property: string) => {
|
|
56
132
|
let defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
|
|
57
133
|
const el = selection.element;
|
|
58
134
|
if (property === "width" || property === "height") {
|
|
@@ -60,7 +136,33 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
|
|
|
60
136
|
defaultValue = Math.round(property === "width" ? rect.width : rect.height);
|
|
61
137
|
} else if (property === "opacity" || property === "autoAlpha") {
|
|
62
138
|
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
63
|
-
|
|
139
|
+
// Use `|| 1` only as a non-finite fallback, not a falsy fallback: an
|
|
140
|
+
// element currently at opacity 0 must seed 0, not 1.
|
|
141
|
+
const current = cs ? Number.parseFloat(cs.opacity) : Number.NaN;
|
|
142
|
+
defaultValue = Number.isFinite(current) ? current : 1;
|
|
143
|
+
}
|
|
144
|
+
const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
|
|
145
|
+
if (sdkSession && sdkDeps) {
|
|
146
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
147
|
+
const handled = await sdkGsapTweenPersist(
|
|
148
|
+
targetPath,
|
|
149
|
+
{
|
|
150
|
+
kind: "set",
|
|
151
|
+
animationId,
|
|
152
|
+
properties: {
|
|
153
|
+
properties: mergeTweenProperties(
|
|
154
|
+
sdkSession,
|
|
155
|
+
animationId,
|
|
156
|
+
{ [property]: defaultValue },
|
|
157
|
+
"to",
|
|
158
|
+
),
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
sdkSession,
|
|
162
|
+
sdkDeps,
|
|
163
|
+
{ label: `Add GSAP ${property}` },
|
|
164
|
+
);
|
|
165
|
+
if (handled) return;
|
|
64
166
|
}
|
|
65
167
|
commitMutationSafely(
|
|
66
168
|
selection,
|
|
@@ -71,24 +173,82 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
|
|
|
71
173
|
[commitMutationSafely],
|
|
72
174
|
);
|
|
73
175
|
|
|
74
|
-
const
|
|
75
|
-
(selection: DomEditSelection, animationId: string, property: string) => {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
176
|
+
const removeProperty = useCallback(
|
|
177
|
+
async (selection: DomEditSelection, animationId: string, property: string, from: boolean) => {
|
|
178
|
+
const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
|
|
179
|
+
if (sdkSession && sdkDeps) {
|
|
180
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
181
|
+
const handled = await sdkGsapRemovePropertyPersist(
|
|
182
|
+
targetPath,
|
|
183
|
+
animationId,
|
|
184
|
+
property,
|
|
185
|
+
from,
|
|
186
|
+
sdkSession,
|
|
187
|
+
sdkDeps,
|
|
188
|
+
{ label: `Remove GSAP ${from ? `from-${property}` : property}` },
|
|
189
|
+
);
|
|
190
|
+
if (handled) return;
|
|
191
|
+
}
|
|
192
|
+
if (from) {
|
|
193
|
+
commitMutationSafely(
|
|
194
|
+
selection,
|
|
195
|
+
{ type: "remove-from-property", animationId, property },
|
|
196
|
+
{
|
|
197
|
+
label: `Remove GSAP from-${property}`,
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
} else {
|
|
201
|
+
commitMutationSafely(
|
|
202
|
+
selection,
|
|
203
|
+
{ type: "remove-property", animationId, property },
|
|
204
|
+
{
|
|
205
|
+
label: `Remove GSAP ${property}`,
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
}
|
|
81
209
|
},
|
|
82
210
|
[commitMutationSafely],
|
|
83
211
|
);
|
|
84
212
|
|
|
213
|
+
const removeGsapProperty = useCallback(
|
|
214
|
+
(selection: DomEditSelection, animationId: string, property: string) =>
|
|
215
|
+
removeProperty(selection, animationId, property, false),
|
|
216
|
+
[removeProperty],
|
|
217
|
+
);
|
|
218
|
+
|
|
85
219
|
const updateGsapFromProperty = useCallback(
|
|
86
|
-
(
|
|
220
|
+
async (
|
|
87
221
|
selection: DomEditSelection,
|
|
88
222
|
animationId: string,
|
|
89
223
|
property: string,
|
|
90
224
|
value: number | string,
|
|
91
225
|
) => {
|
|
226
|
+
const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
|
|
227
|
+
if (sdkSession && sdkDeps) {
|
|
228
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
229
|
+
const handled = await sdkGsapTweenPersist(
|
|
230
|
+
targetPath,
|
|
231
|
+
{
|
|
232
|
+
kind: "set",
|
|
233
|
+
animationId,
|
|
234
|
+
properties: {
|
|
235
|
+
fromProperties: mergeTweenProperties(
|
|
236
|
+
sdkSession,
|
|
237
|
+
animationId,
|
|
238
|
+
{ [property]: value },
|
|
239
|
+
"from",
|
|
240
|
+
),
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
sdkSession,
|
|
244
|
+
sdkDeps,
|
|
245
|
+
{
|
|
246
|
+
label: `Edit GSAP from-${property}`,
|
|
247
|
+
coalesceKey: `gsap:${animationId}:from:${property}`,
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
if (handled) return;
|
|
251
|
+
}
|
|
92
252
|
commitMutationSafely(
|
|
93
253
|
selection,
|
|
94
254
|
{ type: "update-from-property", animationId, property, value },
|
|
@@ -102,8 +262,31 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
|
|
|
102
262
|
);
|
|
103
263
|
|
|
104
264
|
const addGsapFromProperty = useCallback(
|
|
105
|
-
(selection: DomEditSelection, animationId: string, property: string) => {
|
|
265
|
+
async (selection: DomEditSelection, animationId: string, property: string) => {
|
|
106
266
|
const defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
|
|
267
|
+
const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
|
|
268
|
+
if (sdkSession && sdkDeps) {
|
|
269
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
270
|
+
const handled = await sdkGsapTweenPersist(
|
|
271
|
+
targetPath,
|
|
272
|
+
{
|
|
273
|
+
kind: "set",
|
|
274
|
+
animationId,
|
|
275
|
+
properties: {
|
|
276
|
+
fromProperties: mergeTweenProperties(
|
|
277
|
+
sdkSession,
|
|
278
|
+
animationId,
|
|
279
|
+
{ [property]: defaultValue },
|
|
280
|
+
"from",
|
|
281
|
+
),
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
sdkSession,
|
|
285
|
+
sdkDeps,
|
|
286
|
+
{ label: `Add GSAP from-${property}` },
|
|
287
|
+
);
|
|
288
|
+
if (handled) return;
|
|
289
|
+
}
|
|
107
290
|
commitMutationSafely(
|
|
108
291
|
selection,
|
|
109
292
|
{ type: "add-from-property", animationId, property, defaultValue },
|
|
@@ -114,14 +297,9 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
|
|
|
114
297
|
);
|
|
115
298
|
|
|
116
299
|
const removeGsapFromProperty = useCallback(
|
|
117
|
-
(selection: DomEditSelection, animationId: string, property: string) =>
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
{ type: "remove-from-property", animationId, property },
|
|
121
|
-
{ label: `Remove GSAP from-${property}` },
|
|
122
|
-
);
|
|
123
|
-
},
|
|
124
|
-
[commitMutationSafely],
|
|
300
|
+
(selection: DomEditSelection, animationId: string, property: string) =>
|
|
301
|
+
removeProperty(selection, animationId, property, true),
|
|
302
|
+
[removeProperty],
|
|
125
303
|
);
|
|
126
304
|
|
|
127
305
|
return {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act, useState } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useGsapPropertyDebounce } from "./useGsapPropertyDebounce";
|
|
6
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
7
|
+
|
|
8
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
// The SDK path is gated on STUDIO_SDK_CUTOVER_ENABLED; keep it OFF so the flush
|
|
11
|
+
// routes through commitMutationSafely (the spy we count), keeping the test about
|
|
12
|
+
// flush TIMING, not the SDK write path.
|
|
13
|
+
vi.mock("../components/editor/manualEditingAvailability", () => ({
|
|
14
|
+
STUDIO_SDK_CUTOVER_ENABLED: false,
|
|
15
|
+
}));
|
|
16
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
17
|
+
|
|
18
|
+
const selection = { sourceFile: "index.html" } as unknown as DomEditSelection;
|
|
19
|
+
|
|
20
|
+
describe("useGsapPropertyDebounce flush stability (finding #7)", () => {
|
|
21
|
+
let container: HTMLDivElement;
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.useFakeTimers();
|
|
24
|
+
container = document.createElement("div");
|
|
25
|
+
document.body.appendChild(container);
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.useRealTimers();
|
|
29
|
+
container.remove();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("re-rendering the parent while an edit is pending does NOT flush early or duplicate commits", () => {
|
|
33
|
+
const commitMutationSafely = vi.fn();
|
|
34
|
+
let queueEdit: (() => void) | null = null;
|
|
35
|
+
let forceRerender: (() => void) | null = null;
|
|
36
|
+
|
|
37
|
+
function Harness() {
|
|
38
|
+
const [tick, setTick] = useState(0);
|
|
39
|
+
forceRerender = () => setTick((t) => t + 1);
|
|
40
|
+
// A FRESH sdk wrapper literal every render — the exact churn that, before
|
|
41
|
+
// the ref-stabilization fix, re-fired the unmount-flush cleanup effect.
|
|
42
|
+
const ops = useGsapPropertyDebounce(commitMutationSafely, {
|
|
43
|
+
sdkSession: null,
|
|
44
|
+
sdkDeps: null,
|
|
45
|
+
activeCompPath: "index.html",
|
|
46
|
+
});
|
|
47
|
+
queueEdit = () => ops.updateGsapProperty(selection, "tw-1", "x", tick + 1);
|
|
48
|
+
return React.createElement("div", null, String(tick));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const root = createRoot(container);
|
|
52
|
+
act(() => {
|
|
53
|
+
root.render(React.createElement(Harness));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Queue one pending edit.
|
|
57
|
+
act(() => {
|
|
58
|
+
queueEdit?.();
|
|
59
|
+
});
|
|
60
|
+
expect(commitMutationSafely).not.toHaveBeenCalled();
|
|
61
|
+
|
|
62
|
+
// Re-render the parent several times BEFORE the debounce elapses. The bug
|
|
63
|
+
// flushed (and recorded a commit) on every re-render via the cleanup effect.
|
|
64
|
+
act(() => {
|
|
65
|
+
forceRerender?.();
|
|
66
|
+
});
|
|
67
|
+
act(() => {
|
|
68
|
+
forceRerender?.();
|
|
69
|
+
});
|
|
70
|
+
act(() => {
|
|
71
|
+
forceRerender?.();
|
|
72
|
+
});
|
|
73
|
+
expect(commitMutationSafely).not.toHaveBeenCalled();
|
|
74
|
+
|
|
75
|
+
// The debounce fires exactly once.
|
|
76
|
+
act(() => {
|
|
77
|
+
vi.advanceTimersByTime(200);
|
|
78
|
+
});
|
|
79
|
+
expect(commitMutationSafely).toHaveBeenCalledTimes(1);
|
|
80
|
+
|
|
81
|
+
act(() => {
|
|
82
|
+
root.unmount();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { useCallback, useRef } from "react";
|
|
1
|
+
import { useCallback, useMemo, useRef } from "react";
|
|
2
2
|
import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
|
-
import { applySoftReload } from "../utils/gsapSoftReload";
|
|
5
|
-
import {
|
|
6
|
-
import { runShadowGsapKeyframeFidelity } from "../utils/sdkShadowGsapKeyframe";
|
|
4
|
+
import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
|
|
5
|
+
import type { CutoverDeps } from "../utils/sdkCutover";
|
|
7
6
|
import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
|
|
8
7
|
import { createKeyedSerializer } from "./serializeByKey";
|
|
9
8
|
import {
|
|
@@ -46,15 +45,12 @@ async function mutateGsapScript(
|
|
|
46
45
|
|
|
47
46
|
// oxfmt-ignore
|
|
48
47
|
// fallow-ignore-next-line complexity
|
|
49
|
-
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession }: GsapScriptCommitsParams) {
|
|
48
|
+
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) {
|
|
50
49
|
// Serializer for per-key commits (options.serializeKey). Keyed by
|
|
51
50
|
// `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for
|
|
52
|
-
// the same animationId so their POSTs can't interleave
|
|
53
|
-
//
|
|
54
|
-
// false ease mismatches. Held in a ref so the chain survives re-renders.
|
|
51
|
+
// the same animationId so their POSTs can't interleave. Held in a ref so the
|
|
52
|
+
// chain survives re-renders.
|
|
55
53
|
const serializerRef = useRef(createKeyedSerializer());
|
|
56
|
-
// Pre-existing complexity (server mutate + history + reload branches); this PR
|
|
57
|
-
// adds only a guarded shadow-fidelity dispatch.
|
|
58
54
|
// fallow-ignore-next-line complexity
|
|
59
55
|
const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
|
60
56
|
const pid = projectIdRef.current;
|
|
@@ -76,32 +72,13 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
76
72
|
}
|
|
77
73
|
if (result.changed === false) return;
|
|
78
74
|
domEditSaveTimestampRef.current = Date.now();
|
|
79
|
-
// Shadow value fidelity: diff the SDK's GSAP writer output against the
|
|
80
|
-
// server's, from the same pre-op file. Fire-and-forget; server authoritative.
|
|
81
|
-
// Meta-level ops carry shadowGsapOp (add / update-meta / delete via
|
|
82
|
-
// useGsapAnimationOps); keyframe ops carry shadowKeyframeOp (add/remove via
|
|
83
|
-
// useGsapKeyframeOps, handled by the gsap_keyframe block below). Per-property
|
|
84
|
-
// handlers (useGsapPropertyDebounce) don't synthesize one yet — deferred follow-up.
|
|
85
|
-
// scriptText is null when the composition has no GSAP script; nothing to diff.
|
|
86
|
-
const fidelityArgs = resolveGsapFidelityArgs(
|
|
87
|
-
sdkSession,
|
|
88
|
-
options.shadowGsapOp,
|
|
89
|
-
result.before,
|
|
90
|
-
result.scriptText,
|
|
91
|
-
);
|
|
92
|
-
if (fidelityArgs) {
|
|
93
|
-
void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript);
|
|
94
|
-
}
|
|
95
|
-
// Keyframe value fidelity (gsap_keyframe): same serialize-diff approach, but
|
|
96
|
-
// the SDK has no keyframe reader so there is no live-existence path — the diff
|
|
97
|
-
// is the only signal. Guarded on a live session + both scripts to diff.
|
|
98
|
-
if (sdkSession && options.shadowKeyframeOp && result.before != null && result.scriptText != null) {
|
|
99
|
-
void runShadowGsapKeyframeFidelity(result.before, options.shadowKeyframeOp, result.scriptText);
|
|
100
|
-
}
|
|
101
75
|
if (result.before != null && result.after != null) {
|
|
102
76
|
await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
|
|
103
77
|
}
|
|
104
78
|
if (result.after != null) onFileContentChanged?.(targetPath, result.after);
|
|
79
|
+
// Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
|
|
80
|
+
// later SDK-routed edit doesn't serialize the pre-write doc and revert this.
|
|
81
|
+
forceReloadSdkSession?.();
|
|
105
82
|
if (options.skipReload) return;
|
|
106
83
|
if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation);
|
|
107
84
|
options.beforeReload?.();
|
|
@@ -111,12 +88,10 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
111
88
|
reloadPreview();
|
|
112
89
|
}
|
|
113
90
|
onCacheInvalidate();
|
|
114
|
-
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast,
|
|
91
|
+
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]);
|
|
115
92
|
// Every GSAP-script commit is a read-modify-write of one file. Overlapping
|
|
116
|
-
// commits to the SAME file (any op type, any animation) interleave server-side
|
|
117
|
-
//
|
|
118
|
-
// the false ease/value mismatches this serializer exists to prevent. So
|
|
119
|
-
// serialize per target file by default; an explicit serializeKey overrides.
|
|
93
|
+
// commits to the SAME file (any op type, any animation) interleave server-side,
|
|
94
|
+
// so serialize per target file by default; an explicit serializeKey overrides.
|
|
120
95
|
const commitMutation = useCallback(
|
|
121
96
|
(selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
|
122
97
|
const file = selection.sourceFile || activeCompPath || "index.html";
|
|
@@ -127,9 +102,89 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
127
102
|
);
|
|
128
103
|
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
|
|
129
104
|
const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
105
|
+
|
|
106
|
+
// One stable SDK-deps object shared by all GSAP child hooks. Memoized so the
|
|
107
|
+
// hooks' callbacks keep a stable identity (an inline literal here re-fired the
|
|
108
|
+
// property-debounce flush on every render). refresh() soft-reloads (preserving
|
|
109
|
+
// the playhead) and invalidates the panel cache, matching the server path.
|
|
110
|
+
const sdkRefresh = useCallback(
|
|
111
|
+
(after: string) => {
|
|
112
|
+
const script = extractGsapScriptText(after);
|
|
113
|
+
if (!(script && applySoftReload(previewIframeRef.current, script))) reloadPreview();
|
|
114
|
+
onCacheInvalidate();
|
|
115
|
+
},
|
|
116
|
+
[previewIframeRef, reloadPreview, onCacheInvalidate],
|
|
117
|
+
);
|
|
118
|
+
// Reuse the SAME per-file serializer the legacy commitMutation path uses, so
|
|
119
|
+
// SDK gsap-write flushes serialize against legacy commits AND each other —
|
|
120
|
+
// overlapping same-file read-modify-writes can't interleave and lose an edit.
|
|
121
|
+
const serializeByFile = useCallback(
|
|
122
|
+
<T>(key: string, task: () => Promise<T>): Promise<T> => serializerRef.current(key, task),
|
|
123
|
+
[],
|
|
124
|
+
);
|
|
125
|
+
// Read the on-disk bytes of targetPath so the SDK GSAP persist captures the
|
|
126
|
+
// exact prior content as its undo `before` (matching the style/delete paths),
|
|
127
|
+
// instead of a normalized full-DOM re-emit that would reformat the whole file.
|
|
128
|
+
const readProjectFileContent = useCallback(
|
|
129
|
+
async (path: string): Promise<string> => {
|
|
130
|
+
const pid = projectIdRef.current;
|
|
131
|
+
if (!pid) throw new Error("No active project");
|
|
132
|
+
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
|
|
133
|
+
if (!res.ok) throw new Error(`Failed to read ${path}`);
|
|
134
|
+
const data = (await res.json()) as { content?: string };
|
|
135
|
+
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
|
|
136
|
+
return data.content;
|
|
137
|
+
},
|
|
138
|
+
[projectIdRef],
|
|
139
|
+
);
|
|
140
|
+
const sdkDeps = useMemo<CutoverDeps | null>(
|
|
141
|
+
() =>
|
|
142
|
+
writeProjectFile
|
|
143
|
+
? {
|
|
144
|
+
editHistory: { recordEdit: editHistory.recordEdit },
|
|
145
|
+
writeProjectFile,
|
|
146
|
+
reloadPreview,
|
|
147
|
+
domEditSaveTimestampRef,
|
|
148
|
+
refresh: sdkRefresh,
|
|
149
|
+
compositionPath: activeCompPath,
|
|
150
|
+
serialize: serializeByFile,
|
|
151
|
+
readProjectFile: readProjectFileContent,
|
|
152
|
+
}
|
|
153
|
+
: null,
|
|
154
|
+
[
|
|
155
|
+
editHistory.recordEdit,
|
|
156
|
+
writeProjectFile,
|
|
157
|
+
reloadPreview,
|
|
158
|
+
domEditSaveTimestampRef,
|
|
159
|
+
sdkRefresh,
|
|
160
|
+
activeCompPath,
|
|
161
|
+
serializeByFile,
|
|
162
|
+
readProjectFileContent,
|
|
163
|
+
],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const propertyOps = useGsapPropertyDebounce(commitMutationSafely, {
|
|
167
|
+
sdkSession,
|
|
168
|
+
sdkDeps,
|
|
169
|
+
activeCompPath,
|
|
170
|
+
});
|
|
171
|
+
const animationOps = useGsapAnimationOps({
|
|
172
|
+
projectIdRef,
|
|
173
|
+
activeCompPath,
|
|
174
|
+
commitMutation,
|
|
175
|
+
commitMutationSafely,
|
|
176
|
+
showToast,
|
|
177
|
+
sdkSession,
|
|
178
|
+
sdkDeps,
|
|
179
|
+
});
|
|
180
|
+
const keyframeOps = useGsapKeyframeOps({
|
|
181
|
+
activeCompPath,
|
|
182
|
+
commitMutation,
|
|
183
|
+
commitMutationSafely,
|
|
184
|
+
trackGsapSaveFailure,
|
|
185
|
+
sdkSession,
|
|
186
|
+
sdkDeps,
|
|
187
|
+
});
|
|
133
188
|
const arcPathOps = useGsapArcPathOps(commitMutationSafely);
|
|
134
189
|
return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps };
|
|
135
190
|
}
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { shouldReloadSdkSession } from "./useSdkSession";
|
|
3
3
|
|
|
4
|
+
// ── undo-sync contract ────────────────────────────────────────────────────────
|
|
5
|
+
// useSdkSession exposes forceReload() so callers can bypass the 2 s self-write
|
|
6
|
+
// suppress window. useAppHotkeys calls forceReload() after a successful
|
|
7
|
+
// undo/redo that wrote the active composition path. Without it, the suppress
|
|
8
|
+
// window swallows the file-change event and the SDK session stays stale.
|
|
9
|
+
//
|
|
10
|
+
// The React hook internals (useState / useEffect) cannot be unit-tested without
|
|
11
|
+
// a full render environment; the correctness of the suppress-bypass path is
|
|
12
|
+
// covered by the integration tests in usePersistentEditHistory.test.ts
|
|
13
|
+
// (which verify undo writes the correct before-content to disk).
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
4
16
|
describe("shouldReloadSdkSession", () => {
|
|
5
17
|
it("reloads when the changed file is the active composition", () => {
|
|
6
18
|
expect(shouldReloadSdkSession({ path: "scenes/intro.html" }, "scenes/intro.html")).toBe(true);
|