@hyperframes/studio 0.8.74 → 0.8.76

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 (116) hide show
  1. package/dist/assets/hyperframes-player-BkDxgSKG.js +464 -0
  2. package/dist/assets/{index-vccPjvCl.js → index-COX0scwJ.js} +1 -1
  3. package/dist/assets/{index-DhTyI9zH.js → index-DeHir1up.js} +178 -178
  4. package/dist/assets/{index-B86QTmD2.js → index-FmKUmYhC.js} +1 -1
  5. package/dist/{chunk-7BP32G62.js → chunk-IYDE66BY.js} +6 -1
  6. package/dist/chunk-IYDE66BY.js.map +1 -0
  7. package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-2XBFXXSW.js} +4 -2
  8. package/dist/index.d.ts +35 -70
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +4518 -4568
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/components/StudioRightPanels.types.ts +0 -2
  14. package/src/components/editor/DomEditOverlay.test.ts +5 -0
  15. package/src/components/editor/LayersPanel.tsx +3 -14
  16. package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
  17. package/src/components/editor/domEditing.ts +1 -0
  18. package/src/components/editor/domEditingLayers.test.ts +27 -0
  19. package/src/components/editor/domEditingLayers.ts +9 -0
  20. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
  21. package/src/components/editor/overlayFrameLoop.test.ts +16 -0
  22. package/src/components/editor/overlayFrameLoop.ts +11 -0
  23. package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
  24. package/src/components/nle/NLEPreview.test.ts +94 -17
  25. package/src/components/nle/NLEPreview.tsx +14 -0
  26. package/src/components/nle/PreviewPoster.tsx +99 -0
  27. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
  28. package/src/components/panels/VariablesPanel.tsx +0 -2
  29. package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
  30. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
  31. package/src/components/sidebar/CompositionsTab.test.ts +4 -6
  32. package/src/components/sidebar/CompositionsTab.tsx +28 -21
  33. package/src/components/studioColorGradingScope.ts +0 -3
  34. package/src/hooks/domEditDeleteMembers.ts +0 -2
  35. package/src/hooks/gsapScriptCommitTypes.ts +0 -2
  36. package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
  37. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  38. package/src/hooks/timelineAudioGroupCreate.ts +0 -1
  39. package/src/hooks/timelineEditingHelpers.ts +11 -3
  40. package/src/hooks/timelineTimingSync.ts +0 -1
  41. package/src/hooks/timelineTrackVisibility.ts +0 -1
  42. package/src/hooks/useBlockHandlers.ts +0 -2
  43. package/src/hooks/useClipboard.test.ts +69 -10
  44. package/src/hooks/useClipboard.ts +28 -19
  45. package/src/hooks/useDomEditCommits.test.tsx +0 -1
  46. package/src/hooks/useDomEditCommits.ts +0 -4
  47. package/src/hooks/useEditHistoryActions.test.tsx +31 -14
  48. package/src/hooks/useEditHistoryActions.ts +12 -7
  49. package/src/hooks/useEditorSave.ts +0 -3
  50. package/src/hooks/useElementLifecycleOps.ts +0 -1
  51. package/src/hooks/useEnableKeyframes.test.ts +9 -1
  52. package/src/hooks/useFileManager.ts +0 -2
  53. package/src/hooks/useGroupCommits.ts +0 -1
  54. package/src/hooks/useGsapScriptCommits.ts +0 -1
  55. package/src/hooks/useLintModal.test.tsx +34 -1
  56. package/src/hooks/useLintModal.ts +32 -30
  57. package/src/hooks/usePersistentEditHistory.test.ts +221 -315
  58. package/src/hooks/usePersistentEditHistory.ts +155 -342
  59. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
  60. package/src/hooks/usePreviewPersistence.ts +0 -2
  61. package/src/hooks/useProjectCompositionVariables.ts +1 -3
  62. package/src/hooks/useRazorSplit.history.test.tsx +46 -67
  63. package/src/hooks/useRazorSplit.test.ts +17 -47
  64. package/src/hooks/useRenderClipContent.test.ts +29 -0
  65. package/src/hooks/useRenderClipContent.ts +6 -3
  66. package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
  67. package/src/hooks/useSdkSession.ts +8 -2
  68. package/src/hooks/useSlideshowPersist.ts +0 -2
  69. package/src/hooks/useStudioContextValue.ts +6 -1
  70. package/src/hooks/useTimelineAssetDropOps.ts +0 -1
  71. package/src/hooks/useTimelineDeleteOps.test.tsx +85 -2
  72. package/src/hooks/useTimelineDeleteOps.ts +8 -8
  73. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  74. package/src/hooks/useTimelineEditingTypes.ts +0 -2
  75. package/src/hostMount.test.tsx +53 -22
  76. package/src/main.tsx +3 -0
  77. package/src/player/components/CompositionThumbnail.tsx +14 -0
  78. package/src/player/components/Player.test.ts +49 -0
  79. package/src/player/components/Player.tsx +21 -7
  80. package/src/player/components/PlayheadIndicator.tsx +4 -1
  81. package/src/player/components/Timeline.test.ts +1 -3
  82. package/src/player/components/Timeline.virtualization.test.tsx +21 -0
  83. package/src/player/components/TimelineCanvas.tsx +14 -12
  84. package/src/player/components/TimelinePlayheadLayer.tsx +33 -0
  85. package/src/player/components/TimelineRuler.tsx +7 -40
  86. package/src/player/components/timelineClipDragCommit.test.ts +7 -39
  87. package/src/player/components/useSettledScrollLeft.ts +38 -0
  88. package/src/player/hooks/timelineDomBinding.test.ts +50 -0
  89. package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
  90. package/src/player/hooks/timelineSyncHydration.ts +3 -3
  91. package/src/player/hooks/useShadowPreviewReload.ts +20 -1
  92. package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
  93. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +85 -0
  94. package/src/player/hooks/useTimelinePlayer.ts +15 -3
  95. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  96. package/src/player/lib/timelineElementHelpers.ts +45 -9
  97. package/src/player/store/playerStore.test.ts +35 -1
  98. package/src/player/store/playerStore.ts +29 -0
  99. package/src/player/store/readinessSlice.ts +34 -6
  100. package/src/utils/blockInstaller.ts +0 -3
  101. package/src/utils/previewPrefetch.test.ts +22 -0
  102. package/src/utils/previewPrefetch.ts +14 -0
  103. package/src/utils/razorSplitTransaction.test.ts +0 -1
  104. package/src/utils/razorSplitTransaction.ts +1 -1
  105. package/src/utils/sdkEditTransaction.ts +0 -3
  106. package/src/utils/studioFileHistory.test.ts +0 -9
  107. package/src/utils/studioFileHistory.ts +1 -5
  108. package/src/utils/timelineCompositionInsert.ts +0 -1
  109. package/dist/assets/hyperframes-player-Bs9qG2hg.js +0 -464
  110. package/dist/chunk-7BP32G62.js.map +0 -1
  111. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
  112. package/src/utils/editHistory.test.ts +0 -534
  113. package/src/utils/editHistory.ts +0 -236
  114. package/src/utils/editHistoryStorage.test.ts +0 -37
  115. package/src/utils/editHistoryStorage.ts +0 -99
  116. /package/dist/{domEditingLayers-UKMVYXT6.js.map → domEditingLayers-2XBFXXSW.js.map} +0 -0
