@hyperframes/studio 0.5.0-alpha.1 → 0.5.0-alpha.11
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/index-Bl4Deziq.js +105 -0
- package/dist/assets/index-KioPDrX6.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +494 -185
- package/src/captions/components/CaptionOverlay.tsx +2 -1
- package/src/captions/keyboard.test.ts +38 -0
- package/src/captions/keyboard.ts +8 -0
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.tsx +41 -6
- package/src/components/editor/PropertyPanel.tsx +7 -3
- package/src/components/editor/domEditing.test.ts +110 -0
- package/src/components/editor/domEditing.ts +33 -4
- package/src/components/nle/NLELayout.tsx +43 -8
- package/src/components/nle/NLEPreview.tsx +5 -1
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/LeftSidebar.tsx +64 -36
- package/src/hooks/usePersistentEditHistory.test.ts +255 -0
- package/src/hooks/usePersistentEditHistory.ts +336 -0
- package/src/icons/SystemIcons.tsx +4 -0
- package/src/player/components/AudioWaveform.tsx +44 -29
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +42 -10
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/PlayerControls.tsx +117 -49
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/Timeline.tsx +198 -27
- package/src/player/components/timelineEditing.test.ts +2 -2
- package/src/player/components/timelineEditing.ts +1 -1
- package/src/player/components/timelineTheme.ts +3 -3
- package/src/player/components/timelineZoom.test.ts +21 -0
- package/src/player/components/timelineZoom.ts +11 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +138 -0
- package/src/player/hooks/useTimelinePlayer.ts +354 -43
- package/src/player/lib/time.test.ts +29 -1
- package/src/player/lib/time.ts +26 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +5 -1
- package/src/styles/studio.css +9 -0
- package/src/utils/clipboard.test.ts +88 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +38 -0
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/src/utils/timelineAssetDrop.test.ts +64 -4
- package/src/utils/timelineAssetDrop.ts +27 -5
- package/dist/assets/index-Bi30tos-.js +0 -105
- package/dist/assets/index-Dm9VsShj.css +0 -1
|
@@ -35,6 +35,7 @@ interface LeftSidebarProps {
|
|
|
35
35
|
codeChildren?: ReactNode;
|
|
36
36
|
onLint?: () => void;
|
|
37
37
|
linting?: boolean;
|
|
38
|
+
onToggleCollapse?: () => void;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export const LeftSidebar = memo(function LeftSidebar({
|
|
@@ -57,6 +58,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|
|
57
58
|
codeChildren,
|
|
58
59
|
onLint,
|
|
59
60
|
linting,
|
|
61
|
+
onToggleCollapse,
|
|
60
62
|
}: LeftSidebarProps) {
|
|
61
63
|
const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
|
|
62
64
|
|
|
@@ -89,43 +91,69 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|
|
89
91
|
>
|
|
90
92
|
{/* Tabs — Code first */}
|
|
91
93
|
<div className="border-b border-neutral-800/50 px-3 py-3 flex-shrink-0">
|
|
92
|
-
<div
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
<button
|
|
97
|
-
type="button"
|
|
98
|
-
onClick={() => selectTab("code")}
|
|
99
|
-
className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
|
|
100
|
-
tab === "code"
|
|
101
|
-
? "bg-neutral-800 text-white"
|
|
102
|
-
: "text-neutral-500 hover:text-neutral-200"
|
|
103
|
-
}`}
|
|
104
|
-
>
|
|
105
|
-
Code
|
|
106
|
-
</button>
|
|
107
|
-
<button
|
|
108
|
-
type="button"
|
|
109
|
-
onClick={() => selectTab("compositions")}
|
|
110
|
-
className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
|
|
111
|
-
tab === "compositions"
|
|
112
|
-
? "bg-neutral-800 text-white"
|
|
113
|
-
: "text-neutral-500 hover:text-neutral-200"
|
|
114
|
-
}`}
|
|
115
|
-
>
|
|
116
|
-
Compositions
|
|
117
|
-
</button>
|
|
118
|
-
<button
|
|
119
|
-
type="button"
|
|
120
|
-
onClick={() => selectTab("assets")}
|
|
121
|
-
className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
|
|
122
|
-
tab === "assets"
|
|
123
|
-
? "bg-neutral-800 text-white"
|
|
124
|
-
: "text-neutral-500 hover:text-neutral-200"
|
|
125
|
-
}`}
|
|
94
|
+
<div className="flex items-center gap-2">
|
|
95
|
+
<div
|
|
96
|
+
className="grid min-w-0 flex-1 gap-1 rounded-[18px] bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
|
|
97
|
+
style={{ gridTemplateColumns: "0.9fr 1.25fr 0.9fr" }}
|
|
126
98
|
>
|
|
127
|
-
|
|
128
|
-
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={() => selectTab("code")}
|
|
102
|
+
className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
|
|
103
|
+
tab === "code"
|
|
104
|
+
? "bg-neutral-800 text-white"
|
|
105
|
+
: "text-neutral-500 hover:text-neutral-200"
|
|
106
|
+
}`}
|
|
107
|
+
>
|
|
108
|
+
Code
|
|
109
|
+
</button>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
onClick={() => selectTab("compositions")}
|
|
113
|
+
className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
|
|
114
|
+
tab === "compositions"
|
|
115
|
+
? "bg-neutral-800 text-white"
|
|
116
|
+
: "text-neutral-500 hover:text-neutral-200"
|
|
117
|
+
}`}
|
|
118
|
+
>
|
|
119
|
+
Compositions
|
|
120
|
+
</button>
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
onClick={() => selectTab("assets")}
|
|
124
|
+
className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
|
|
125
|
+
tab === "assets"
|
|
126
|
+
? "bg-neutral-800 text-white"
|
|
127
|
+
: "text-neutral-500 hover:text-neutral-200"
|
|
128
|
+
}`}
|
|
129
|
+
>
|
|
130
|
+
Assets
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
{onToggleCollapse && (
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onClick={onToggleCollapse}
|
|
137
|
+
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
|
|
138
|
+
title="Hide sidebar"
|
|
139
|
+
aria-label="Hide sidebar"
|
|
140
|
+
>
|
|
141
|
+
<svg
|
|
142
|
+
width="14"
|
|
143
|
+
height="14"
|
|
144
|
+
viewBox="0 0 24 24"
|
|
145
|
+
fill="none"
|
|
146
|
+
stroke="currentColor"
|
|
147
|
+
strokeWidth="1.5"
|
|
148
|
+
strokeLinecap="round"
|
|
149
|
+
strokeLinejoin="round"
|
|
150
|
+
aria-hidden="true"
|
|
151
|
+
>
|
|
152
|
+
<path d="m14 7-5 5 5 5" />
|
|
153
|
+
<path d="M19 4v16" />
|
|
154
|
+
</svg>
|
|
155
|
+
</button>
|
|
156
|
+
)}
|
|
129
157
|
</div>
|
|
130
158
|
</div>
|
|
131
159
|
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createEmptyEditHistory } from "../utils/editHistory";
|
|
3
|
+
import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage";
|
|
4
|
+
import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
|
|
5
|
+
import {
|
|
6
|
+
createPersistentEditHistoryController,
|
|
7
|
+
createPersistentEditHistoryStore,
|
|
8
|
+
} from "./usePersistentEditHistory";
|
|
9
|
+
|
|
10
|
+
describe("createPersistentEditHistoryController", () => {
|
|
11
|
+
it("records history and reloads it for the same project", async () => {
|
|
12
|
+
const storage = createMemoryEditHistoryStorage();
|
|
13
|
+
const first = await createPersistentEditHistoryController({
|
|
14
|
+
projectId: "project-1",
|
|
15
|
+
storage,
|
|
16
|
+
now: () => 100,
|
|
17
|
+
onChange: () => {},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
await first.recordEdit({
|
|
21
|
+
label: "Move layer",
|
|
22
|
+
kind: "manual",
|
|
23
|
+
files: { "index.html": { before: "a", after: "b" } },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const second = await createPersistentEditHistoryController({
|
|
27
|
+
projectId: "project-1",
|
|
28
|
+
storage,
|
|
29
|
+
now: () => 200,
|
|
30
|
+
onChange: () => {},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(second.snapshot().canUndo).toBe(true);
|
|
34
|
+
expect(second.snapshot().undoLabel).toBe("Move layer");
|
|
35
|
+
expect(second.snapshot().undoPaths).toEqual(["index.html"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("undo applies files through the provided callback and persists redo state", async () => {
|
|
39
|
+
const storage = createMemoryEditHistoryStorage();
|
|
40
|
+
const controller = await createPersistentEditHistoryController({
|
|
41
|
+
projectId: "project-1",
|
|
42
|
+
storage,
|
|
43
|
+
now: () => 100,
|
|
44
|
+
onChange: () => {},
|
|
45
|
+
});
|
|
46
|
+
await controller.recordEdit({
|
|
47
|
+
label: "Move layer",
|
|
48
|
+
kind: "manual",
|
|
49
|
+
files: { "index.html": { before: "a", after: "b" } },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const result = await controller.undo({
|
|
53
|
+
readFile: async (path) => {
|
|
54
|
+
expect(path).toBe("index.html");
|
|
55
|
+
return "b";
|
|
56
|
+
},
|
|
57
|
+
writeFile: async (path, content) => {
|
|
58
|
+
expect(path).toBe("index.html");
|
|
59
|
+
expect(content).toBe("a");
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
expect(result.ok).toBe(true);
|
|
63
|
+
|
|
64
|
+
expect(controller.snapshot().canUndo).toBe(false);
|
|
65
|
+
expect(controller.snapshot().canRedo).toBe(true);
|
|
66
|
+
expect(controller.snapshot().redoPaths).toEqual(["index.html"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("keeps in-memory history when storage saves fail", async () => {
|
|
70
|
+
const storage: EditHistoryStorageAdapter = {
|
|
71
|
+
async get() {
|
|
72
|
+
return null;
|
|
73
|
+
},
|
|
74
|
+
async set() {
|
|
75
|
+
throw new Error("IndexedDB unavailable");
|
|
76
|
+
},
|
|
77
|
+
async delete() {},
|
|
78
|
+
};
|
|
79
|
+
const controller = await createPersistentEditHistoryController({
|
|
80
|
+
projectId: "project-1",
|
|
81
|
+
storage,
|
|
82
|
+
now: () => 100,
|
|
83
|
+
onChange: () => {},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await expect(
|
|
87
|
+
controller.recordEdit({
|
|
88
|
+
label: "Move layer",
|
|
89
|
+
kind: "manual",
|
|
90
|
+
files: { "index.html": { before: "a", after: "b" } },
|
|
91
|
+
}),
|
|
92
|
+
).resolves.toBeUndefined();
|
|
93
|
+
|
|
94
|
+
expect(controller.snapshot().canUndo).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("serializes concurrent record edits against the latest state", async () => {
|
|
98
|
+
const storage = createMemoryEditHistoryStorage();
|
|
99
|
+
let timestamp = 100;
|
|
100
|
+
const store = createPersistentEditHistoryStore({
|
|
101
|
+
projectId: "project-1",
|
|
102
|
+
storage,
|
|
103
|
+
initialState: createEmptyEditHistory(),
|
|
104
|
+
now: () => timestamp++,
|
|
105
|
+
onChange: () => {},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await Promise.all([
|
|
109
|
+
store.recordEdit({
|
|
110
|
+
label: "Move layer",
|
|
111
|
+
kind: "manual",
|
|
112
|
+
files: { "index.html": { before: "a", after: "b" } },
|
|
113
|
+
}),
|
|
114
|
+
store.recordEdit({
|
|
115
|
+
label: "Resize layer",
|
|
116
|
+
kind: "manual",
|
|
117
|
+
files: { "index.html": { before: "b", after: "c" } },
|
|
118
|
+
}),
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
expect(store.snapshot().state.undo.map((entry) => entry.label)).toEqual([
|
|
122
|
+
"Move layer",
|
|
123
|
+
"Resize layer",
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("still coalesces concurrent source edits that share a coalesce key", async () => {
|
|
128
|
+
const storage = createMemoryEditHistoryStorage();
|
|
129
|
+
let timestamp = 100;
|
|
130
|
+
const store = createPersistentEditHistoryStore({
|
|
131
|
+
projectId: "project-1",
|
|
132
|
+
storage,
|
|
133
|
+
initialState: createEmptyEditHistory(),
|
|
134
|
+
now: () => timestamp++,
|
|
135
|
+
onChange: () => {},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await Promise.all([
|
|
139
|
+
store.recordEdit({
|
|
140
|
+
label: "Edit source",
|
|
141
|
+
kind: "source",
|
|
142
|
+
coalesceKey: "source:index.html",
|
|
143
|
+
files: { "index.html": { before: "a", after: "b" } },
|
|
144
|
+
}),
|
|
145
|
+
store.recordEdit({
|
|
146
|
+
label: "Edit source",
|
|
147
|
+
kind: "source",
|
|
148
|
+
coalesceKey: "source:index.html",
|
|
149
|
+
files: { "index.html": { before: "b", after: "c" } },
|
|
150
|
+
}),
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
expect(store.snapshot().state.undo).toHaveLength(1);
|
|
154
|
+
expect(store.snapshot().state.undo[0].files["index.html"].before).toBe("a");
|
|
155
|
+
expect(store.snapshot().state.undo[0].files["index.html"].after).toBe("c");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("reads undo hashes from the live top entry during queued undo calls", async () => {
|
|
159
|
+
const storage = createMemoryEditHistoryStorage();
|
|
160
|
+
let timestamp = 100;
|
|
161
|
+
const store = createPersistentEditHistoryStore({
|
|
162
|
+
projectId: "project-1",
|
|
163
|
+
storage,
|
|
164
|
+
initialState: createEmptyEditHistory(),
|
|
165
|
+
now: () => timestamp++,
|
|
166
|
+
onChange: () => {},
|
|
167
|
+
});
|
|
168
|
+
await store.recordEdit({
|
|
169
|
+
label: "Edit first file",
|
|
170
|
+
kind: "manual",
|
|
171
|
+
files: { "first.html": { before: "first-before", after: "first-after" } },
|
|
172
|
+
});
|
|
173
|
+
await store.recordEdit({
|
|
174
|
+
label: "Edit second file",
|
|
175
|
+
kind: "manual",
|
|
176
|
+
files: { "second.html": { before: "second-before", after: "second-after" } },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const files: Record<string, string> = {
|
|
180
|
+
"first.html": "first-after",
|
|
181
|
+
"second.html": "second-after",
|
|
182
|
+
};
|
|
183
|
+
const readPaths: string[] = [];
|
|
184
|
+
|
|
185
|
+
await Promise.all([
|
|
186
|
+
store.undo({
|
|
187
|
+
readFile: async (path) => {
|
|
188
|
+
readPaths.push(path);
|
|
189
|
+
return files[path];
|
|
190
|
+
},
|
|
191
|
+
writeFile: async (path, content) => {
|
|
192
|
+
files[path] = content;
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
store.undo({
|
|
196
|
+
readFile: async (path) => {
|
|
197
|
+
readPaths.push(path);
|
|
198
|
+
return files[path];
|
|
199
|
+
},
|
|
200
|
+
writeFile: async (path, content) => {
|
|
201
|
+
files[path] = content;
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
expect(readPaths).toEqual(["second.html", "first.html"]);
|
|
207
|
+
expect(files).toEqual({
|
|
208
|
+
"first.html": "first-before",
|
|
209
|
+
"second.html": "second-before",
|
|
210
|
+
});
|
|
211
|
+
expect(store.snapshot().canUndo).toBe(false);
|
|
212
|
+
expect(store.snapshot().canRedo).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("rolls back files when an undo write fails partway through", async () => {
|
|
216
|
+
const storage = createMemoryEditHistoryStorage();
|
|
217
|
+
const store = createPersistentEditHistoryStore({
|
|
218
|
+
projectId: "project-1",
|
|
219
|
+
storage,
|
|
220
|
+
initialState: createEmptyEditHistory(),
|
|
221
|
+
now: () => 100,
|
|
222
|
+
onChange: () => {},
|
|
223
|
+
});
|
|
224
|
+
await store.recordEdit({
|
|
225
|
+
label: "Edit files",
|
|
226
|
+
kind: "manual",
|
|
227
|
+
files: {
|
|
228
|
+
"first.html": { before: "first-before", after: "first-after" },
|
|
229
|
+
"second.html": { before: "second-before", after: "second-after" },
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const files: Record<string, string> = {
|
|
234
|
+
"first.html": "first-after",
|
|
235
|
+
"second.html": "second-after",
|
|
236
|
+
};
|
|
237
|
+
const result = store.undo({
|
|
238
|
+
readFile: async (path) => files[path],
|
|
239
|
+
writeFile: async (path, content) => {
|
|
240
|
+
if (path === "second.html" && content === "second-before") {
|
|
241
|
+
throw new Error("write failed");
|
|
242
|
+
}
|
|
243
|
+
files[path] = content;
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await expect(result).rejects.toThrow("write failed");
|
|
248
|
+
expect(files).toEqual({
|
|
249
|
+
"first.html": "first-after",
|
|
250
|
+
"second.html": "second-after",
|
|
251
|
+
});
|
|
252
|
+
expect(store.snapshot().undoLabel).toBe("Edit files");
|
|
253
|
+
expect(store.snapshot().canRedo).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,336 @@
|
|
|
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 EditHistoryKind,
|
|
11
|
+
type EditHistoryState,
|
|
12
|
+
} from "../utils/editHistory";
|
|
13
|
+
import {
|
|
14
|
+
createIndexedDbEditHistoryStorage,
|
|
15
|
+
loadEditHistoryState,
|
|
16
|
+
saveEditHistoryState,
|
|
17
|
+
type EditHistoryStorageAdapter,
|
|
18
|
+
} from "../utils/editHistoryStorage";
|
|
19
|
+
|
|
20
|
+
interface RecordEditInput {
|
|
21
|
+
label: string;
|
|
22
|
+
kind: EditHistoryKind;
|
|
23
|
+
coalesceKey?: string;
|
|
24
|
+
files: BuildEditHistoryEntryInput["files"];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ApplyCallbacks {
|
|
28
|
+
readFile: (path: string) => Promise<string>;
|
|
29
|
+
writeFile: (path: string, content: string) => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface UsePersistentEditHistoryOptions {
|
|
33
|
+
projectId: string | null;
|
|
34
|
+
storage?: EditHistoryStorageAdapter;
|
|
35
|
+
now?: () => number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ApplyResult {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
reason?: "empty" | "content-mismatch";
|
|
41
|
+
label?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PersistentEditHistoryStoreOptions {
|
|
45
|
+
projectId: string;
|
|
46
|
+
storage: EditHistoryStorageAdapter;
|
|
47
|
+
initialState: EditHistoryState;
|
|
48
|
+
now?: () => number;
|
|
49
|
+
onChange: (state: EditHistoryState) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type EditHistoryMutation<T> = (state: EditHistoryState) => Promise<{
|
|
53
|
+
state: EditHistoryState;
|
|
54
|
+
result: T;
|
|
55
|
+
}>;
|
|
56
|
+
|
|
57
|
+
function createEntryId(now: number): string {
|
|
58
|
+
return `edit-${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function snapshotEditHistoryState(state: EditHistoryState) {
|
|
62
|
+
const undoEntry = state.undo[state.undo.length - 1] ?? null;
|
|
63
|
+
const redoEntry = state.redo[state.redo.length - 1] ?? null;
|
|
64
|
+
return {
|
|
65
|
+
canUndo: Boolean(undoEntry),
|
|
66
|
+
canRedo: Boolean(redoEntry),
|
|
67
|
+
undoLabel: undoEntry?.label ?? null,
|
|
68
|
+
redoLabel: redoEntry?.label ?? null,
|
|
69
|
+
undoPaths: undoEntry ? Object.keys(undoEntry.files) : [],
|
|
70
|
+
redoPaths: redoEntry ? Object.keys(redoEntry.files) : [],
|
|
71
|
+
state,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readCurrentFileHashes(
|
|
76
|
+
paths: string[],
|
|
77
|
+
readFile: (path: string) => Promise<string>,
|
|
78
|
+
): Promise<{
|
|
79
|
+
currentFiles: Record<string, string>;
|
|
80
|
+
currentHashes: Record<string, string>;
|
|
81
|
+
}> {
|
|
82
|
+
const currentFiles: Record<string, string> = {};
|
|
83
|
+
const currentHashes: Record<string, string> = {};
|
|
84
|
+
for (const path of paths) {
|
|
85
|
+
const content = await readFile(path);
|
|
86
|
+
currentFiles[path] = content;
|
|
87
|
+
currentHashes[path] = hashEditHistoryContent(content);
|
|
88
|
+
}
|
|
89
|
+
return { currentFiles, currentHashes };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function writeFilesWithRollback({
|
|
93
|
+
files,
|
|
94
|
+
rollbackFiles,
|
|
95
|
+
writeFile,
|
|
96
|
+
}: {
|
|
97
|
+
files: Record<string, string>;
|
|
98
|
+
rollbackFiles: Record<string, string>;
|
|
99
|
+
writeFile: (path: string, content: string) => Promise<void>;
|
|
100
|
+
}): Promise<void> {
|
|
101
|
+
const writtenPaths: string[] = [];
|
|
102
|
+
try {
|
|
103
|
+
for (const [path, content] of Object.entries(files)) {
|
|
104
|
+
await writeFile(path, content);
|
|
105
|
+
writtenPaths.push(path);
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
try {
|
|
109
|
+
for (const path of writtenPaths.reverse()) {
|
|
110
|
+
await writeFile(path, rollbackFiles[path]);
|
|
111
|
+
}
|
|
112
|
+
} catch (rollbackError) {
|
|
113
|
+
throw new AggregateError(
|
|
114
|
+
[error, rollbackError],
|
|
115
|
+
"Failed to apply edit history and rollback did not complete",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createPersistentEditHistoryStore({
|
|
123
|
+
projectId,
|
|
124
|
+
storage,
|
|
125
|
+
initialState,
|
|
126
|
+
now = Date.now,
|
|
127
|
+
onChange,
|
|
128
|
+
}: PersistentEditHistoryStoreOptions) {
|
|
129
|
+
let state = initialState;
|
|
130
|
+
let queue = Promise.resolve();
|
|
131
|
+
|
|
132
|
+
const save = async (nextState: EditHistoryState) => {
|
|
133
|
+
state = nextState;
|
|
134
|
+
onChange(nextState);
|
|
135
|
+
try {
|
|
136
|
+
await saveEditHistoryState(storage, projectId, nextState);
|
|
137
|
+
} catch {
|
|
138
|
+
// Keep in-memory history usable when IndexedDB is unavailable.
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const mutate = async <T>(mutation: EditHistoryMutation<T>): Promise<T> => {
|
|
143
|
+
const run = queue.then(async () => {
|
|
144
|
+
const { state: nextState, result } = await mutation(state);
|
|
145
|
+
if (nextState !== state) await save(nextState);
|
|
146
|
+
return result;
|
|
147
|
+
});
|
|
148
|
+
queue = run.then(
|
|
149
|
+
() => undefined,
|
|
150
|
+
() => undefined,
|
|
151
|
+
);
|
|
152
|
+
return run;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
snapshot: () => snapshotEditHistoryState(state),
|
|
157
|
+
async recordEdit(input: RecordEditInput) {
|
|
158
|
+
await mutate<void>(async (currentState) => {
|
|
159
|
+
const timestamp = now();
|
|
160
|
+
const entry = buildEditHistoryEntry({
|
|
161
|
+
...input,
|
|
162
|
+
id: createEntryId(timestamp),
|
|
163
|
+
projectId,
|
|
164
|
+
now: timestamp,
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
state: pushEditHistoryEntry(currentState, entry),
|
|
168
|
+
result: undefined,
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
async undo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
|
|
173
|
+
return mutate<ApplyResult>(async (currentState) => {
|
|
174
|
+
const entry = currentState.undo[currentState.undo.length - 1];
|
|
175
|
+
if (!entry) {
|
|
176
|
+
return {
|
|
177
|
+
state: currentState,
|
|
178
|
+
result: { ok: false, reason: "empty" },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const { currentFiles, currentHashes } = await readCurrentFileHashes(
|
|
182
|
+
Object.keys(entry.files),
|
|
183
|
+
callbacks.readFile,
|
|
184
|
+
);
|
|
185
|
+
const result = undoEditHistory(currentState, currentHashes, now());
|
|
186
|
+
if (!result.ok) {
|
|
187
|
+
return {
|
|
188
|
+
state: currentState,
|
|
189
|
+
result: { ok: false, reason: result.reason },
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
await writeFilesWithRollback({
|
|
193
|
+
files: result.filesToWrite,
|
|
194
|
+
rollbackFiles: currentFiles,
|
|
195
|
+
writeFile: callbacks.writeFile,
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
state: result.state,
|
|
199
|
+
result: { ok: true, label: result.entry.label },
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
async redo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
|
|
204
|
+
return mutate<ApplyResult>(async (currentState) => {
|
|
205
|
+
const entry = currentState.redo[currentState.redo.length - 1];
|
|
206
|
+
if (!entry) {
|
|
207
|
+
return {
|
|
208
|
+
state: currentState,
|
|
209
|
+
result: { ok: false, reason: "empty" },
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const { currentFiles, currentHashes } = await readCurrentFileHashes(
|
|
213
|
+
Object.keys(entry.files),
|
|
214
|
+
callbacks.readFile,
|
|
215
|
+
);
|
|
216
|
+
const result = redoEditHistory(currentState, currentHashes, now());
|
|
217
|
+
if (!result.ok) {
|
|
218
|
+
return {
|
|
219
|
+
state: currentState,
|
|
220
|
+
result: { ok: false, reason: result.reason },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
await writeFilesWithRollback({
|
|
224
|
+
files: result.filesToWrite,
|
|
225
|
+
rollbackFiles: currentFiles,
|
|
226
|
+
writeFile: callbacks.writeFile,
|
|
227
|
+
});
|
|
228
|
+
return {
|
|
229
|
+
state: result.state,
|
|
230
|
+
result: { ok: true, label: result.entry.label },
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function createPersistentEditHistoryController({
|
|
238
|
+
projectId,
|
|
239
|
+
storage,
|
|
240
|
+
now = Date.now,
|
|
241
|
+
onChange,
|
|
242
|
+
}: {
|
|
243
|
+
projectId: string;
|
|
244
|
+
storage: EditHistoryStorageAdapter;
|
|
245
|
+
now?: () => number;
|
|
246
|
+
onChange: (state: EditHistoryState) => void;
|
|
247
|
+
}) {
|
|
248
|
+
let state = await loadEditHistoryState(storage, projectId);
|
|
249
|
+
const store = createPersistentEditHistoryStore({
|
|
250
|
+
projectId,
|
|
251
|
+
storage,
|
|
252
|
+
initialState: state,
|
|
253
|
+
now,
|
|
254
|
+
onChange: (nextState) => {
|
|
255
|
+
state = nextState;
|
|
256
|
+
onChange(nextState);
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
return store;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function usePersistentEditHistory(options: UsePersistentEditHistoryOptions) {
|
|
264
|
+
const storage = useMemo(
|
|
265
|
+
() => options.storage ?? createIndexedDbEditHistoryStorage(),
|
|
266
|
+
[options.storage],
|
|
267
|
+
);
|
|
268
|
+
const now = options.now ?? Date.now;
|
|
269
|
+
const [state, setState] = useState<EditHistoryState>(() => createEmptyEditHistory());
|
|
270
|
+
const [loaded, setLoaded] = useState(false);
|
|
271
|
+
const projectId = options.projectId;
|
|
272
|
+
const storeRef = useRef<ReturnType<typeof createPersistentEditHistoryStore> | null>(null);
|
|
273
|
+
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
let cancelled = false;
|
|
276
|
+
const emptyState = createEmptyEditHistory();
|
|
277
|
+
storeRef.current = null;
|
|
278
|
+
setState(emptyState);
|
|
279
|
+
setLoaded(false);
|
|
280
|
+
if (!projectId) {
|
|
281
|
+
setLoaded(true);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
loadEditHistoryState(storage, projectId)
|
|
286
|
+
.then((loadedState) => {
|
|
287
|
+
if (cancelled) return;
|
|
288
|
+
storeRef.current = createPersistentEditHistoryStore({
|
|
289
|
+
projectId,
|
|
290
|
+
storage,
|
|
291
|
+
initialState: loadedState,
|
|
292
|
+
now,
|
|
293
|
+
onChange: setState,
|
|
294
|
+
});
|
|
295
|
+
setState(loadedState);
|
|
296
|
+
})
|
|
297
|
+
.catch(() => {
|
|
298
|
+
if (cancelled) return;
|
|
299
|
+
storeRef.current = createPersistentEditHistoryStore({
|
|
300
|
+
projectId,
|
|
301
|
+
storage,
|
|
302
|
+
initialState: emptyState,
|
|
303
|
+
now,
|
|
304
|
+
onChange: setState,
|
|
305
|
+
});
|
|
306
|
+
setState(emptyState);
|
|
307
|
+
})
|
|
308
|
+
.finally(() => {
|
|
309
|
+
if (!cancelled) setLoaded(true);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
return () => {
|
|
313
|
+
cancelled = true;
|
|
314
|
+
};
|
|
315
|
+
}, [now, projectId, storage]);
|
|
316
|
+
|
|
317
|
+
const recordEdit = useCallback(async (input: RecordEditInput) => {
|
|
318
|
+
await storeRef.current?.recordEdit(input);
|
|
319
|
+
}, []);
|
|
320
|
+
|
|
321
|
+
const undo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
|
322
|
+
return storeRef.current?.undo(callbacks) ?? { ok: false, reason: "empty" };
|
|
323
|
+
}, []);
|
|
324
|
+
|
|
325
|
+
const redo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
|
326
|
+
return storeRef.current?.redo(callbacks) ?? { ok: false, reason: "empty" };
|
|
327
|
+
}, []);
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
loaded,
|
|
331
|
+
...snapshotEditHistoryState(state),
|
|
332
|
+
recordEdit,
|
|
333
|
+
undo,
|
|
334
|
+
redo,
|
|
335
|
+
};
|
|
336
|
+
}
|