@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
@@ -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
- function mount(result: {
15
- ok: boolean;
16
- reason?: string;
17
- message?: string;
18
- label?: string;
19
- paths?: string[];
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?: Record<string, { previous: string; restored: string }>;
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
- await waitForPendingDomEditSaves();
71
- const result = await editHistory[direction]({
72
- readFile: readHistoryFile,
73
- serialize: serializeHistoryFiles,
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 restore = { paths: result.paths, files: result.files };
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
+ });
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import type { HistoryListItem, HistoryResult } from "@hyperframes/studio-server";
3
3
  import { studioFileContentVersion, studioWriteHeaders } from "../utils/studioFileVersion";
4
+ import type { RestoreFiles } from "../utils/gsapUndoRestore";
4
5
 
5
6
  interface RecordEditInput {
6
7
  label: string;
@@ -18,11 +19,6 @@ export interface UsePersistentEditHistoryOptions {
18
19
  projectId: string | null;
19
20
  }
20
21
 
21
- interface ApplyRestoredFile {
22
- previous: string;
23
- restored: string;
24
- }
25
-
26
22
  interface ApplyResult {
27
23
  ok: boolean;
28
24
  /** content-mismatch: `paths` changed after the step's entry. failed: `message` says why. */
@@ -30,7 +26,8 @@ interface ApplyResult {
30
26
  message?: string;
31
27
  label?: string;
32
28
  paths?: string[];
33
- files?: Record<string, ApplyRestoredFile>;
29
+ undoes?: string;
30
+ files?: RestoreFiles;
34
31
  }
35
32
 
36
33
  interface NextStep {
@@ -47,6 +44,69 @@ interface HistoryView {
47
44
  }
48
45
 
49
46
  const EMPTY: HistoryView = { entries: [], back: null, forward: null };
47
+ /** ponytail: the newest entries' content is kept; older ones step through the server only. */
48
+ const OWN_ENTRIES_KEPT = 100;
49
+
50
+ type OwnFiles = Record<string, { before: string; after: string }>;
51
+
52
+ function createOwnHistory() {
53
+ const own = new Map<string, OwnFiles>();
54
+ let next: Record<"undo" | "redo", NextStep | null> | null = null;
55
+ let changes = 0;
56
+ const remember = (id: string, files: OwnFiles) => {
57
+ const known = own.get(id) ?? {};
58
+ for (const [path, { before, after }] of Object.entries(files)) {
59
+ known[path] = { before: known[path]?.before ?? before, after };
60
+ }
61
+ own.delete(id);
62
+ own.set(id, known);
63
+ if (own.size > OWN_ENTRIES_KEPT) own.delete(own.keys().next().value!);
64
+ };
65
+ return {
66
+ remember,
67
+ overtake: () => {
68
+ changes += 1;
69
+ next = null;
70
+ return changes;
71
+ },
72
+ offered: (seen: number, view: HistoryView) => {
73
+ if (seen === changes) next = { undo: view.back, redo: view.forward };
74
+ },
75
+ changes: () => changes,
76
+ stepped: (entry: { id: string; undoes?: string }) => {
77
+ const undone = entry.undoes ? own.get(entry.undoes) : undefined;
78
+ if (!undone) return;
79
+ const swapped = Object.entries(undone).map(([path, f]) => [
80
+ path,
81
+ { before: f.after, after: f.before },
82
+ ]);
83
+ remember(entry.id, Object.fromEntries(swapped));
84
+ },
85
+ afterOf: (id: string | undefined): Record<string, string> =>
86
+ Object.fromEntries(Object.entries((id && own.get(id)) || {}).map(([p, f]) => [p, f.after])),
87
+ predict: (direction: "undo" | "redo"): { id: string; files: RestoreFiles } | null => {
88
+ const step = next?.[direction];
89
+ const files = step ? own.get(step.id) : undefined;
90
+ const paths = files ? Object.keys(files) : [];
91
+ if (
92
+ !files ||
93
+ paths.length !== step!.paths.length ||
94
+ !paths.every((p) => step!.paths.includes(p))
95
+ )
96
+ return null;
97
+ const restore = paths.map((path) => [
98
+ path,
99
+ { previous: files[path]!.after, restored: files[path]!.before },
100
+ ]);
101
+ return { id: step!.id, files: Object.fromEntries(restore) };
102
+ },
103
+ clear: () => {
104
+ own.clear();
105
+ next = null;
106
+ changes += 1;
107
+ },
108
+ };
109
+ }
50
110
  const DEFAULT_COALESCE_MS = 300;
51
111
 
52
112
  function historyUrl(projectId: string, path = ""): string {
@@ -71,12 +131,13 @@ async function post(
71
131
  return { ok: false, status: response.status, error: reply?.error ?? `HTTP ${response.status}` };
72
132
  }
73
133
 
74
- /** Whether a drag's claim is held open; a failed one is logged (its write lands as an outside change; 404: none). */
75
- function claimHeld(reply: Awaited<ReturnType<typeof post>>, label: string): boolean {
76
- if (reply.ok) return Boolean((reply.body as { claimed: { id: string } | null } | null)?.claimed);
134
+ /** The entry id a claim took, or null; a failed one is logged (its write lands as an outside change; 404: none). */
135
+ function claimHeld(reply: Awaited<ReturnType<typeof post>>, label: string): string | null {
136
+ if (reply.ok)
137
+ return (reply.body as { claimed: { id: string } | null } | null)?.claimed?.id ?? null;
77
138
  if (reply.status !== 404)
78
139
  console.error(`"${label}" was not recorded as your edit: ${reply.error}`);
79
- return false;
140
+ return null;
80
141
  }
81
142
 
82
143
  async function overwroteVersions(files: RecordEditInput["files"]): Promise<Record<string, string>> {
@@ -106,7 +167,7 @@ async function restoredFiles(
106
167
  paths: readonly string[],
107
168
  previous: Record<string, string> | null,
108
169
  readFile: (path: string) => Promise<string>,
109
- ): Promise<Record<string, ApplyRestoredFile> | undefined> {
170
+ ): Promise<RestoreFiles | undefined> {
110
171
  if (!previous || paths.some((path) => !(path in previous))) return undefined;
111
172
  const restored = await readAll(paths, readFile);
112
173
  if (!restored) return undefined;
@@ -128,36 +189,43 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
128
189
  const heldClaimRef = useRef<{ paths: string[]; at: number } | null>(null);
129
190
  const projectIdRef = useRef(projectId);
130
191
  projectIdRef.current = projectId;
192
+ const [own] = useState(createOwnHistory);
131
193
 
132
194
  const refresh = useCallback(async () => {
133
195
  if (!projectId) return;
196
+ const seen = own.changes();
134
197
  const response = await fetch(historyUrl(projectId)).catch(() => null);
135
198
  const next = response?.ok ? ((await response.json()) as HistoryView) : EMPTY;
136
- if (projectIdRef.current === projectId) setView(next);
137
- }, [projectId]);
199
+ if (projectIdRef.current !== projectId) return;
200
+ setView(next);
201
+ own.offered(seen, next);
202
+ }, [projectId, own]);
138
203
 
139
204
  useEffect(() => {
140
205
  setView(EMPTY);
141
206
  setLoaded(false);
142
207
  heldClaimRef.current = null;
208
+ own.clear();
143
209
  void refresh().finally(() => setLoaded(true));
144
- }, [refresh]);
210
+ }, [refresh, own]);
145
211
 
146
212
  const recordEdit = useCallback(
147
213
  async ({ label, coalesceKey, coalesceMs, files }: RecordEditInput) => {
148
214
  if (!projectId) return;
149
215
  const paths = Object.keys(files);
216
+ own.overtake();
150
217
  const reply = await post(historyUrl(projectId, "/claim"), {
151
218
  label,
152
219
  paths,
153
220
  overwrote: await overwroteVersions(files),
154
221
  ...(coalesceKey && { coalesceKey, idleMs: coalesceMs ?? DEFAULT_COALESCE_MS }),
155
222
  });
156
- heldClaimRef.current =
157
- claimHeld(reply, label) && coalesceKey ? { paths, at: Date.now() } : null;
223
+ const claimed = claimHeld(reply, label);
224
+ if (claimed) own.remember(claimed, files);
225
+ heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null;
158
226
  void refresh();
159
227
  },
160
- [projectId, refresh],
228
+ [projectId, refresh, own],
161
229
  );
162
230
 
163
231
  const step = useCallback(
@@ -165,6 +233,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
165
233
  if (!projectId) return { ok: false, reason: "empty" };
166
234
  const next = direction === "undo" ? view.back : view.forward;
167
235
  const paths = [...new Set([...(next?.paths ?? []), ...(heldClaimRef.current?.paths ?? [])])];
236
+ own.overtake();
168
237
  const run = async (): Promise<ApplyResult> => {
169
238
  const previous = await readAll(paths, callbacks.readFile);
170
239
  const posted = await post(
@@ -183,19 +252,30 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
183
252
  return { ok: false, reason: "content-mismatch", paths: files };
184
253
  }
185
254
  if (!reply.entry) return { ok: false, reason: "empty" };
255
+ own.stepped(reply.entry);
186
256
  const changed = reply.entry.files.map((file) => file.path);
187
257
  return {
188
258
  ok: true,
189
259
  label: reply.entry.label,
260
+ undoes: reply.entry.undoes,
190
261
  paths: changed,
191
- files: await restoredFiles(changed, previous, callbacks.readFile),
262
+ files: await restoredFiles(
263
+ changed,
264
+ { ...own.afterOf(reply.entry.undoes), ...previous },
265
+ callbacks.readFile,
266
+ ),
192
267
  };
193
268
  };
194
269
  return callbacks.serialize ? callbacks.serialize(paths, run) : run();
195
270
  },
196
- [projectId, view, refresh],
271
+ [projectId, view, refresh, own],
197
272
  );
198
273
 
274
+ const noteOutsideChange = useCallback(() => {
275
+ own.overtake();
276
+ void refresh();
277
+ }, [own, refresh]);
278
+
199
279
  const undo = useCallback((callbacks: ApplyCallbacks) => step("undo", callbacks), [step]);
200
280
  const redo = useCallback((callbacks: ApplyCallbacks) => step("redo", callbacks), [step]);
201
281
 
@@ -220,5 +300,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
220
300
  recordEdit,
221
301
  undo,
222
302
  redo,
303
+ predict: own.predict,
304
+ noteOutsideChange,
223
305
  };
224
306
  }
@@ -8,17 +8,22 @@ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
8
8
  import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue";
9
9
  import {
10
10
  flushStudioPendingEdits,
11
+ hasStudioPendingEdits,
11
12
  type StudioPendingEditsDrainResult,
12
13
  } from "../utils/studioPendingEdits";
13
14
  import { trackStudioEvent } from "../utils/studioTelemetry";
14
- import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore";
15
+ import {
16
+ applyUndoRestoreToPreview,
17
+ showRestoreInPlace,
18
+ type RestoreFiles,
19
+ } from "../utils/gsapUndoRestore";
15
20
  import { usePlayerStore } from "../player";
16
21
  import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
17
22
 
18
23
  /** The restore payload the undo/redo preview-sync consumes (from the history store). */
19
24
  interface HistoryPreviewRestore {
20
25
  paths?: string[];
21
- files?: Record<string, UndoRestoreFile>;
26
+ files?: RestoreFiles;
22
27
  }
23
28
 
24
29
  // ── Types ──
@@ -217,6 +222,23 @@ export function usePreviewPersistence({
217
222
  [previewIframeRef, activeCompPathRef, reloadPreview],
218
223
  );
219
224
 
225
+ // A restore the server has not confirmed yet: in place now, or not at all. A GSAP script re-run is not
226
+ // synchronous, and a pending save would land under it.
227
+ const showHistoryRestoreNow = useCallback(
228
+ (files: RestoreFiles): (() => void) | null => {
229
+ if (!domEditSaveQueueRef.current?.isIdle() || hasStudioPendingEdits()) return null;
230
+ const iframe = previewIframeRef.current;
231
+ const now = () => usePlayerStore.getState().currentTime;
232
+ const putBack = showRestoreInPlace(iframe, activeCompPathRef.current, files, now());
233
+ if (!putBack) return null;
234
+ return () => {
235
+ if (putBack(now())) syncStoredAutomationFromPreview(iframe?.contentDocument ?? null);
236
+ else void syncHistoryPreviewAfterApply({ paths: Object.keys(files) });
237
+ };
238
+ },
239
+ [previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply],
240
+ );
241
+
220
242
  // ── Migrate legacy studio-motion.json ──
221
243
  // Projects that used the old JSON-file approach may still have a populated
222
244
  // `.hyperframes/studio-motion.json`. The studio no longer reads from it, but
@@ -239,5 +261,6 @@ export function usePreviewPersistence({
239
261
  applyCurrentStudioManualEditsToPreview,
240
262
  applyStudioManualEditsToPreview,
241
263
  syncHistoryPreviewAfterApply,
264
+ showHistoryRestoreNow,
242
265
  };
243
266
  }
@@ -36,6 +36,7 @@ interface UseStudioExternalFileChangesOptions {
36
36
  previewPersistence: PreviewPersistence;
37
37
  pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
38
38
  reloadPreview: () => void;
39
+ onOutsideChange?: () => void;
39
40
  }
40
41
 
41
42
  /** Connects the app's save queues, recovery storage, and reload surfaces to one owner. */
@@ -47,14 +48,17 @@ export function useStudioExternalFileChanges({
47
48
  previewPersistence,
48
49
  pendingTimelineEditPathRef,
49
50
  reloadPreview,
51
+ onOutsideChange,
50
52
  }: UseStudioExternalFileChangesOptions) {
51
53
  const { flushPendingSourceSave, discardPendingSourceSave } = fileManager;
52
54
  const { drainPendingDomEditSaves, resetDomEditSaveQueueBreaker } = previewPersistence;
53
55
  const bumpThumbnailRevisions = usePlayerStore((state) => state.bumpThumbnailRevisions);
54
56
  const onAcceptedPersistedFileChange = useCallback(
55
- (_path: string, affectedCompositions: readonly string[] | null) =>
56
- bumpThumbnailRevisions(affectedCompositions),
57
- [bumpThumbnailRevisions],
57
+ (_path: string, affectedCompositions: readonly string[] | null) => {
58
+ onOutsideChange?.();
59
+ bumpThumbnailRevisions(affectedCompositions);
60
+ },
61
+ [bumpThumbnailRevisions, onOutsideChange],
58
62
  );
59
63
  const drainPendingChanges = useCallback(async () => {
60
64
  const source = await flushPendingSourceSave();
package/src/index.ts CHANGED
@@ -12,6 +12,10 @@ export type { ShortcutHint, ShortcutSection } from "./player/components/studioSh
12
12
  export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
13
13
  export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb";
14
14
  export { useCompositionStack } from "./components/nle/useCompositionStack";
15
+ export { Dock } from "./components/dock/Dock";
16
+ export { useDockLayoutStore } from "./components/dock/dockLayoutStore";
17
+ export type { DockController } from "./components/dock/dockLayoutStore";
18
+ export type { PanelId } from "./components/dock/panelRegistry";
15
19
 
16
20
  // Player (preview, timeline, playback controls)
17
21
  export {
@@ -183,6 +187,9 @@ export { DomEditProvider, useDomEditSelectionContext } from "./contexts/DomEditC
183
187
  export { PreviewReadOnlyProvider } from "./components/editor/previewReadOnlyContext";
184
188
  export { ConnectedDomEditOverlay } from "./components/editor/ConnectedDomEditOverlay";
185
189
  export type { ConnectedDomEditOverlayProps } from "./components/editor/ConnectedDomEditOverlay";
190
+ export { useDomEditZOrder } from "./components/editor/useDomEditZOrder";
191
+ export type { DomEditZOrder } from "./components/editor/useDomEditZOrder";
192
+ export type { ZOrderAction } from "./components/editor/canvasContextMenuZOrder";
186
193
  export type { DomEditCapabilities, DomEditSelection } from "./components/editor/domEditingTypes";
187
194
  export { useDomStyleCommit } from "./hooks/useDomStyleCommit";
188
195
  export type { UseDomStyleCommitOptions } from "./hooks/useDomStyleCommit";