@@ -1,51 +1,23 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import {
3
- buildEditHistoryEntry,
4
- createEmptyEditHistory,
5
- hashEditHistoryContent,
6
- pushEditHistoryEntry,
7
- redoEditHistory,
8
- undoEditHistory,
9
- type BuildEditHistoryEntryInput,
10
- type EditHistoryEntry,
11
- type EditHistoryKind,
12
- type EditHistoryState,
13
- type EditHistoryTransitionResult,
14
- } from "../utils/editHistory";
15
- import {
16
- createIndexedDbEditHistoryStorage,
17
- loadEditHistoryState,
18
- saveEditHistoryState,
19
- type EditHistoryStorageAdapter,
20
- } from "../utils/editHistoryStorage";
2
+ import type { HistoryListItem, HistoryResult } from "@hyperframes/studio-server";
3
+ import { studioFileContentVersion, studioWriteHeaders } from "../utils/studioFileVersion";
21
4
 
22
5
  interface RecordEditInput {
23
6
  label: string;
24
- kind: EditHistoryKind;
25
7
  coalesceKey?: string;
26
8
  coalesceMs?: number;
27
- files: BuildEditHistoryEntryInput["files"];
9
+ files: Record<string, { before: string; after: string }>;
28
10
  }
29
11
 
30
12
  interface ApplyCallbacks {
31
13
  readFile: (path: string) => Promise<string>;
32
- writeFile: (path: string, content: string) => Promise<void>;
33
14
  serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
34
15
  }
