@hyperframes/studio 0.8.106 → 0.8.108
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-dl6K6ABp.js → hyperframes-player-BakGew45.js} +1 -1
- package/dist/assets/{index-ooaZZhKf.js → index-C5dyUHwk.js} +1 -1
- package/dist/assets/{index-CMiYVGv5.js → index-C842tcVa.js} +203 -208
- package/dist/assets/{index-DKT5NF1Y.js → index-CPe7d_wO.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-ICTZJPQQ.js → chunk-AOWVEVEU.js} +3 -494
- package/dist/chunk-AOWVEVEU.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +377 -12
- package/dist/index.html +2 -2
- package/dist/index.js +13445 -11874
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +6 -0
- package/src/components/EditorShell.tsx +2 -0
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- 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/DomEditOverlay.tsx +12 -18
- package/src/components/editor/PropertyPanel.tsx +1 -14
- package/src/components/editor/PropertyPanelFlat.tsx +3 -6
- package/src/components/editor/SnapToolbar.test.tsx +32 -1
- package/src/components/editor/SnapToolbar.tsx +3 -4
- 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/domEditOverlayStartGesture.ts +5 -2
- 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/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +28 -5
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +15 -1
- package/src/components/editor/mediaAudioEdits.test.ts +271 -0
- package/src/components/editor/mediaAudioEdits.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 +290 -87
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useDomEditNudge.test.tsx +134 -2
- package/src/components/editor/useDomEditNudge.ts +46 -23
- 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/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
- package/src/components/panels/SlideshowPanel.test.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +12 -7
- package/src/contexts/DomEditContext.test.tsx +62 -0
- package/src/contexts/DomEditContext.tsx +94 -77
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/canvasQuickSaves.test.tsx +72 -7
- package/src/hooks/domEditCommitTypes.ts +10 -0
- package/src/hooks/domEditPersistFailure.ts +12 -1
- package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
- package/src/hooks/domStyleCommit.ts +2 -8
- package/src/hooks/gsapDragCommit.ts +3 -8
- package/src/hooks/gsapDragPositionCommit.ts +6 -1
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
- package/src/hooks/gsapResizeIntercept.test.ts +3 -11
- package/src/hooks/gsapResizeIntercept.ts +6 -2
- package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
- package/src/hooks/inspectorSaveReload.test.tsx +107 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +17 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +3 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- package/src/hooks/useDomEditAttributeCommits.ts +71 -29
- package/src/hooks/useDomEditCommits.test.tsx +172 -92
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
- package/src/hooks/useDomEditPersist.ts +28 -13
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -3
- package/src/hooks/useDomEditTextCommits.ts +4 -8
- package/src/hooks/useDomGeometryCommits.ts +11 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +163 -3
- 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/useGsapAwareEditing.ts +6 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +9 -7
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/usePreviewPersistence.ts +6 -0
- package/src/hooks/useRazorSplit.ts +14 -1
- package/src/hooks/useRenderClipContent.test.ts +102 -0
- package/src/hooks/useRenderClipContent.ts +30 -23
- package/src/hooks/useTimelineEditing.ts +2 -2
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +8 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.tsx +22 -0
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineClip.tsx +2 -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/clipMenuAudioItems.test.tsx +125 -0
- package/src/player/components/clipMenuAudioItems.tsx +108 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +142 -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 +235 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/lib/playbackShortcuts.ts +3 -7
- package/src/player/lib/timelineElementHelpers.test.ts +16 -0
- package/src/player/lib/timelineElementHelpers.ts +25 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- 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.ts +3 -2
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
- package/src/utils/gsapUndoRestore.test.ts +145 -1
- package/src/utils/gsapUndoRestore.ts +70 -21
- package/src/utils/previewRelativePath.ts +19 -0
- package/src/utils/sourcePatcher.ts +2 -2
- package/src/utils/studioFontHelpers.ts +2 -23
- package/src/utils/studioHelpers.test.ts +30 -0
- package/src/utils/studioHelpers.ts +18 -4
- package/src/utils/studioPendingEdits.test.ts +129 -0
- package/src/utils/studioPendingEdits.ts +61 -18
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/src/utils/typingTarget.test.ts +13 -0
- package/src/utils/typingTarget.ts +5 -2
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-ICTZJPQQ.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
-
import { act, createElement } from "react";
|
|
2
|
+
import { act, createElement, type ReactElement } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
@@ -10,6 +10,11 @@ import {
|
|
|
10
10
|
openProjectHistory,
|
|
11
11
|
type StudioApiAdapter,
|
|
12
12
|
} from "@hyperframes/studio-server";
|
|
13
|
+
import { useDomEditNudge } from "../components/editor/useDomEditNudge";
|
|
14
|
+
import { DomEditProvider, useDomEditActionsContext } from "../contexts/DomEditContext";
|
|
15
|
+
import { __resetForTests as resetNudgeKeys } from "../utils/canvasNudgeGate";
|
|
16
|
+
import { trackedStudioEdit } from "../utils/studioPendingEdits";
|
|
17
|
+
import { makeSelection } from "./domSelectionTestHarness";
|
|
13
18
|
import { useEditHistoryActions } from "./useEditHistoryActions";
|
|
14
19
|
import { usePersistentEditHistory } from "./usePersistentEditHistory";
|
|
15
20
|
import { usePreviewPersistence } from "./usePreviewPersistence";
|
|
@@ -19,6 +24,7 @@ const page = (left: string, top = "0px") =>
|
|
|
19
24
|
const BEFORE = page("10px");
|
|
20
25
|
const AFTER = page("50px");
|
|
21
26
|
const OUTSIDE = page("50px", "99px");
|
|
27
|
+
const NUDGED = page("51px");
|
|
22
28
|
|
|
23
29
|
const cleanup: Array<() => unknown> = [];
|
|
24
30
|
let scratch = "";
|
|
@@ -78,14 +84,15 @@ async function studio() {
|
|
|
78
84
|
showToast: () => {},
|
|
79
85
|
syncHistoryPreviewAfterApply: persistence.syncHistoryPreviewAfterApply,
|
|
80
86
|
showHistoryRestoreNow: persistence.showHistoryRestoreNow,
|
|
81
|
-
waitForPendingDomEditSaves: persistence.
|
|
87
|
+
waitForPendingDomEditSaves: persistence.settlePendingEdits,
|
|
82
88
|
});
|
|
83
89
|
return null;
|
|
84
90
|
}
|
|
85
91
|
const root = createRoot(document.createElement("div"));
|
|
86
92
|
await act(async () => root.render(createElement(Harness)));
|
|
87
93
|
cleanup.push(() => act(() => root.unmount()));
|
|
88
|
-
const
|
|
94
|
+
const element = (id: string) => iframe.contentDocument!.getElementById(id)!;
|
|
95
|
+
const box = () => element("box").style.left;
|
|
89
96
|
const other = () => iframe.contentDocument!.getElementById("other")!.style.top;
|
|
90
97
|
const show = (html: string) => {
|
|
91
98
|
iframe.contentDocument!.documentElement.innerHTML = new DOMParser().parseFromString(
|
|
@@ -105,8 +112,16 @@ async function studio() {
|
|
|
105
112
|
);
|
|
106
113
|
await vi.waitFor(() => expect(history.undoLabel).toBe("Move layer"));
|
|
107
114
|
};
|
|
115
|
+
/** Mounts `ui` beside Studio, as the canvas is, for the rest of the test. */
|
|
116
|
+
const mount = (ui: ReactElement) => {
|
|
117
|
+
const canvas = createRoot(document.createElement("div"));
|
|
118
|
+
act(() => canvas.render(ui));
|
|
119
|
+
cleanup.push(() => act(() => canvas.unmount()));
|
|
120
|
+
};
|
|
108
121
|
return {
|
|
109
122
|
history: () => history,
|
|
123
|
+
element,
|
|
124
|
+
mount,
|
|
110
125
|
persistence: () => persistence,
|
|
111
126
|
actions: () => actions,
|
|
112
127
|
box,
|
|
@@ -164,3 +179,148 @@ it("an undo after an outside write ends with the preview showing the file the se
|
|
|
164
179
|
expect(s.other()).toBe("0px");
|
|
165
180
|
expect(s.box()).toBe("50px");
|
|
166
181
|
});
|
|
182
|
+
|
|
183
|
+
/** The canvas's arrow-key nudge on the box; `save` stands in for the burst's save once the keys stop. */
|
|
184
|
+
function Nudge({ target, save }: { target: HTMLElement; save: () => Promise<void> }) {
|
|
185
|
+
const selection = makeSelection("Box", target);
|
|
186
|
+
const ref = <T,>(current: T) => ({ current });
|
|
187
|
+
useDomEditNudge({
|
|
188
|
+
selection,
|
|
189
|
+
groupSelections: [],
|
|
190
|
+
allowCanvasMovement: true,
|
|
191
|
+
selectionRef: ref(selection),
|
|
192
|
+
overlayRectRef: ref({ left: 0, top: 0, width: 100, height: 40, editScaleX: 1, editScaleY: 1 }),
|
|
193
|
+
groupOverlayItemsRef: ref([]),
|
|
194
|
+
gestureRef: ref(null),
|
|
195
|
+
groupGestureRef: ref(null),
|
|
196
|
+
blockedMoveRef: ref(null),
|
|
197
|
+
onManualDragStartRef: ref(() => {}),
|
|
198
|
+
onBlockedMoveRef: ref(() => {}),
|
|
199
|
+
onPathOffsetCommitRef: ref(save),
|
|
200
|
+
onGroupPathOffsetCommitRef: ref(async () => {}),
|
|
201
|
+
});
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
it("an undo pressed while a nudge waits for more keys never shows the move before it undone", async () => {
|
|
206
|
+
const s = await studio();
|
|
207
|
+
await s.edit();
|
|
208
|
+
resetNudgeKeys();
|
|
209
|
+
let finish!: () => void;
|
|
210
|
+
const save = vi.fn(async () => {
|
|
211
|
+
await new Promise<void>((resolve) => (finish = resolve));
|
|
212
|
+
writeFileSync(s.path, NUDGED);
|
|
213
|
+
await s.history().recordEdit({
|
|
214
|
+
label: "Move layer",
|
|
215
|
+
files: { "index.html": { before: AFTER, after: NUDGED } },
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
s.mount(createElement(Nudge, { target: s.element("box"), save }));
|
|
219
|
+
act(() => {
|
|
220
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const nudge = () => s.element("box").style.getPropertyValue("translate");
|
|
224
|
+
expect(nudge()).not.toBe("");
|
|
225
|
+
|
|
226
|
+
const undone = s.actions().undo();
|
|
227
|
+
expect(s.box()).toBe("50px");
|
|
228
|
+
expect(nudge()).toBe("");
|
|
229
|
+
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
|
|
230
|
+
expect(nudge()).toBe("");
|
|
231
|
+
finish();
|
|
232
|
+
await act(() => undone);
|
|
233
|
+
expect(save).toHaveBeenCalledTimes(1);
|
|
234
|
+
expect(s.file()).toBe(AFTER);
|
|
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");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {
|
|
271
|
+
const s = await studio();
|
|
272
|
+
await s.edit();
|
|
273
|
+
const box = s.element("box");
|
|
274
|
+
let fail!: () => void;
|
|
275
|
+
const handleDomStyleCommit = vi.fn(async () => {
|
|
276
|
+
box.style.left = "70px";
|
|
277
|
+
try {
|
|
278
|
+
await s.persistence().queueDomEditSave(async () => {
|
|
279
|
+
await new Promise<void>((resolve) => (fail = resolve));
|
|
280
|
+
throw new Error("The save failed.");
|
|
281
|
+
});
|
|
282
|
+
} catch (error) {
|
|
283
|
+
box.style.left = "50px";
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
let actions!: ReturnType<typeof useDomEditActionsContext>;
|
|
288
|
+
function Canvas() {
|
|
289
|
+
actions = useDomEditActionsContext();
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
const value = { handleDomStyleCommit } as unknown as Parameters<
|
|
293
|
+
typeof DomEditProvider
|
|
294
|
+
>[0]["value"];
|
|
295
|
+
s.mount(
|
|
296
|
+
<DomEditProvider value={value}>
|
|
297
|
+
<Canvas />
|
|
298
|
+
</DomEditProvider>,
|
|
299
|
+
);
|
|
300
|
+
const failed = actions.handleDomStyleCommit("left", "70px");
|
|
301
|
+
|
|
302
|
+
const undone = s.actions().undo();
|
|
303
|
+
await vi.waitFor(() => expect(fail).toBeTypeOf("function"));
|
|
304
|
+
fail();
|
|
305
|
+
await expect(failed).rejects.toThrow("The save failed.");
|
|
306
|
+
await act(() => undone);
|
|
307
|
+
expect(s.file()).toBe(BEFORE);
|
|
308
|
+
expect(s.box()).toBe("10px");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("an undo pressed while a tracked timeline edit fails still undoes the edit before it", async () => {
|
|
312
|
+
const s = await studio();
|
|
313
|
+
await s.edit();
|
|
314
|
+
let fail!: () => void;
|
|
315
|
+
const timelineEdit = trackedStudioEdit(
|
|
316
|
+
() => new Promise<void>((_, reject) => (fail = () => reject(new Error("The save failed.")))),
|
|
317
|
+
);
|
|
318
|
+
const failed = timelineEdit();
|
|
319
|
+
|
|
320
|
+
const undone = s.actions().undo();
|
|
321
|
+
fail();
|
|
322
|
+
await expect(failed).rejects.toThrow("The save failed.");
|
|
323
|
+
await act(() => undone);
|
|
324
|
+
expect(s.file()).toBe(BEFORE);
|
|
325
|
+
expect(s.box()).toBe("10px");
|
|
326
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -93,6 +93,7 @@ export interface UseGsapAwareEditingParams {
|
|
|
93
93
|
next: { width: number; height: number },
|
|
94
94
|
offset?: { x: number; y: number },
|
|
95
95
|
restore?: () => void,
|
|
96
|
+
undoKey?: string,
|
|
96
97
|
) => Promise<void>;
|
|
97
98
|
commitPositionPatchToHtml: ElementOffsetStagerDeps["commitPositionPatchToHtml"];
|
|
98
99
|
// GSAP script commit ops (from useGsapScriptCommits)
|
|
@@ -394,12 +395,11 @@ export function useGsapAwareEditing({
|
|
|
394
395
|
makeFetchFallback(selection),
|
|
395
396
|
);
|
|
396
397
|
assertGsapEditPersisted(outcome);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
//
|
|
402
|
-
// element snapped back to its authored position on every drag.
|
|
398
|
+
// Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
|
|
399
|
+
if (outcome.status === "element-size")
|
|
400
|
+
await handleDomBoxSizeCommit(selection, next, undefined, undefined, coalesceKey);
|
|
401
|
+
else cropUndoKey = coalesceKey;
|
|
402
|
+
// What the resize did, not what its tweens suggest: a scale hold still commits a size.
|
|
403
403
|
const ownsDragOffset =
|
|
404
404
|
outcome.status === "persisted" && outcome.ownsDragOffset === true;
|
|
405
405
|
logResize("intercept-handled", {
|
|
@@ -30,6 +30,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
30
30
|
import type { MutationResult } from "./gsapScriptCommitTypes";
|
|
31
31
|
import { persistSdkSerialize } from "../utils/sdkCutover";
|
|
32
32
|
import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
|
|
33
|
+
import { hasStudioPendingEdits } from "../utils/studioPendingEdits";
|
|
33
34
|
|
|
34
35
|
// ── applyPreviewSync (pure preview-sync decision) ────────────────────────────
|
|
35
36
|
|
|
@@ -426,6 +427,24 @@ function mockFetchResult(over: Partial<MutationResult> = {}): void {
|
|
|
426
427
|
);
|
|
427
428
|
}
|
|
428
429
|
|
|
430
|
+
describe("a GSAP script commit", () => {
|
|
431
|
+
it("counts as a pending edit from its call until it lands, so a quick Cmd+Z waits for it", async () => {
|
|
432
|
+
mockFetchResult();
|
|
433
|
+
const deps = renderCommitHook();
|
|
434
|
+
let committed!: Promise<unknown>;
|
|
435
|
+
act(() => {
|
|
436
|
+
committed = deps.api.commitMutation(
|
|
437
|
+
selection,
|
|
438
|
+
{ type: "remove-all-keyframes", animationId: "a" },
|
|
439
|
+
{ label: "Remove all keyframes" },
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
expect(hasStudioPendingEdits()).toBe(true);
|
|
443
|
+
await act(async () => void (await committed));
|
|
444
|
+
expect(hasStudioPendingEdits()).toBe(false);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
429
448
|
describe("runCommit — instantPatch wiring", () => {
|
|
430
449
|
it("explains a deliberate mutation that the server safely rejected as unchanged", async () => {
|
|
431
450
|
mockFetchResult({ changed: false });
|
|
@@ -7,6 +7,7 @@ import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload"
|
|
|
7
7
|
import type { SoftReloadResult } from "../utils/gsapSoftReload";
|
|
8
8
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
9
9
|
import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
|
|
10
|
+
import { trackedStudioEdit } from "../utils/studioPendingEdits";
|
|
10
11
|
import {
|
|
11
12
|
getStudioSaveErrorMessage,
|
|
12
13
|
isStudioSaveErrorAlreadyToasted,
|
|
@@ -416,20 +417,20 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
416
417
|
}
|
|
417
418
|
return run();
|
|
418
419
|
};
|
|
419
|
-
const commit: CommitMutation = (selection, mutation, options) => {
|
|
420
|
+
const commit: CommitMutation = trackedStudioEdit((selection, mutation, options) => {
|
|
420
421
|
if (!activeProjectId) return Promise.resolve();
|
|
421
422
|
const file = selection.sourceFile || activeCompPath || "index.html";
|
|
422
423
|
return serializeCommit(file, options.serializeKey, () =>
|
|
423
424
|
runCommit(activeProjectId, activeCompPath, file, selection, mutation, options),
|
|
424
425
|
);
|
|
425
|
-
};
|
|
426
|
-
commit.batch = (calls, options) => {
|
|
426
|
+
}) as CommitMutation;
|
|
427
|
+
commit.batch = trackedStudioEdit((calls: CommitMutationCall[], options: CommitMutationOptions) => {
|
|
427
428
|
if (!activeProjectId) return Promise.resolve();
|
|
428
429
|
const file = calls[0]?.selection.sourceFile || activeCompPath || "index.html";
|
|
429
430
|
return serializeCommit(file, options.serializeKey, () =>
|
|
430
431
|
runBatchCommit(activeProjectId, activeCompPath, file, calls, options),
|
|
431
432
|
);
|
|
432
|
-
};
|
|
433
|
+
});
|
|
433
434
|
return commit;
|
|
434
435
|
}, [runCommit, runBatchCommit, activeCompPath, activeProjectId, writeProjectFile]);
|
|
435
436
|
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
|