@hyperframes/studio 0.7.58 → 0.7.60
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-CtTDO63S.js → hyperframes-player-3XTTaVNf.js} +1 -1
- package/dist/assets/{index-C47jAC3Q.js → index-D6etaey-.js} +1 -1
- package/dist/assets/index-DXbu6IPT.css +1 -0
- package/dist/assets/{index-DeQPzqwH.js → index-Dh_WhagG.js} +1 -1
- package/dist/assets/index-cH6NfVV_.js +426 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.html +2 -2
- package/dist/index.js +13755 -8627
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.d.ts +5 -0
- package/dist/styles/tailwind-preset.js +8 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -4
- package/src/components/DesignPanelPromoteProvider.tsx +15 -2
- package/src/components/StudioRightPanel.tsx +42 -53
- package/src/components/editor/AnimationCard.test.tsx +134 -0
- package/src/components/editor/AnimationCard.tsx +19 -4
- package/src/components/editor/ArcPathControls.tsx +1 -0
- package/src/components/editor/GestureRecordControl.tsx +7 -1
- package/src/components/editor/GsapAnimationSection.tsx +109 -14
- package/src/components/editor/InspectorHeaderActions.tsx +42 -4
- package/src/components/editor/PropertyPanel.test.tsx +948 -0
- package/src/components/editor/PropertyPanel.tsx +144 -148
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
- package/src/components/editor/PropertyPanelFlat.tsx +580 -0
- package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
- package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
- package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
- package/src/components/editor/gsapLivePreview.ts +34 -0
- package/src/components/editor/manualEditingAvailability.test.ts +10 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
- package/src/components/editor/propertyPanelColor.test.tsx +28 -0
- package/src/components/editor/propertyPanelColor.tsx +31 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
- package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
- package/src/components/editor/propertyPanelFill.tsx +22 -5
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +107 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
- package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
- package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
- package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
- package/src/components/editor/propertyPanelFont.test.tsx +30 -0
- package/src/components/editor/propertyPanelFont.tsx +136 -95
- package/src/components/editor/propertyPanelHelpers.ts +73 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
- package/src/components/editor/propertyPanelSections.test.tsx +161 -0
- package/src/components/editor/propertyPanelSections.tsx +154 -90
- package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +14 -1
- package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
- package/src/components/editor/propertyPanelValueTier.ts +28 -0
- package/src/components/editor/useColorGradingController.test.ts +418 -0
- package/src/components/editor/useColorGradingController.ts +596 -0
- package/src/components/panels/VariablesPanel.tsx +4 -0
- package/src/components/sidebar/CompositionsTab.test.ts +28 -2
- package/src/components/sidebar/CompositionsTab.tsx +3 -1
- package/src/components/storyboard/FramePoster.tsx +24 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
- package/src/components/storyboard/StoryboardGrid.tsx +23 -1
- package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
- package/src/components/storyboard/StoryboardView.tsx +9 -2
- package/src/components/storyboard/frameComments.test.ts +95 -0
- package/src/components/storyboard/frameComments.ts +115 -0
- package/src/components/storyboard/useFrameComments.ts +82 -0
- package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
- package/src/contexts/DesignPanelInputContext.tsx +48 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/VariablePromoteContext.test.tsx +67 -0
- package/src/contexts/VariablePromoteContext.tsx +10 -5
- package/src/hooks/domEditCommitRunner.ts +11 -0
- package/src/hooks/gsapScriptCommitTypes.ts +6 -4
- package/src/hooks/timelineEditingHelpers.ts +2 -2
- package/src/hooks/timelineTrackVisibility.test.ts +52 -0
- package/src/hooks/timelineTrackVisibility.ts +27 -10
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomEditAttributeCommits.ts +123 -1
- package/src/hooks/useDomEditCommits.test.tsx +41 -1
- package/src/hooks/useDomEditCommits.ts +31 -177
- package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
- package/src/hooks/useDomEditSession.ts +18 -6
- package/src/hooks/useDomEditTextCommits.ts +14 -9
- package/src/hooks/useDomEditWiring.ts +10 -9
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +8 -3
- package/src/hooks/useFileManager.projectOwnership.test.tsx +109 -0
- package/src/hooks/useFileManager.ts +151 -65
- package/src/hooks/useGsapAnimationOps.ts +7 -6
- package/src/hooks/useGsapKeyframeOps.ts +6 -5
- package/src/hooks/useGsapPropertyDebounce.ts +43 -28
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
- package/src/hooks/useGsapScriptCommits.ts +150 -50
- package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
- package/src/hooks/useGsapSelectionHandlers.ts +94 -31
- package/src/hooks/useInspectorSplitResize.ts +51 -0
- package/src/hooks/usePanelLayout.test.ts +118 -0
- package/src/hooks/usePanelLayout.ts +24 -1
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
- package/src/hooks/usePersistentEditHistory.test.ts +49 -1
- package/src/hooks/usePersistentEditHistory.ts +71 -31
- package/src/hooks/usePreviewPersistence.ts +4 -1
- package/src/hooks/useProjectCompositionVariables.ts +10 -10
- package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
- package/src/hooks/useProjectSignaturePoll.ts +68 -0
- package/src/hooks/useRazorSplit.history.test.tsx +12 -4
- package/src/hooks/useRazorSplit.test.tsx +88 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
- package/src/hooks/useRazorSplit.ts +50 -16
- package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
- package/src/hooks/useSdkSession.ts +155 -14
- package/src/hooks/useSlideshowPersist.ts +7 -1
- package/src/hooks/useStoryboard.ts +23 -4
- package/src/hooks/useTimelineEditing.test.tsx +4 -1
- package/src/hooks/useTimelineEditing.ts +18 -10
- package/src/hooks/useTimelineEditingTypes.ts +5 -1
- package/src/hooks/useTimelineGroupEditing.ts +17 -3
- package/src/hooks/useVariablesPersist.ts +9 -7
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/styles/studio.css +22 -0
- package/src/styles/tailwind-preset.shared.js +7 -0
- package/src/utils/designInputTracking.test.ts +97 -0
- package/src/utils/designInputTracking.ts +80 -0
- package/src/utils/domEditSaveQueue.test.ts +19 -0
- package/src/utils/domEditSaveQueue.ts +2 -1
- package/src/utils/sdkCutover.gate.test.ts +7 -7
- package/src/utils/sdkCutover.test.ts +511 -62
- package/src/utils/sdkCutover.ts +181 -219
- package/src/utils/sdkEditTransaction.ts +293 -0
- package/src/utils/sdkResolverAttempts.ts +94 -0
- package/src/utils/sdkResolverShadow.test.ts +148 -0
- package/src/utils/sdkResolverShadow.ts +139 -106
- package/src/utils/setSlideshowManifest.test.ts +0 -9
- package/src/utils/setSlideshowManifest.ts +20 -27
- package/src/utils/studioFileHistory.test.ts +41 -0
- package/src/utils/studioFileHistory.ts +34 -29
- package/src/utils/studioFileMutationCoordinator.ts +45 -0
- package/src/utils/studioFileVersion.test.ts +36 -0
- package/src/utils/studioFileVersion.ts +23 -0
- package/src/utils/studioHelpers.test.ts +45 -0
- package/src/utils/studioHelpers.ts +45 -14
- package/src/utils/studioSaveDiagnostics.test.ts +18 -0
- package/src/utils/studioSaveDiagnostics.ts +38 -0
- package/dist/assets/index-B_UvTX3E.js +0 -423
- package/dist/assets/index-uahwWkgw.css +0 -1
|
@@ -30,6 +30,7 @@ interface RecordEditInput {
|
|
|
30
30
|
interface ApplyCallbacks {
|
|
31
31
|
readFile: (path: string) => Promise<string>;
|
|
32
32
|
writeFile: (path: string, content: string) => Promise<void>;
|
|
33
|
+
serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
interface UsePersistentEditHistoryOptions {
|
|
@@ -167,31 +168,32 @@ async function applyHistoryStep(
|
|
|
167
168
|
if (!entry) {
|
|
168
169
|
return { state: currentState, result: { ok: false, reason: "empty" } };
|
|
169
170
|
}
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
callbacks.readFile
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
171
|
+
const paths = Object.keys(entry.files);
|
|
172
|
+
const apply = async (): Promise<{ state: EditHistoryState; result: ApplyResult }> => {
|
|
173
|
+
const { currentFiles, currentHashes } = await readCurrentFileHashes(paths, callbacks.readFile);
|
|
174
|
+
const result = transition(currentState, currentHashes, now());
|
|
175
|
+
if (!result.ok) {
|
|
176
|
+
return {
|
|
177
|
+
state: currentState,
|
|
178
|
+
result: { ok: false, reason: result.reason },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
await writeFilesWithRollback({
|
|
182
|
+
files: result.filesToWrite,
|
|
183
|
+
rollbackFiles: currentFiles,
|
|
184
|
+
writeFile: callbacks.writeFile,
|
|
185
|
+
});
|
|
176
186
|
return {
|
|
177
|
-
state:
|
|
178
|
-
result: {
|
|
187
|
+
state: result.state,
|
|
188
|
+
result: {
|
|
189
|
+
ok: true,
|
|
190
|
+
label: result.entry.label,
|
|
191
|
+
paths: Object.keys(result.entry.files),
|
|
192
|
+
files: restoredFilesMap(result.filesToWrite, currentFiles),
|
|
193
|
+
},
|
|
179
194
|
};
|
|
180
|
-
}
|
|
181
|
-
await writeFilesWithRollback({
|
|
182
|
-
files: result.filesToWrite,
|
|
183
|
-
rollbackFiles: currentFiles,
|
|
184
|
-
writeFile: callbacks.writeFile,
|
|
185
|
-
});
|
|
186
|
-
return {
|
|
187
|
-
state: result.state,
|
|
188
|
-
result: {
|
|
189
|
-
ok: true,
|
|
190
|
-
label: result.entry.label,
|
|
191
|
-
paths: Object.keys(result.entry.files),
|
|
192
|
-
files: restoredFilesMap(result.filesToWrite, currentFiles),
|
|
193
|
-
},
|
|
194
195
|
};
|
|
196
|
+
return callbacks.serialize ? callbacks.serialize(paths, apply) : apply();
|
|
195
197
|
}
|
|
196
198
|
|
|
197
199
|
export function createPersistentEditHistoryStore({
|
|
@@ -305,11 +307,15 @@ export function usePersistentEditHistory(options: UsePersistentEditHistoryOption
|
|
|
305
307
|
const [loaded, setLoaded] = useState(false);
|
|
306
308
|
const projectId = options.projectId;
|
|
307
309
|
const storeRef = useRef<ReturnType<typeof createPersistentEditHistoryStore> | null>(null);
|
|
310
|
+
const storeProjectIdRef = useRef<string | null>(null);
|
|
311
|
+
const activeProjectIdRef = useRef(projectId);
|
|
312
|
+
activeProjectIdRef.current = projectId;
|
|
308
313
|
|
|
309
314
|
useEffect(() => {
|
|
310
315
|
let cancelled = false;
|
|
311
316
|
const emptyState = createEmptyEditHistory();
|
|
312
317
|
storeRef.current = null;
|
|
318
|
+
storeProjectIdRef.current = null;
|
|
313
319
|
setState(emptyState);
|
|
314
320
|
setLoaded(false);
|
|
315
321
|
if (!projectId) {
|
|
@@ -327,6 +333,7 @@ export function usePersistentEditHistory(options: UsePersistentEditHistoryOption
|
|
|
327
333
|
now,
|
|
328
334
|
onChange: setState,
|
|
329
335
|
});
|
|
336
|
+
storeProjectIdRef.current = projectId;
|
|
330
337
|
setState(loadedState);
|
|
331
338
|
})
|
|
332
339
|
.catch(() => {
|
|
@@ -338,6 +345,7 @@ export function usePersistentEditHistory(options: UsePersistentEditHistoryOption
|
|
|
338
345
|
now,
|
|
339
346
|
onChange: setState,
|
|
340
347
|
});
|
|
348
|
+
storeProjectIdRef.current = projectId;
|
|
341
349
|
setState(emptyState);
|
|
342
350
|
})
|
|
343
351
|
.finally(() => {
|
|
@@ -349,17 +357,49 @@ export function usePersistentEditHistory(options: UsePersistentEditHistoryOption
|
|
|
349
357
|
};
|
|
350
358
|
}, [now, projectId, storage]);
|
|
351
359
|
|
|
352
|
-
const recordEdit = useCallback(
|
|
353
|
-
|
|
354
|
-
|
|
360
|
+
const recordEdit = useCallback(
|
|
361
|
+
async (input: RecordEditInput) => {
|
|
362
|
+
if (!projectId) return;
|
|
363
|
+
if (activeProjectIdRef.current !== projectId) {
|
|
364
|
+
throw new Error(`Cannot record an edit for inactive project ${projectId}`);
|
|
365
|
+
}
|
|
366
|
+
const store = storeRef.current;
|
|
367
|
+
if (!store) return;
|
|
368
|
+
if (storeProjectIdRef.current !== projectId) {
|
|
369
|
+
throw new Error(`Edit history store does not belong to project ${projectId}`);
|
|
370
|
+
}
|
|
371
|
+
await store.recordEdit(input);
|
|
372
|
+
},
|
|
373
|
+
[projectId],
|
|
374
|
+
);
|
|
355
375
|
|
|
356
|
-
const undo = useCallback(
|
|
357
|
-
|
|
358
|
-
|
|
376
|
+
const undo = useCallback(
|
|
377
|
+
async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
|
378
|
+
if (
|
|
379
|
+
!projectId ||
|
|
380
|
+
activeProjectIdRef.current !== projectId ||
|
|
381
|
+
storeProjectIdRef.current !== projectId
|
|
382
|
+
) {
|
|
383
|
+
return { ok: false, reason: "empty" };
|
|
384
|
+
}
|
|
385
|
+
return storeRef.current?.undo(callbacks) ?? { ok: false, reason: "empty" };
|
|
386
|
+
},
|
|
387
|
+
[projectId],
|
|
388
|
+
);
|
|
359
389
|
|
|
360
|
-
const redo = useCallback(
|
|
361
|
-
|
|
362
|
-
|
|
390
|
+
const redo = useCallback(
|
|
391
|
+
async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
|
392
|
+
if (
|
|
393
|
+
!projectId ||
|
|
394
|
+
activeProjectIdRef.current !== projectId ||
|
|
395
|
+
storeProjectIdRef.current !== projectId
|
|
396
|
+
) {
|
|
397
|
+
return { ok: false, reason: "empty" };
|
|
398
|
+
}
|
|
399
|
+
return storeRef.current?.redo(callbacks) ?? { ok: false, reason: "empty" };
|
|
400
|
+
},
|
|
401
|
+
[projectId],
|
|
402
|
+
);
|
|
363
403
|
|
|
364
404
|
return {
|
|
365
405
|
loaded,
|
|
@@ -133,7 +133,10 @@ export function usePreviewPersistence({
|
|
|
133
133
|
if (!domEditSaveQueueRef.current) {
|
|
134
134
|
domEditSaveQueueRef.current = createDomEditSaveQueue({
|
|
135
135
|
onOpen: (event) => {
|
|
136
|
-
const message =
|
|
136
|
+
const message =
|
|
137
|
+
event.statusCode === 409
|
|
138
|
+
? "Save paused: this file changed elsewhere. Reload and review the latest version before reapplying your edit."
|
|
139
|
+
: "Auto-save is paused. Check your connection.";
|
|
137
140
|
setDomEditSaveQueuePaused(message);
|
|
138
141
|
showToastRef.current(message, "error");
|
|
139
142
|
trackStudioEvent("save_queue_paused", {
|
|
@@ -103,17 +103,16 @@ export function useEditVariablesInFile(deps: EditVariablesDeps) {
|
|
|
103
103
|
return useCallback(
|
|
104
104
|
async (path: string, label: string, mutate: (session: Composition) => void): Promise<void> => {
|
|
105
105
|
const originalContent = await readProjectFile(path);
|
|
106
|
-
const comp = await openComposition(originalContent, { history: false });
|
|
107
|
-
let after: string;
|
|
108
|
-
try {
|
|
109
|
-
mutate(comp);
|
|
110
|
-
after = comp.serialize();
|
|
111
|
-
} finally {
|
|
112
|
-
comp.dispose();
|
|
113
|
-
}
|
|
114
|
-
if (after === originalContent) return;
|
|
115
106
|
await persistSdkSerialize(
|
|
116
|
-
|
|
107
|
+
async (onDiskBefore) => {
|
|
108
|
+
const comp = await openComposition(onDiskBefore, { history: false });
|
|
109
|
+
try {
|
|
110
|
+
mutate(comp);
|
|
111
|
+
return comp.serialize();
|
|
112
|
+
} finally {
|
|
113
|
+
comp.dispose();
|
|
114
|
+
}
|
|
115
|
+
},
|
|
117
116
|
path,
|
|
118
117
|
originalContent,
|
|
119
118
|
{
|
|
@@ -122,6 +121,7 @@ export function useEditVariablesInFile(deps: EditVariablesDeps) {
|
|
|
122
121
|
reloadPreview,
|
|
123
122
|
domEditSaveTimestampRef,
|
|
124
123
|
compositionPath: path,
|
|
124
|
+
readProjectFile,
|
|
125
125
|
},
|
|
126
126
|
{ label },
|
|
127
127
|
);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { useProjectSignaturePoll } from "./useProjectSignaturePoll";
|
|
7
|
+
|
|
8
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
9
|
+
|
|
10
|
+
function HookHost({
|
|
11
|
+
signature,
|
|
12
|
+
onChange,
|
|
13
|
+
}: {
|
|
14
|
+
signature: string | undefined;
|
|
15
|
+
onChange: () => void;
|
|
16
|
+
}) {
|
|
17
|
+
useProjectSignaturePoll("demo", signature, onChange);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let root: Root | null = null;
|
|
22
|
+
let container: HTMLElement | null = null;
|
|
23
|
+
|
|
24
|
+
function mount(signature: string | undefined, onChange: () => void): void {
|
|
25
|
+
act(() => {
|
|
26
|
+
if (!root) {
|
|
27
|
+
container = document.createElement("div");
|
|
28
|
+
document.body.appendChild(container);
|
|
29
|
+
root = createRoot(container);
|
|
30
|
+
}
|
|
31
|
+
root.render(<HookHost signature={signature} onChange={onChange} />);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function unmount(): void {
|
|
36
|
+
if (root) {
|
|
37
|
+
act(() => root?.unmount());
|
|
38
|
+
root = null;
|
|
39
|
+
}
|
|
40
|
+
container?.remove();
|
|
41
|
+
container = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function mockSignatureResponse(signature: string): ReturnType<typeof vi.fn> {
|
|
45
|
+
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ signature }) }));
|
|
46
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
47
|
+
return fetchMock;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("useProjectSignaturePoll", () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.useFakeTimers();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
unmount();
|
|
57
|
+
vi.useRealTimers();
|
|
58
|
+
vi.unstubAllGlobals();
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("fires onChange when the polled signature moves past the loaded one", async () => {
|
|
63
|
+
const fetchMock = mockSignatureResponse("sig-b");
|
|
64
|
+
const onChange = vi.fn();
|
|
65
|
+
mount("sig-a", onChange);
|
|
66
|
+
|
|
67
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
68
|
+
expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/signature");
|
|
69
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("stays quiet while the polled signature matches", async () => {
|
|
73
|
+
mockSignatureResponse("sig-a");
|
|
74
|
+
const onChange = vi.fn();
|
|
75
|
+
mount("sig-a", onChange);
|
|
76
|
+
|
|
77
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
78
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does not poll before a baseline signature exists", async () => {
|
|
82
|
+
const fetchMock = mockSignatureResponse("sig-a");
|
|
83
|
+
const onChange = vi.fn();
|
|
84
|
+
mount(undefined, onChange);
|
|
85
|
+
|
|
86
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
87
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
88
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("re-arms against the caller's refreshed signature without re-mounting", async () => {
|
|
92
|
+
mockSignatureResponse("sig-b");
|
|
93
|
+
const onChange = vi.fn();
|
|
94
|
+
mount("sig-a", onChange);
|
|
95
|
+
|
|
96
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
97
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
98
|
+
|
|
99
|
+
// The caller refetched and now holds the polled signature — no more firing.
|
|
100
|
+
mount("sig-b", onChange);
|
|
101
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
102
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("skips ticks while the tab is hidden and re-checks on visibility", async () => {
|
|
106
|
+
const fetchMock = mockSignatureResponse("sig-b");
|
|
107
|
+
const onChange = vi.fn();
|
|
108
|
+
let hidden = true;
|
|
109
|
+
vi.spyOn(document, "hidden", "get").mockImplementation(() => hidden);
|
|
110
|
+
mount("sig-a", onChange);
|
|
111
|
+
|
|
112
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
113
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
114
|
+
|
|
115
|
+
hidden = false;
|
|
116
|
+
await act(async () => {
|
|
117
|
+
document.dispatchEvent(new Event("visibilitychange"));
|
|
118
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
119
|
+
});
|
|
120
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("stops polling after unmount", async () => {
|
|
124
|
+
mockSignatureResponse("sig-b");
|
|
125
|
+
const onChange = vi.fn();
|
|
126
|
+
mount("sig-a", onChange);
|
|
127
|
+
|
|
128
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
129
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
130
|
+
|
|
131
|
+
unmount();
|
|
132
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
133
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { buildProjectApiPath } from "../utils/projectRouting";
|
|
3
|
+
|
|
4
|
+
const POLL_INTERVAL_MS = 2000;
|
|
5
|
+
|
|
6
|
+
/** One poll: the current signature, or null on any failure (skip this tick). */
|
|
7
|
+
async function fetchProjectSignature(projectId: string): Promise<string | null> {
|
|
8
|
+
try {
|
|
9
|
+
const res = await fetch(buildProjectApiPath(projectId, "/signature"));
|
|
10
|
+
if (!res.ok) return null;
|
|
11
|
+
const body = (await res.json()) as { signature?: string };
|
|
12
|
+
return typeof body.signature === "string" ? body.signature : null;
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Poll the project's content signature and fire `onChange` when it no longer
|
|
20
|
+
* matches `currentSignature` — the storyboard board uses this to refresh itself
|
|
21
|
+
* while an agent writes sketch frames to disk.
|
|
22
|
+
*
|
|
23
|
+
* The comparison baseline is the signature the caller's data was loaded with,
|
|
24
|
+
* so a refetch triggered by `onChange` naturally re-arms the poll with the new
|
|
25
|
+
* value. Ticks are skipped while the tab is hidden (a visibility flip re-checks
|
|
26
|
+
* immediately) and while a previous request is still in flight; request
|
|
27
|
+
* failures are ignored — polling degrades to today's manual-reload behavior.
|
|
28
|
+
*/
|
|
29
|
+
export function useProjectSignaturePoll(
|
|
30
|
+
projectId: string | null,
|
|
31
|
+
currentSignature: string | undefined,
|
|
32
|
+
onChange: () => void,
|
|
33
|
+
): void {
|
|
34
|
+
const signatureRef = useRef(currentSignature);
|
|
35
|
+
const onChangeRef = useRef(onChange);
|
|
36
|
+
signatureRef.current = currentSignature;
|
|
37
|
+
onChangeRef.current = onChange;
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!projectId) return;
|
|
41
|
+
let disposed = false;
|
|
42
|
+
let inFlight = false;
|
|
43
|
+
|
|
44
|
+
const tick = async () => {
|
|
45
|
+
if (disposed || inFlight || document.hidden) return;
|
|
46
|
+
// No baseline yet (initial storyboard fetch still loading, or an older
|
|
47
|
+
// server without the signature field) — nothing to compare against.
|
|
48
|
+
if (signatureRef.current === undefined) return;
|
|
49
|
+
inFlight = true;
|
|
50
|
+
const latest = await fetchProjectSignature(projectId);
|
|
51
|
+
inFlight = false;
|
|
52
|
+
if (disposed || latest === null) return;
|
|
53
|
+
if (latest !== signatureRef.current) onChangeRef.current();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const interval = window.setInterval(() => void tick(), POLL_INTERVAL_MS);
|
|
57
|
+
const onVisibilityChange = () => {
|
|
58
|
+
if (!document.hidden) void tick();
|
|
59
|
+
};
|
|
60
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
61
|
+
|
|
62
|
+
return () => {
|
|
63
|
+
disposed = true;
|
|
64
|
+
window.clearInterval(interval);
|
|
65
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
66
|
+
};
|
|
67
|
+
}, [projectId]);
|
|
68
|
+
}
|
|
@@ -76,9 +76,10 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
|
|
|
76
76
|
// Mirror the server: rewrites the GSAP script for the new id, writes to
|
|
77
77
|
// disk, returns the final content.
|
|
78
78
|
disk["index.html"] = SPLIT_GSAP;
|
|
79
|
-
|
|
79
|
+
const version = `"test-gsap-${SPLIT_GSAP.length}"`;
|
|
80
|
+
return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP, version }), {
|
|
80
81
|
status: 200,
|
|
81
|
-
headers: { "Content-Type": "application/json" },
|
|
82
|
+
headers: { "Content-Type": "application/json", ETag: version },
|
|
82
83
|
});
|
|
83
84
|
}
|
|
84
85
|
// The fixture has no GSAP script — mirror the server's 400 response.
|
|
@@ -89,9 +90,16 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
|
|
|
89
90
|
}
|
|
90
91
|
if (u.includes("/file-mutations/split-element/")) {
|
|
91
92
|
disk["index.html"] = SPLIT;
|
|
93
|
+
const version = `"test-split-${SPLIT.length}"`;
|
|
92
94
|
return new Response(
|
|
93
|
-
JSON.stringify({
|
|
94
|
-
|
|
95
|
+
JSON.stringify({
|
|
96
|
+
ok: true,
|
|
97
|
+
changed: true,
|
|
98
|
+
content: SPLIT,
|
|
99
|
+
newId: "clip1-split",
|
|
100
|
+
version,
|
|
101
|
+
}),
|
|
102
|
+
{ status: 200, headers: { "Content-Type": "application/json", ETag: version } },
|
|
95
103
|
);
|
|
96
104
|
}
|
|
97
105
|
if (u.includes("/files/")) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { TimelineElement } from "../player";
|
|
6
|
+
import { useRazorSplit } from "./useRazorSplit";
|
|
7
|
+
|
|
8
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
11
|
+
return new Response(JSON.stringify(body), {
|
|
12
|
+
status,
|
|
13
|
+
headers: { "Content-Type": "application/json" },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("useRazorSplit mutation versions", () => {
|
|
18
|
+
afterEach(() => vi.restoreAllMocks());
|
|
19
|
+
|
|
20
|
+
it("observes each out-of-band mutation version before the OCC writer runs", async () => {
|
|
21
|
+
const original = '<div id="clip" data-start="0" data-duration="4">Clip</div>';
|
|
22
|
+
const htmlSplit =
|
|
23
|
+
'<div id="clip" data-start="0" data-duration="2">Clip</div><div id="clip-split" data-start="2" data-duration="2">Clip</div>';
|
|
24
|
+
const final = `${htmlSplit}<script>window.__timelines = {}</script>`;
|
|
25
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
26
|
+
const url = String(input);
|
|
27
|
+
if (url.includes("/files/")) return jsonResponse({ content: original });
|
|
28
|
+
if (url.includes("/file-mutations/split-element/")) {
|
|
29
|
+
return jsonResponse({ ok: true, changed: true, content: htmlSplit, version: '"v-html"' });
|
|
30
|
+
}
|
|
31
|
+
if (url.includes("/gsap-mutations/")) {
|
|
32
|
+
return jsonResponse({ ok: true, changed: true, after: final, version: '"v-gsap"' });
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`Unexpected request: ${url}`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const order: string[] = [];
|
|
38
|
+
const observeProjectFileVersion = vi.fn((path: string, version: string | null) => {
|
|
39
|
+
order.push(`observe:${path}:${version}`);
|
|
40
|
+
});
|
|
41
|
+
const writeProjectFile = vi.fn(async () => {
|
|
42
|
+
order.push("write");
|
|
43
|
+
});
|
|
44
|
+
const recordEdit = vi.fn().mockResolvedValue(undefined);
|
|
45
|
+
let split: ((element: TimelineElement, splitTime: number) => Promise<void>) | undefined;
|
|
46
|
+
const container = document.createElement("div");
|
|
47
|
+
document.body.appendChild(container);
|
|
48
|
+
const root = createRoot(container);
|
|
49
|
+
|
|
50
|
+
function Harness() {
|
|
51
|
+
split = useRazorSplit({
|
|
52
|
+
projectId: "p1",
|
|
53
|
+
activeCompPath: "index.html",
|
|
54
|
+
showToast: vi.fn(),
|
|
55
|
+
writeProjectFile,
|
|
56
|
+
observeProjectFileVersion,
|
|
57
|
+
recordEdit,
|
|
58
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
59
|
+
reloadPreview: vi.fn(),
|
|
60
|
+
}).handleRazorSplit;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await act(async () => root.render(React.createElement(Harness)));
|
|
65
|
+
await act(async () => {
|
|
66
|
+
await split?.(
|
|
67
|
+
{
|
|
68
|
+
id: "clip",
|
|
69
|
+
domId: "clip",
|
|
70
|
+
hfId: "clip",
|
|
71
|
+
tag: "div",
|
|
72
|
+
start: 0,
|
|
73
|
+
duration: 4,
|
|
74
|
+
track: 0,
|
|
75
|
+
timingSource: "authored",
|
|
76
|
+
},
|
|
77
|
+
2,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(order).toEqual(['observe:index.html:"v-html"', 'observe:index.html:"v-gsap"', "write"]);
|
|
82
|
+
expect(writeProjectFile).toHaveBeenCalledWith("index.html", final, original);
|
|
83
|
+
expect(recordEdit).toHaveBeenCalledTimes(1);
|
|
84
|
+
|
|
85
|
+
act(() => root.unmount());
|
|
86
|
+
container.remove();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -36,10 +36,11 @@ export function createSplitFetchMock(
|
|
|
36
36
|
onSplit?.(path, JSON.parse(String(init?.body)) as SplitBody);
|
|
37
37
|
// Return content that differs from the original so `changed` is true.
|
|
38
38
|
const after = `${disk[path]}<!--split-->`;
|
|
39
|
+
const version = `"test-${path}-${after.length}"`;
|
|
39
40
|
disk[path] = after; // server writes the split to disk
|
|
40
|
-
return new Response(JSON.stringify({ ok: true, changed: true, content: after }), {
|
|
41
|
+
return new Response(JSON.stringify({ ok: true, changed: true, content: after, version }), {
|
|
41
42
|
status: 200,
|
|
42
|
-
headers: { "Content-Type": "application/json" },
|
|
43
|
+
headers: { "Content-Type": "application/json", ETag: version },
|
|
43
44
|
});
|
|
44
45
|
}
|
|
45
46
|
if (u.includes("/files/")) {
|