@hyperframes/studio 0.8.100 → 0.8.102

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.
Files changed (51) hide show
  1. package/dist/assets/{hyperframes-player-fWt87gyh.js → hyperframes-player-BuRiFtrF.js} +1 -1
  2. package/dist/assets/{index-DofdzdxJ.js → index-BM35fjFf.js} +1 -1
  3. package/dist/assets/{index-D34Oy5DN.js → index-BT3sauee.js} +1 -1
  4. package/dist/assets/{index-D6TjRAvz.js → index-C2utDZt-.js} +114 -114
  5. package/dist/{chunk-U3EE4ACX.js → chunk-7X4XRP3L.js} +14 -13
  6. package/dist/chunk-7X4XRP3L.js.map +1 -0
  7. package/dist/index.d.ts +101 -21
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1211 -990
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +2 -0
  14. package/src/components/dock/Dock.test.tsx +78 -3
  15. package/src/components/dock/Dock.tsx +45 -18
  16. package/src/components/dock/dockLayout.ts +27 -14
  17. package/src/components/dock/dockLayoutSchema.ts +11 -4
  18. package/src/components/dock/dockLayoutStore.ts +4 -2
  19. package/src/components/editor/CanvasContextMenu.test.tsx +23 -2
  20. package/src/components/editor/CanvasContextMenu.tsx +7 -10
  21. package/src/components/editor/ConnectedDomEditOverlay.tsx +3 -128
  22. package/src/components/editor/canvasContextMenuZOrder.ts +13 -0
  23. package/src/components/editor/gsapAnimatesProperty.ts +0 -29
  24. package/src/components/editor/gsapLivePreview.test.ts +46 -1
  25. package/src/components/editor/propertyPanelHelpers.ts +3 -0
  26. package/src/components/editor/useDomEditZOrder.ts +185 -0
  27. package/src/components/nle/PreviewOverlaysTestMocks.ts +4 -0
  28. package/src/domEditExports.test.tsx +92 -0
  29. package/src/hooks/appHotkeysDispatch.ts +1 -7
  30. package/src/hooks/gsapRuntimeKeyframes.ts +6 -0
  31. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +67 -2
  32. package/src/hooks/useAppHotkeys.ts +18 -45
  33. package/src/hooks/useDomEditCommits.test.tsx +94 -90
  34. package/src/hooks/useEditHistoryActions.paint.test.tsx +166 -0
  35. package/src/hooks/useEditHistoryActions.test.tsx +67 -9
  36. package/src/hooks/useEditHistoryActions.ts +36 -8
  37. package/src/hooks/usePersistentEditHistory.test.ts +46 -0
  38. package/src/hooks/usePersistentEditHistory.ts +101 -19
  39. package/src/hooks/usePreviewPersistence.ts +25 -2
  40. package/src/hooks/useStudioExternalFileChanges.ts +7 -3
  41. package/src/index.ts +7 -0
  42. package/src/player/components/timelineDragDrop.test.tsx +61 -5
  43. package/src/player/components/timelineDragDrop.ts +29 -8
  44. package/src/player/components/useTimelineProviderState.tsx +1 -0
  45. package/src/utils/domEditSaveQueue.ts +7 -2
  46. package/src/utils/gsapSoftReload.ts +1 -1
  47. package/src/utils/gsapUndoRestore.test.ts +46 -0
  48. package/src/utils/gsapUndoRestore.ts +166 -91
  49. package/src/utils/studioPendingEdits.ts +14 -5
  50. package/src/utils/studioUiPreferences.ts +8 -6
  51. package/dist/chunk-U3EE4ACX.js.map +0 -1
@@ -7,10 +7,15 @@ import type { DomEditSelection } from "../components/editor/domEditing";
7
7
  import { usePlayerStore } from "../player/store/playerStore";
8
8
  import { useAppHotkeys } from "./useAppHotkeys";
9
9
  import { mountReactHarness } from "./domSelectionTestHarness";
10
+ import { trackStudioEvent } from "../utils/studioTelemetry";
11
+
12
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
10
13
 
11
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
15
 
13
16
  const domDelete = vi.fn(async () => undefined);
17
+ const historyUndo = vi.fn(async () => ({ ok: false }));
18
+ const historyRedo = vi.fn(async () => ({ ok: false }));
14
19
  let root: Root | null = null;
15
20
  let sync: ((iframe: HTMLIFrameElement | null) => void) | null = null;
16
21
 