35
16
 
36
17
  export interface UsePersistentEditHistoryOptions {
37
18
  projectId: string | null;
38
- storage?: EditHistoryStorageAdapter;
39
- now?: () => number;
40
19
  }
41
20
 
42
- /**
43
- * Per-file content the restore just applied. `restored` is the bytes written to
44
- * disk (the undo/redo target); `previous` is what was on disk immediately before
45
- * (the current live preview state). The undo preview-sync diffs these to decide
46
- * whether the restore is soft-reloadable (attributes/style/GSAP-script only) or
47
- * needs a full iframe reload.
48
- */
49
21
  interface ApplyRestoredFile {
50
22
  previous: string;
51
23
  restored: string;
@@ -53,357 +25,198 @@ interface ApplyRestoredFile {
53
25
 
54
26
  interface ApplyResult {
55
27
  ok: boolean;
56
- reason?: "empty" | "content-mismatch";
28
+ /** content-mismatch: `paths` changed after the step's entry. failed: `message` says why. */
29
+ reason?: "empty" | "content-mismatch" | "failed";
30
+ message?: string;
57
31
  label?: string;
58
32
  paths?: string[];
59
33
  files?: Record<string, ApplyRestoredFile>;
60
34
  }
61
35
 
62
- interface PersistentEditHistoryStoreOptions {
63
- projectId: string;
64
- storage: EditHistoryStorageAdapter;
65
- initialState: EditHistoryState;
66
- now?: () => number;
67
- onChange: (state: EditHistoryState) => void;
36
+ interface NextStep {
37
+ id: string;
38
+ label: string;
39
+ endedAt: number;
40
+ paths: string[];
68
41
  }
69
42
 
70
- type EditHistoryMutation<T> = (state: EditHistoryState) => Promise<{
71
- state: EditHistoryState;
72
- result: T;
73
- }>;
74
-
75
- /** Pair the just-written (`restored`) bytes with the pre-write (`previous`) bytes per path. */
76
- function restoredFilesMap(
77
- filesToWrite: Record<string, string>,
78
- currentFiles: Record<string, string>,
79
- ): Record<string, ApplyRestoredFile> {
80
- const out: Record<string, ApplyRestoredFile> = {};
81
- for (const [path, restored] of Object.entries(filesToWrite)) {
82
- out[path] = { previous: currentFiles[path] ?? "", restored };
83
- }
84
- return out;
43
+ interface HistoryView {
44
+ entries: HistoryListItem[];
45
+ back: NextStep | null;
46
+ forward: NextStep | null;
85
47
  }
86
48
 
87
- function createEntryId(now: number): string {
88
- return `edit-${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
49
+ const EMPTY: HistoryView = { entries: [], back: null, forward: null };
50
+ const DEFAULT_COALESCE_MS = 300;
51
+
52
+ function historyUrl(projectId: string, path = ""): string {
53
+ return `/api/projects/${encodeURIComponent(projectId)}/history${path}`;
89
54
  }
90
55
 
91
- function snapshotEditHistoryState(state: EditHistoryState) {
92
- const undoEntry = state.undo[state.undo.length - 1] ?? null;
93
- const redoEntry = state.redo[state.redo.length - 1] ?? null;
94
- return {
95
- canUndo: Boolean(undoEntry),
96
- canRedo: Boolean(redoEntry),
97
- undoLabel: undoEntry?.label ?? null,
98
- redoLabel: redoEntry?.label ?? null,
99
- undoPaths: undoEntry ? Object.keys(undoEntry.files) : [],
100
- redoPaths: redoEntry ? Object.keys(redoEntry.files) : [],
101
- state,
102
- };
56
+ async function post(
57
+ url: string,
58
+ body: object,
59
+ headers: Record<string, string> = {},
60
+ ): Promise<{ ok: true; body: unknown } | { ok: false; status: number; error: string }> {
61
+ const response = await fetch(url, {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/json", ...headers },
64
+ body: JSON.stringify(body),
65
+ }).catch(() => null);
66
+ if (!response) return { ok: false, status: 0, error: "Studio could not reach its server." };
67
+ const reply = (await response.json().catch(() => null)) as { error?: string } | null;
68
+ if (response.ok && reply) return { ok: true, body: reply };
69
+ if (response.ok)
70
+ return { ok: false, status: response.status, error: "The history's reply was unreadable." };
71
+ return { ok: false, status: response.status, error: reply?.error ?? `HTTP ${response.status}` };
103
72
  }
104
73
 
105
- async function readCurrentFileHashes(
106
- paths: string[],
107
- readFile: (path: string) => Promise<string>,
108
- ): Promise<{
109
- currentFiles: Record<string, string>;
110
- currentHashes: Record<string, string>;
111
- }> {
112
- const currentFiles: Record<string, string> = {};
113
- const currentHashes: Record<string, string> = {};
114
- for (const path of paths) {
115
- const content = await readFile(path);
116
- currentFiles[path] = content;
117
- currentHashes[path] = hashEditHistoryContent(content);
118
- }
119
- return { currentFiles, currentHashes };
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);
77
+ if (reply.status !== 404)
78
+ console.error(`"${label}" was not recorded as your edit: ${reply.error}`);
79
+ return false;
120
80
  }
121
81
 
122
- async function writeFilesWithRollback({
123
- files,
124
- rollbackFiles,
125
- writeFile,
126
- }: {
127
- files: Record<string, string>;
128
- rollbackFiles: Record<string, string>;
129
- writeFile: (path: string, content: string) => Promise<void>;
130
- }): Promise<void> {
131
- const writtenPaths: string[] = [];
132
- try {
133
- for (const [path, content] of Object.entries(files)) {
134
- await writeFile(path, content);
135
- writtenPaths.push(path);
136
- }
137
- } catch (error) {
138
- try {
139
- for (const path of writtenPaths.reverse()) {
140
- await writeFile(path, rollbackFiles[path]);
141
- }
142
- } catch (rollbackError) {
143
- throw new AggregateError(
144
- [error, rollbackError],
145
- "Failed to apply edit history and rollback did not complete",
146
- );
147
- }
148
- throw error;
149
- }
82
+ async function overwroteVersions(files: RecordEditInput["files"]): Promise<Record<string, string>> {
83
+ const pairs = await Promise.all(
84
+ Object.entries(files).map(async ([path, { before }]) => [
85
+ path,
86
+ await studioFileContentVersion(before),
87
+ ]),
88
+ );
89
+ return Object.fromEntries(pairs);
150
90
  }
151
91
 
152
- /**
153
- * Apply one undo/redo step: read current on-disk hashes, run the direction's
154
- * transition, write the restored files with rollback, and shape the ApplyResult.
155
- * `entry` is the stack top used to know which paths to hash before applying.
156
- */
157
- async function applyHistoryStep(
158
- currentState: EditHistoryState,
159
- entry: EditHistoryEntry | undefined,
160
- transition: (
161
- state: EditHistoryState,
162
- currentHashes: Record<string, string>,
163
- now: number,
164
- ) => EditHistoryTransitionResult,
165
- now: () => number,
166
- callbacks: ApplyCallbacks,
167
- ): Promise<{ state: EditHistoryState; result: ApplyResult }> {
168
- if (!entry) {
169
- return { state: currentState, result: { ok: false, reason: "empty" } };
92
+ async function readAll(
93
+ paths: readonly string[],
94
+ readFile: (path: string) => Promise<string>,
95
+ ): Promise<Record<string, string> | null> {
96
+ const contents: Record<string, string> = {};
97
+ for (const path of paths) {
98
+ const content = await readFile(path).catch(() => null);
99
+ if (content === null) return null;
100
+ contents[path] = content;
170
101
  }
171
- const paths = Object.keys(entry.files);
172
- const apply = async (): Promise<{ state: EditHistoryState; result: ApplyResult }> => {
173
- const { currentFiles, currentHashes } = await readCurrentFileHashes(paths, callbacks.readFile);
174
- const result = transition(currentState, currentHashes, now());
175
- if (!result.ok) {
176
- return {
177
- state: currentState,
178
- result: { ok: false, reason: result.reason },
179
- };
180
- }
181
- await writeFilesWithRollback({
182
- files: result.filesToWrite,
183
- rollbackFiles: currentFiles,
184
- writeFile: callbacks.writeFile,
185
- });
186
- return {
187
- state: result.state,
188
- result: {
189
- ok: true,
190
- label: result.entry.label,
191
- paths: Object.keys(result.entry.files),
192
- files: restoredFilesMap(result.filesToWrite, currentFiles),
193
- },
194
- };
195
- };
196
- return callbacks.serialize ? callbacks.serialize(paths, apply) : apply();
102
+ return contents;
197
103
  }
198
104
 
199
- export function createPersistentEditHistoryStore({
200
- projectId,
201
- storage,
202
- initialState,
203
- now = Date.now,
204
- onChange,
205
- }: PersistentEditHistoryStoreOptions) {
206
- let state = initialState;
207
- let queue = Promise.resolve();
208
-
209
- const save = async (nextState: EditHistoryState) => {
210
- state = nextState;
211
- onChange(nextState);
212
- try {
213
- await saveEditHistoryState(storage, projectId, nextState);
214
- } catch {
215
- // Keep in-memory history usable when IndexedDB is unavailable.
216
- }
217
- };
218
-
219
- const mutate = async <T>(mutation: EditHistoryMutation<T>): Promise<T> => {
220
- const run = queue.then(async () => {
221
- const { state: nextState, result } = await mutation(state);
222
- if (nextState !== state) await save(nextState);
223
- return result;
224
- });
225
- queue = run.then(
226
- () => undefined,
227
- () => undefined,
228
- );
229
- return run;
230
- };
231
-
232
- return {
233
- snapshot: () => snapshotEditHistoryState(state),
234
- async recordEdit(input: RecordEditInput) {
235
- await mutate<void>(async (currentState) => {
236
- const timestamp = now();
237
- const entry = buildEditHistoryEntry({
238
- ...input,
239
- id: createEntryId(timestamp),
240
- projectId,
241
- now: timestamp,
242
- });
243
- return {
244
- state: pushEditHistoryEntry(currentState, entry),
245
- result: undefined,
246
- };
247
- });
248
- },
249
- async undo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
250
- return mutate<ApplyResult>((currentState) =>
251
- applyHistoryStep(
252
- currentState,
253
- currentState.undo[currentState.undo.length - 1],
254
- undoEditHistory,
255
- now,
256
- callbacks,
257
- ),
258
- );
259
- },
260
- async redo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
261
- return mutate<ApplyResult>((currentState) =>
262
- applyHistoryStep(
263
- currentState,
264
- currentState.redo[currentState.redo.length - 1],
265
- redoEditHistory,
266
- now,
267
- callbacks,
268
- ),
269
- );
270
- },
271
- };
105
+ async function restoredFiles(
106
+ paths: readonly string[],
107
+ previous: Record<string, string> | null,
108
+ readFile: (path: string) => Promise<string>,
109
+ ): Promise<Record<string, ApplyRestoredFile> | undefined> {
110
+ if (!previous || paths.some((path) => !(path in previous))) return undefined;
111
+ const restored = await readAll(paths, readFile);
112
+ if (!restored) return undefined;
113
+ return Object.fromEntries(
114
+ paths.map((path) => [path, { previous: previous[path]!, restored: restored[path]! }]),
115
+ );
272
116
  }
273
117
 
274
- export async function createPersistentEditHistoryController({
275
- projectId,
276
- storage,
277
- now = Date.now,
278
- onChange,
279
- }: {
280
- projectId: string;
281
- storage: EditHistoryStorageAdapter;
282
- now?: () => number;
283
- onChange: (state: EditHistoryState) => void;
284
- }) {
285
- let state = await loadEditHistoryState(storage, projectId);
286
- const store = createPersistentEditHistoryStore({
287
- projectId,
288
- storage,
289
- initialState: state,
290
- now,
291
- onChange: (nextState) => {
292
- state = nextState;
293
- onChange(nextState);
294
- },
295
- });
296
-
297
- return store;
118
+ function redoneAt(view: HistoryView): number | null {
119
+ const undo = view.entries.find((entry) => entry.id === view.forward?.id);
120
+ return view.entries.find((entry) => entry.id === undo?.undoes)?.endedAt ?? null;
298
121
  }
299
122
 
300
- export function usePersistentEditHistory(options: UsePersistentEditHistoryOptions) {
301
- const storage = useMemo(
302
- () => options.storage ?? createIndexedDbEditHistoryStorage(),
303
- [options.storage],
304
- );
305
- const now = options.now ?? Date.now;
306
- const [state, setState] = useState<EditHistoryState>(() => createEmptyEditHistory());
123
+ /** Studio's undo over the server's project history: an edit claims what it wrote; Cmd+Z steps every writer's. */
124
+ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistoryOptions) {
125
+ const [view, setView] = useState<HistoryView>(EMPTY);
307
126
  const [loaded, setLoaded] = useState(false);
308
- const projectId = options.projectId;
309
- const storeRef = useRef<ReturnType<typeof createPersistentEditHistoryStore> | null>(null);
310
- const storeProjectIdRef = useRef<string | null>(null);
311
- const activeProjectIdRef = useRef(projectId);
312
- activeProjectIdRef.current = projectId;
127
+ // A coalescing claim stays on the server until its drag goes idle, so it is not in `view` yet.
128
+ const heldClaimRef = useRef<{ paths: string[]; at: number } | null>(null);
129
+ const projectIdRef = useRef(projectId);
130
+ projectIdRef.current = projectId;
131
+
132
+ const refresh = useCallback(async () => {
133
+ if (!projectId) return;
134
+ const response = await fetch(historyUrl(projectId)).catch(() => null);
135
+ const next = response?.ok ? ((await response.json()) as HistoryView) : EMPTY;
136
+ if (projectIdRef.current === projectId) setView(next);
137
+ }, [projectId]);
313
138
 
314
139
  useEffect(() => {
315
- let cancelled = false;
316
- const emptyState = createEmptyEditHistory();
317
- storeRef.current = null;
318
- storeProjectIdRef.current = null;
319
- setState(emptyState);
140
+ setView(EMPTY);
320
141
  setLoaded(false);
321
- if (!projectId) {
322
- setLoaded(true);
323
- return;
324
- }
325
-
326
- loadEditHistoryState(storage, projectId)
327
- .then((loadedState) => {
328
- if (cancelled) return;
329
- storeRef.current = createPersistentEditHistoryStore({
330
- projectId,
331
- storage,
332
- initialState: loadedState,
333
- now,
334
- onChange: setState,
335
- });
336
- storeProjectIdRef.current = projectId;
337
- setState(loadedState);
338
- })
339
- .catch(() => {
340
- if (cancelled) return;
341
- storeRef.current = createPersistentEditHistoryStore({
342
- projectId,
343
- storage,
344
- initialState: emptyState,
345
- now,
346
- onChange: setState,
347
- });
348
- storeProjectIdRef.current = projectId;
349
- setState(emptyState);
350
- })
351
- .finally(() => {
352
- if (!cancelled) setLoaded(true);
353
- });
354
-
355
- return () => {
356
- cancelled = true;
357
- };
358
- }, [now, projectId, storage]);
142
+ heldClaimRef.current = null;
143
+ void refresh().finally(() => setLoaded(true));
144
+ }, [refresh]);
359
145
 
360
146
  const recordEdit = useCallback(
361
- async (input: RecordEditInput) => {
147
+ async ({ label, coalesceKey, coalesceMs, files }: RecordEditInput) => {
362
148
  if (!projectId) return;
363
- if (activeProjectIdRef.current !== projectId) {
364
- throw new Error(`Cannot record an edit for inactive project ${projectId}`);
365
- }
366
- const store = storeRef.current;
367
- if (!store) return;
368
- if (storeProjectIdRef.current !== projectId) {
369
- throw new Error(`Edit history store does not belong to project ${projectId}`);
370
- }
371
- await store.recordEdit(input);
149
+ const paths = Object.keys(files);
150
+ const reply = await post(historyUrl(projectId, "/claim"), {
151
+ label,
152
+ paths,
153
+ overwrote: await overwroteVersions(files),
154
+ ...(coalesceKey && { coalesceKey, idleMs: coalesceMs ?? DEFAULT_COALESCE_MS }),
155
+ });
156
+ heldClaimRef.current =
157
+ claimHeld(reply, label) && coalesceKey ? { paths, at: Date.now() } : null;
158
+ void refresh();
372
159
  },
373
- [projectId],
160
+ [projectId, refresh],
374
161
  );
375
162
 
376
- const undo = useCallback(
377
- async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
378
- if (
379
- !projectId ||
380
- activeProjectIdRef.current !== projectId ||
381
- storeProjectIdRef.current !== projectId
382
- ) {
383
- return { ok: false, reason: "empty" };
384
- }
385
- return storeRef.current?.undo(callbacks) ?? { ok: false, reason: "empty" };
163
+ const step = useCallback(
164
+ async (direction: "undo" | "redo", callbacks: ApplyCallbacks): Promise<ApplyResult> => {
165
+ if (!projectId) return { ok: false, reason: "empty" };
166
+ const next = direction === "undo" ? view.back : view.forward;
167
+ const paths = [...new Set([...(next?.paths ?? []), ...(heldClaimRef.current?.paths ?? [])])];
168
+ const run = async (): Promise<ApplyResult> => {
169
+ const previous = await readAll(paths, callbacks.readFile);
170
+ const posted = await post(
171
+ historyUrl(projectId, "/step"),
172
+ { direction: direction === "undo" ? "back" : "forward" },
173
+ studioWriteHeaders(),
174
+ );
175
+ heldClaimRef.current = null;
176
+ void refresh();
177
+ // 404: this app keeps no history, so there is nothing to step.
178
+ if (!posted.ok && posted.status === 404) return { ok: false, reason: "empty" };
179
+ if (!posted.ok) return { ok: false, reason: "failed", message: posted.error };
180
+ const reply = posted.body as HistoryResult;
181
+ if (!reply.ok) {
182
+ const { files } = reply.conflict;
183
+ return { ok: false, reason: "content-mismatch", paths: files };
184
+ }
185
+ if (!reply.entry) return { ok: false, reason: "empty" };
186
+ const changed = reply.entry.files.map((file) => file.path);
187
+ return {
188
+ ok: true,
189
+ label: reply.entry.label,
190
+ paths: changed,
191
+ files: await restoredFiles(changed, previous, callbacks.readFile),
192
+ };
193
+ };
194
+ return callbacks.serialize ? callbacks.serialize(paths, run) : run();
386
195
  },
387
- [projectId],
196
+ [projectId, view, refresh],
388
197
  );
389
198
 
390
- const redo = useCallback(
391
- async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
392
- if (
393
- !projectId ||
394
- activeProjectIdRef.current !== projectId ||
395
- storeProjectIdRef.current !== projectId
396
- ) {
397
- return { ok: false, reason: "empty" };
398
- }
399
- return storeRef.current?.redo(callbacks) ?? { ok: false, reason: "empty" };
400
- },
401
- [projectId],
402
- );
199
+ const undo = useCallback((callbacks: ApplyCallbacks) => step("undo", callbacks), [step]);
200
+ const redo = useCallback((callbacks: ApplyCallbacks) => step("redo", callbacks), [step]);
201
+
202
+ const state = useMemo(() => {
203
+ const backAt = Math.max(view.back?.endedAt ?? 0, heldClaimRef.current?.at ?? 0);
204
+ const redoAt = redoneAt(view);
205
+ return {
206
+ undo: backAt ? [{ createdAt: backAt }] : [],
207
+ redo: redoAt === null ? [] : [{ createdAt: redoAt }],
208
+ };
209
+ }, [view]);
403
210
 
404
211
  return {
405
212
  loaded,
406
- ...snapshotEditHistoryState(state),
213
+ canUndo: Boolean(view.back),
214
+ canRedo: Boolean(view.forward),
215
+ undoLabel: view.back?.label,
216
+ redoLabel: view.forward?.label,
217
+ undoPaths: view.back?.paths ?? [],
218
+ redoPaths: view.forward?.paths ?? [],
219
+ state,
407
220
  recordEdit,
408
221
  undo,
409
222
  redo,
@@ -18,6 +18,8 @@ let container: HTMLElement | null = null;
18
18
 
19
19
  beforeEach(() => {
20
20
  usePlayerStore.setState({
21
+ timelineProjectId: "demo",
22
+ previewBooted: true,
21
23
  elements: [
22
24
  {
23
25
  id: "lab",
@@ -5,7 +5,6 @@ import {
5
5
  reapplyPositionEditsAfterSeek,
6
6
  } from "../components/editor/manualEdits";
7
7
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
8
- import type { EditHistoryKind } from "../utils/editHistory";
9
8
  import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue";
10
9
  import {
11
10
  flushStudioPendingEdits,
@@ -26,7 +25,6 @@ interface HistoryPreviewRestore {
26
25
 
27
26
  interface RecordEditInput {
28
27
  label: string;
29
- kind: EditHistoryKind;
30
28
  coalesceKey?: string;
31
29
  files: Record<string, { before: string; after: string }>;
32
30
  }
@@ -1,12 +1,10 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
2
  import { openComposition, type Composition, type CompositionVariable } from "@hyperframes/sdk";
3
3
  import { persistSdkSerialize } from "../utils/sdkCutover";
4
- import type { EditHistoryKind } from "../utils/editHistory";
5
4
 
6
- /** Records an edit into the studio's undo history (label + kind + per-file before/after). */
5
+ /** Records an edit into the studio's undo history (label + per-file before/after). */
7
6
  export type RecordEditFn = (entry: {
8
7
  label: string;
9
- kind: EditHistoryKind;
10
8
  files: Record<string, { before: string; after: string }>;
11
9
  }) => Promise<void>;
12
10