@hyperframes/studio 0.8.75 → 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.
- package/dist/assets/hyperframes-player-BkDxgSKG.js +464 -0
- package/dist/assets/{index-BiFKcNjs.js → index-COX0scwJ.js} +1 -1
- package/dist/assets/{index-BlUYw1MB.js → index-DeHir1up.js} +156 -156
- package/dist/assets/{index-HjexPsny.js → index-FmKUmYhC.js} +1 -1
- package/dist/{chunk-7BP32G62.js → chunk-IYDE66BY.js} +6 -1
- package/dist/chunk-IYDE66BY.js.map +1 -0
- package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-2XBFXXSW.js} +4 -2
- package/dist/index.d.ts +34 -69
- package/dist/index.html +1 -1
- package/dist/index.js +4147 -4253
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanels.types.ts +0 -2
- package/src/components/editor/DomEditOverlay.test.ts +5 -0
- package/src/components/editor/LayersPanel.tsx +3 -14
- package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingLayers.test.ts +27 -0
- package/src/components/editor/domEditingLayers.ts +9 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
- package/src/components/editor/overlayFrameLoop.test.ts +16 -0
- package/src/components/editor/overlayFrameLoop.ts +11 -0
- package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
- package/src/components/nle/NLEPreview.test.ts +94 -17
- package/src/components/nle/NLEPreview.tsx +14 -0
- package/src/components/nle/PreviewPoster.tsx +99 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
- package/src/components/panels/VariablesPanel.tsx +0 -2
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
- package/src/components/sidebar/CompositionsTab.test.ts +4 -6
- package/src/components/sidebar/CompositionsTab.tsx +28 -21
- package/src/components/studioColorGradingScope.ts +0 -3
- package/src/hooks/domEditDeleteMembers.ts +0 -2
- package/src/hooks/gsapScriptCommitTypes.ts +0 -2
- package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
- package/src/hooks/keyframeCacheAstLoad.ts +2 -1
- package/src/hooks/timelineAudioGroupCreate.ts +0 -1
- package/src/hooks/timelineEditingHelpers.ts +0 -3
- package/src/hooks/timelineTimingSync.ts +0 -1
- package/src/hooks/timelineTrackVisibility.ts +0 -1
- package/src/hooks/useBlockHandlers.ts +0 -2
- package/src/hooks/useClipboard.test.ts +69 -10
- package/src/hooks/useClipboard.ts +28 -19
- package/src/hooks/useDomEditCommits.test.tsx +0 -1
- package/src/hooks/useDomEditCommits.ts +0 -4
- package/src/hooks/useEditHistoryActions.test.tsx +31 -14
- package/src/hooks/useEditHistoryActions.ts +12 -7
- package/src/hooks/useEditorSave.ts +0 -3
- package/src/hooks/useElementLifecycleOps.ts +0 -1
- package/src/hooks/useEnableKeyframes.test.ts +9 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useGroupCommits.ts +0 -1
- package/src/hooks/useGsapScriptCommits.ts +0 -1
- package/src/hooks/useLintModal.test.tsx +34 -1
- package/src/hooks/useLintModal.ts +32 -30
- package/src/hooks/usePersistentEditHistory.test.ts +221 -315
- package/src/hooks/usePersistentEditHistory.ts +155 -342
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
- package/src/hooks/usePreviewPersistence.ts +0 -2
- package/src/hooks/useProjectCompositionVariables.ts +1 -3
- package/src/hooks/useRazorSplit.history.test.tsx +46 -67
- package/src/hooks/useRazorSplit.test.ts +17 -47
- package/src/hooks/useRenderClipContent.test.ts +29 -0
- package/src/hooks/useRenderClipContent.ts +6 -3
- package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
- package/src/hooks/useSdkSession.ts +8 -2
- package/src/hooks/useSlideshowPersist.ts +0 -2
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useTimelineAssetDropOps.ts +0 -1
- package/src/hooks/useTimelineDeleteOps.ts +0 -1
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/hooks/useTimelineEditingTypes.ts +0 -2
- package/src/hostMount.test.tsx +53 -22
- package/src/main.tsx +3 -0
- package/src/player/components/CompositionThumbnail.tsx +14 -0
- package/src/player/components/Player.test.ts +49 -0
- package/src/player/components/Player.tsx +21 -7
- package/src/player/components/timelineClipDragCommit.test.ts +7 -39
- package/src/player/hooks/timelineDomBinding.test.ts +50 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
- package/src/player/hooks/timelineSyncHydration.ts +3 -3
- package/src/player/hooks/useShadowPreviewReload.ts +1 -0
- package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
- package/src/player/hooks/useTimelinePlayer.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/timelineElementHelpers.ts +45 -9
- package/src/player/store/playerStore.test.ts +35 -1
- package/src/player/store/playerStore.ts +29 -0
- package/src/player/store/readinessSlice.ts +34 -6
- package/src/utils/blockInstaller.ts +0 -3
- package/src/utils/previewPrefetch.test.ts +22 -0
- package/src/utils/previewPrefetch.ts +14 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/razorSplitTransaction.ts +1 -1
- package/src/utils/sdkEditTransaction.ts +0 -3
- package/src/utils/studioFileHistory.test.ts +0 -9
- package/src/utils/studioFileHistory.ts +1 -5
- package/src/utils/timelineCompositionInsert.ts +0 -1
- package/dist/assets/hyperframes-player-WbBVbWuO.js +0 -464
- package/dist/chunk-7BP32G62.js.map +0 -1
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
- package/src/utils/editHistory.test.ts +0 -534
- package/src/utils/editHistory.ts +0 -236
- package/src/utils/editHistoryStorage.test.ts +0 -37
- package/src/utils/editHistoryStorage.ts +0 -99
- /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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
309
|
-
const
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
316
|
-
const emptyState = createEmptyEditHistory();
|
|
317
|
-
storeRef.current = null;
|
|
318
|
-
storeProjectIdRef.current = null;
|
|
319
|
-
setState(emptyState);
|
|
140
|
+
setView(EMPTY);
|
|
320
141
|
setLoaded(false);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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 (
|
|
147
|
+
async ({ label, coalesceKey, coalesceMs, files }: RecordEditInput) => {
|
|
362
148
|
if (!projectId) return;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
|
377
|
-
async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
|
378
|
-
if (
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -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 +
|
|
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
|
|