@@ -35,8 +40,8 @@ function Harness() {
35
40
  domEditSelectionRef: selectionRef,
36
41
  clearDomSelectionRef: useRef<() => void>(() => undefined),
37
42
  editHistory: {
38
- undo: vi.fn(async () => ({ ok: false })),
39
- redo: vi.fn(async () => ({ ok: false })),
43
+ undo: historyUndo,
44
+ redo: historyRedo,
40
45
  state: { undo: [], redo: [] },
41
46
  },
42
47
  readOptionalProjectFile: vi.fn(async () => ""),
@@ -62,6 +67,9 @@ afterEach(() => {
62
67
  document.body.innerHTML = "";
63
68
  usePlayerStore.getState().reset();
64
69
  domDelete.mockClear();
70
+ historyUndo.mockClear();
71
+ historyRedo.mockClear();
72
+ vi.mocked(trackStudioEvent).mockClear();
65
73
  });
66
74
 
67
75
  describe("preview iframe hotkey forwarding", () => {
@@ -87,4 +95,61 @@ describe("preview iframe hotkey forwarding", () => {
87
95
 
88
96
  expect(domDelete).toHaveBeenCalledTimes(1);
89
97
  });
98
+
99
+ function mountWithPreview(): Window & typeof globalThis {
100
+ root = mountReactHarness(<Harness />);
101
+ const iframe = document.createElement("iframe");
102
+ document.body.append(iframe);
103
+ act(() => sync?.(iframe));
104
+ const inner = iframe.contentWindow as (Window & typeof globalThis) | null;
105
+ if (!inner) throw new Error("expected an iframe window");
106
+ return inner;
107
+ }
108
+
109
+ async function press(inner: Window & typeof globalThis, init: KeyboardEventInit) {
110
+ await act(async () => {
111
+ inner.document.body.dispatchEvent(
112
+ new inner.KeyboardEvent("keydown", { ...init, bubbles: true }),
113
+ );
114
+ });
115
+ }
116
+
117
+ it("takes one tracked undo step per Cmd+Z pressed inside the preview", async () => {
118
+ await press(mountWithPreview(), { key: "z", ctrlKey: true });
119
+
120
+ expect(historyUndo).toHaveBeenCalledTimes(1);
121
+ expect(trackStudioEvent).toHaveBeenCalledTimes(1);
122
+ expect(trackStudioEvent).toHaveBeenCalledWith("keyboard_shortcut", { action: "undo" });
123
+ });
124
+
125
+ it.each([
126
+ ["Shift+Cmd+Z", { key: "Z", metaKey: true, shiftKey: true }],
127
+ ["Ctrl+Y", { key: "y", ctrlKey: true }],
128
+ ])("takes one tracked redo step per %s pressed inside the preview", async (_, init) => {
129
+ await press(mountWithPreview(), init);
130
+
131
+ expect(historyRedo).toHaveBeenCalledTimes(1);
132
+ expect(trackStudioEvent).toHaveBeenCalledTimes(1);
133
+ expect(trackStudioEvent).toHaveBeenCalledWith("keyboard_shortcut", { action: "redo" });
134
+ });
135
+
136
+ it("stops handling preview keys once the app unmounts", async () => {
137
+ root = mountReactHarness(<Harness />);
138
+ const iframe = document.createElement("iframe");
139
+ document.body.append(iframe);
140
+ const inner = iframe.contentWindow as (Window & typeof globalThis) | null;
141
+ if (!inner) throw new Error("expected an iframe window");
142
+ const add = vi.spyOn(inner, "addEventListener");
143
+ const remove = vi.spyOn(inner, "removeEventListener");
144
+ act(() => sync?.(iframe));
145
+ act(() => root?.unmount());
146
+ root = null;
147
+
148
+ await press(inner, { key: "z", ctrlKey: true });
149
+
150
+ expect(historyUndo).not.toHaveBeenCalled();
151
+ // happy-dom removes a listener whatever its capture flag; a browser does not, so pin the flag.
152
+ expect(add).toHaveBeenCalled();
153
+ expect(remove.mock.calls).toEqual(add.mock.calls);
154
+ });
90
155
  });
@@ -8,13 +8,7 @@ import {
8
8
  applyCaptionModelToIframe,
9
9
  isCaptionPreviewVisible,
10
10
  } from "../captions/components/CaptionOverlayUtils";
11
- import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
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(t: EventTarget | null, type: string, h: EventListener) {
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, because a keypress lands in
243
- * whichever document has focus and clicking the canvas puts focus in there.
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
- let doc: Document | null = null;
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
- safeAddListener(win, "keydown", handler, true);
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
- doc?.addEventListener("keydown", handleHistoryHotkey, true);
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, handleHistoryHotkey],
250
+ [handleAppKeyDown],
278
251
  );
