@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.
- package/dist/assets/{hyperframes-player-fWt87gyh.js → hyperframes-player-BuRiFtrF.js} +1 -1
- package/dist/assets/{index-DofdzdxJ.js → index-BM35fjFf.js} +1 -1
- package/dist/assets/{index-D34Oy5DN.js → index-BT3sauee.js} +1 -1
- package/dist/assets/{index-D6TjRAvz.js → index-C2utDZt-.js} +114 -114
- package/dist/{chunk-U3EE4ACX.js → chunk-7X4XRP3L.js} +14 -13
- package/dist/chunk-7X4XRP3L.js.map +1 -0
- package/dist/index.d.ts +101 -21
- package/dist/index.html +1 -1
- package/dist/index.js +1211 -990
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +2 -0
- package/src/components/dock/Dock.test.tsx +78 -3
- package/src/components/dock/Dock.tsx +45 -18
- package/src/components/dock/dockLayout.ts +27 -14
- package/src/components/dock/dockLayoutSchema.ts +11 -4
- package/src/components/dock/dockLayoutStore.ts +4 -2
- package/src/components/editor/CanvasContextMenu.test.tsx +23 -2
- package/src/components/editor/CanvasContextMenu.tsx +7 -10
- package/src/components/editor/ConnectedDomEditOverlay.tsx +3 -128
- package/src/components/editor/canvasContextMenuZOrder.ts +13 -0
- package/src/components/editor/gsapAnimatesProperty.ts +0 -29
- package/src/components/editor/gsapLivePreview.test.ts +46 -1
- package/src/components/editor/propertyPanelHelpers.ts +3 -0
- package/src/components/editor/useDomEditZOrder.ts +185 -0
- package/src/components/nle/PreviewOverlaysTestMocks.ts +4 -0
- package/src/domEditExports.test.tsx +92 -0
- package/src/hooks/appHotkeysDispatch.ts +1 -7
- package/src/hooks/gsapRuntimeKeyframes.ts +6 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +67 -2
- package/src/hooks/useAppHotkeys.ts +18 -45
- package/src/hooks/useDomEditCommits.test.tsx +94 -90
- package/src/hooks/useEditHistoryActions.paint.test.tsx +166 -0
- package/src/hooks/useEditHistoryActions.test.tsx +67 -9
- package/src/hooks/useEditHistoryActions.ts +36 -8
- package/src/hooks/usePersistentEditHistory.test.ts +46 -0
- package/src/hooks/usePersistentEditHistory.ts +101 -19
- package/src/hooks/usePreviewPersistence.ts +25 -2
- package/src/hooks/useStudioExternalFileChanges.ts +7 -3
- package/src/index.ts +7 -0
- package/src/player/components/timelineDragDrop.test.tsx +61 -5
- package/src/player/components/timelineDragDrop.ts +29 -8
- package/src/player/components/useTimelineProviderState.tsx +1 -0
- package/src/utils/domEditSaveQueue.ts +7 -2
- package/src/utils/gsapSoftReload.ts +1 -1
- package/src/utils/gsapUndoRestore.test.ts +46 -0
- package/src/utils/gsapUndoRestore.ts +166 -91
- package/src/utils/studioPendingEdits.ts +14 -5
- package/src/utils/studioUiPreferences.ts +8 -6
- package/dist/chunk-U3EE4ACX.js.map +0 -1
|
@@ -11,24 +11,36 @@ import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryA
|
|
|
11
11
|
let root: Root | null = null;
|
|
12
12
|
afterEach(() => act(() => root?.unmount()));
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
type RestoreFiles = Record<string, { previous: string; restored: string }>;
|
|
15
|
+
type Prediction = { id: string; files: RestoreFiles };
|
|
16
|
+
|
|
17
|
+
function mount(
|
|
18
|
+
result: {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
reason?: string;
|
|
21
|
+
message?: string;
|
|
22
|
+
label?: string;
|
|
23
|
+
paths?: string[];
|
|
24
|
+
undoes?: string;
|
|
25
|
+
files?: RestoreFiles;
|
|
26
|
+
},
|
|
27
|
+
predicted?: Prediction,
|
|
28
|
+
) {
|
|
21
29
|
const editHistory = {
|
|
22
30
|
undo: vi.fn<EditHistoryHandle["undo"]>(async () => result),
|
|
23
31
|
redo: vi.fn<EditHistoryHandle["redo"]>(async () => result),
|
|
32
|
+
predict: () => predicted ?? null,
|
|
24
33
|
};
|
|
34
|
+
const putBack = vi.fn();
|
|
25
35
|
const deps = {
|
|
26
36
|
editHistory,
|
|
27
37
|
readOptionalProjectFile: vi.fn(async () => ""),
|
|
28
38
|
readProjectFile: vi.fn(async () => ""),
|
|
29
39
|
writeProjectFile: vi.fn(async () => undefined),
|
|
30
40
|
showToast: vi.fn(),
|
|
31
|
-
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
|
|
41
|
+
syncHistoryPreviewAfterApply: vi.fn(async (_restore: unknown) => undefined),
|
|
42
|
+
putBack,
|
|
43
|
+
showHistoryRestoreNow: vi.fn((_files: RestoreFiles) => putBack),
|
|
32
44
|
waitForPendingDomEditSaves: vi.fn(async () => undefined),
|
|
33
45
|
onAfterUndoRedo: vi.fn(),
|
|
34
46
|
activeCompPath: "index.html",
|
|
@@ -44,7 +56,53 @@ function mount(result: {
|
|
|
44
56
|
return { deps, actions };
|
|
45
57
|
}
|
|
46
58
|
|
|
59
|
+
const PREDICTED = { id: "e1", files: { "index.html": { previous: "B", restored: "A" } } };
|
|
60
|
+
const SERVER_FILES = { "index.html": { previous: "B", restored: "A2" } };
|
|
61
|
+
|
|
47
62
|
describe("useEditHistoryActions", () => {
|
|
63
|
+
it("corrects a shown step from the server's restore, diffed from what the preview shows", async () => {
|
|
64
|
+
const { deps, actions } = mount(
|
|
65
|
+
{ ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
|
|
66
|
+
PREDICTED,
|
|
67
|
+
);
|
|
68
|
+
await act(() => actions.undo());
|
|
69
|
+
expect(deps.showHistoryRestoreNow).toHaveBeenCalledWith(PREDICTED.files);
|
|
70
|
+
expect(deps.putBack).not.toHaveBeenCalled();
|
|
71
|
+
expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalledWith({
|
|
72
|
+
paths: ["index.html"],
|
|
73
|
+
files: { "index.html": { previous: "A", restored: "A2" } },
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("puts a shown step back and applies the server's own restore when it stepped another entry", async () => {
|
|
78
|
+
const { deps, actions } = mount(
|
|
79
|
+
{
|
|
80
|
+
ok: true,
|
|
81
|
+
label: "Undid: Outside",
|
|
82
|
+
paths: ["index.html"],
|
|
83
|
+
undoes: "e0",
|
|
84
|
+
files: SERVER_FILES,
|
|
85
|
+
},
|
|
86
|
+
PREDICTED,
|
|
87
|
+
);
|
|
88
|
+
await act(() => actions.undo());
|
|
89
|
+
expect(deps.putBack).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalledWith({
|
|
91
|
+
paths: ["index.html"],
|
|
92
|
+
files: SERVER_FILES,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("puts a shown step back when the server refuses it", async () => {
|
|
97
|
+
const { deps, actions } = mount(
|
|
98
|
+
{ ok: false, reason: "failed", message: "disk full" },
|
|
99
|
+
PREDICTED,
|
|
100
|
+
);
|
|
101
|
+
await act(() => actions.undo());
|
|
102
|
+
expect(deps.putBack).toHaveBeenCalledTimes(1);
|
|
103
|
+
expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
48
106
|
it("undo resyncs the preview and toasts the step as the history names it", async () => {
|
|
49
107
|
const { deps, actions } = mount({ ok: true, label: "Undid: Move clip", paths: ["index.html"] });
|
|
50
108
|
await act(() => actions.undo());
|
|
@@ -80,7 +138,7 @@ describe("useEditHistoryActions", () => {
|
|
|
80
138
|
expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled();
|
|
81
139
|
});
|
|
82
140
|
|
|
83
|
-
it("says why when the history could not take the step", async () => {
|
|
141
|
+
it("says why when the history could not take the step, with nothing shown to take back", async () => {
|
|
84
142
|
const { deps, actions } = mount({ ok: false, reason: "failed", message: "disk full" });
|
|
85
143
|
await act(() => actions.undo());
|
|
86
144
|
expect(deps.showToast).toHaveBeenCalledWith("Undo failed: disk full", "error");
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { useCallback, useMemo } from "react";
|
|
3
3
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
4
4
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
5
|
+
import type { RestoreFiles } from "../utils/gsapUndoRestore";
|
|
5
6
|
|
|
6
7
|
interface HistoryResult {
|
|
7
8
|
ok: boolean;
|
|
@@ -9,8 +10,9 @@ interface HistoryResult {
|
|
|
9
10
|
message?: string;
|
|
10
11
|
label?: string;
|
|
11
12
|
paths?: string[];
|
|
13
|
+
undoes?: string;
|
|
12
14
|
/** Per-file restored/previous content, used to soft-apply the preview. */
|
|
13
|
-
files?:
|
|
15
|
+
files?: RestoreFiles;
|
|
14
16
|
}
|
|
15
17
|
interface HistoryFileCallbacks {
|
|
16
18
|
readFile: (path: string) => Promise<string>;
|
|
@@ -19,6 +21,7 @@ interface HistoryFileCallbacks {
|
|
|
19
21
|
export interface EditHistoryHandle {
|
|
20
22
|
undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
|
21
23
|
redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
|
24
|
+
predict?: (direction: "undo" | "redo") => { id: string; files: RestoreFiles } | null;
|
|
22
25
|
state: {
|
|
23
26
|
undo: ReadonlyArray<{ createdAt: number }>;
|
|
24
27
|
redo: ReadonlyArray<{ createdAt: number }>;
|
|
@@ -26,12 +29,13 @@ export interface EditHistoryHandle {
|
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export interface UseEditHistoryActionsOptions {
|
|
29
|
-
editHistory: Pick<EditHistoryHandle, "undo" | "redo">;
|
|
32
|
+
editHistory: Pick<EditHistoryHandle, "undo" | "redo" | "predict">;
|
|
30
33
|
readOptionalProjectFile: (path: string) => Promise<string>;
|
|
31
34
|
readProjectFile: (path: string) => Promise<string>;
|
|
32
35
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
33
36
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
34
37
|
syncHistoryPreviewAfterApply: (restore: Pick<HistoryResult, "paths" | "files">) => Promise<void>;
|
|
38
|
+
showHistoryRestoreNow?: (files: RestoreFiles) => (() => void) | null;
|
|
35
39
|
waitForPendingDomEditSaves: () => Promise<void>;
|
|
36
40
|
onAfterUndoRedo?: (restore: Pick<HistoryResult, "paths" | "files">) => void;
|
|
37
41
|
/** Active composition path — decides whether undo/redo must resync the SDK session. */
|
|
@@ -48,6 +52,7 @@ export function useEditHistoryActions({
|
|
|
48
52
|
writeProjectFile,
|
|
49
53
|
showToast,
|
|
50
54
|
syncHistoryPreviewAfterApply,
|
|
55
|
+
showHistoryRestoreNow,
|
|
51
56
|
waitForPendingDomEditSaves,
|
|
52
57
|
onAfterUndoRedo,
|
|
53
58
|
activeCompPath,
|
|
@@ -67,11 +72,22 @@ export function useEditHistoryActions({
|
|
|
67
72
|
const apply = useCallback(
|
|
68
73
|
async (direction: "undo" | "redo") => {
|
|
69
74
|
const noun = direction === "undo" ? "Undo" : "Redo";
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
// Paint the step in the key's own task when this tab knows it; the server's answer then confirms or corrects.
|
|
76
|
+
const predicted = editHistory.predict?.(direction) ?? null;
|
|
77
|
+
const putBack = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
|
|
78
|
+
let result: HistoryResult = { ok: false, reason: "failed" };
|
|
79
|
+
let serverSteppedShown = false;
|
|
80
|
+
try {
|
|
81
|
+
await waitForPendingDomEditSaves();
|
|
82
|
+
result = await editHistory[direction]({
|
|
83
|
+
readFile: readHistoryFile,
|
|
84
|
+
serialize: serializeHistoryFiles,
|
|
85
|
+
});
|
|
86
|
+
serverSteppedShown =
|
|
87
|
+
Boolean(putBack && result.ok && result.label) && result.undoes === predicted?.id;
|
|
88
|
+
} finally {
|
|
89
|
+
if (putBack && !serverSteppedShown) putBack();
|
|
90
|
+
}
|
|
75
91
|
if (!result.ok && result.reason === "content-mismatch") {
|
|
76
92
|
showToast(
|
|
77
93
|
`Can't ${direction}: ${result.paths?.join(", ")} changed since that edit.`,
|
|
@@ -84,7 +100,8 @@ export function useEditHistoryActions({
|
|
|
84
100
|
return;
|
|
85
101
|
}
|
|
86
102
|
if (result.ok && result.label) {
|
|
87
|
-
const
|
|
103
|
+
const files = serverSteppedShown ? fromShown(result.files, predicted!.files) : result.files;
|
|
104
|
+
const restore = { paths: result.paths, files };
|
|
88
105
|
onAfterUndoRedo?.(restore);
|
|
89
106
|
if (activeCompPath && result.paths?.includes(activeCompPath)) {
|
|
90
107
|
forceReloadSdkSession?.();
|
|
@@ -98,6 +115,7 @@ export function useEditHistoryActions({
|
|
|
98
115
|
readHistoryFile,
|
|
99
116
|
showToast,
|
|
100
117
|
syncHistoryPreviewAfterApply,
|
|
118
|
+
showHistoryRestoreNow,
|
|
101
119
|
waitForPendingDomEditSaves,
|
|
102
120
|
serializeHistoryFiles,
|
|
103
121
|
onAfterUndoRedo,
|
|
@@ -110,3 +128,13 @@ export function useEditHistoryActions({
|
|
|
110
128
|
const redo = useCallback(() => apply("redo"), [apply]);
|
|
111
129
|
return useMemo(() => ({ undo, redo }), [undo, redo]);
|
|
112
130
|
}
|
|
131
|
+
|
|
132
|
+
function fromShown(files: RestoreFiles | undefined, shown: RestoreFiles) {
|
|
133
|
+
if (!files) return files;
|
|
134
|
+
return Object.fromEntries(
|
|
135
|
+
Object.entries(files).map(([path, f]) => [
|
|
136
|
+
path,
|
|
137
|
+
{ previous: shown[path]?.restored ?? f.previous, restored: f.restored },
|
|
138
|
+
]),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -76,6 +76,7 @@ it("an edit Studio saved is undone and redone by the project's history, with the
|
|
|
76
76
|
expect(undone).toEqual({
|
|
77
77
|
ok: true,
|
|
78
78
|
label: "Undid: Moved Title",
|
|
79
|
+
undoes: expect.any(String),
|
|
79
80
|
paths: ["index.html"],
|
|
80
81
|
files: { "index.html": { previous: "B", restored: "A" } },
|
|
81
82
|
});
|
|
@@ -241,3 +242,48 @@ it("a step that cannot reach the server says so", async () => {
|
|
|
241
242
|
message: "Studio could not reach its server.",
|
|
242
243
|
});
|
|
243
244
|
});
|
|
245
|
+
|
|
246
|
+
it("predicts a step from what this tab wrote, and not while a claim or step may have moved the history", async () => {
|
|
247
|
+
const { hook, save, readFile } = await studio();
|
|
248
|
+
save("B");
|
|
249
|
+
const claim = hook().recordEdit({
|
|
250
|
+
label: "Moved Title",
|
|
251
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
252
|
+
});
|
|
253
|
+
expect(hook().predict("undo")).toBeNull();
|
|
254
|
+
await act(() => claim);
|
|
255
|
+
const predicted = await vi.waitFor(() => {
|
|
256
|
+
const next = hook().predict("undo");
|
|
257
|
+
expect(next?.files).toEqual({ "index.html": { previous: "B", restored: "A" } });
|
|
258
|
+
return next!;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
hook().noteOutsideChange();
|
|
262
|
+
expect(hook().predict("undo")).toBeNull();
|
|
263
|
+
await vi.waitFor(() => expect(hook().predict("undo")?.id).toBe(predicted.id));
|
|
264
|
+
|
|
265
|
+
const undone = hook().undo({ readFile });
|
|
266
|
+
expect(hook().predict("undo")).toBeNull();
|
|
267
|
+
expect((await act(() => undone)).undoes).toBe(predicted.id);
|
|
268
|
+
await vi.waitFor(() =>
|
|
269
|
+
expect(hook().predict("redo")?.files).toEqual({
|
|
270
|
+
"index.html": { previous: "A", restored: "B" },
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("an undo taken before the view caught up with the edit still reports the preview's before and after", async () => {
|
|
276
|
+
const { hook, save, readFile } = await studio();
|
|
277
|
+
save("B");
|
|
278
|
+
const record = hook().recordEdit({
|
|
279
|
+
label: "Moved Title",
|
|
280
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
281
|
+
});
|
|
282
|
+
const undo = hook().undo;
|
|
283
|
+
await act(() => record);
|
|
284
|
+
const undone = await act(() => undo({ readFile }));
|
|
285
|
+
expect(undone).toMatchObject({
|
|
286
|
+
ok: true,
|
|
287
|
+
files: { "index.html": { previous: "B", restored: "A" } },
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
75
|
-
function claimHeld(reply: Awaited<ReturnType<typeof post>>, label: string):
|
|
76
|
-
if (reply.ok)
|
|
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
|
|
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<
|
|
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
|
|
137
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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(
|
|
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 {
|
|
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?:
|
|
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
|
-
|
|
57
|
-
|
|
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";
|