@hyperframes/studio 0.8.115 → 0.8.117
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-C-9N8nk0.js → hyperframes-player-5fcgSstJ.js} +1 -1
- package/dist/assets/{index-CAvZ0AA-.js → index-C5qKdpgl.js} +205 -203
- package/dist/assets/{index-BoUM6Xqb.js → index-XJSawwYe.js} +1 -1
- package/dist/assets/{index-CVuorIMv.js → index-tg2LXUeu.js} +1 -1
- package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
- package/dist/chunk-3F47EFCT.js.map +1 -0
- package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
- package/dist/index.d.ts +132 -41
- package/dist/index.html +1 -1
- package/dist/index.js +1184 -1084
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/captions/hooks/useCaptionSync.ts +10 -6
- package/src/components/ProjectUnreachableBanner.tsx +3 -3
- package/src/components/editor/DomEditOverlay.tsx +1 -0
- package/src/components/editor/domEditOverlayGestures.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
- package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
- package/src/components/editor/domGroupMoveBy.ts +95 -0
- package/src/components/editor/dragUndoPaint.test.ts +5 -5
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
- package/src/components/editor/manualEditingAvailability.ts +8 -0
- package/src/components/editor/manualOffsetDrag.ts +8 -9
- package/src/components/editor/motionPathSelection.test.ts +6 -31
- package/src/components/editor/plainTranslate.ts +5 -3
- package/src/components/editor/probeSourceElement.ts +2 -1
- package/src/components/editor/propertyPanelFont.tsx +3 -2
- package/src/components/editor/propertyPanelHelpers.ts +2 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
- package/src/components/editor/rotationDraft.ts +2 -1
- package/src/components/editor/useColorGradingController.ts +2 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
- package/src/components/editor/useFxCarveNodes.ts +2 -1
- package/src/components/editor/useFxLevelling.ts +2 -1
- package/src/components/feedback/projectProvenance.ts +2 -1
- package/src/components/nle/NLEContext.test.ts +1 -0
- package/src/components/nle/NLEContext.tsx +2 -1
- package/src/components/nle/NLEPreview.tsx +12 -10
- package/src/components/nle/PreviewPoster.tsx +2 -1
- package/src/components/renders/useFfmpegStatus.ts +2 -1
- package/src/components/renders/useRenderQueue.ts +5 -4
- package/src/components/sidebar/AssetsTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/domSelectionTestHarness.ts +23 -0
- package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
- package/src/hooks/domStyleCommit.ts +8 -1
- package/src/hooks/draggedGsapPosition.ts +10 -0
- package/src/hooks/gsapDragCommit.test.ts +75 -164
- package/src/hooks/gsapDragCommit.ts +2 -105
- package/src/hooks/gsapDragPositionCommit.ts +30 -393
- package/src/hooks/gsapEditOutcome.ts +22 -2
- package/src/hooks/gsapGroupPreflight.ts +34 -0
- package/src/hooks/gsapMutationClient.ts +4 -3
- package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
- package/src/hooks/gsapParsedTween.test.ts +62 -0
- package/src/hooks/gsapParsedTween.ts +243 -0
- package/src/hooks/gsapPositionDetection.test.ts +78 -0
- package/src/hooks/gsapPositionDetection.ts +52 -33
- package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
- package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
- package/src/hooks/gsapResizeIntercept.test.ts +63 -11
- package/src/hooks/gsapResizeIntercept.ts +79 -169
- package/src/hooks/gsapResizeSweep.test.ts +16 -33
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
- package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
- package/src/hooks/gsapRuntimeBridge.ts +105 -131
- package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
- package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
- package/src/hooks/gsapScriptCommitTypes.ts +9 -1
- package/src/hooks/gsapShared.ts +7 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
- package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
- package/src/hooks/gsapValueAtPlayhead.ts +292 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
- package/src/hooks/keyframeCacheAstLoad.ts +2 -1
- package/src/hooks/newTweenTarget.test.ts +41 -76
- package/src/hooks/plainRotation.ts +5 -1
- package/src/hooks/timelineTimingSync.test.ts +1 -0
- package/src/hooks/timelineTimingSync.ts +2 -1
- package/src/hooks/useAskAgentModal.ts +2 -1
- package/src/hooks/useBlockCatalog.ts +2 -1
- package/src/hooks/useCaptionDetection.ts +2 -1
- package/src/hooks/useCompositionContentLoader.ts +2 -1
- package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
- package/src/hooks/useDomEditPersist.test.tsx +335 -0
- package/src/hooks/useDomEditSession.test.tsx +30 -1
- package/src/hooks/useDomEditSession.ts +10 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -2
- package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
- package/src/hooks/useDomGeometryCommit.ts +7 -9
- package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
- package/src/hooks/useEditHistoryActions.test.tsx +15 -0
- package/src/hooks/useEditHistoryActions.ts +16 -5
- package/src/hooks/useElementLifecycleOps.ts +2 -1
- package/src/hooks/useEnableKeyframes.test.ts +32 -1
- package/src/hooks/useEnableKeyframes.ts +2 -1
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
- package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
- package/src/hooks/useFileManager.ts +14 -10
- package/src/hooks/useFileTree.ts +3 -2
- package/src/hooks/useFrameCapture.ts +5 -1
- package/src/hooks/useFreezeFrame.test.ts +39 -3
- package/src/hooks/useFreezeFrame.ts +7 -3
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +42 -29
- package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
- package/src/hooks/useGsapAwareEditing.ts +17 -8
- package/src/hooks/useGsapAwareGroupMove.ts +18 -18
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
- package/src/hooks/useGsapScriptCommits.test.tsx +72 -2
- package/src/hooks/useGsapScriptCommits.ts +21 -17
- package/src/hooks/useLintModal.ts +2 -1
- package/src/hooks/usePersistentEditHistory.test.ts +302 -3
- package/src/hooks/usePersistentEditHistory.ts +50 -17
- package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
- package/src/hooks/usePreviewPersistence.ts +13 -11
- package/src/hooks/useProjectFileWriter.ts +20 -19
- package/src/hooks/useRemoveBackground.ts +2 -1
- package/src/hooks/useSdkSession.ts +2 -1
- package/src/hooks/useServerConnection.ts +4 -3
- package/src/hooks/useStudioSelectionPublisher.ts +2 -1
- package/src/hooks/useThumbnailStill.test.ts +100 -0
- package/src/hooks/useThumbnailStill.ts +37 -0
- package/src/hooks/useTimelineDeleteOps.ts +2 -1
- package/src/index.ts +4 -0
- package/src/player/components/AudioWaveform.tsx +3 -2
- package/src/player/components/CompositionThumbnail.tsx +2 -1
- package/src/player/components/clipMenuNormalize.ts +2 -1
- package/src/player/components/clipPeakMap.ts +2 -1
- package/src/player/hooks/useTimelinePlayer.ts +5 -1
- package/src/player/lib/thumbnailScheduler.test.ts +16 -0
- package/src/player/lib/thumbnailScheduler.ts +10 -2
- package/src/player/sceneSwap.ts +2 -1
- package/src/telemetry/client.ts +2 -1
- package/src/utils/blockInstaller.ts +2 -1
- package/src/utils/elementGsap.test.ts +9 -0
- package/src/utils/elementGsap.ts +7 -0
- package/src/utils/gsapSoftReload.test.ts +39 -0
- package/src/utils/gsapSoftReload.ts +9 -14
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -41
- package/src/utils/gsapUndoRestore.ts +3 -6
- package/src/utils/previewPrefetch.ts +2 -1
- package/src/utils/razorSplitTransaction.ts +12 -8
- package/src/utils/softReloadTargets.ts +5 -6
- package/src/utils/studioApiFetch.test.ts +75 -0
- package/src/utils/studioApiFetch.ts +13 -0
- package/src/utils/studioFileHistory.ts +5 -1
- package/src/utils/studioHelpers.ts +7 -1
- package/src/utils/studioPendingEdits.test.ts +23 -6
- package/src/utils/studioPendingEdits.ts +32 -8
- package/src/utils/studioTelemetry.ts +2 -1
- package/src/utils/timelineCompositionInsert.ts +3 -2
- package/src/webmcp/StudioAgentTools.tsx +2 -1
- package/dist/chunk-TXZMMOMA.js.map +0 -1
- /package/dist/{domEditingLayers-D7VYFJJD.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
|
@@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client";
|
|
|
4
4
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
7
|
+
import { afterEach, beforeEach, expect, it, vi, type Mock } from "vitest";
|
|
8
8
|
import {
|
|
9
9
|
createStudioApi,
|
|
10
10
|
openProjectHistory,
|
|
@@ -168,6 +168,20 @@ it("an undo pressed while a save is still running waits for the server instead o
|
|
|
168
168
|
expect(s.box()).toBe("10px");
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
+
it("an undo waits for a save queued before its key, not one queued after", async () => {
|
|
172
|
+
const s = await studio();
|
|
173
|
+
await s.edit();
|
|
174
|
+
let land!: () => void;
|
|
175
|
+
void s.persistence().queueDomEditSave(() => new Promise<void>((resolve) => (land = resolve)));
|
|
176
|
+
await vi.waitFor(() => expect(land).toBeTypeOf("function"));
|
|
177
|
+
|
|
178
|
+
const undone = s.actions().undo();
|
|
179
|
+
void s.persistence().queueDomEditSave(() => new Promise<void>(() => {}));
|
|
180
|
+
land();
|
|
181
|
+
await act(() => undone);
|
|
182
|
+
expect(s.file()).toBe(BEFORE);
|
|
183
|
+
});
|
|
184
|
+
|
|
171
185
|
it("an undo after an outside write ends with the preview showing the file the server restored", async () => {
|
|
172
186
|
const s = await studio();
|
|
173
187
|
await s.edit();
|
|
@@ -202,10 +216,8 @@ function Nudge({ target, save }: { target: HTMLElement; save: () => Promise<void
|
|
|
202
216
|
return null;
|
|
203
217
|
}
|
|
204
218
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
await s.edit();
|
|
208
|
-
resetNudgeKeys();
|
|
219
|
+
/** A nudge save that waits for `finish()`, then writes and records the nudge as the real save does. */
|
|
220
|
+
function queuedNudgeSave(s: Awaited<ReturnType<typeof studio>>) {
|
|
209
221
|
let finish!: () => void;
|
|
210
222
|
const save = vi.fn(async () => {
|
|
211
223
|
await new Promise<void>((resolve) => (finish = resolve));
|
|
@@ -215,6 +227,24 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
|
|
|
215
227
|
files: { "index.html": { before: AFTER, after: NUDGED } },
|
|
216
228
|
});
|
|
217
229
|
});
|
|
230
|
+
return { save, finish: () => finish() };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Mounts the nudge, presses ArrowRight once and waits for its save; returns the nudge's translate reader. */
|
|
234
|
+
async function pressNudge(s: Awaited<ReturnType<typeof studio>>, save: Mock<() => Promise<void>>) {
|
|
235
|
+
s.mount(createElement(Nudge, { target: s.element("box"), save }));
|
|
236
|
+
act(() => {
|
|
237
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
|
|
238
|
+
});
|
|
239
|
+
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
|
|
240
|
+
return () => s.element("box").style.getPropertyValue("translate");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
it("an undo pressed while a nudge waits for more keys never shows the move before it undone", async () => {
|
|
244
|
+
const s = await studio();
|
|
245
|
+
await s.edit();
|
|
246
|
+
resetNudgeKeys();
|
|
247
|
+
const { save, finish } = queuedNudgeSave(s);
|
|
218
248
|
s.mount(createElement(Nudge, { target: s.element("box"), save }));
|
|
219
249
|
act(() => {
|
|
220
250
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
|
|
@@ -240,21 +270,8 @@ it("an undo pressed while a nudge's save is queued shows the nudge undone in the
|
|
|
240
270
|
const s = await studio();
|
|
241
271
|
await s.edit();
|
|
242
272
|
resetNudgeKeys();
|
|
243
|
-
|
|
244
|
-
const
|
|
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");
|
|
273
|
+
const { save, finish } = queuedNudgeSave(s);
|
|
274
|
+
const nudge = await pressNudge(s, save);
|
|
258
275
|
expect(nudge()).not.toBe("");
|
|
259
276
|
|
|
260
277
|
const undone = s.actions().undo();
|
|
@@ -267,6 +284,51 @@ it("an undo pressed while a nudge's save is queued shows the nudge undone in the
|
|
|
267
284
|
expect(s.box()).toBe("50px");
|
|
268
285
|
});
|
|
269
286
|
|
|
287
|
+
/** A nudge whose save is queued and then rejects, and the history requests the undo sends. */
|
|
288
|
+
async function failingNudge(s: Awaited<ReturnType<typeof studio>>) {
|
|
289
|
+
resetNudgeKeys();
|
|
290
|
+
let fail!: () => void;
|
|
291
|
+
const save = vi.fn(
|
|
292
|
+
() => new Promise<void>((_, reject) => (fail = () => reject(new Error("The save failed.")))),
|
|
293
|
+
);
|
|
294
|
+
const nudge = await pressNudge(s, save);
|
|
295
|
+
const steps: string[] = [];
|
|
296
|
+
const real = globalThis.fetch;
|
|
297
|
+
vi.stubGlobal("fetch", (url: string, init?: RequestInit) => {
|
|
298
|
+
if (/\/history\/(step|undo)$/.test(url)) steps.push(url);
|
|
299
|
+
return real(url, init);
|
|
300
|
+
});
|
|
301
|
+
return { fail: () => fail(), steps, nudge };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
it("an undo pressed while a nudge's save fails keeps the nudge undone and steps no older edit", async () => {
|
|
305
|
+
const s = await studio();
|
|
306
|
+
await s.edit();
|
|
307
|
+
const n = await failingNudge(s);
|
|
308
|
+
expect(n.nudge()).not.toBe("");
|
|
309
|
+
|
|
310
|
+
const undone = s.actions().undo();
|
|
311
|
+
expect(n.nudge()).toBe("");
|
|
312
|
+
n.fail();
|
|
313
|
+
await act(() => undone);
|
|
314
|
+
expect(n.steps).toEqual([]);
|
|
315
|
+
expect(s.file()).toBe(AFTER);
|
|
316
|
+
expect(s.box()).toBe("50px");
|
|
317
|
+
expect(n.nudge()).toBe("");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("an undo pressed while the only edit's save fails leaves the screen as the file, nothing put back", async () => {
|
|
321
|
+
const s = await studio();
|
|
322
|
+
s.show(BEFORE);
|
|
323
|
+
const n = await failingNudge(s);
|
|
324
|
+
|
|
325
|
+
const undone = s.actions().undo();
|
|
326
|
+
n.fail();
|
|
327
|
+
await act(() => undone);
|
|
328
|
+
expect(s.file()).toBe(BEFORE);
|
|
329
|
+
expect(n.nudge()).toBe("");
|
|
330
|
+
});
|
|
331
|
+
|
|
270
332
|
it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {
|
|
271
333
|
const s = await studio();
|
|
272
334
|
await s.edit();
|
|
@@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
7
7
|
import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryActions";
|
|
8
|
+
import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
|
|
8
9
|
|
|
9
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
11
|
|
|
@@ -30,6 +31,7 @@ function mount(
|
|
|
30
31
|
undo: vi.fn<EditHistoryHandle["undo"]>(async () => result),
|
|
31
32
|
redo: vi.fn<EditHistoryHandle["redo"]>(async () => result),
|
|
32
33
|
predict: () => predicted ?? null,
|
|
34
|
+
claims: () => 7,
|
|
33
35
|
};
|
|
34
36
|
const putBack = vi.fn();
|
|
35
37
|
const deps = {
|
|
@@ -60,6 +62,19 @@ const PREDICTED = { id: "e1", files: { "index.html": { previous: "B", restored:
|
|
|
60
62
|
const SERVER_FILES = { "index.html": { previous: "B", restored: "A2" } };
|
|
61
63
|
|
|
62
64
|
describe("useEditHistoryActions", () => {
|
|
65
|
+
it("asks for the edit claimed after the key's claim count only when an edit was saving at the key", async () => {
|
|
66
|
+
const { deps, actions } = mount({ ok: false, reason: "empty" });
|
|
67
|
+
await act(() => actions.undo());
|
|
68
|
+
expect(deps.editHistory.undo.mock.calls[0]![0].claimedAfter).toBeUndefined();
|
|
69
|
+
|
|
70
|
+
let land!: () => void;
|
|
71
|
+
trackStudioPendingEdit(new Promise<void>((resolve) => (land = resolve)));
|
|
72
|
+
const undone = actions.undo();
|
|
73
|
+
land();
|
|
74
|
+
await act(() => undone);
|
|
75
|
+
expect(deps.editHistory.undo.mock.calls[1]![0].claimedAfter).toBe(7);
|
|
76
|
+
});
|
|
77
|
+
|
|
63
78
|
it("corrects a shown step from the server's restore, diffed from what the preview shows", async () => {
|
|
64
79
|
const { deps, actions } = mount(
|
|
65
80
|
{ ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
|
|
@@ -3,7 +3,10 @@ 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 {
|
|
6
|
+
import {
|
|
7
|
+
hasStudioPendingEdits,
|
|
8
|
+
paintBackNewestStudioPendingEdit,
|
|
9
|
+
} from "../utils/studioPendingEdits";
|
|
7
10
|
|
|
8
11
|
interface HistoryResult {
|
|
9
12
|
ok: boolean;
|
|
@@ -18,11 +21,13 @@ interface HistoryResult {
|
|
|
18
21
|
interface HistoryFileCallbacks {
|
|
19
22
|
readFile: (path: string) => Promise<string>;
|
|
20
23
|
serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
|
|
24
|
+
claimedAfter?: number;
|
|
21
25
|
}
|
|
22
26
|
export interface EditHistoryHandle {
|
|
23
27
|
undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
|
24
28
|
redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
|
25
29
|
predict?: (direction: "undo" | "redo") => { id: string; files: RestoreFiles } | null;
|
|
30
|
+
claims?: () => number;
|
|
26
31
|
state: {
|
|
27
32
|
undo: ReadonlyArray<{ createdAt: number }>;
|
|
28
33
|
redo: ReadonlyArray<{ createdAt: number }>;
|
|
@@ -30,7 +35,7 @@ export interface EditHistoryHandle {
|
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export interface UseEditHistoryActionsOptions {
|
|
33
|
-
editHistory: Pick<EditHistoryHandle, "undo" | "redo" | "predict">;
|
|
38
|
+
editHistory: Pick<EditHistoryHandle, "undo" | "redo" | "predict" | "claims">;
|
|
34
39
|
readOptionalProjectFile: (path: string) => Promise<string>;
|
|
35
40
|
readProjectFile: (path: string) => Promise<string>;
|
|
36
41
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
@@ -77,22 +82,28 @@ export function useEditHistoryActions({
|
|
|
77
82
|
const predicted = editHistory.predict?.(direction) ?? null;
|
|
78
83
|
const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
|
|
79
84
|
const pendingEditShown =
|
|
80
|
-
!predictedShown && direction === "undo" ?
|
|
81
|
-
const putBack = predictedShown ?? pendingEditShown;
|
|
85
|
+
!predictedShown && direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
|
|
86
|
+
const putBack = predictedShown ?? pendingEditShown?.showAgain;
|
|
87
|
+
const claimedAfter =
|
|
88
|
+
direction === "undo" && hasStudioPendingEdits() ? editHistory.claims?.() : undefined;
|
|
82
89
|
let result: HistoryResult = { ok: false, reason: "failed" };
|
|
83
90
|
let serverSteppedShown = false;
|
|
91
|
+
let revertIsTheUndo = false;
|
|
84
92
|
try {
|
|
85
93
|
await waitForPendingDomEditSaves();
|
|
94
|
+
revertIsTheUndo = Boolean(pendingEditShown && !(await pendingEditShown.landed()));
|
|
95
|
+
if (revertIsTheUndo) return;
|
|
86
96
|
result = await editHistory[direction]({
|
|
87
97
|
readFile: readHistoryFile,
|
|
88
98
|
serialize: serializeHistoryFiles,
|
|
99
|
+
claimedAfter,
|
|
89
100
|
});
|
|
90
101
|
const stepped = Boolean(result.ok && result.label);
|
|
91
102
|
serverSteppedShown = predictedShown
|
|
92
103
|
? stepped && result.undoes === predicted?.id
|
|
93
104
|
: stepped && Boolean(pendingEditShown);
|
|
94
105
|
} finally {
|
|
95
|
-
if (putBack && !serverSteppedShown) putBack();
|
|
106
|
+
if (putBack && !serverSteppedShown && !revertIsTheUndo) putBack();
|
|
96
107
|
}
|
|
97
108
|
if (!result.ok && result.reason === "content-mismatch") {
|
|
98
109
|
showToast(
|
|
@@ -24,6 +24,7 @@ import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditComm
|
|
|
24
24
|
import { domEditCommitDeclined, type DomEditCommitOutcome } from "./domEditCommitRunner";
|
|
25
25
|
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
|
|
26
26
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
27
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
27
28
|
|
|
28
29
|
interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams {
|
|
29
30
|
/** Route delete through SDK when session resolves the hf-id. */
|
|
@@ -156,7 +157,7 @@ export function useElementLifecycleOps({
|
|
|
156
157
|
writeFile: writeProjectFile,
|
|
157
158
|
recordEdit: editHistory.recordEdit,
|
|
158
159
|
rewrite: async (originalContent) => {
|
|
159
|
-
const removeResponse = await
|
|
160
|
+
const removeResponse = await studioApiFetch(
|
|
160
161
|
buildProjectApiPath(
|
|
161
162
|
pid,
|
|
162
163
|
`/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`,
|
|
@@ -378,7 +378,10 @@ function makeElementSelection(): DomEditSelection {
|
|
|
378
378
|
} as DomEditSelection;
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
-
function stubFlatTweenConversion(
|
|
381
|
+
function stubFlatTweenConversion(
|
|
382
|
+
id: string,
|
|
383
|
+
easeEach?: string,
|
|
384
|
+
): {
|
|
382
385
|
flat: GsapAnimation;
|
|
383
386
|
converted: GsapAnimation;
|
|
384
387
|
} {
|
|
@@ -393,6 +396,7 @@ function stubFlatTweenConversion(id: string): {
|
|
|
393
396
|
{ percentage: 0, properties: { x: 0 } },
|
|
394
397
|
{ percentage: 100, properties: { x: 10 } },
|
|
395
398
|
],
|
|
399
|
+
...(easeEach && { easeEach }),
|
|
396
400
|
},
|
|
397
401
|
});
|
|
398
402
|
flatTweenResponses(flat, converted);
|
|
@@ -437,6 +441,33 @@ describe("useEnableKeyframes — flat tween transaction", () => {
|
|
|
437
441
|
expect(convertOptions?.coalesceKey).toEqual(expect.any(String));
|
|
438
442
|
});
|
|
439
443
|
|
|
444
|
+
it("keeps the converted tween's easeEach when it extends it to the playhead", async () => {
|
|
445
|
+
window.location.hash = "#/project/test-project";
|
|
446
|
+
usePlayerStore.setState({ currentTime: 3 });
|
|
447
|
+
const { flat } = stubFlatTweenConversion("flat-1", "none");
|
|
448
|
+
const commitMutation = vi.fn(async () => undefined);
|
|
449
|
+
const enable = renderEnableKeyframes({
|
|
450
|
+
domEditSelection: makeElementSelection(),
|
|
451
|
+
selectedGsapAnimations: [flat],
|
|
452
|
+
previewIframeRef: {
|
|
453
|
+
current: {
|
|
454
|
+
contentWindow: { gsap: { getProperty: () => 10 } },
|
|
455
|
+
} as unknown as HTMLIFrameElement,
|
|
456
|
+
},
|
|
457
|
+
handleGsapAddAnimation: vi.fn(),
|
|
458
|
+
handleGsapConvertToKeyframes: vi.fn(async () => undefined),
|
|
459
|
+
handleGsapRemoveKeyframe: vi.fn(),
|
|
460
|
+
commitMutation,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
await act(async () => enable());
|
|
464
|
+
|
|
465
|
+
expect(commitMutation.mock.calls[0]?.[0]).toMatchObject({
|
|
466
|
+
type: "replace-with-keyframes",
|
|
467
|
+
easeEach: "none",
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
440
471
|
it("passes the convert coalesce key to an inside-range batch edit", async () => {
|
|
441
472
|
window.location.hash = "#/project/test-project";
|
|
442
473
|
usePlayerStore.setState({ currentTime: 1.5 });
|
|
@@ -15,6 +15,7 @@ import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCa
|
|
|
15
15
|
import {
|
|
16
16
|
existingTweenTargetSelector,
|
|
17
17
|
computeElementPercentage,
|
|
18
|
+
keyframeEases,
|
|
18
19
|
KEYFRAME_PCT_MATCH,
|
|
19
20
|
isInstantHold,
|
|
20
21
|
resolveEditableTweenDuration,
|
|
@@ -266,7 +267,7 @@ async function extendKeyframedTweenToPlayhead(
|
|
|
266
267
|
position: extended.position,
|
|
267
268
|
duration: extended.duration,
|
|
268
269
|
keyframes: extended.keyframes,
|
|
269
|
-
|
|
270
|
+
...keyframeEases(anim),
|
|
270
271
|
},
|
|
271
272
|
{
|
|
272
273
|
label: "Add keyframe",
|
|
@@ -83,6 +83,25 @@ describe("external file change coordinator", () => {
|
|
|
83
83
|
expect(captured.handle?.blocked).toBeNull();
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
it("keeps one file-change subscription across re-renders and delivers to the latest callbacks", async () => {
|
|
87
|
+
const on = vi.fn((_event: string, next: HotHandler) => void (handler = next));
|
|
88
|
+
vi.stubGlobal("__HF_STUDIO_HOT_TEST_ADAPTER__", { on, off: vi.fn() });
|
|
89
|
+
const { options } = await mountCoordinator();
|
|
90
|
+
on.mockClear();
|
|
91
|
+
const root = createRoot(document.createElement("div"));
|
|
92
|
+
roots.push(root);
|
|
93
|
+
function Probe({ onAccepted }: { onAccepted: () => void }) {
|
|
94
|
+
useExternalFileChangeCoordinator({ ...options, onAcceptedPersistedFileChange: onAccepted });
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const latest = vi.fn();
|
|
98
|
+
await act(async () => root.render(<Probe onAccepted={vi.fn()} />));
|
|
99
|
+
await act(async () => root.render(<Probe onAccepted={latest} />));
|
|
100
|
+
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
|
|
101
|
+
expect(on).toHaveBeenCalledOnce();
|
|
102
|
+
expect(latest).toHaveBeenCalledOnce();
|
|
103
|
+
});
|
|
104
|
+
|
|
86
105
|
// The file tree (useFileTree) is only ever refreshed from Studio's own file
|
|
87
106
|
// operations (create/delete/rename/upload) — never on an external change.
|
|
88
107
|
// Without this call, an agent removing or replacing a composition updates
|
|
@@ -448,9 +448,11 @@ export function useExternalFileChangeCoordinator({
|
|
|
448
448
|
[projectId, pendingTimelineEditPathRef, startDrainLoop, onAcceptedPersistedFileChange],
|
|
449
449
|
);
|
|
450
450
|
|
|
451
|
+
const processChangeRef = useRef(processChange);
|
|
452
|
+
processChangeRef.current = processChange;
|
|
451
453
|
useEffect(() => {
|
|
452
454
|
// One decoder for all three transports; the rungs only choose the channel.
|
|
453
|
-
const handler = (delivery?: unknown) =>
|
|
455
|
+
const handler = (delivery?: unknown) => processChangeRef.current(decodeFileChange(delivery));
|
|
454
456
|
const adapter = testHotAdapter();
|
|
455
457
|
if (adapter) {
|
|
456
458
|
adapter.on("hf:file-change", handler);
|
|
@@ -461,7 +463,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
461
463
|
return () => import.meta.hot?.off?.("hf:file-change", handler);
|
|
462
464
|
}
|
|
463
465
|
return sseFileChangeChannel(handler);
|
|
464
|
-
}, [
|
|
466
|
+
}, []);
|
|
465
467
|
|
|
466
468
|
const retry = useCallback(async () => {
|
|
467
469
|
const current = blockedRef.current;
|
|
@@ -129,6 +129,7 @@ describe("useFileManager project ownership", () => {
|
|
|
129
129
|
|
|
130
130
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
131
131
|
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
|
|
132
|
+
{ credentials: "omit" },
|
|
132
133
|
);
|
|
133
134
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
134
135
|
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
|
|
@@ -136,14 +137,18 @@ describe("useFileManager project ownership", () => {
|
|
|
136
137
|
);
|
|
137
138
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
138
139
|
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
|
|
140
|
+
{ credentials: "omit" },
|
|
139
141
|
);
|
|
140
142
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
141
143
|
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
|
|
142
144
|
expect.objectContaining({ method: "PUT", body: "A_NEW" }),
|
|
143
145
|
);
|
|
144
|
-
expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html"
|
|
146
|
+
expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html", {
|
|
147
|
+
credentials: "omit",
|
|
148
|
+
});
|
|
145
149
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
146
150
|
"/api/projects/project-b%23fragment/files/index.html?optional=1",
|
|
151
|
+
{ credentials: "omit" },
|
|
147
152
|
);
|
|
148
153
|
|
|
149
154
|
await act(async () => root.unmount());
|
|
@@ -8,6 +8,7 @@ import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordina
|
|
|
8
8
|
import { useFileTree } from "./useFileTree";
|
|
9
9
|
import { useEditorSave } from "./useEditorSave";
|
|
10
10
|
import { useProjectFileWriter } from "./useProjectFileWriter";
|
|
11
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
11
12
|
|
|
12
13
|
// ── Types ──
|
|
13
14
|
|
|
@@ -133,7 +134,7 @@ export function useFileManager({
|
|
|
133
134
|
setEditingFile({ path, content: null });
|
|
134
135
|
return;
|
|
135
136
|
}
|
|
136
|
-
|
|
137
|
+
studioApiFetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`)
|
|
137
138
|
.then((r) => {
|
|
138
139
|
if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
|
|
139
140
|
return r.json();
|
|
@@ -167,9 +168,12 @@ export function useFileManager({
|
|
|
167
168
|
const requestId = ++revealRequestIdRef.current;
|
|
168
169
|
const controller = new AbortController();
|
|
169
170
|
revealAbortRef.current = controller;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
studioApiFetch(
|
|
172
|
+
`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(sourceFile)}`,
|
|
173
|
+
{
|
|
174
|
+
signal: controller.signal,
|
|
175
|
+
},
|
|
176
|
+
)
|
|
173
177
|
.then((r) => r.json())
|
|
174
178
|
.then((data: { content?: string; version?: string }) => {
|
|
175
179
|
if (requestId !== revealRequestIdRef.current) return;
|
|
@@ -200,7 +204,7 @@ export function useFileManager({
|
|
|
200
204
|
|
|
201
205
|
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
|
|
202
206
|
try {
|
|
203
|
-
const res = await
|
|
207
|
+
const res = await studioApiFetch(`/api/projects/${encodeURIComponent(pid)}/upload${qs}`, {
|
|
204
208
|
method: "POST",
|
|
205
209
|
body: formData,
|
|
206
210
|
});
|
|
@@ -246,7 +250,7 @@ export function useFileManager({
|
|
|
246
250
|
content =
|
|
247
251
|
'<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n\n</body>\n</html>\n';
|
|
248
252
|
}
|
|
249
|
-
const res = await
|
|
253
|
+
const res = await studioApiFetch(
|
|
250
254
|
`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`,
|
|
251
255
|
{
|
|
252
256
|
method: "POST",
|
|
@@ -270,7 +274,7 @@ export function useFileManager({
|
|
|
270
274
|
async (path: string) => {
|
|
271
275
|
const pid = projectIdRef.current;
|
|
272
276
|
if (!pid) return;
|
|
273
|
-
const res = await
|
|
277
|
+
const res = await studioApiFetch(
|
|
274
278
|
`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path + "/.gitkeep")}`,
|
|
275
279
|
{
|
|
276
280
|
method: "POST",
|
|
@@ -293,7 +297,7 @@ export function useFileManager({
|
|
|
293
297
|
async (path: string) => {
|
|
294
298
|
const pid = projectIdRef.current;
|
|
295
299
|
if (!pid) return;
|
|
296
|
-
const res = await
|
|
300
|
+
const res = await studioApiFetch(
|
|
297
301
|
`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`,
|
|
298
302
|
{
|
|
299
303
|
method: "DELETE",
|
|
@@ -315,7 +319,7 @@ export function useFileManager({
|
|
|
315
319
|
async (oldPath: string, newPath: string) => {
|
|
316
320
|
const pid = projectIdRef.current;
|
|
317
321
|
if (!pid) return;
|
|
318
|
-
const res = await
|
|
322
|
+
const res = await studioApiFetch(
|
|
319
323
|
`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(oldPath)}`,
|
|
320
324
|
{
|
|
321
325
|
method: "PATCH",
|
|
@@ -342,7 +346,7 @@ export function useFileManager({
|
|
|
342
346
|
async (path: string) => {
|
|
343
347
|
const pid = projectIdRef.current;
|
|
344
348
|
if (!pid) return;
|
|
345
|
-
const res = await
|
|
349
|
+
const res = await studioApiFetch(`/api/projects/${encodeURIComponent(pid)}/duplicate-file`, {
|
|
346
350
|
method: "POST",
|
|
347
351
|
headers: { "Content-Type": "application/json" },
|
|
348
352
|
body: JSON.stringify({ path }),
|
package/src/hooks/useFileTree.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
|
|
3
3
|
import { FONT_EXT } from "../utils/mediaTypes";
|
|
4
4
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
5
5
|
import { captureProjectProvenance } from "../components/feedback/projectProvenance";
|
|
6
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
6
7
|
|
|
7
8
|
interface UseFileTreeOptions {
|
|
8
9
|
projectId: string | null;
|
|
@@ -38,7 +39,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
|
|
|
38
39
|
if (!projectId) return;
|
|
39
40
|
let cancelled = false;
|
|
40
41
|
setFetched({ projectId, loaded: false, fileTree: [], compositionPaths: [], projectDir: null });
|
|
41
|
-
|
|
42
|
+
studioApiFetch(buildProjectApiPath(projectId))
|
|
42
43
|
// An unresolvable project answers 404 with a JSON body, so without this
|
|
43
44
|
// the error path parsed cleanly and the success branch below recorded an
|
|
44
45
|
// empty tree — and an empty provenance snapshot — for a project that was
|
|
@@ -84,7 +85,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
|
|
|
84
85
|
refreshAbortRef.current = controller;
|
|
85
86
|
let data: { files?: string[]; compositions?: string[] };
|
|
86
87
|
try {
|
|
87
|
-
const res = await
|
|
88
|
+
const res = await studioApiFetch(buildProjectApiPath(pid), { signal: controller.signal });
|
|
88
89
|
data = await res.json();
|
|
89
90
|
} catch (err) {
|
|
90
91
|
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
@@ -2,6 +2,7 @@ import { useMemo, useState, useCallback, useRef, type MouseEvent } from "react";
|
|
|
2
2
|
import { useMountEffect } from "./useMountEffect";
|
|
3
3
|
import { liveTime, usePlayerStore } from "../player";
|
|
4
4
|
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
|
|
5
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
5
6
|
|
|
6
7
|
interface UseFrameCaptureParams {
|
|
7
8
|
projectId: string | null;
|
|
@@ -67,7 +68,10 @@ export function useFrameCapture({
|
|
|
67
68
|
const controller = new AbortController();
|
|
68
69
|
const timeout = setTimeout(() => controller.abort(), 30000);
|
|
69
70
|
try {
|
|
70
|
-
const response = await
|
|
71
|
+
const response = await studioApiFetch(href, {
|
|
72
|
+
cache: "no-store",
|
|
73
|
+
signal: controller.signal,
|
|
74
|
+
});
|
|
71
75
|
clearTimeout(timeout);
|
|
72
76
|
if (!response.ok) {
|
|
73
77
|
let msg = `Capture failed (${response.status})`;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
1
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
5
|
import type { TimelineElement } from "../player";
|
|
3
|
-
import { requestFreezeFrame } from "./useFreezeFrame";
|
|
6
|
+
import { requestFreezeFrame, useFreezeFrame } from "./useFreezeFrame";
|
|
4
7
|
|
|
5
8
|
afterEach(() => vi.unstubAllGlobals());
|
|
6
9
|
|
|
@@ -29,14 +32,15 @@ function stubFetch(freezeResponse: { status: number; body: unknown }) {
|
|
|
29
32
|
|
|
30
33
|
describe("requestFreezeFrame", () => {
|
|
31
34
|
it("posts the clip target, file version and an authored-time playhead", async () => {
|
|
32
|
-
const
|
|
35
|
+
const frozen = { before: "a", after: "b", version: "v2", stillPath: "assets/freeze/t.png" };
|
|
36
|
+
const calls = stubFetch({ status: 200, body: frozen });
|
|
33
37
|
const result = await requestFreezeFrame({
|
|
34
38
|
projectId: "p",
|
|
35
39
|
path: "scene.html",
|
|
36
40
|
element,
|
|
37
41
|
playhead: 7.5,
|
|
38
42
|
});
|
|
39
|
-
expect(result).toEqual(
|
|
43
|
+
expect(result).toEqual(frozen);
|
|
40
44
|
const post = calls[1];
|
|
41
45
|
expect(post?.url).toContain("/file-mutations/freeze-frame");
|
|
42
46
|
expect(post?.body).toMatchObject({
|
|
@@ -54,3 +58,35 @@ describe("requestFreezeFrame", () => {
|
|
|
54
58
|
).rejects.toThrow("Move the playhead inside a video clip to freeze");
|
|
55
59
|
});
|
|
56
60
|
});
|
|
61
|
+
|
|
62
|
+
describe("useFreezeFrame", () => {
|
|
63
|
+
it("records the still it made with the edit, so Undo removes it", async () => {
|
|
64
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
65
|
+
stubFetch({
|
|
66
|
+
status: 200,
|
|
67
|
+
body: { before: "a", after: "b", version: "v2", stillPath: "assets/freeze/talk.png" },
|
|
68
|
+
});
|
|
69
|
+
const recordEdit = vi.fn(async () => {});
|
|
70
|
+
let freeze!: ReturnType<typeof useFreezeFrame>;
|
|
71
|
+
function Harness() {
|
|
72
|
+
freeze = useFreezeFrame({
|
|
73
|
+
projectId: "p",
|
|
74
|
+
activeCompPath: "index.html",
|
|
75
|
+
showToast: () => {},
|
|
76
|
+
writeProjectFile: async () => {},
|
|
77
|
+
recordEdit,
|
|
78
|
+
reloadPreview: () => {},
|
|
79
|
+
});
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const root = createRoot(document.createElement("div"));
|
|
83
|
+
await act(async () => root.render(createElement(Harness)));
|
|
84
|
+
await act(() => freeze({ ...element, sourceFile: "index.html" }, 7.5));
|
|
85
|
+
expect(recordEdit).toHaveBeenCalledWith({
|
|
86
|
+
label: "Freeze frame",
|
|
87
|
+
files: { "index.html": { before: "a", after: "b" } },
|
|
88
|
+
created: ["assets/freeze/talk.png"],
|
|
89
|
+
});
|
|
90
|
+
act(() => root.unmount());
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -6,6 +6,7 @@ import { buildProjectApiPath } from "../utils/projectRouting";
|
|
|
6
6
|
import { markStudioWriteToken } from "../utils/studioFileVersion";
|
|
7
7
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
8
8
|
import type { RecordEditInput } from "../utils/studioFileHistory";
|
|
9
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
9
10
|
|
|
10
11
|
type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
11
12
|
|
|
@@ -13,6 +14,7 @@ interface FreezeFrameResponse {
|
|
|
13
14
|
before: string;
|
|
14
15
|
after: string;
|
|
15
16
|
version: string;
|
|
17
|
+
stillPath: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
|
|
@@ -21,7 +23,8 @@ function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
|
|
|
21
23
|
return (
|
|
22
24
|
typeof body.before === "string" &&
|
|
23
25
|
typeof body.after === "string" &&
|
|
24
|
-
typeof body.version === "string"
|
|
26
|
+
typeof body.version === "string" &&
|
|
27
|
+
typeof body.stillPath === "string"
|
|
25
28
|
);
|
|
26
29
|
}
|
|
27
30
|
|
|
@@ -31,7 +34,7 @@ function errorOf(value: unknown): string | null {
|
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
async function readVersion(projectId: string, path: string): Promise<string> {
|
|
34
|
-
const response = await
|
|
37
|
+
const response = await studioApiFetch(
|
|
35
38
|
buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
|
|
36
39
|
);
|
|
37
40
|
const body: unknown = await response.json().catch(() => null);
|
|
@@ -53,7 +56,7 @@ export async function requestFreezeFrame(input: {
|
|
|
53
56
|
const expectedVersion = await readVersion(input.projectId, input.path);
|
|
54
57
|
const transactionToken = `freeze:${crypto.randomUUID()}`;
|
|
55
58
|
markStudioWriteToken(transactionToken);
|
|
56
|
-
const response = await
|
|
59
|
+
const response = await studioApiFetch(
|
|
57
60
|
buildProjectApiPath(input.projectId, "/file-mutations/freeze-frame"),
|
|
58
61
|
{
|
|
59
62
|
method: "POST",
|
|
@@ -108,6 +111,7 @@ export function useFreezeFrame(options: UseFreezeFrameOptions) {
|
|
|
108
111
|
await opts.recordEdit({
|
|
109
112
|
label: "Freeze frame",
|
|
110
113
|
files: { [path]: { before: result.before, after: result.after } },
|
|
114
|
+
created: [result.stillPath],
|
|
111
115
|
});
|
|
112
116
|
opts.observeProjectFileVersion?.(path, result.version);
|
|
113
117
|
});
|