279
252
 
280
253
  useEffect(
@@ -42,6 +42,7 @@ interface RenderDomEditCommitsOptions {
42
42
  onTrySdkPersist?: () => Promise<CutoverResult>;
43
43
  queueDomEditSave?: <T>(save: () => Promise<T>) => Promise<T>;
44
44
  projectIdRef?: MutableRefObject<string | null>;
45
+ readOnlyPreview?: boolean;
45
46
  }
46
47
 
47
48
  type FetchHandler = (
@@ -232,7 +233,7 @@ function renderDomEditCommits(
232
233
  target === selection.element ? selection : null,
233
234
  ),
234
235
  onTrySdkPersist: options.onTrySdkPersist,
235
- readOnlyPreview: false,
236
+ readOnlyPreview: options.readOnlyPreview ?? false,
236
237
  });
237
238
  return null;
238
239
  }
@@ -266,97 +267,100 @@ describe("useDomEditCommits z-index reorder persistence", () => {
266
267
  document.body.replaceChildren();
267
268
  });
268
269
 
269
- it("persists an N-element reorder with one batch POST, one undo entry, and NO iframe reload", async () => {
270
- const original =
271
- '<div id="a" style="z-index: 1"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 3"></div>';
272
- const after =
273
- '<div id="a" style="z-index: 3"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 1"></div>';
274
- const fetchMock = vi.fn(
275
- async (
276
- input: Parameters<typeof fetch>[0],
277
- _init?: Parameters<typeof fetch>[1],
278
- ): Promise<Response> => {
279
- const url = requestUrl(input);
280
- if (url.endsWith("/file-mutations/patch-element-batches")) {
281
- return jsonResponse({
282
- durable: true,
283
- files: [
284
- {
285
- sourceFile: "index.html",
286
- changed: true,
287
- matched: [true, true, true],
288
- before: original,
289
- after,
290
- },
291
- ],
292
- });
293
- }
294
- throw new Error(`Unexpected fetch: ${url}`);
295
- },
296
- );
297
- vi.stubGlobal("fetch", fetchMock);
298
- const { iframe, element } = createPreviewElement(
299
- '<div data-hf-id="hf-card"></div><div id="b"></div><div id="c"></div>',
300
- );
301
- element.id = "a";
302
- const elements = [
303
- element,
304
- iframe.contentDocument!.getElementById("b")!,
305
- iframe.contentDocument!.getElementById("c")!,
306
- ];
307
- const rendered = renderDomEditCommits(createSelection(element), iframe);
308
-
309
- try {
310
- await act(async () => {
311
- await rendered.hook.handleDomZIndexReorderCommit(
312
- elements.map((item, index) => ({
313
- element: item,
314
- zIndex: 3 - index,
315
- id: item.id,
316
- sourceFile: "index.html",
317
- })),
318
- "z-reorder:test",
319
- );
320
- });
321
-
322
- const batchPosts = fetchMock.mock.calls.filter(([input]) =>
323
- requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
270
+ it.each([false, true])(
271
+ "persists an N-element reorder with one batch POST, one undo entry, and NO iframe reload (read-only preview %s: the timeline's z sync stays editable)",
272
+ async (readOnlyPreview) => {
273
+ const original =
274
+ '<div id="a" style="z-index: 1"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 3"></div>';
275
+ const after =
276
+ '<div id="a" style="z-index: 3"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 1"></div>';
277
+ const fetchMock = vi.fn(
278
+ async (
279
+ input: Parameters<typeof fetch>[0],
280
+ _init?: Parameters<typeof fetch>[1],
281
+ ): Promise<Response> => {
282
+ const url = requestUrl(input);
283
+ if (url.endsWith("/file-mutations/patch-element-batches")) {
284
+ return jsonResponse({
285
+ durable: true,
286
+ files: [
287
+ {
288
+ sourceFile: "index.html",
289
+ changed: true,
290
+ matched: [true, true, true],
291
+ before: original,
292
+ after,
293
+ },
294
+ ],
295
+ });
296
+ }
297
+ throw new Error(`Unexpected fetch: ${url}`);
298
+ },
324
299
  );
325
- const singlePosts = fetchMock.mock.calls.filter(([input]) =>
326
- requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
300
+ vi.stubGlobal("fetch", fetchMock);
301
+ const { iframe, element } = createPreviewElement(
302
+ '<div data-hf-id="hf-card"></div><div id="b"></div><div id="c"></div>',
327
303
  );
328
- expect(batchPosts).toHaveLength(1);
329
- expect(singlePosts).toHaveLength(0);
330
- expect(JSON.parse(String(batchPosts[0]?.[1]?.body))).toEqual({
331
- batches: [
332
- {
333
- sourceFile: "index.html",
334
- patches: expect.arrayContaining([
335
- expect.objectContaining({ target: expect.objectContaining({ id: "a" }) }),
336
- expect.objectContaining({ target: expect.objectContaining({ id: "b" }) }),
337
- expect.objectContaining({ target: expect.objectContaining({ id: "c" }) }),
338
- ]),
339
- },
340
- ],
341
- });
342
- expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
343
- expect(rendered.recordEdit).toHaveBeenCalledWith({
344
- label: "Reorder layers",
345
- coalesceKey: "z-reorder:test",
346
- // Unbounded per-gesture fold window (keys are unique per gesture):
347
- // the z entry and its mirror/lane counterpart fold across the server
348
- // round-trip that separates them.
349
- coalesceMs: Number.POSITIVE_INFINITY,
350
- files: { "index.html": { before: original, after } },
351
- });
352
- // FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
353
- // The live DOM + store already hold the final state and the server matched
354
- // every style-only patch, so the reload is provably redundant.
355
- expect(rendered.reloadPreview).not.toHaveBeenCalled();
356
- } finally {
357
- rendered.cleanup();
358
- }
359
- });
304
+ element.id = "a";
305
+ const elements = [
306
+ element,
307
+ iframe.contentDocument!.getElementById("b")!,
308
+ iframe.contentDocument!.getElementById("c")!,
309
+ ];
310
+ const rendered = renderDomEditCommits(createSelection(element), iframe, { readOnlyPreview });
311
+
312
+ try {
313
+ await act(async () => {
314
+ await rendered.hook.handleDomZIndexReorderCommit(
315
+ elements.map((item, index) => ({
316
+ element: item,
317
+ zIndex: 3 - index,
318
+ id: item.id,
319
+ sourceFile: "index.html",
320
+ })),
321
+ "z-reorder:test",
322
+ );
323
+ });
324
+
325
+ const batchPosts = fetchMock.mock.calls.filter(([input]) =>
326
+ requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
327
+ );
328
+ const singlePosts = fetchMock.mock.calls.filter(([input]) =>
329
+ requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
330
+ );
331
+ expect(batchPosts).toHaveLength(1);
332
+ expect(singlePosts).toHaveLength(0);
333
+ expect(JSON.parse(String(batchPosts[0]?.[1]?.body))).toEqual({
334
+ batches: [
335
+ {
336
+ sourceFile: "index.html",
337
+ patches: expect.arrayContaining([
338
+ expect.objectContaining({ target: expect.objectContaining({ id: "a" }) }),
339
+ expect.objectContaining({ target: expect.objectContaining({ id: "b" }) }),
340
+ expect.objectContaining({ target: expect.objectContaining({ id: "c" }) }),
341
+ ]),
342
+ },
343
+ ],
344
+ });
345
+ expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
346
+ expect(rendered.recordEdit).toHaveBeenCalledWith({
347
+ label: "Reorder layers",
348
+ coalesceKey: "z-reorder:test",
349
+ // Unbounded per-gesture fold window (keys are unique per gesture):
350
+ // the z entry and its mirror/lane counterpart fold across the server
351
+ // round-trip that separates them.
352
+ coalesceMs: Number.POSITIVE_INFINITY,
353
+ files: { "index.html": { before: original, after } },
354
+ });
355
+ // FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
356
+ // The live DOM + store already hold the final state and the server matched
357
+ // every style-only patch, so the reload is provably redundant.
358
+ expect(rendered.reloadPreview).not.toHaveBeenCalled();
359
+ } finally {
360
+ rendered.cleanup();
361
+ }
362
+ },
363
+ );
360
364
 
361
365
  it("falls back to reloading when the server response omits matched[]", async () => {
362
366
  // Without a matched[] confirmation the persist can't be proven in sync with
@@ -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
+ });