@hyperframes/studio 0.8.107 → 0.8.109
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-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
- package/dist/assets/{index-D8GKc2D9.js → index-HTFVHUJ4.js} +1 -1
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +53 -4
- package/dist/index.html +2 -2
- package/dist/index.js +15746 -13173
- package/dist/index.js.map +1 -1
- package/dist/player.js +3 -3
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +8 -11
- package/src/components/EditorShell.tsx +6 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/manualOffsetDrag.ts +11 -0
- package/src/components/editor/mediaAudioEdits.test.ts +79 -4
- package/src/components/editor/mediaAudioEdits.ts +66 -24
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useDomEditNudge.ts +17 -12
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.ts +123 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +26 -2
- package/src/hooks/useRenderClipContent.test.ts +108 -4
- package/src/hooks/useRenderClipContent.ts +33 -23
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +38 -57
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +2 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +124 -77
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/TimelineClip.test.tsx +17 -0
- package/src/player/components/TimelineClip.tsx +8 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- package/src/player/components/audioClipLink.test.ts +228 -31
- package/src/player/components/audioClipLink.ts +72 -25
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +168 -0
- package/src/player/components/clipMenuAudioItems.tsx +116 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +147 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +233 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +12 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +4 -1
- package/src/player/components/timelineLinkSelection.ts +23 -0
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +21 -0
- package/src/player/components/useTimelineProviderState.tsx +3 -0
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/lib/timelineElementHelpers.ts +9 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -0
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/previewRelativePath.ts +19 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioPendingEdits.test.ts +66 -0
- package/src/utils/studioPendingEdits.ts +45 -11
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-4OZC426H.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -206,7 +206,9 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
|
|
|
206
206
|
const s = await studio();
|
|
207
207
|
await s.edit();
|
|
208
208
|
resetNudgeKeys();
|
|
209
|
+
let finish!: () => void;
|
|
209
210
|
const save = vi.fn(async () => {
|
|
211
|
+
await new Promise<void>((resolve) => (finish = resolve));
|
|
210
212
|
writeFileSync(s.path, NUDGED);
|
|
211
213
|
await s.history().recordEdit({
|
|
212
214
|
label: "Move layer",
|
|
@@ -218,12 +220,51 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
|
|
|
218
220
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
|
|
219
221
|
});
|
|
220
222
|
|
|
223
|
+
const nudge = () => s.element("box").style.getPropertyValue("translate");
|
|
224
|
+
expect(nudge()).not.toBe("");
|
|
225
|
+
|
|
221
226
|
const undone = s.actions().undo();
|
|
222
227
|
expect(s.box()).toBe("50px");
|
|
228
|
+
expect(nudge()).toBe("");
|
|
229
|
+
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
|
|
230
|
+
expect(nudge()).toBe("");
|
|
231
|
+
finish();
|
|
223
232
|
await act(() => undone);
|
|
224
233
|
expect(save).toHaveBeenCalledTimes(1);
|
|
225
234
|
expect(s.file()).toBe(AFTER);
|
|
226
235
|
expect(s.box()).toBe("50px");
|
|
236
|
+
expect(nudge()).toBe("");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("an undo pressed while a nudge's save is queued shows the nudge undone in the key's own task", async () => {
|
|
240
|
+
const s = await studio();
|
|
241
|
+
await s.edit();
|
|
242
|
+
resetNudgeKeys();
|
|
243
|
+
let finish!: () => void;
|
|
244
|
+
const save = vi.fn(async () => {
|
|
245
|
+
await new Promise<void>((resolve) => (finish = resolve));
|
|
246
|
+
writeFileSync(s.path, NUDGED);
|
|
247
|
+
await s.history().recordEdit({
|
|
248
|
+
label: "Move layer",
|
|
249
|
+
files: { "index.html": { before: AFTER, after: NUDGED } },
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
s.mount(createElement(Nudge, { target: s.element("box"), save }));
|
|
253
|
+
act(() => {
|
|
254
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
|
|
255
|
+
});
|
|
256
|
+
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
|
|
257
|
+
const nudge = () => s.element("box").style.getPropertyValue("translate");
|
|
258
|
+
expect(nudge()).not.toBe("");
|
|
259
|
+
|
|
260
|
+
const undone = s.actions().undo();
|
|
261
|
+
expect(nudge()).toBe("");
|
|
262
|
+
expect(s.box()).toBe("50px");
|
|
263
|
+
finish();
|
|
264
|
+
await act(() => undone);
|
|
265
|
+
expect(s.file()).toBe(AFTER);
|
|
266
|
+
expect(nudge()).toBe("");
|
|
267
|
+
expect(s.box()).toBe("50px");
|
|
227
268
|
});
|
|
228
269
|
|
|
229
270
|
it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {
|
|
@@ -3,6 +3,7 @@ import { useCallback, useMemo } from "react";
|
|
|
3
3
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
4
4
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
5
5
|
import type { RestoreFiles } from "../utils/gsapUndoRestore";
|
|
6
|
+
import { revertNewestStudioPendingEdit } from "../utils/studioPendingEdits";
|
|
6
7
|
|
|
7
8
|
interface HistoryResult {
|
|
8
9
|
ok: boolean;
|
|
@@ -74,7 +75,10 @@ export function useEditHistoryActions({
|
|
|
74
75
|
const noun = direction === "undo" ? "Undo" : "Redo";
|
|
75
76
|
// Paint the step in the key's own task when this tab knows it; the server's answer then confirms or corrects.
|
|
76
77
|
const predicted = editHistory.predict?.(direction) ?? null;
|
|
77
|
-
const
|
|
78
|
+
const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
|
|
79
|
+
const pendingEditShown =
|
|
80
|
+
!predictedShown && direction === "undo" ? revertNewestStudioPendingEdit() : null;
|
|
81
|
+
const putBack = predictedShown ?? pendingEditShown;
|
|
78
82
|
let result: HistoryResult = { ok: false, reason: "failed" };
|
|
79
83
|
let serverSteppedShown = false;
|
|
80
84
|
try {
|
|
@@ -83,8 +87,10 @@ export function useEditHistoryActions({
|
|
|
83
87
|
readFile: readHistoryFile,
|
|
84
88
|
serialize: serializeHistoryFiles,
|
|
85
89
|
});
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
const stepped = Boolean(result.ok && result.label);
|
|
91
|
+
serverSteppedShown = predictedShown
|
|
92
|
+
? stepped && result.undoes === predicted?.id
|
|
93
|
+
: stepped && Boolean(pendingEditShown);
|
|
88
94
|
} finally {
|
|
89
95
|
if (putBack && !serverSteppedShown) putBack();
|
|
90
96
|
}
|
|
@@ -100,7 +106,10 @@ export function useEditHistoryActions({
|
|
|
100
106
|
return;
|
|
101
107
|
}
|
|
102
108
|
if (result.ok && result.label) {
|
|
103
|
-
const files =
|
|
109
|
+
const files =
|
|
110
|
+
serverSteppedShown && predictedShown
|
|
111
|
+
? fromShown(result.files, predicted!.files)
|
|
112
|
+
: result.files;
|
|
104
113
|
const restore = { paths: result.paths, files };
|
|
105
114
|
onAfterUndoRedo?.(restore);
|
|
106
115
|
if (activeCompPath && result.paths?.includes(activeCompPath)) {
|
|
@@ -19,6 +19,22 @@ afterEach(() => {
|
|
|
19
19
|
document.body.innerHTML = "";
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
// The runtime's element-picked message, posted by `from`.
|
|
23
|
+
const picked = (from: Window | null, selector: string) =>
|
|
24
|
+
act(() => {
|
|
25
|
+
window.dispatchEvent(
|
|
26
|
+
new MessageEvent("message", {
|
|
27
|
+
source: from,
|
|
28
|
+
data: {
|
|
29
|
+
source: "hf-preview",
|
|
30
|
+
type: "element-picked",
|
|
31
|
+
elementInfo: { selector, tagName: "h1" },
|
|
32
|
+
...runtimeProtocolMetadata(30),
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
22
38
|
// The preview page: the saved markup plus what the runtime adds to it.
|
|
23
39
|
function mountPreview(mounted = ""): HTMLIFrameElement {
|
|
24
40
|
const iframe = document.createElement("iframe");
|
|
@@ -61,19 +77,7 @@ function mountPicker(
|
|
|
61
77
|
}
|
|
62
78
|
root = createRoot(document.createElement("div"));
|
|
63
79
|
act(() => root?.render(React.createElement(Harness)));
|
|
64
|
-
|
|
65
|
-
window.dispatchEvent(
|
|
66
|
-
new MessageEvent("message", {
|
|
67
|
-
source: iframe.contentWindow,
|
|
68
|
-
data: {
|
|
69
|
-
source: "hf-preview",
|
|
70
|
-
type: "element-picked",
|
|
71
|
-
elementInfo: { selector, tagName: "h1" },
|
|
72
|
-
...runtimeProtocolMetadata(30),
|
|
73
|
-
},
|
|
74
|
-
}),
|
|
75
|
-
);
|
|
76
|
-
});
|
|
80
|
+
picked(iframe.contentWindow, selector);
|
|
77
81
|
const picker = () => api as ReturnType<typeof useElementPicker>;
|
|
78
82
|
return {
|
|
79
83
|
picker,
|
|
@@ -170,3 +174,48 @@ describe("an edit to a picked element without an id", () => {
|
|
|
170
174
|
expect(synced).toEqual([]);
|
|
171
175
|
});
|
|
172
176
|
});
|
|
177
|
+
|
|
178
|
+
describe("a pick across a reload that swaps the preview frame", () => {
|
|
179
|
+
function mountSwapping() {
|
|
180
|
+
const live = mountPreview();
|
|
181
|
+
const ref: { current: HTMLIFrameElement | null } = { current: live };
|
|
182
|
+
let api: ReturnType<typeof useElementPicker> | null = null;
|
|
183
|
+
function Harness() {
|
|
184
|
+
api = useElementPicker(ref);
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
root = createRoot(document.createElement("div"));
|
|
188
|
+
act(() => root?.render(React.createElement(Harness)));
|
|
189
|
+
return { live, ref, picker: () => api as ReturnType<typeof useElementPicker> };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
it("keeps a pick posted by the frame it was made in after a new frame took its place", () => {
|
|
193
|
+
const { live, ref, picker } = mountSwapping();
|
|
194
|
+
picked(live.contentWindow, "h1");
|
|
195
|
+
act(() => picker().clearPick());
|
|
196
|
+
// The reload promotes its frame before the old frame's pick message is handled.
|
|
197
|
+
const old = live.contentWindow;
|
|
198
|
+
ref.current = mountPreview();
|
|
199
|
+
live.remove();
|
|
200
|
+
picked(old, "h1.next");
|
|
201
|
+
expect(picker().pickedElement?.selector).toBe("h1.next");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("does not keep trusting a zoomed frame once the zoom is gone", () => {
|
|
205
|
+
const { picker } = mountSwapping();
|
|
206
|
+
const zoom = mountPreview();
|
|
207
|
+
act(() => picker().setActiveIframe(zoom));
|
|
208
|
+
picked(zoom.contentWindow, "h1");
|
|
209
|
+
expect(picker().pickedElement?.selector).toBe("h1");
|
|
210
|
+
act(() => picker().setActiveIframe(null));
|
|
211
|
+
picked(zoom.contentWindow, "h1.late");
|
|
212
|
+
expect(picker().pickedElement?.selector).toBe("h1");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("still ignores a frame it never showed", () => {
|
|
216
|
+
const { picker } = mountSwapping();
|
|
217
|
+
const stranger = mountPreview();
|
|
218
|
+
picked(stranger.contentWindow, "h1");
|
|
219
|
+
expect(picker().pickedElement).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -97,6 +97,8 @@ export function useElementPicker(
|
|
|
97
97
|
setPickedElement(null);
|
|
98
98
|
}, []);
|
|
99
99
|
|
|
100
|
+
const windowsThatWerePrimary = useRef(new WeakSet<MessageEventSource>());
|
|
101
|
+
|
|
100
102
|
// Listen for picker messages from the iframe
|
|
101
103
|
useMountEffect(() => {
|
|
102
104
|
// One guard per message field, then one branch per message type.
|
|
@@ -105,10 +107,16 @@ export function useElementPicker(
|
|
|
105
107
|
const data = e.data;
|
|
106
108
|
if (data?.source !== "hf-preview") return;
|
|
107
109
|
if (!acceptStudioRuntimeMessage(data)) return;
|
|
108
|
-
//
|
|
110
|
+
// The primary frame, the active override, or a primary frame a reload just swapped out.
|
|
109
111
|
const activeIframe = getActiveIframe();
|
|
110
112
|
if (!activeIframe) return;
|
|
111
|
-
if (e.source
|
|
113
|
+
if (!e.source) return;
|
|
114
|
+
if (e.source === iframeRef.current?.contentWindow)
|
|
115
|
+
windowsThatWerePrimary.current.add(e.source);
|
|
116
|
+
else if (
|
|
117
|
+
e.source !== activeIframe.contentWindow &&
|
|
118
|
+
!windowsThatWerePrimary.current.has(e.source)
|
|
119
|
+
)
|
|
112
120
|
return;
|
|
113
121
|
|
|
114
122
|
if (data.type === "element-picked" && data.elementInfo) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
3
|
+
import { requestFreezeFrame } from "./useFreezeFrame";
|
|
4
|
+
|
|
5
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
6
|
+
|
|
7
|
+
const element: TimelineElement = {
|
|
8
|
+
id: "talk",
|
|
9
|
+
domId: "talk",
|
|
10
|
+
tag: "video",
|
|
11
|
+
start: 5,
|
|
12
|
+
duration: 6,
|
|
13
|
+
track: 0,
|
|
14
|
+
parentCompositionStart: 4,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function stubFetch(freezeResponse: { status: number; body: unknown }) {
|
|
18
|
+
const calls: Array<{ url: string; body: unknown }> = [];
|
|
19
|
+
vi.stubGlobal(
|
|
20
|
+
"fetch",
|
|
21
|
+
vi.fn(async (url: string, init?: { body?: string }) => {
|
|
22
|
+
calls.push({ url, body: init?.body ? JSON.parse(init.body) : null });
|
|
23
|
+
if (url.includes("/files/")) return new Response(JSON.stringify({ version: "v1" }));
|
|
24
|
+
return new Response(JSON.stringify(freezeResponse.body), { status: freezeResponse.status });
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
return calls;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("requestFreezeFrame", () => {
|
|
31
|
+
it("posts the clip target, file version and an authored-time playhead", async () => {
|
|
32
|
+
const calls = stubFetch({ status: 200, body: { before: "a", after: "b", version: "v2" } });
|
|
33
|
+
const result = await requestFreezeFrame({
|
|
34
|
+
projectId: "p",
|
|
35
|
+
path: "scene.html",
|
|
36
|
+
element,
|
|
37
|
+
playhead: 7.5,
|
|
38
|
+
});
|
|
39
|
+
expect(result).toEqual({ before: "a", after: "b", version: "v2" });
|
|
40
|
+
const post = calls[1];
|
|
41
|
+
expect(post?.url).toContain("/file-mutations/freeze-frame");
|
|
42
|
+
expect(post?.body).toMatchObject({
|
|
43
|
+
path: "scene.html",
|
|
44
|
+
expectedVersion: "v1",
|
|
45
|
+
target: { id: "talk" },
|
|
46
|
+
playhead: 3.5,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("surfaces the server's error", async () => {
|
|
51
|
+
stubFetch({ status: 400, body: { error: "Move the playhead inside a video clip to freeze" } });
|
|
52
|
+
await expect(
|
|
53
|
+
requestFreezeFrame({ projectId: "p", path: "index.html", element, playhead: 7 }),
|
|
54
|
+
).rejects.toThrow("Move the playhead inside a video clip to freeze");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
3
|
+
import { toAuthoredStart } from "../player/store/timelineElement";
|
|
4
|
+
import { buildPatchTarget } from "../utils/timelineElementSplit";
|
|
5
|
+
import { buildProjectApiPath } from "../utils/projectRouting";
|
|
6
|
+
import { markStudioWriteToken } from "../utils/studioFileVersion";
|
|
7
|
+
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
8
|
+
import type { RecordEditInput } from "../utils/studioFileHistory";
|
|
9
|
+
|
|
10
|
+
type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
11
|
+
|
|
12
|
+
interface FreezeFrameResponse {
|
|
13
|
+
before: string;
|
|
14
|
+
after: string;
|
|
15
|
+
version: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
|
|
19
|
+
if (typeof value !== "object" || value === null) return false;
|
|
20
|
+
const body: Partial<Record<keyof FreezeFrameResponse, unknown>> = value;
|
|
21
|
+
return (
|
|
22
|
+
typeof body.before === "string" &&
|
|
23
|
+
typeof body.after === "string" &&
|
|
24
|
+
typeof body.version === "string"
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function errorOf(value: unknown): string | null {
|
|
29
|
+
if (typeof value !== "object" || value === null || !("error" in value)) return null;
|
|
30
|
+
return typeof value.error === "string" ? value.error : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readVersion(projectId: string, path: string): Promise<string> {
|
|
34
|
+
const response = await fetch(
|
|
35
|
+
buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
|
|
36
|
+
);
|
|
37
|
+
const body: unknown = await response.json().catch(() => null);
|
|
38
|
+
const version =
|
|
39
|
+
typeof body === "object" && body !== null && "version" in body ? body.version : null;
|
|
40
|
+
if (!response.ok || typeof version !== "string") throw new Error(`Could not read ${path}`);
|
|
41
|
+
return version;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** POST the freeze to studio-server, which extracts the still and writes the whole edit at once. */
|
|
45
|
+
export async function requestFreezeFrame(input: {
|
|
46
|
+
projectId: string;
|
|
47
|
+
path: string;
|
|
48
|
+
element: TimelineElement;
|
|
49
|
+
playhead: number;
|
|
50
|
+
}): Promise<FreezeFrameResponse> {
|
|
51
|
+
const target = buildPatchTarget(input.element);
|
|
52
|
+
if (!target) throw new Error("This clip has no id to freeze it by");
|
|
53
|
+
const expectedVersion = await readVersion(input.projectId, input.path);
|
|
54
|
+
const transactionToken = `freeze:${crypto.randomUUID()}`;
|
|
55
|
+
markStudioWriteToken(transactionToken);
|
|
56
|
+
const response = await fetch(
|
|
57
|
+
buildProjectApiPath(input.projectId, "/file-mutations/freeze-frame"),
|
|
58
|
+
{
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: {
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
"X-Hyperframes-Write-Token": transactionToken,
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify({
|
|
65
|
+
path: input.path,
|
|
66
|
+
expectedVersion,
|
|
67
|
+
target,
|
|
68
|
+
playhead: toAuthoredStart(input.element, input.playhead),
|
|
69
|
+
transactionToken,
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
const body: unknown = await response.json().catch(() => null);
|
|
74
|
+
if (!response.ok || !isFreezeFrameResponse(body)) {
|
|
75
|
+
throw new Error(errorOf(body) ?? `Freeze frame failed (${response.status})`);
|
|
76
|
+
}
|
|
77
|
+
return body;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface UseFreezeFrameOptions {
|
|
81
|
+
projectId: string | null;
|
|
82
|
+
activeCompPath: string | null;
|
|
83
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
84
|
+
writeProjectFile: ProjectFileWriter;
|
|
85
|
+
observeProjectFileVersion?: (path: string, version: string | null) => void;
|
|
86
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
87
|
+
reloadPreview: () => void;
|
|
88
|
+
forceReloadSdkSession?: () => void;
|
|
89
|
+
isRecordingRef?: React.RefObject<boolean>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function useFreezeFrame(options: UseFreezeFrameOptions) {
|
|
93
|
+
const optionsRef = useRef(options);
|
|
94
|
+
optionsRef.current = options;
|
|
95
|
+
|
|
96
|
+
return useCallback(async (element: TimelineElement, playhead: number) => {
|
|
97
|
+
const opts = optionsRef.current;
|
|
98
|
+
if (opts.isRecordingRef?.current) {
|
|
99
|
+
opts.showToast("Cannot edit timeline while recording", "error");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const projectId = opts.projectId;
|
|
103
|
+
if (!projectId) return;
|
|
104
|
+
const path = element.sourceFile || opts.activeCompPath || "index.html";
|
|
105
|
+
try {
|
|
106
|
+
await serializeStudioFileMutations(opts.writeProjectFile, [path], async () => {
|
|
107
|
+
const result = await requestFreezeFrame({ projectId, path, element, playhead });
|
|
108
|
+
await opts.recordEdit({
|
|
109
|
+
label: "Freeze frame",
|
|
110
|
+
files: { [path]: { before: result.before, after: result.after } },
|
|
111
|
+
});
|
|
112
|
+
opts.observeProjectFileVersion?.(path, result.version);
|
|
113
|
+
});
|
|
114
|
+
opts.forceReloadSdkSession?.();
|
|
115
|
+
opts.reloadPreview();
|
|
116
|
+
opts.showToast(`Froze a 2 s still at ${playhead.toFixed(2)}s`, "info");
|
|
117
|
+
} catch (error) {
|
|
118
|
+
opts.showToast(error instanceof Error ? error.message : "Freeze frame failed", "error");
|
|
119
|
+
}
|
|
120
|
+
}, []);
|
|
121
|
+
}
|
|
@@ -10,13 +10,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
10
10
|
const patchRuntimeTweenInPlace = vi.fn<(...args: unknown[]) => boolean>();
|
|
11
11
|
const applySoftReload = vi.fn<(...args: unknown[]) => string>();
|
|
12
12
|
const trackStudioEvent = vi.fn();
|
|
13
|
+
const readNestedFiles = vi.fn<(...args: unknown[]) => unknown>(() => null);
|
|
13
14
|
|
|
14
15
|
vi.mock("./gsapRuntimePatch", () => ({
|
|
15
16
|
patchRuntimeTweenInPlace: (...args: unknown[]) => patchRuntimeTweenInPlace(...args),
|
|
16
17
|
}));
|
|
17
|
-
vi.mock("../utils/gsapSoftReload", () => ({
|
|
18
|
+
vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({
|
|
19
|
+
...(await importOriginal<typeof import("../utils/gsapSoftReload")>()),
|
|
18
20
|
applySoftReload: (...args: unknown[]) => applySoftReload(...args),
|
|
19
21
|
extractGsapScriptText: () => "",
|
|
22
|
+
readNestedFiles: (...args: unknown[]) => readNestedFiles(...args),
|
|
20
23
|
}));
|
|
21
24
|
vi.mock("../utils/studioTelemetry", () => ({
|
|
22
25
|
trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
|
|
@@ -364,6 +367,7 @@ let cleanup: (() => void) | null = null;
|
|
|
364
367
|
function renderCommitHook(
|
|
365
368
|
options: {
|
|
366
369
|
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
|
370
|
+
iframe?: HTMLIFrameElement;
|
|
367
371
|
} = {},
|
|
368
372
|
) {
|
|
369
373
|
const reloadPreview = vi.fn();
|
|
@@ -378,7 +382,7 @@ function renderCommitHook(
|
|
|
378
382
|
captured.api = useGsapScriptCommits({
|
|
379
383
|
projectIdRef: { current: "proj-1" },
|
|
380
384
|
activeCompPath: "index.html",
|
|
381
|
-
previewIframeRef: { current: FAKE_IFRAME },
|
|
385
|
+
previewIframeRef: { current: options.iframe ?? FAKE_IFRAME },
|
|
382
386
|
editHistory: { recordEdit },
|
|
383
387
|
reloadPreview,
|
|
384
388
|
onCacheInvalidate,
|
|
@@ -578,6 +582,7 @@ describe("runCommit — instantPatch wiring", () => {
|
|
|
578
582
|
patchRuntimeTweenInPlace.mockReset();
|
|
579
583
|
applySoftReload.mockReset();
|
|
580
584
|
trackStudioEvent.mockReset();
|
|
585
|
+
readNestedFiles.mockReset().mockReturnValue(null);
|
|
581
586
|
});
|
|
582
587
|
afterEach(() => {
|
|
583
588
|
cleanup?.();
|
|
@@ -585,6 +590,83 @@ describe("runCommit — instantPatch wiring", () => {
|
|
|
585
590
|
vi.unstubAllGlobals();
|
|
586
591
|
});
|
|
587
592
|
|
|
593
|
+
const NESTED_SCRIPT = 'window.__timelines["root"] = tl;';
|
|
594
|
+
const SUB = `<template><div data-composition-id="sub"><div id="nwid" style="left: 40px"></div></div></template>`;
|
|
595
|
+
|
|
596
|
+
// A preview whose top-level timeline tweens an element written in a nested composition file.
|
|
597
|
+
function nestedPreview(): HTMLIFrameElement {
|
|
598
|
+
const doc = document.implementation.createHTMLDocument("");
|
|
599
|
+
doc.body.innerHTML = `<div data-composition-id="root"><div data-composition-file="compositions/sub.html"><div id="nwid"></div></div></div>`;
|
|
600
|
+
const nwid = doc.getElementById("nwid")!;
|
|
601
|
+
const tween = { targets: () => [nwid], vars: { width: 400 } };
|
|
602
|
+
return {
|
|
603
|
+
contentDocument: doc,
|
|
604
|
+
contentWindow: { __timelines: { root: { getChildren: () => [tween] } } },
|
|
605
|
+
} as unknown as HTMLIFrameElement;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// The mutation endpoint answers the commit; the files endpoint serves the nested file, or fails.
|
|
609
|
+
function mockServer(fileOk: boolean): ReturnType<typeof vi.fn> {
|
|
610
|
+
const body = {
|
|
611
|
+
ok: true,
|
|
612
|
+
changed: true,
|
|
613
|
+
before: "BEFORE",
|
|
614
|
+
after: "AFTER",
|
|
615
|
+
scriptText: NESTED_SCRIPT,
|
|
616
|
+
};
|
|
617
|
+
const fetch = vi.fn(async (url: string) =>
|
|
618
|
+
url.includes("/files/")
|
|
619
|
+
? ({ ok: fileOk, json: async () => ({ content: SUB }) } as unknown as Response)
|
|
620
|
+
: ({ ok: true, json: async () => body } as unknown as Response),
|
|
621
|
+
);
|
|
622
|
+
vi.stubGlobal("fetch", fetch);
|
|
623
|
+
return fetch;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function useRealNestedRead() {
|
|
627
|
+
const actual =
|
|
628
|
+
await vi.importActual<typeof import("../utils/gsapSoftReload")>("../utils/gsapSoftReload");
|
|
629
|
+
readNestedFiles.mockImplementation((...args) =>
|
|
630
|
+
actual.readNestedFiles(...(args as Parameters<typeof actual.readNestedFiles>)),
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
it("hands the soft reload the nested composition file it read for a reset element", async () => {
|
|
635
|
+
await useRealNestedRead();
|
|
636
|
+
applySoftReload.mockReturnValue("applied");
|
|
637
|
+
const fetch = mockServer(true);
|
|
638
|
+
const deps = renderCommitHook({ iframe: nestedPreview() });
|
|
639
|
+
|
|
640
|
+
await act(async () => {
|
|
641
|
+
await deps.api.commitMutation(selection, { x: 10 }, { label: "drag", softReload: true });
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"));
|
|
645
|
+
expect(applySoftReload).toHaveBeenCalledWith(
|
|
646
|
+
expect.anything(),
|
|
647
|
+
NESTED_SCRIPT,
|
|
648
|
+
expect.objectContaining({ nestedFiles: new Map([["compositions/sub.html", SUB]]) }),
|
|
649
|
+
);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("reloads the preview in full when that nested file cannot be read", async () => {
|
|
653
|
+
await useRealNestedRead();
|
|
654
|
+
applySoftReload.mockReturnValue("cannot-soft-reload");
|
|
655
|
+
mockServer(false);
|
|
656
|
+
const deps = renderCommitHook({ iframe: nestedPreview() });
|
|
657
|
+
|
|
658
|
+
await act(async () => {
|
|
659
|
+
await deps.api.commitMutation(selection, { x: 10 }, { label: "drag", softReload: true });
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
expect(applySoftReload).toHaveBeenCalledWith(
|
|
663
|
+
expect.anything(),
|
|
664
|
+
NESTED_SCRIPT,
|
|
665
|
+
expect.objectContaining({ nestedFiles: null }),
|
|
666
|
+
);
|
|
667
|
+
expect(deps.reloadPreview).toHaveBeenCalledTimes(1);
|
|
668
|
+
});
|
|
669
|
+
|
|
588
670
|
it("instantPatch succeeds: persists, invalidates cache, NO reload", async () => {
|
|
589
671
|
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
590
672
|
mockFetchResult();
|
|
@@ -3,7 +3,12 @@ import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mu
|
|
|
3
3
|
import { readProjectFileContent as readSharedProjectFileContent } from "../utils/studioFileHistory";
|
|
4
4
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
5
5
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
applySoftReload,
|
|
8
|
+
extractGsapScriptText,
|
|
9
|
+
readNestedFiles,
|
|
10
|
+
settleNestedReads,
|
|
11
|
+
} from "../utils/gsapSoftReload";
|
|
7
12
|
import type { SoftReloadResult } from "../utils/gsapSoftReload";
|
|
8
13
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
9
14
|
import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
|
|
@@ -142,9 +147,10 @@ function refreshMutationPreview(
|
|
|
142
147
|
options: CommitMutationOptions,
|
|
143
148
|
reloadPreview: () => void,
|
|
144
149
|
onCacheInvalidate: () => void,
|
|
150
|
+
nestedFiles?: Map<string, string> | null,
|
|
145
151
|
): void {
|
|
146
152
|
options.beforeReload?.();
|
|
147
|
-
applyPreviewSync(iframe, result, options, reloadPreview);
|
|
153
|
+
applyPreviewSync(iframe, result, options, reloadPreview, nestedFiles);
|
|
148
154
|
onCacheInvalidate();
|
|
149
155
|
}
|
|
150
156
|
|
|
@@ -168,6 +174,7 @@ function syncCommittedGsapMutation({
|
|
|
168
174
|
forceReloadSdkSession,
|
|
169
175
|
reloadPreview,
|
|
170
176
|
onCacheInvalidate,
|
|
177
|
+
nestedFiles,
|
|
171
178
|
}: {
|
|
172
179
|
iframe: HTMLIFrameElement | null;
|
|
173
180
|
selection: DomEditSelection;
|
|
@@ -179,6 +186,7 @@ function syncCommittedGsapMutation({
|
|
|
179
186
|
forceReloadSdkSession?: () => void;
|
|
180
187
|
reloadPreview: () => void;
|
|
181
188
|
onCacheInvalidate: () => void;
|
|
189
|
+
nestedFiles?: Map<string, string> | null;
|
|
182
190
|
}): void {
|
|
183
191
|
if (result.after != null) onFileContentChanged?.(targetPath, result.after);
|
|
184
192
|
// Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
|
|
@@ -196,7 +204,7 @@ function syncCommittedGsapMutation({
|
|
|
196
204
|
iframe?.contentDocument,
|
|
197
205
|
);
|
|
198
206
|
}
|
|
199
|
-
refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate);
|
|
207
|
+
refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate, nestedFiles);
|
|
200
208
|
}
|
|
201
209
|
|
|
202
210
|
/**
|
|
@@ -219,6 +227,7 @@ function softReloadOrEscalate(
|
|
|
219
227
|
reloadPreview: () => void,
|
|
220
228
|
origin: "preview_sync" | "sdk_refresh",
|
|
221
229
|
authoredHtml?: string,
|
|
230
|
+
nestedFiles?: Map<string, string> | null,
|
|
222
231
|
): void {
|
|
223
232
|
// Seek the rebuilt timeline to the studio's own authoritative scrub position,
|
|
224
233
|
// not the iframe's raw `__player.getTime()` — see the comment in
|
|
@@ -228,6 +237,7 @@ function softReloadOrEscalate(
|
|
|
228
237
|
onAsyncFailure: reloadPreview,
|
|
229
238
|
currentTimeOverride: currentTime,
|
|
230
239
|
authoredHtml,
|
|
240
|
+
nestedFiles,
|
|
231
241
|
});
|
|
232
242
|
if (result === "applied") return;
|
|
233
243
|
trackStudioEvent("gsap_soft_reload_outcome", {
|
|
@@ -252,6 +262,7 @@ export function applyPreviewSync(
|
|
|
252
262
|
result: MutationResult,
|
|
253
263
|
options: CommitMutationOptions,
|
|
254
264
|
reloadPreview: () => void,
|
|
265
|
+
nestedFiles?: Map<string, string> | null,
|
|
255
266
|
): void {
|
|
256
267
|
const patches = instantPatchesFor(options);
|
|
257
268
|
let needsFallback = options.previewFallbackLatch?.pending === true;
|
|
@@ -297,6 +308,7 @@ export function applyPreviewSync(
|
|
|
297
308
|
reloadPreview,
|
|
298
309
|
"preview_sync",
|
|
299
310
|
result.after ?? undefined,
|
|
311
|
+
nestedFiles,
|
|
300
312
|
);
|
|
301
313
|
} else {
|
|
302
314
|
reloadPreview();
|
|
@@ -349,6 +361,13 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
349
361
|
return;
|
|
350
362
|
}
|
|
351
363
|
await recordMutationEdit(targetPath, result, options);
|
|
364
|
+
const nestedFiles = await settleNestedReads(
|
|
365
|
+
result.scriptText
|
|
366
|
+
? readNestedFiles(previewIframeRef.current, result.scriptText, (path) =>
|
|
367
|
+
readSharedProjectFileContent(projectId, path),
|
|
368
|
+
)
|
|
369
|
+
: null,
|
|
370
|
+
);
|
|
352
371
|
// The durable mutation belongs to the project captured when it was queued.
|
|
353
372
|
// A later project must never receive its file state or preview refresh.
|
|
354
373
|
if (!isActiveCommitTarget(projectIdRef, activeCompPathRef, projectId, compositionPath)) return;
|
|
@@ -363,6 +382,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
363
382
|
forceReloadSdkSession,
|
|
364
383
|
reloadPreview,
|
|
365
384
|
onCacheInvalidate,
|
|
385
|
+
nestedFiles,
|
|
366
386
|
});
|
|
367
387
|
}, [projectIdRef, previewIframeRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]);
|
|
368
388
|
|
|
@@ -243,8 +243,14 @@ it("a step that cannot reach the server says so", async () => {
|
|
|
243
243
|
});
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
-
it("predicts a step from what this tab wrote, and not while a claim or step may have moved the history", async () => {
|
|
246
|
+
it("predicts a step from what this tab wrote as soon as its save ends, and not while a claim or step may have moved the history", async () => {
|
|
247
247
|
const { hook, save, readFile } = await studio();
|
|
248
|
+
const real = globalThis.fetch;
|
|
249
|
+
const slowView = (url: string, init?: RequestInit) =>
|
|
250
|
+
url.endsWith("/history")
|
|
251
|
+
? new Promise((r) => setTimeout(r, 50)).then(() => real(url, init))
|
|
252
|
+
: real(url, init);
|
|
253
|
+
vi.stubGlobal("fetch", slowView);
|
|
248
254
|
save("B");
|
|
249
255
|
const claim = hook().recordEdit({
|
|
250
256
|
label: "Moved Title",
|
|
@@ -252,11 +258,8 @@ it("predicts a step from what this tab wrote, and not while a claim or step may
|
|
|
252
258
|
});
|
|
253
259
|
expect(hook().predict("undo")).toBeNull();
|
|
254
260
|
await act(() => claim);
|
|
255
|
-
const predicted =
|
|
256
|
-
|
|
257
|
-
expect(next?.files).toEqual({ "index.html": { previous: "B", restored: "A" } });
|
|
258
|
-
return next!;
|
|
259
|
-
});
|
|
261
|
+
const predicted = hook().predict("undo")!;
|
|
262
|
+
expect(predicted?.files).toEqual({ "index.html": { previous: "B", restored: "A" } });
|
|
260
263
|
|
|
261
264
|
hook().noteOutsideChange();
|
|
262
265
|
expect(hook().predict("undo")).toBeNull();
|
|
@@ -223,7 +223,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
223
223
|
const claimed = claimHeld(reply, label);
|
|
224
224
|
if (claimed) own.remember(claimed, files);
|
|
225
225
|
heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null;
|
|
226
|
-
|
|
226
|
+
await refresh();
|
|
227
227
|
},
|
|
228
228
|
[projectId, refresh, own],
|
|
229
229
|
);
|