@hyperframes/studio 0.7.92 → 0.7.93
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/{hyperframes-player-9qOa08Tv.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CDTN2ZVa.js → index-CCGnc_0M.js} +1 -1
- package/dist/assets/index-D78KEjgB.css +1 -0
- package/dist/assets/index-Dp1zVb9X.js +428 -0
- package/dist/assets/{index-Bw4lNdtO.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +20 -20
- package/dist/index.html +2 -2
- package/dist/index.js +7055 -5321
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +14 -14
- package/src/components/DesignPanelPromoteProvider.tsx +1 -1
- package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
- package/src/components/ExternalFileConflictBanner.tsx +255 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
- package/src/components/editor/propertyPanelTypes.ts +9 -3
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/FileManagerContext.tsx +12 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
- package/src/hooks/externalFileReloadBus.test.ts +34 -0
- package/src/hooks/externalFileReloadBus.ts +18 -0
- package/src/hooks/gsapDragCommit.test.ts +39 -1
- package/src/hooks/gsapDragCommit.ts +12 -40
- package/src/hooks/gsapEditOutcome.ts +72 -0
- package/src/hooks/gsapResizeIntercept.test.ts +90 -2
- package/src/hooks/gsapResizeIntercept.ts +44 -16
- package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
- package/src/hooks/gsapRuntimeBridge.ts +93 -28
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
- package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
- package/src/hooks/useDomGeometryCommits.ts +20 -0
- package/src/hooks/useEditorSave.test.tsx +131 -0
- package/src/hooks/useEditorSave.ts +113 -39
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
- package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
- package/src/hooks/useFileManager.ts +32 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
- package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
- package/src/hooks/useGsapAwareEditing.ts +88 -27
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
- package/src/hooks/usePreviewPersistence.test.ts +23 -0
- package/src/hooks/usePreviewPersistence.ts +22 -58
- package/src/hooks/useSdkSession.ts +8 -52
- package/src/hooks/useStudioExternalFileChanges.ts +81 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -7
- package/src/hooks/useStudioSessionStart.ts +15 -0
- package/src/hooks/useStudioTestHooks.test.tsx +11 -7
- package/src/player/components/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +24 -5
- package/src/player/components/Timeline.tsx +40 -40
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +15 -3
- package/src/player/components/TimelineClipDiamonds.test.tsx +95 -10
- package/src/player/components/TimelineClipDiamonds.tsx +53 -13
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineLanes.test.tsx +64 -6
- package/src/player/components/TimelineLanes.tsx +81 -64
- package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
- package/src/player/components/TimelinePropertyLanes.tsx +9 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.tsx +16 -0
- package/src/player/components/TimelineTrackRow.tsx +58 -7
- package/src/player/components/timelineDiamondTypes.ts +14 -7
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/useTimelineClipRenderWindow.ts +4 -35
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
- package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
- package/src/player/components/useTimelineKeyboardActor.ts +147 -0
- package/src/player/components/useTimelineLogicalFocus.ts +80 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
- package/src/player/components/useTimelineLogicalRows.ts +47 -0
- package/src/player/components/useTimelineRowVirtualization.ts +16 -26
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +29 -22
- package/src/player/store/playerStore.ts +24 -29
- package/src/player/store/timelineFocusState.ts +15 -0
- package/src/utils/domEditSaveQueue.test.ts +37 -1
- package/src/utils/domEditSaveQueue.ts +22 -4
- package/src/utils/externalConflictStorage.test.ts +95 -0
- package/src/utils/externalConflictStorage.ts +172 -0
- package/src/utils/studioFileVersion.test.ts +28 -1
- package/src/utils/studioFileVersion.ts +30 -0
- package/src/utils/studioPendingEdits.test.ts +108 -1
- package/src/utils/studioPendingEdits.ts +39 -2
- package/src/utils/studioSaveDiagnostics.ts +5 -0
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-DIntkrQl.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- package/src/player/components/useTimelineRevealClip.ts +0 -180
|
@@ -30,6 +30,8 @@ import { logResize, logResizeSettle } from "../utils/resizeDebug";
|
|
|
30
30
|
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
|
31
31
|
import { runGestureTransaction } from "./gestureTransaction";
|
|
32
32
|
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
33
|
+
import { assertGsapEditPersisted } from "./gsapEditOutcome";
|
|
34
|
+
import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback";
|
|
33
35
|
|
|
34
36
|
// Distinct coalesceKey per group drag so consecutive group drags don't fold
|
|
35
37
|
// into one another's undo entry (module-local counter, not Date.now()).
|
|
@@ -42,7 +44,10 @@ export interface UseGsapAwareEditingParams {
|
|
|
42
44
|
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
43
45
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
44
46
|
bumpGsapCache: () => void;
|
|
45
|
-
makeFetchFallback: (
|
|
47
|
+
makeFetchFallback: (
|
|
48
|
+
selection: DomEditSelection,
|
|
49
|
+
options?: GsapAnimationFetchOptions,
|
|
50
|
+
) => () => Promise<GsapAnimation[]>;
|
|
46
51
|
trackGsapInteractionFailure: (
|
|
47
52
|
error: unknown,
|
|
48
53
|
selection: DomEditSelection,
|
|
@@ -112,7 +117,7 @@ export function useGsapAwareEditing({
|
|
|
112
117
|
) => {
|
|
113
118
|
if (gsapCommitMutation) {
|
|
114
119
|
try {
|
|
115
|
-
await tryGsapDragIntercept(
|
|
120
|
+
const outcome = await tryGsapDragIntercept(
|
|
116
121
|
selection,
|
|
117
122
|
next,
|
|
118
123
|
selectedGsapAnimations,
|
|
@@ -121,6 +126,7 @@ export function useGsapAwareEditing({
|
|
|
121
126
|
makeFetchFallback(selection),
|
|
122
127
|
modifiers,
|
|
123
128
|
);
|
|
129
|
+
assertGsapEditPersisted(outcome);
|
|
124
130
|
} catch (error) {
|
|
125
131
|
trackGsapInteractionFailure(error, selection, "drag", "Move animated layer");
|
|
126
132
|
throw error;
|
|
@@ -155,16 +161,42 @@ export function useGsapAwareEditing({
|
|
|
155
161
|
coalesceKey,
|
|
156
162
|
coalesceMs: Number.POSITIVE_INFINITY,
|
|
157
163
|
});
|
|
164
|
+
const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
|
|
165
|
+
// Editability is user-atomic: prove every member can be written before
|
|
166
|
+
// the first source mutation. Network failures after this point retain the
|
|
167
|
+
// existing multi-request semantics, but a blocked member can never leave
|
|
168
|
+
// earlier siblings partially moved.
|
|
169
|
+
for (const { selection } of updates) {
|
|
170
|
+
try {
|
|
171
|
+
const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
|
|
172
|
+
preflightAnimations.set(selection, animations);
|
|
173
|
+
const outcome = await tryGsapDragIntercept(
|
|
174
|
+
selection,
|
|
175
|
+
{ x: 0, y: 0 },
|
|
176
|
+
animations,
|
|
177
|
+
previewIframeRef.current,
|
|
178
|
+
coalescedCommit,
|
|
179
|
+
undefined,
|
|
180
|
+
{ preflightOnly: true },
|
|
181
|
+
);
|
|
182
|
+
assertGsapEditPersisted(outcome);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
158
188
|
for (const { selection, next } of updates) {
|
|
159
189
|
try {
|
|
160
|
-
await tryGsapDragIntercept(
|
|
190
|
+
const outcome = await tryGsapDragIntercept(
|
|
161
191
|
selection,
|
|
162
192
|
next,
|
|
163
|
-
[],
|
|
193
|
+
preflightAnimations.get(selection) ?? [],
|
|
164
194
|
previewIframeRef.current,
|
|
165
195
|
coalescedCommit,
|
|
166
196
|
makeFetchFallback(selection),
|
|
197
|
+
{ preflightPassed: true },
|
|
167
198
|
);
|
|
199
|
+
assertGsapEditPersisted(outcome);
|
|
168
200
|
} catch (error) {
|
|
169
201
|
trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
|
|
170
202
|
throw error;
|
|
@@ -217,7 +249,7 @@ export function useGsapAwareEditing({
|
|
|
217
249
|
if (gsapCommitMutation) {
|
|
218
250
|
const commitMutation = commit(gsapCommitMutation);
|
|
219
251
|
try {
|
|
220
|
-
const
|
|
252
|
+
const outcome = await tryGsapResizeIntercept(
|
|
221
253
|
selection,
|
|
222
254
|
next,
|
|
223
255
|
selectedGsapAnimations,
|
|
@@ -225,26 +257,26 @@ export function useGsapAwareEditing({
|
|
|
225
257
|
commitMutation,
|
|
226
258
|
makeFetchFallback(selection),
|
|
227
259
|
);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
logResizeSettle(selection.element, scaleRoute ? "gsap-scale" : "gsap-size");
|
|
246
|
-
return;
|
|
260
|
+
assertGsapEditPersisted(outcome);
|
|
261
|
+
logResize("intercept-handled", {
|
|
262
|
+
scaleRoute,
|
|
263
|
+
willForwardOffset: !!(offset && !scaleRoute),
|
|
264
|
+
});
|
|
265
|
+
// Scale-route resize persists its residual position internally.
|
|
266
|
+
// Width/height persists the already-settled anchor through drag.
|
|
267
|
+
if (offset && !scaleRoute) {
|
|
268
|
+
const dragOutcome = await tryGsapDragIntercept(
|
|
269
|
+
selection,
|
|
270
|
+
offset,
|
|
271
|
+
selectedGsapAnimations,
|
|
272
|
+
previewIframeRef.current,
|
|
273
|
+
commitMutation,
|
|
274
|
+
makeFetchFallback(selection),
|
|
275
|
+
);
|
|
276
|
+
assertGsapEditPersisted(dragOutcome);
|
|
247
277
|
}
|
|
278
|
+
logResizeSettle(selection.element, scaleRoute ? "gsap-scale" : "gsap-size");
|
|
279
|
+
return;
|
|
248
280
|
} catch (error) {
|
|
249
281
|
trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
|
|
250
282
|
throw error;
|
|
@@ -278,8 +310,9 @@ export function useGsapAwareEditing({
|
|
|
278
310
|
try {
|
|
279
311
|
// Single source of truth for rotation too: tryGsapRotationIntercept handles
|
|
280
312
|
// tweened elements (keyframes) and static ones (a tl.set), so there's no
|
|
281
|
-
// CSS-var fallback.
|
|
282
|
-
|
|
313
|
+
// CSS-var fallback. Selectorless/computed source rejects so the gesture
|
|
314
|
+
// transaction can restore its draft instead of reporting a false success.
|
|
315
|
+
const outcome = await tryGsapRotationIntercept(
|
|
283
316
|
selection,
|
|
284
317
|
next.angle,
|
|
285
318
|
selectedGsapAnimations,
|
|
@@ -287,6 +320,7 @@ export function useGsapAwareEditing({
|
|
|
287
320
|
gsapCommitMutation,
|
|
288
321
|
makeFetchFallback(selection),
|
|
289
322
|
);
|
|
323
|
+
assertGsapEditPersisted(outcome);
|
|
290
324
|
} catch (error) {
|
|
291
325
|
trackGsapInteractionFailure(error, selection, "rotation", "Rotate animated layer");
|
|
292
326
|
throw error;
|
|
@@ -304,7 +338,10 @@ export function useGsapAwareEditing({
|
|
|
304
338
|
|
|
305
339
|
// ── Animated property commit ──
|
|
306
340
|
|
|
307
|
-
const {
|
|
341
|
+
const {
|
|
342
|
+
commitAnimatedProperty: commitAnimatedPropertyRaw,
|
|
343
|
+
commitAnimatedProperties: commitAnimatedPropertiesRaw,
|
|
344
|
+
} = useAnimatedPropertyCommit({
|
|
308
345
|
selectedGsapAnimations,
|
|
309
346
|
gsapCommitMutation,
|
|
310
347
|
addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time),
|
|
@@ -313,6 +350,30 @@ export function useGsapAwareEditing({
|
|
|
313
350
|
bumpGsapCache,
|
|
314
351
|
});
|
|
315
352
|
|
|
353
|
+
const commitAnimatedProperties = useCallback(
|
|
354
|
+
async (selection: DomEditSelection, properties: Record<string, number | string>) => {
|
|
355
|
+
try {
|
|
356
|
+
await commitAnimatedPropertiesRaw(selection, properties);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
trackGsapInteractionFailure(error, selection, "property", "Edit animated property");
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
[commitAnimatedPropertiesRaw, trackGsapInteractionFailure],
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
const commitAnimatedProperty = useCallback(
|
|
366
|
+
async (selection: DomEditSelection, property: string, value: number | string) => {
|
|
367
|
+
try {
|
|
368
|
+
await commitAnimatedPropertyRaw(selection, property, value);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
trackGsapInteractionFailure(error, selection, "property", "Edit animated property");
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
[commitAnimatedPropertyRaw, trackGsapInteractionFailure],
|
|
375
|
+
);
|
|
376
|
+
|
|
316
377
|
// ── Arc path wrappers ──
|
|
317
378
|
|
|
318
379
|
const handleSetArcPath = useCallback(
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
6
|
+
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
7
|
+
import { GsapEditBlockedError } from "./gsapEditOutcome";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
const trackStudioSaveFailure = vi.hoisted(() => vi.fn());
|
|
12
|
+
vi.mock("../utils/studioSaveDiagnostics", () => ({ trackStudioSaveFailure }));
|
|
13
|
+
|
|
14
|
+
import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
|
|
15
|
+
|
|
16
|
+
describe("useGsapInteractionFailureTelemetry", () => {
|
|
17
|
+
it("surfaces the blocked reason instead of a generic save failure", () => {
|
|
18
|
+
const showToast = vi.fn();
|
|
19
|
+
const selection = {
|
|
20
|
+
id: "clip",
|
|
21
|
+
selector: "#clip",
|
|
22
|
+
element: document.createElement("div"),
|
|
23
|
+
} as unknown as DomEditSelection;
|
|
24
|
+
let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
|
|
25
|
+
function Harness() {
|
|
26
|
+
report = useGsapInteractionFailureTelemetry("index.html", showToast);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const root = mountReactHarness(<Harness />);
|
|
30
|
+
|
|
31
|
+
act(() => report(new GsapEditBlockedError("unroll-required"), selection, "drag", "Move"));
|
|
32
|
+
|
|
33
|
+
expect(showToast).toHaveBeenCalledWith(
|
|
34
|
+
"This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
|
|
35
|
+
"error",
|
|
36
|
+
);
|
|
37
|
+
expect(trackStudioSaveFailure).toHaveBeenCalledWith(
|
|
38
|
+
expect.objectContaining({ source: "gsap_commit", mutationType: "drag", targetId: "clip" }),
|
|
39
|
+
);
|
|
40
|
+
act(() => root.unmount());
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
3
|
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
|
4
|
+
import { isGsapEditBlockedError } from "./gsapEditOutcome";
|
|
4
5
|
|
|
5
6
|
export function useGsapInteractionFailureTelemetry(
|
|
6
7
|
activeCompPath: string | null,
|
|
@@ -18,7 +19,10 @@ export function useGsapInteractionFailureTelemetry(
|
|
|
18
19
|
targetSelector: selection.selector,
|
|
19
20
|
targetSourceFile: selection.sourceFile,
|
|
20
21
|
});
|
|
21
|
-
showToast(
|
|
22
|
+
showToast(
|
|
23
|
+
isGsapEditBlockedError(error) ? error.message : "Failed to save animated edit.",
|
|
24
|
+
"error",
|
|
25
|
+
);
|
|
22
26
|
},
|
|
23
27
|
[activeCompPath, showToast],
|
|
24
28
|
);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
3
|
+
import { drainStudioSaveQueues } from "./usePreviewPersistence";
|
|
4
|
+
|
|
5
|
+
describe("drainStudioSaveQueues", () => {
|
|
6
|
+
it("does not erase a pending-field conflict with a clean DOM queue", async () => {
|
|
7
|
+
const conflict = new StudioFileConflictError({
|
|
8
|
+
filePath: "index.html",
|
|
9
|
+
currentVersion: "v2",
|
|
10
|
+
currentContent: "external",
|
|
11
|
+
attemptedContent: "studio",
|
|
12
|
+
});
|
|
13
|
+
const waitForDomQueue = vi.fn(async () => ({ status: "clean" as const }));
|
|
14
|
+
|
|
15
|
+
await expect(
|
|
16
|
+
drainStudioSaveQueues(
|
|
17
|
+
async () => ({ status: "conflict" as const, error: conflict }),
|
|
18
|
+
waitForDomQueue,
|
|
19
|
+
),
|
|
20
|
+
).resolves.toEqual({ status: "conflict", error: conflict });
|
|
21
|
+
expect(waitForDomQueue).not.toHaveBeenCalled();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -3,12 +3,14 @@ import { useMountEffect } from "./useMountEffect";
|
|
|
3
3
|
import {
|
|
4
4
|
installStudioManualEditSeekReapply,
|
|
5
5
|
reapplyPositionEditsAfterSeek,
|
|
6
|
-
readStudioFileChangePath,
|
|
7
6
|
} from "../components/editor/manualEdits";
|
|
8
7
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
9
8
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
10
|
-
import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
|
|
11
|
-
import {
|
|
9
|
+
import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue";
|
|
10
|
+
import {
|
|
11
|
+
flushStudioPendingEdits,
|
|
12
|
+
type StudioPendingEditsDrainResult,
|
|
13
|
+
} from "../utils/studioPendingEdits";
|
|
12
14
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
13
15
|
import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore";
|
|
14
16
|
import { usePlayerStore } from "../player";
|
|
@@ -29,19 +31,12 @@ interface RecordEditInput {
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
interface UsePreviewPersistenceParams {
|
|
32
|
-
projectId: string | null;
|
|
33
34
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
34
35
|
readOptionalProjectFile: (path: string) => Promise<string>;
|
|
35
36
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
36
37
|
recordEdit: (entry: RecordEditInput) => Promise<void>;
|
|
37
38
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
38
39
|
activeCompPathRef: React.MutableRefObject<string | null>;
|
|
39
|
-
/** Shared timestamp ref — written by any studio save (code tab, timeline, DOM edits).
|
|
40
|
-
* Used to suppress file-change echoes so we don't reload after our own saves. */
|
|
41
|
-
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
42
|
-
/** Tracks in-flight timeline edits that patch the iframe DOM directly. File-change
|
|
43
|
-
* events for these paths are always suppressed since the preview is already up-to-date. */
|
|
44
|
-
pendingTimelineEditPathRef?: React.MutableRefObject<Set<string>>;
|
|
45
40
|
/** Called to reload the preview after undo/redo or external file changes. */
|
|
46
41
|
reloadPreview: () => void;
|
|
47
42
|
}
|
|
@@ -71,19 +66,13 @@ function installManualEditReapply(iframe: HTMLIFrameElement): void {
|
|
|
71
66
|
}
|
|
72
67
|
}
|
|
73
68
|
|
|
74
|
-
function
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const pendingTimelinePaths = pendingTimelineEditPathRef?.current;
|
|
82
|
-
if (pendingTimelinePaths?.has(changedPath)) {
|
|
83
|
-
pendingTimelinePaths.delete(changedPath);
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
return Date.now() - domEditSaveTimestampRef.current >= 4000;
|
|
69
|
+
export async function drainStudioSaveQueues(
|
|
70
|
+
flushPendingFields: () => Promise<StudioPendingEditsDrainResult>,
|
|
71
|
+
waitForDomQueue: () => Promise<DomEditSaveDrainResult>,
|
|
72
|
+
): Promise<StudioPendingEditsDrainResult | DomEditSaveDrainResult> {
|
|
73
|
+
const pending = await flushPendingFields();
|
|
74
|
+
if (pending.status !== "clean") return pending;
|
|
75
|
+
return waitForDomQueue();
|
|
87
76
|
}
|
|
88
77
|
|
|
89
78
|
// fallow-ignore-next-line complexity
|
|
@@ -107,16 +96,13 @@ async function clearLegacyStudioMotionFile(
|
|
|
107
96
|
// ── Hook ──
|
|
108
97
|
|
|
109
98
|
export function usePreviewPersistence({
|
|
110
|
-
projectId,
|
|
111
99
|
showToast,
|
|
112
100
|
readOptionalProjectFile: _readOptionalProjectFile,
|
|
113
101
|
writeProjectFile: _writeProjectFile,
|
|
114
102
|
recordEdit: _recordEdit,
|
|
115
103
|
previewIframeRef,
|
|
116
104
|
activeCompPathRef,
|
|
117
|
-
domEditSaveTimestampRef,
|
|
118
105
|
reloadPreview,
|
|
119
|
-
pendingTimelineEditPathRef,
|
|
120
106
|
}: UsePreviewPersistenceParams) {
|
|
121
107
|
void _recordEdit;
|
|
122
108
|
|
|
@@ -152,22 +138,23 @@ export function usePreviewPersistence({
|
|
|
152
138
|
});
|
|
153
139
|
}
|
|
154
140
|
|
|
155
|
-
// Keep a ref to the latest projectId so async save callbacks always read the
|
|
156
|
-
// current value, even when the callback was captured in a stale closure.
|
|
157
|
-
const projectIdRef = useRef(projectId);
|
|
158
|
-
projectIdRef.current = projectId;
|
|
159
|
-
|
|
160
141
|
// ── Queue / drain helpers ──
|
|
161
142
|
|
|
162
143
|
const queueDomEditSave = useCallback(<T>(save: () => Promise<T>): Promise<T> => {
|
|
163
144
|
return domEditSaveQueueRef.current?.enqueue(save) ?? save();
|
|
164
145
|
}, []);
|
|
165
146
|
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
147
|
+
const drainPendingDomEditSaves = useCallback(async () => {
|
|
148
|
+
return drainStudioSaveQueues(flushStudioPendingEdits, async () => {
|
|
149
|
+
return (await domEditSaveQueueRef.current?.waitForIdle()) ?? { status: "clean" as const };
|
|
150
|
+
});
|
|
169
151
|
}, []);
|
|
170
152
|
|
|
153
|
+
const waitForPendingDomEditSaves = useCallback(async (): Promise<void> => {
|
|
154
|
+
const result = await drainPendingDomEditSaves();
|
|
155
|
+
if (result.status !== "clean") throw result.error;
|
|
156
|
+
}, [drainPendingDomEditSaves]);
|
|
157
|
+
|
|
171
158
|
const resetDomEditSaveQueueBreaker = useCallback(() => {
|
|
172
159
|
domEditSaveQueueRef.current?.reset();
|
|
173
160
|
setDomEditSaveQueuePaused(null);
|
|
@@ -235,35 +222,12 @@ export function usePreviewPersistence({
|
|
|
235
222
|
void clearLegacyStudioMotionFile(_readOptionalProjectFile, _writeProjectFile);
|
|
236
223
|
});
|
|
237
224
|
|
|
238
|
-
// ── Listen for external file changes (HMR / SSE) ──
|
|
239
|
-
useMountEffect(() => {
|
|
240
|
-
const handler = (payload?: unknown) => {
|
|
241
|
-
if (
|
|
242
|
-
shouldReloadForStudioFileChange(
|
|
243
|
-
payload,
|
|
244
|
-
pendingTimelineEditPathRef,
|
|
245
|
-
domEditSaveTimestampRef,
|
|
246
|
-
)
|
|
247
|
-
) {
|
|
248
|
-
// fallow-ignore-next-line code-duplication
|
|
249
|
-
reloadPreview();
|
|
250
|
-
}
|
|
251
|
-
};
|
|
252
|
-
if (import.meta.hot) {
|
|
253
|
-
import.meta.hot.on("hf:file-change", handler);
|
|
254
|
-
return () => import.meta.hot?.off?.("hf:file-change", handler);
|
|
255
|
-
}
|
|
256
|
-
// SSE fallback for embedded studio server
|
|
257
|
-
const es = new EventSource("/api/events");
|
|
258
|
-
es.addEventListener("file-change", handler);
|
|
259
|
-
return () => es.close();
|
|
260
|
-
});
|
|
261
|
-
|
|
262
225
|
return {
|
|
263
226
|
domTextCommitVersionRef,
|
|
264
227
|
domEditSaveQueueRef,
|
|
265
228
|
applyStudioManualEditsToPreviewRef,
|
|
266
229
|
queueDomEditSave,
|
|
230
|
+
drainPendingDomEditSaves,
|
|
267
231
|
waitForPendingDomEditSaves,
|
|
268
232
|
domEditSaveQueuePaused,
|
|
269
233
|
resetDomEditSaveQueueBreaker,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
-
import type { MutableRefObject } from "react";
|
|
3
2
|
import { openComposition } from "@hyperframes/sdk";
|
|
4
3
|
import type { Composition } from "@hyperframes/sdk";
|
|
5
4
|
import { readStudioFileChangePath } from "../components/editor/manualEdits";
|
|
6
5
|
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
|
7
6
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
8
7
|
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
8
|
+
import { addExternalFileReloadListener } from "./externalFileReloadBus";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Read a project file's content, or undefined on a non-2xx (optional read).
|
|
@@ -48,22 +48,6 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
|
|
|
48
48
|
* stale. The session has NO persist queue — Studio is the sole file writer; see
|
|
49
49
|
* the open effect below.
|
|
50
50
|
*/
|
|
51
|
-
// Reload-suppression baseline: a file-change within this window of our own SDK
|
|
52
|
-
// cutover write is a CANDIDATE echo, but the decision is content-identity based
|
|
53
|
-
// (isSelfWriteEcho) not time-only — so an undo write that lands inside the window
|
|
54
|
-
// still reloads (its reverted bytes were never registered as a self-write). The
|
|
55
|
-
// window only bounds how long a registered self-write stays suppressible.
|
|
56
|
-
const SELF_WRITE_SUPPRESS_MS = 2000;
|
|
57
|
-
|
|
58
|
-
/** Best-effort read of the changed file's content from a file-change payload. */
|
|
59
|
-
function readFileChangeContent(payload: unknown): string | null {
|
|
60
|
-
if (!payload || typeof payload !== "object") return null;
|
|
61
|
-
const record = payload as Record<string, unknown>;
|
|
62
|
-
if (typeof record.content === "string") return record.content;
|
|
63
|
-
if ("data" in record) return readFileChangeContent(record.data);
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
51
|
/**
|
|
68
52
|
* Decide whether a file-change for the active composition should reload the SDK
|
|
69
53
|
* session. `content` is the new on-disk bytes (from the payload or a re-read);
|
|
@@ -157,7 +141,6 @@ function disposeSdkSession(session: Composition): void {
|
|
|
157
141
|
export function useSdkSession(
|
|
158
142
|
projectId: string | null,
|
|
159
143
|
activeCompPath: string | null,
|
|
160
|
-
domEditSaveTimestampRef?: MutableRefObject<number>,
|
|
161
144
|
): SdkSessionHandle {
|
|
162
145
|
const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
|
|
163
146
|
const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
|
|
@@ -171,40 +154,13 @@ export function useSdkSession(
|
|
|
171
154
|
const reloadTokenRef = useRef(reloadToken);
|
|
172
155
|
reloadTokenRef.current = reloadToken;
|
|
173
156
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const withinWindow =
|
|
182
|
-
!!domEditSaveTimestampRef &&
|
|
183
|
-
Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS;
|
|
184
|
-
const decide = (content: string | null) => {
|
|
185
|
-
if (shouldReloadOnFileChange(compPath, content, withinWindow)) setReloadToken((t) => t + 1);
|
|
186
|
-
};
|
|
187
|
-
const payloadContent = readFileChangeContent(payload);
|
|
188
|
-
// Prefer payload content; otherwise re-read so the decision is by IDENTITY
|
|
189
|
-
// (an undo's reverted bytes won't match a registered self-write → reload).
|
|
190
|
-
if (payloadContent != null || readProjectId == null) {
|
|
191
|
-
decide(payloadContent);
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
readProjectFileOptional(readProjectId, compPath)
|
|
195
|
-
.then((c) => decide(c ?? null))
|
|
196
|
-
.catch(() => decide(null));
|
|
197
|
-
};
|
|
198
|
-
if (import.meta.hot) {
|
|
199
|
-
import.meta.hot.on("hf:file-change", handler);
|
|
200
|
-
return () => import.meta.hot?.off?.("hf:file-change", handler);
|
|
201
|
-
}
|
|
202
|
-
// SSE fallback for the embedded studio server.
|
|
203
|
-
const es = new EventSource("/api/events");
|
|
204
|
-
es.addEventListener("file-change", handler);
|
|
205
|
-
return () => es.close();
|
|
206
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
207
|
-
}, [activeCompPath, projectId]);
|
|
157
|
+
useEffect(
|
|
158
|
+
() =>
|
|
159
|
+
addExternalFileReloadListener((changedPath) => {
|
|
160
|
+
if (changedPath === activeCompPathRef.current) setReloadToken((token) => token + 1);
|
|
161
|
+
}),
|
|
162
|
+
[],
|
|
163
|
+
);
|
|
208
164
|
|
|
209
165
|
// ── Open / re-open the session ──
|
|
210
166
|
useEffect(() => {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useCallback, type MutableRefObject } from "react";
|
|
2
|
+
import {
|
|
3
|
+
deleteExternalConflictSnapshot,
|
|
4
|
+
loadExternalConflictSnapshot,
|
|
5
|
+
persistExternalConflictSnapshot,
|
|
6
|
+
persistExternalFailureSnapshot,
|
|
7
|
+
} from "../utils/externalConflictStorage";
|
|
8
|
+
import { notifyExternalFileReload } from "./externalFileReloadBus";
|
|
9
|
+
import { useExternalFileChangeCoordinator } from "./useExternalFileChangeCoordinator";
|
|
10
|
+
import type { useFileManager } from "./useFileManager";
|
|
11
|
+
import type { usePreviewPersistence } from "./usePreviewPersistence";
|
|
12
|
+
|
|
13
|
+
type FileManager = Pick<
|
|
14
|
+
ReturnType<typeof useFileManager>,
|
|
15
|
+
| "editingFile"
|
|
16
|
+
| "flushPendingSourceSave"
|
|
17
|
+
| "discardPendingSourceSave"
|
|
18
|
+
| "getPendingSourceCandidate"
|
|
19
|
+
| "overwriteExternalConflict"
|
|
20
|
+
| "readProjectFile"
|
|
21
|
+
| "updateEditingFileContent"
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
type PreviewPersistence = Pick<
|
|
25
|
+
ReturnType<typeof usePreviewPersistence>,
|
|
26
|
+
"drainPendingDomEditSaves" | "resetDomEditSaveQueueBreaker"
|
|
27
|
+
>;
|
|
28
|
+
|
|
29
|
+
interface UseStudioExternalFileChangesOptions {
|
|
30
|
+
projectId: string | null;
|
|
31
|
+
activeCompPath: string | null;
|
|
32
|
+
masterCompPath: string | null;
|
|
33
|
+
fileManager: FileManager;
|
|
34
|
+
previewPersistence: PreviewPersistence;
|
|
35
|
+
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
|
|
36
|
+
reloadPreview: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Connects the app's save queues, recovery storage, and reload surfaces to one owner. */
|
|
40
|
+
export function useStudioExternalFileChanges({
|
|
41
|
+
projectId,
|
|
42
|
+
activeCompPath,
|
|
43
|
+
masterCompPath,
|
|
44
|
+
fileManager,
|
|
45
|
+
previewPersistence,
|
|
46
|
+
pendingTimelineEditPathRef,
|
|
47
|
+
reloadPreview,
|
|
48
|
+
}: UseStudioExternalFileChangesOptions) {
|
|
49
|
+
const { flushPendingSourceSave, discardPendingSourceSave } = fileManager;
|
|
50
|
+
const { drainPendingDomEditSaves, resetDomEditSaveQueueBreaker } = previewPersistence;
|
|
51
|
+
const drainPendingChanges = useCallback(async () => {
|
|
52
|
+
const source = await flushPendingSourceSave();
|
|
53
|
+
if (source.status !== "clean") return source;
|
|
54
|
+
return drainPendingDomEditSaves();
|
|
55
|
+
}, [drainPendingDomEditSaves, flushPendingSourceSave]);
|
|
56
|
+
|
|
57
|
+
const discardPendingChanges = useCallback(() => {
|
|
58
|
+
discardPendingSourceSave();
|
|
59
|
+
resetDomEditSaveQueueBreaker();
|
|
60
|
+
}, [discardPendingSourceSave, resetDomEditSaveQueueBreaker]);
|
|
61
|
+
|
|
62
|
+
return useExternalFileChangeCoordinator({
|
|
63
|
+
projectId,
|
|
64
|
+
activeCompPath,
|
|
65
|
+
recoveryFilePath: fileManager.editingFile?.path ?? activeCompPath ?? masterCompPath,
|
|
66
|
+
pendingTimelineEditPathRef,
|
|
67
|
+
drainPendingChanges,
|
|
68
|
+
getPendingCandidate: fileManager.getPendingSourceCandidate,
|
|
69
|
+
discardPendingChanges,
|
|
70
|
+
reloadPreview,
|
|
71
|
+
reloadSdkSession: notifyExternalFileReload,
|
|
72
|
+
persistConflictSnapshot: persistExternalConflictSnapshot,
|
|
73
|
+
persistFailureSnapshot: persistExternalFailureSnapshot,
|
|
74
|
+
loadConflictSnapshot: loadExternalConflictSnapshot,
|
|
75
|
+
deleteConflictSnapshot: deleteExternalConflictSnapshot,
|
|
76
|
+
overwriteConflict: fileManager.overwriteExternalConflict,
|
|
77
|
+
readProjectFile: fileManager.readProjectFile,
|
|
78
|
+
onUseExternalFile: fileManager.updateEditingFileContent,
|
|
79
|
+
resetSaveQueues: resetDomEditSaveQueueBreaker,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect
|
|
1
|
+
import { useEffect } from "react";
|
|
2
2
|
import { useSdkSession } from "./useSdkSession";
|
|
3
3
|
import { usePreviewVariablesStore } from "./previewVariablesStore";
|
|
4
4
|
|
|
@@ -17,18 +17,13 @@ import { usePreviewVariablesStore } from "./previewVariablesStore";
|
|
|
17
17
|
export function useStudioSdkSessions(
|
|
18
18
|
projectId: string | null,
|
|
19
19
|
activeCompPath: string | null,
|
|
20
|
-
domEditSaveTimestampRef: MutableRefObject<number>,
|
|
21
20
|
masterCompPath: string | null,
|
|
22
21
|
) {
|
|
23
22
|
// On the master view (no explicit comp) the schema panels target the project's
|
|
24
23
|
// resolved main composition — the first `.html` in the tree, not a hardcoded
|
|
25
24
|
// "index.html" that may not exist. `null` when the project has no composition
|
|
26
25
|
// yet, which correctly leaves the session (and the panels) empty.
|
|
27
|
-
const sdkHandle = useSdkSession(
|
|
28
|
-
projectId,
|
|
29
|
-
activeCompPath ?? masterCompPath,
|
|
30
|
-
domEditSaveTimestampRef,
|
|
31
|
-
);
|
|
26
|
+
const sdkHandle = useSdkSession(projectId, activeCompPath ?? masterCompPath);
|
|
32
27
|
const editFlowSdkSession = activeCompPath ? sdkHandle.session : null;
|
|
33
28
|
useEffect(() => {
|
|
34
29
|
usePreviewVariablesStore.getState().setValues(null);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { hasFiredSessionStart, markSessionStartFired } from "../telemetry/config";
|
|
3
|
+
import { trackStudioSessionStart } from "../telemetry/events";
|
|
4
|
+
|
|
5
|
+
export function useStudioSessionStart(
|
|
6
|
+
projectId: string | null,
|
|
7
|
+
resolving: boolean,
|
|
8
|
+
waitingForServer: boolean,
|
|
9
|
+
): void {
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (resolving || waitingForServer || hasFiredSessionStart()) return;
|
|
12
|
+
markSessionStartFired();
|
|
13
|
+
trackStudioSessionStart({ has_project: projectId != null });
|
|
14
|
+
}, [projectId, resolving, waitingForServer]);
|
|
15
|
+
}
|