@hyperframes/studio 0.8.100 → 0.8.101
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-fWt87gyh.js → hyperframes-player-CFJLrHRF.js} +1 -1
- package/dist/assets/{index-D6TjRAvz.js → index-BBNVnnyH.js} +114 -114
- package/dist/assets/{index-DofdzdxJ.js → index-C33EDBL2.js} +1 -1
- package/dist/assets/{index-D34Oy5DN.js → index-CJOzbslX.js} +1 -1
- package/dist/{chunk-U3EE4ACX.js → chunk-7X4XRP3L.js} +14 -13
- package/dist/chunk-7X4XRP3L.js.map +1 -0
- package/dist/index.d.ts +91 -21
- package/dist/index.html +1 -1
- package/dist/index.js +403 -226
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +2 -0
- package/src/components/dock/Dock.test.tsx +78 -3
- package/src/components/dock/Dock.tsx +45 -18
- package/src/components/dock/dockLayout.ts +27 -14
- package/src/components/dock/dockLayoutSchema.ts +11 -4
- package/src/components/dock/dockLayoutStore.ts +4 -2
- package/src/hooks/appHotkeysDispatch.ts +1 -7
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +67 -2
- package/src/hooks/useAppHotkeys.ts +18 -45
- package/src/hooks/useEditHistoryActions.paint.test.tsx +166 -0
- package/src/hooks/useEditHistoryActions.test.tsx +67 -9
- package/src/hooks/useEditHistoryActions.ts +36 -8
- package/src/hooks/usePersistentEditHistory.test.ts +46 -0
- package/src/hooks/usePersistentEditHistory.ts +101 -19
- package/src/hooks/usePreviewPersistence.ts +25 -2
- package/src/hooks/useStudioExternalFileChanges.ts +7 -3
- package/src/index.ts +4 -0
- package/src/player/components/timelineDragDrop.test.tsx +61 -5
- package/src/player/components/timelineDragDrop.ts +29 -8
- package/src/player/components/useTimelineProviderState.tsx +1 -0
- package/src/utils/domEditSaveQueue.ts +7 -2
- package/src/utils/gsapSoftReload.ts +1 -1
- package/src/utils/gsapUndoRestore.test.ts +46 -0
- package/src/utils/gsapUndoRestore.ts +166 -91
- package/src/utils/studioPendingEdits.ts +14 -5
- package/src/utils/studioUiPreferences.ts +8 -6
- package/dist/chunk-U3EE4ACX.js.map +0 -1
|
@@ -8,13 +8,7 @@ import {
|
|
|
8
8
|
applyCaptionModelToIframe,
|
|
9
9
|
isCaptionPreviewVisible,
|
|
10
10
|
} from "../captions/components/CaptionOverlayUtils";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
type HotkeyCallbacks,
|
|
14
|
-
dispatchModifierKey,
|
|
15
|
-
dispatchPlainKey,
|
|
16
|
-
handleUndoRedoKey,
|
|
17
|
-
} from "./appHotkeysDispatch";
|
|
11
|
+
import { type HotkeyCallbacks, dispatchModifierKey, dispatchPlainKey } from "./appHotkeysDispatch";
|
|
18
12
|
import {
|
|
19
13
|
useEditHistoryActions,
|
|
20
14
|
type EditHistoryHandle,
|
|
@@ -36,9 +30,14 @@ function safeAddListener(t: EventTarget | null, type: string, h: EventListener,
|
|
|
36
30
|
/* cross-origin */
|
|
37
31
|
}
|
|
38
32
|
}
|
|
39
|
-
function safeRemoveListener(
|
|
33
|
+
function safeRemoveListener(
|
|
34
|
+
t: EventTarget | null,
|
|
35
|
+
type: string,
|
|
36
|
+
h: EventListener,
|
|
37
|
+
capture = false,
|
|
38
|
+
) {
|
|
40
39
|
try {
|
|
41
|
-
t?.removeEventListener(type, h);
|
|
40
|
+
t?.removeEventListener(type, h, capture);
|
|
42
41
|
} catch {
|
|
43
42
|
/* cross-origin */
|
|
44
43
|
}
|
|
@@ -86,6 +85,7 @@ interface UseAppHotkeysParams {
|
|
|
86
85
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
87
86
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
88
87
|
syncHistoryPreviewAfterApply: UseEditHistoryActionsOptions["syncHistoryPreviewAfterApply"];
|
|
88
|
+
showHistoryRestoreNow?: UseEditHistoryActionsOptions["showHistoryRestoreNow"];
|
|
89
89
|
waitForPendingDomEditSaves: () => Promise<void>;
|
|
90
90
|
handleCopy: () => boolean;
|
|
91
91
|
handlePaste: () => Promise<void>;
|
|
@@ -124,6 +124,7 @@ export function useAppHotkeys({
|
|
|
124
124
|
writeProjectFile,
|
|
125
125
|
showToast,
|
|
126
126
|
syncHistoryPreviewAfterApply,
|
|
127
|
+
showHistoryRestoreNow,
|
|
127
128
|
waitForPendingDomEditSaves,
|
|
128
129
|
handleCopy,
|
|
129
130
|
handlePaste,
|
|
@@ -150,6 +151,7 @@ export function useAppHotkeys({
|
|
|
150
151
|
writeProjectFile,
|
|
151
152
|
showToast,
|
|
152
153
|
syncHistoryPreviewAfterApply,
|
|
154
|
+
showHistoryRestoreNow,
|
|
153
155
|
waitForPendingDomEditSaves,
|
|
154
156
|
onAfterUndoRedo,
|
|
155
157
|
activeCompPath,
|
|
@@ -229,52 +231,23 @@ export function useAppHotkeys({
|
|
|
229
231
|
|
|
230
232
|
// ── Preview iframe forwarding ──
|
|
231
233
|
|
|
232
|
-
const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
|
|
233
|
-
if (!(event.metaKey || event.ctrlKey) || shouldIgnoreHistoryShortcut(event.target)) return;
|
|
234
|
-
handleUndoRedoKey(
|
|
235
|
-
event,
|
|
236
|
-
() => void cbRef.current.handleUndo(),
|
|
237
|
-
() => void cbRef.current.handleRedo(),
|
|
238
|
-
);
|
|
239
|
-
}, []);
|
|
240
|
-
|
|
241
234
|
/**
|
|
242
|
-
* Give the preview iframe the app's hotkeys
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* Must run on every iframe LOAD, not once when the element mounts: a reload
|
|
246
|
-
* keeps the same element (so no ref callback) and the same WindowProxy (so an
|
|
247
|
-
* identity check sees no change) while replacing the inner window that holds
|
|
248
|
-
* the listeners. Attaching once left Delete dead in the canvas after the first
|
|
249
|
-
* reload — press it with a selection and nothing happened, no toast, nothing
|
|
250
|
-
* to explain it — while undo/redo kept working because they re-attached here.
|
|
235
|
+
* Give the preview iframe the app's hotkeys: clicking the canvas puts focus in there.
|
|
236
|
+
* Runs on every iframe LOAD: a reload keeps the element and WindowProxy but replaces the
|
|
237
|
+
* inner window holding the listeners, which once left Delete dead after the first reload.
|
|
251
238
|
*/
|
|
252
239
|
const syncPreviewHotkeys = useCallback(
|
|
253
240
|
(iframe: HTMLIFrameElement | null) => {
|
|
254
241
|
previewHistoryCleanupRef.current?.();
|
|
255
242
|
previewHistoryCleanupRef.current = null;
|
|
256
243
|
const win = iframeContentWindow(iframe);
|
|
257
|
-
|
|
258
|
-
try {
|
|
259
|
-
doc = iframe?.contentDocument ?? null;
|
|
260
|
-
} catch {
|
|
261
|
-
doc = null;
|
|
262
|
-
}
|
|
263
|
-
if (!win && !doc) return;
|
|
264
|
-
const handler = handleHistoryHotkey as EventListener;
|
|
244
|
+
if (!win) return;
|
|
265
245
|
const appHandler = handleAppKeyDown as EventListener;
|
|
266
|
-
|
|
267
|
-
// Window only: the history pair also listens on the document, and a
|
|
268
|
-
// capture listener on both would run the app handler twice per press.
|
|
246
|
+
// Window only: a capture listener on the document too would run it twice per press.
|
|
269
247
|
safeAddListener(win, "keydown", appHandler, true);
|
|
270
|
-
|
|
271
|
-
previewHistoryCleanupRef.current = () => {
|
|
272
|
-
safeRemoveListener(win, "keydown", handler);
|
|
273
|
-
safeRemoveListener(win, "keydown", appHandler);
|
|
274
|
-
doc?.removeEventListener("keydown", handleHistoryHotkey, true);
|
|
275
|
-
};
|
|
248
|
+
previewHistoryCleanupRef.current = () => safeRemoveListener(win, "keydown", appHandler, true);
|
|
276
249
|
},
|
|
277
|
-
[handleAppKeyDown
|
|
250
|
+
[handleAppKeyDown],
|
|
278
251
|
);
|
|
279
252
|
|
|
280
253
|
useEffect(
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
createStudioApi,
|
|
10
|
+
openProjectHistory,
|
|
11
|
+
type StudioApiAdapter,
|
|
12
|
+
} from "@hyperframes/studio-server";
|
|
13
|
+
import { useEditHistoryActions } from "./useEditHistoryActions";
|
|
14
|
+
import { usePersistentEditHistory } from "./usePersistentEditHistory";
|
|
15
|
+
import { usePreviewPersistence } from "./usePreviewPersistence";
|
|
16
|
+
|
|
17
|
+
const page = (left: string, top = "0px") =>
|
|
18
|
+
`<!doctype html><html><head></head><body><div id="root" data-composition-id="main"><div id="box" style="position: absolute; left: ${left}"></div><div id="other" style="position: absolute; top: ${top}"></div></div></body></html>`;
|
|
19
|
+
const BEFORE = page("10px");
|
|
20
|
+
const AFTER = page("50px");
|
|
21
|
+
const OUTSIDE = page("50px", "99px");
|
|
22
|
+
|
|
23
|
+
const cleanup: Array<() => unknown> = [];
|
|
24
|
+
let scratch = "";
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
scratch = mkdtempSync(join(tmpdir(), "hf-undo-paint-"));
|
|
27
|
+
});
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
for (const step of cleanup.splice(0).reverse()) await step();
|
|
30
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
31
|
+
vi.unstubAllGlobals();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Studio's undo wired as App.tsx wires it, over the real history engine, with a live preview document. */
|
|
35
|
+
async function studio() {
|
|
36
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
37
|
+
const dir = join(scratch, "project");
|
|
38
|
+
mkdirSync(dir);
|
|
39
|
+
const path = join(dir, "index.html");
|
|
40
|
+
writeFileSync(path, BEFORE);
|
|
41
|
+
const engine = await openProjectHistory({
|
|
42
|
+
projectDir: dir,
|
|
43
|
+
historyRoot: join(scratch, "history"),
|
|
44
|
+
});
|
|
45
|
+
cleanup.push(() => engine.close());
|
|
46
|
+
const api = createStudioApi({
|
|
47
|
+
listProjects: () => [],
|
|
48
|
+
resolveProject: (id: string) => (id === "demo" ? { id, dir } : null),
|
|
49
|
+
history: () => engine,
|
|
50
|
+
} as unknown as StudioApiAdapter);
|
|
51
|
+
vi.stubGlobal("fetch", (url: string, init?: RequestInit) =>
|
|
52
|
+
api.request(url.replace(/^\/api/, ""), init),
|
|
53
|
+
);
|
|
54
|
+
const iframe = document.createElement("iframe");
|
|
55
|
+
document.body.append(iframe);
|
|
56
|
+
cleanup.push(() => iframe.remove());
|
|
57
|
+
const reloads = vi.fn();
|
|
58
|
+
const readFile = async (p: string) => readFileSync(join(dir, p), "utf8");
|
|
59
|
+
let history!: ReturnType<typeof usePersistentEditHistory>;
|
|
60
|
+
let persistence!: ReturnType<typeof usePreviewPersistence>;
|
|
61
|
+
let actions!: ReturnType<typeof useEditHistoryActions>;
|
|
62
|
+
function Harness() {
|
|
63
|
+
history = usePersistentEditHistory({ projectId: "demo" });
|
|
64
|
+
persistence = usePreviewPersistence({
|
|
65
|
+
showToast: () => {},
|
|
66
|
+
readOptionalProjectFile: async () => "",
|
|
67
|
+
writeProjectFile: async () => {},
|
|
68
|
+
recordEdit: async () => {},
|
|
69
|
+
previewIframeRef: { current: iframe },
|
|
70
|
+
activeCompPathRef: { current: "index.html" },
|
|
71
|
+
reloadPreview: reloads,
|
|
72
|
+
});
|
|
73
|
+
actions = useEditHistoryActions({
|
|
74
|
+
editHistory: history,
|
|
75
|
+
readOptionalProjectFile: async () => "",
|
|
76
|
+
readProjectFile: readFile,
|
|
77
|
+
writeProjectFile: async (p, content) => writeFileSync(join(dir, p), content),
|
|
78
|
+
showToast: () => {},
|
|
79
|
+
syncHistoryPreviewAfterApply: persistence.syncHistoryPreviewAfterApply,
|
|
80
|
+
showHistoryRestoreNow: persistence.showHistoryRestoreNow,
|
|
81
|
+
waitForPendingDomEditSaves: persistence.waitForPendingDomEditSaves,
|
|
82
|
+
});
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const root = createRoot(document.createElement("div"));
|
|
86
|
+
await act(async () => root.render(createElement(Harness)));
|
|
87
|
+
cleanup.push(() => act(() => root.unmount()));
|
|
88
|
+
const box = () => iframe.contentDocument!.getElementById("box")!.style.left;
|
|
89
|
+
const other = () => iframe.contentDocument!.getElementById("other")!.style.top;
|
|
90
|
+
const show = (html: string) => {
|
|
91
|
+
iframe.contentDocument!.documentElement.innerHTML = new DOMParser().parseFromString(
|
|
92
|
+
html,
|
|
93
|
+
"text/html",
|
|
94
|
+
).documentElement.innerHTML;
|
|
95
|
+
};
|
|
96
|
+
/** A move Studio saved: the live preview already shows it, then the file and the history claim land. */
|
|
97
|
+
const edit = async () => {
|
|
98
|
+
show(AFTER);
|
|
99
|
+
writeFileSync(path, AFTER);
|
|
100
|
+
await act(() =>
|
|
101
|
+
history.recordEdit({
|
|
102
|
+
label: "Move layer",
|
|
103
|
+
files: { "index.html": { before: BEFORE, after: AFTER } },
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
await vi.waitFor(() => expect(history.undoLabel).toBe("Move layer"));
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
history: () => history,
|
|
110
|
+
persistence: () => persistence,
|
|
111
|
+
actions: () => actions,
|
|
112
|
+
box,
|
|
113
|
+
other,
|
|
114
|
+
show,
|
|
115
|
+
path,
|
|
116
|
+
edit,
|
|
117
|
+
file: () => readFileSync(path, "utf8"),
|
|
118
|
+
reloads,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
it("undo and redo of a style edit paint in the key's own task, before the server answers", async () => {
|
|
123
|
+
const s = await studio();
|
|
124
|
+
await s.edit();
|
|
125
|
+
|
|
126
|
+
const undone = s.actions().undo();
|
|
127
|
+
expect(s.box()).toBe("10px");
|
|
128
|
+
await act(() => undone);
|
|
129
|
+
expect(s.file()).toBe(BEFORE);
|
|
130
|
+
expect(s.box()).toBe("10px");
|
|
131
|
+
|
|
132
|
+
await vi.waitFor(() => expect(s.history().redoLabel).toBeTruthy());
|
|
133
|
+
const redone = s.actions().redo();
|
|
134
|
+
expect(s.box()).toBe("50px");
|
|
135
|
+
await act(() => redone);
|
|
136
|
+
expect(s.file()).toBe(AFTER);
|
|
137
|
+
expect(s.box()).toBe("50px");
|
|
138
|
+
expect(s.reloads).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("an undo pressed while a save is still running waits for the server instead of guessing", async () => {
|
|
142
|
+
const s = await studio();
|
|
143
|
+
await s.edit();
|
|
144
|
+
let finish!: () => void;
|
|
145
|
+
const saving = new Promise<void>((resolve) => (finish = resolve));
|
|
146
|
+
void s.persistence().queueDomEditSave(() => saving);
|
|
147
|
+
|
|
148
|
+
const undone = s.actions().undo();
|
|
149
|
+
expect(s.box()).toBe("50px");
|
|
150
|
+
finish();
|
|
151
|
+
await act(() => undone);
|
|
152
|
+
expect(s.file()).toBe(BEFORE);
|
|
153
|
+
expect(s.box()).toBe("10px");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("an undo after an outside write ends with the preview showing the file the server restored", async () => {
|
|
157
|
+
const s = await studio();
|
|
158
|
+
await s.edit();
|
|
159
|
+
writeFileSync(s.path, OUTSIDE);
|
|
160
|
+
s.show(OUTSIDE);
|
|
161
|
+
|
|
162
|
+
await act(() => s.actions().undo());
|
|
163
|
+
expect(s.file()).toBe(AFTER);
|
|
164
|
+
expect(s.other()).toBe("0px");
|
|
165
|
+
expect(s.box()).toBe("50px");
|
|
166
|
+
});
|
|
@@ -11,24 +11,36 @@ import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryA
|
|
|
11
11
|
let root: Root | null = null;
|
|
12
12
|
afterEach(() => act(() => root?.unmount()));
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
type RestoreFiles = Record<string, { previous: string; restored: string }>;
|
|
15
|
+
type Prediction = { id: string; files: RestoreFiles };
|
|
16
|
+
|
|
17
|
+
function mount(
|
|
18
|
+
result: {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
reason?: string;
|
|
21
|
+
message?: string;
|
|
22
|
+
label?: string;
|
|
23
|
+
paths?: string[];
|
|
24
|
+
undoes?: string;
|
|
25
|
+
files?: RestoreFiles;
|
|
26
|
+
},
|
|
27
|
+
predicted?: Prediction,
|
|
28
|
+
) {
|
|
21
29
|
const editHistory = {
|
|
22
30
|
undo: vi.fn<EditHistoryHandle["undo"]>(async () => result),
|
|
23
31
|
redo: vi.fn<EditHistoryHandle["redo"]>(async () => result),
|
|
32
|
+
predict: () => predicted ?? null,
|
|
24
33
|
};
|
|
34
|
+
const putBack = vi.fn();
|
|
25
35
|
const deps = {
|
|
26
36
|
editHistory,
|
|
27
37
|
readOptionalProjectFile: vi.fn(async () => ""),
|
|
28
38
|
readProjectFile: vi.fn(async () => ""),
|
|
29
39
|
writeProjectFile: vi.fn(async () => undefined),
|
|
30
40
|
showToast: vi.fn(),
|
|
31
|
-
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
|
|
41
|
+
syncHistoryPreviewAfterApply: vi.fn(async (_restore: unknown) => undefined),
|
|
42
|
+
putBack,
|
|
43
|
+
showHistoryRestoreNow: vi.fn((_files: RestoreFiles) => putBack),
|
|
32
44
|
waitForPendingDomEditSaves: vi.fn(async () => undefined),
|
|
33
45
|
onAfterUndoRedo: vi.fn(),
|
|
34
46
|
activeCompPath: "index.html",
|
|
@@ -44,7 +56,53 @@ function mount(result: {
|
|
|
44
56
|
return { deps, actions };
|
|
45
57
|
}
|
|
46
58
|
|
|
59
|
+
const PREDICTED = { id: "e1", files: { "index.html": { previous: "B", restored: "A" } } };
|
|
60
|
+
const SERVER_FILES = { "index.html": { previous: "B", restored: "A2" } };
|
|
61
|
+
|
|
47
62
|
describe("useEditHistoryActions", () => {
|
|
63
|
+
it("corrects a shown step from the server's restore, diffed from what the preview shows", async () => {
|
|
64
|
+
const { deps, actions } = mount(
|
|
65
|
+
{ ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
|
|
66
|
+
PREDICTED,
|
|
67
|
+
);
|
|
68
|
+
await act(() => actions.undo());
|
|
69
|
+
expect(deps.showHistoryRestoreNow).toHaveBeenCalledWith(PREDICTED.files);
|
|
70
|
+
expect(deps.putBack).not.toHaveBeenCalled();
|
|
71
|
+
expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalledWith({
|
|
72
|
+
paths: ["index.html"],
|
|
73
|
+
files: { "index.html": { previous: "A", restored: "A2" } },
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("puts a shown step back and applies the server's own restore when it stepped another entry", async () => {
|
|
78
|
+
const { deps, actions } = mount(
|
|
79
|
+
{
|
|
80
|
+
ok: true,
|
|
81
|
+
label: "Undid: Outside",
|
|
82
|
+
paths: ["index.html"],
|
|
83
|
+
undoes: "e0",
|
|
84
|
+
files: SERVER_FILES,
|
|
85
|
+
},
|
|
86
|
+
PREDICTED,
|
|
87
|
+
);
|
|
88
|
+
await act(() => actions.undo());
|
|
89
|
+
expect(deps.putBack).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalledWith({
|
|
91
|
+
paths: ["index.html"],
|
|
92
|
+
files: SERVER_FILES,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("puts a shown step back when the server refuses it", async () => {
|
|
97
|
+
const { deps, actions } = mount(
|
|
98
|
+
{ ok: false, reason: "failed", message: "disk full" },
|
|
99
|
+
PREDICTED,
|
|
100
|
+
);
|
|
101
|
+
await act(() => actions.undo());
|
|
102
|
+
expect(deps.putBack).toHaveBeenCalledTimes(1);
|
|
103
|
+
expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
48
106
|
it("undo resyncs the preview and toasts the step as the history names it", async () => {
|
|
49
107
|
const { deps, actions } = mount({ ok: true, label: "Undid: Move clip", paths: ["index.html"] });
|
|
50
108
|
await act(() => actions.undo());
|
|
@@ -80,7 +138,7 @@ describe("useEditHistoryActions", () => {
|
|
|
80
138
|
expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled();
|
|
81
139
|
});
|
|
82
140
|
|
|
83
|
-
it("says why when the history could not take the step", async () => {
|
|
141
|
+
it("says why when the history could not take the step, with nothing shown to take back", async () => {
|
|
84
142
|
const { deps, actions } = mount({ ok: false, reason: "failed", message: "disk full" });
|
|
85
143
|
await act(() => actions.undo());
|
|
86
144
|
expect(deps.showToast).toHaveBeenCalledWith("Undo failed: disk full", "error");
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { useCallback, useMemo } from "react";
|
|
3
3
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
4
4
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
5
|
+
import type { RestoreFiles } from "../utils/gsapUndoRestore";
|
|
5
6
|
|
|
6
7
|
interface HistoryResult {
|
|
7
8
|
ok: boolean;
|
|
@@ -9,8 +10,9 @@ interface HistoryResult {
|
|
|
9
10
|
message?: string;
|
|
10
11
|
label?: string;
|
|
11
12
|
paths?: string[];
|
|
13
|
+
undoes?: string;
|
|
12
14
|
/** Per-file restored/previous content, used to soft-apply the preview. */
|
|
13
|
-
files?:
|
|
15
|
+
files?: RestoreFiles;
|
|
14
16
|
}
|
|
15
17
|
interface HistoryFileCallbacks {
|
|
16
18
|
readFile: (path: string) => Promise<string>;
|
|
@@ -19,6 +21,7 @@ interface HistoryFileCallbacks {
|
|
|
19
21
|
export interface EditHistoryHandle {
|
|
20
22
|
undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
|
21
23
|
redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
|
24
|
+
predict?: (direction: "undo" | "redo") => { id: string; files: RestoreFiles } | null;
|
|
22
25
|
state: {
|
|
23
26
|
undo: ReadonlyArray<{ createdAt: number }>;
|
|
24
27
|
redo: ReadonlyArray<{ createdAt: number }>;
|
|
@@ -26,12 +29,13 @@ export interface EditHistoryHandle {
|
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export interface UseEditHistoryActionsOptions {
|
|
29
|
-
editHistory: Pick<EditHistoryHandle, "undo" | "redo">;
|
|
32
|
+
editHistory: Pick<EditHistoryHandle, "undo" | "redo" | "predict">;
|
|
30
33
|
readOptionalProjectFile: (path: string) => Promise<string>;
|
|
31
34
|
readProjectFile: (path: string) => Promise<string>;
|
|
32
35
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
33
36
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
34
37
|
syncHistoryPreviewAfterApply: (restore: Pick<HistoryResult, "paths" | "files">) => Promise<void>;
|
|
38
|
+
showHistoryRestoreNow?: (files: RestoreFiles) => (() => void) | null;
|
|
35
39
|
waitForPendingDomEditSaves: () => Promise<void>;
|
|
36
40
|
onAfterUndoRedo?: (restore: Pick<HistoryResult, "paths" | "files">) => void;
|
|
37
41
|
/** Active composition path — decides whether undo/redo must resync the SDK session. */
|
|
@@ -48,6 +52,7 @@ export function useEditHistoryActions({
|
|
|
48
52
|
writeProjectFile,
|
|
49
53
|
showToast,
|
|
50
54
|
syncHistoryPreviewAfterApply,
|
|
55
|
+
showHistoryRestoreNow,
|
|
51
56
|
waitForPendingDomEditSaves,
|
|
52
57
|
onAfterUndoRedo,
|
|
53
58
|
activeCompPath,
|
|
@@ -67,11 +72,22 @@ export function useEditHistoryActions({
|
|
|
67
72
|
const apply = useCallback(
|
|
68
73
|
async (direction: "undo" | "redo") => {
|
|
69
74
|
const noun = direction === "undo" ? "Undo" : "Redo";
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
// Paint the step in the key's own task when this tab knows it; the server's answer then confirms or corrects.
|
|
76
|
+
const predicted = editHistory.predict?.(direction) ?? null;
|
|
77
|
+
const putBack = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
|
|
78
|
+
let result: HistoryResult = { ok: false, reason: "failed" };
|
|
79
|
+
let serverSteppedShown = false;
|
|
80
|
+
try {
|
|
81
|
+
await waitForPendingDomEditSaves();
|
|
82
|
+
result = await editHistory[direction]({
|
|
83
|
+
readFile: readHistoryFile,
|
|
84
|
+
serialize: serializeHistoryFiles,
|
|
85
|
+
});
|
|
86
|
+
serverSteppedShown =
|
|
87
|
+
Boolean(putBack && result.ok && result.label) && result.undoes === predicted?.id;
|
|
88
|
+
} finally {
|
|
89
|
+
if (putBack && !serverSteppedShown) putBack();
|
|
90
|
+
}
|
|
75
91
|
if (!result.ok && result.reason === "content-mismatch") {
|
|
76
92
|
showToast(
|
|
77
93
|
`Can't ${direction}: ${result.paths?.join(", ")} changed since that edit.`,
|
|
@@ -84,7 +100,8 @@ export function useEditHistoryActions({
|
|
|
84
100
|
return;
|
|
85
101
|
}
|
|
86
102
|
if (result.ok && result.label) {
|
|
87
|
-
const
|
|
103
|
+
const files = serverSteppedShown ? fromShown(result.files, predicted!.files) : result.files;
|
|
104
|
+
const restore = { paths: result.paths, files };
|
|
88
105
|
onAfterUndoRedo?.(restore);
|
|
89
106
|
if (activeCompPath && result.paths?.includes(activeCompPath)) {
|
|
90
107
|
forceReloadSdkSession?.();
|
|
@@ -98,6 +115,7 @@ export function useEditHistoryActions({
|
|
|
98
115
|
readHistoryFile,
|
|
99
116
|
showToast,
|
|
100
117
|
syncHistoryPreviewAfterApply,
|
|
118
|
+
showHistoryRestoreNow,
|
|
101
119
|
waitForPendingDomEditSaves,
|
|
102
120
|
serializeHistoryFiles,
|
|
103
121
|
onAfterUndoRedo,
|
|
@@ -110,3 +128,13 @@ export function useEditHistoryActions({
|
|
|
110
128
|
const redo = useCallback(() => apply("redo"), [apply]);
|
|
111
129
|
return useMemo(() => ({ undo, redo }), [undo, redo]);
|
|
112
130
|
}
|
|
131
|
+
|
|
132
|
+
function fromShown(files: RestoreFiles | undefined, shown: RestoreFiles) {
|
|
133
|
+
if (!files) return files;
|
|
134
|
+
return Object.fromEntries(
|
|
135
|
+
Object.entries(files).map(([path, f]) => [
|
|
136
|
+
path,
|
|
137
|
+
{ previous: shown[path]?.restored ?? f.previous, restored: f.restored },
|
|
138
|
+
]),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -76,6 +76,7 @@ it("an edit Studio saved is undone and redone by the project's history, with the
|
|
|
76
76
|
expect(undone).toEqual({
|
|
77
77
|
ok: true,
|
|
78
78
|
label: "Undid: Moved Title",
|
|
79
|
+
undoes: expect.any(String),
|
|
79
80
|
paths: ["index.html"],
|
|
80
81
|
files: { "index.html": { previous: "B", restored: "A" } },
|
|
81
82
|
});
|
|
@@ -241,3 +242,48 @@ it("a step that cannot reach the server says so", async () => {
|
|
|
241
242
|
message: "Studio could not reach its server.",
|
|
242
243
|
});
|
|
243
244
|
});
|
|
245
|
+
|
|
246
|
+
it("predicts a step from what this tab wrote, and not while a claim or step may have moved the history", async () => {
|
|
247
|
+
const { hook, save, readFile } = await studio();
|
|
248
|
+
save("B");
|
|
249
|
+
const claim = hook().recordEdit({
|
|
250
|
+
label: "Moved Title",
|
|
251
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
252
|
+
});
|
|
253
|
+
expect(hook().predict("undo")).toBeNull();
|
|
254
|
+
await act(() => claim);
|
|
255
|
+
const predicted = await vi.waitFor(() => {
|
|
256
|
+
const next = hook().predict("undo");
|
|
257
|
+
expect(next?.files).toEqual({ "index.html": { previous: "B", restored: "A" } });
|
|
258
|
+
return next!;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
hook().noteOutsideChange();
|
|
262
|
+
expect(hook().predict("undo")).toBeNull();
|
|
263
|
+
await vi.waitFor(() => expect(hook().predict("undo")?.id).toBe(predicted.id));
|
|
264
|
+
|
|
265
|
+
const undone = hook().undo({ readFile });
|
|
266
|
+
expect(hook().predict("undo")).toBeNull();
|
|
267
|
+
expect((await act(() => undone)).undoes).toBe(predicted.id);
|
|
268
|
+
await vi.waitFor(() =>
|
|
269
|
+
expect(hook().predict("redo")?.files).toEqual({
|
|
270
|
+
"index.html": { previous: "A", restored: "B" },
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("an undo taken before the view caught up with the edit still reports the preview's before and after", async () => {
|
|
276
|
+
const { hook, save, readFile } = await studio();
|
|
277
|
+
save("B");
|
|
278
|
+
const record = hook().recordEdit({
|
|
279
|
+
label: "Moved Title",
|
|
280
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
281
|
+
});
|
|
282
|
+
const undo = hook().undo;
|
|
283
|
+
await act(() => record);
|
|
284
|
+
const undone = await act(() => undo({ readFile }));
|
|
285
|
+
expect(undone).toMatchObject({
|
|
286
|
+
ok: true,
|
|
287
|
+
files: { "index.html": { previous: "B", restored: "A" } },
|
|
288
|
+
});
|
|
289
|
+
});
|