@hyperframes/studio 0.5.7 → 0.6.0-alpha.10
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-14zH9lqh.css +1 -0
- package/dist/assets/index-B-16fRnH.js +108 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2965 -186
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.test.ts +241 -0
- package/src/components/editor/DomEditOverlay.tsx +1300 -0
- package/src/components/editor/MotionPanel.tsx +651 -0
- package/src/components/editor/PropertyPanel.test.ts +116 -0
- package/src/components/editor/PropertyPanel.tsx +2829 -205
- package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
- package/src/components/editor/TimelineLayerPanel.tsx +113 -0
- package/src/components/editor/colorValue.test.ts +82 -0
- package/src/components/editor/colorValue.ts +175 -0
- package/src/components/editor/domEditing.test.ts +1120 -0
- package/src/components/editor/domEditing.ts +1117 -0
- package/src/components/editor/floatingPanel.test.ts +34 -0
- package/src/components/editor/floatingPanel.ts +54 -0
- package/src/components/editor/fontAssets.ts +32 -0
- package/src/components/editor/fontCatalog.ts +126 -0
- package/src/components/editor/gradientValue.test.ts +89 -0
- package/src/components/editor/gradientValue.ts +445 -0
- package/src/components/editor/manualEditingAvailability.test.ts +131 -0
- package/src/components/editor/manualEditingAvailability.ts +62 -0
- package/src/components/editor/manualEdits.test.ts +945 -0
- package/src/components/editor/manualEdits.ts +1409 -0
- package/src/components/editor/manualOffsetDrag.test.ts +140 -0
- package/src/components/editor/manualOffsetDrag.ts +307 -0
- package/src/components/editor/studioMotion.test.ts +355 -0
- package/src/components/editor/studioMotion.ts +632 -0
- package/src/components/nle/NLELayout.test.ts +12 -0
- package/src/components/nle/NLELayout.tsx +84 -22
- package/src/components/nle/NLEPreview.tsx +56 -5
- package/src/components/renders/RenderQueue.tsx +24 -11
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/CompositionsTab.test.ts +16 -1
- package/src/components/sidebar/CompositionsTab.tsx +117 -45
- package/src/components/sidebar/LeftSidebar.tsx +194 -179
- package/src/hooks/usePersistentEditHistory.test.ts +256 -0
- package/src/hooks/usePersistentEditHistory.ts +337 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +50 -13
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/Player.test.ts +58 -0
- package/src/player/components/Player.tsx +88 -5
- package/src/player/components/PlayerControls.tsx +20 -7
- package/src/player/components/Timeline.test.ts +20 -0
- package/src/player/components/Timeline.tsx +147 -40
- package/src/player/components/TimelineClip.test.ts +92 -0
- package/src/player/components/TimelineClip.tsx +241 -7
- package/src/player/components/timelineEditing.test.ts +16 -3
- package/src/player/components/timelineEditing.ts +10 -3
- package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
- package/src/player/hooks/useTimelinePlayer.ts +287 -16
- package/src/player/store/playerStore.ts +2 -0
- package/src/utils/clipboard.test.ts +89 -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/mediaTypes.ts +1 -1
- package/src/utils/sourcePatcher.test.ts +128 -1
- package/src/utils/sourcePatcher.ts +130 -18
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/src/utils/timelineAssetDrop.test.ts +31 -11
- package/src/utils/timelineAssetDrop.ts +22 -2
- package/src/utils/timelineDiscovery.ts +1 -1
- package/src/utils/timelineInspector.test.ts +79 -0
- package/src/utils/timelineInspector.ts +116 -0
- package/dist/assets/index-04Mp2wOn.css +0 -1
- package/dist/assets/index-Dcw3BoVw.js +0 -93
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildEditHistoryEntry,
|
|
4
|
+
canApplyEditHistoryEntry,
|
|
5
|
+
createEmptyEditHistory,
|
|
6
|
+
hashEditHistoryContent,
|
|
7
|
+
pushEditHistoryEntry,
|
|
8
|
+
redoEditHistory,
|
|
9
|
+
undoEditHistory,
|
|
10
|
+
} from "./editHistory";
|
|
11
|
+
|
|
12
|
+
describe("edit history", () => {
|
|
13
|
+
it("pushes changed file snapshots onto undo and clears redo", () => {
|
|
14
|
+
const state = createEmptyEditHistory();
|
|
15
|
+
const entry = buildEditHistoryEntry({
|
|
16
|
+
projectId: "project-1",
|
|
17
|
+
label: "Move layer",
|
|
18
|
+
files: {
|
|
19
|
+
"index.html": {
|
|
20
|
+
before: '<div style="left: 0px"></div>',
|
|
21
|
+
after: '<div style="left: 20px"></div>',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
now: 100,
|
|
25
|
+
id: "entry-1",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const withUndo = pushEditHistoryEntry(state, entry);
|
|
29
|
+
const redoEntry = buildEditHistoryEntry({
|
|
30
|
+
projectId: "project-1",
|
|
31
|
+
label: "Redoable edit",
|
|
32
|
+
files: {
|
|
33
|
+
"index.html": {
|
|
34
|
+
before: '<div style="left: 20px"></div>',
|
|
35
|
+
after: '<div style="left: 40px"></div>',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
now: 200,
|
|
39
|
+
id: "redo-entry",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const next = pushEditHistoryEntry(
|
|
43
|
+
{
|
|
44
|
+
...withUndo,
|
|
45
|
+
redo: [redoEntry],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
...entry,
|
|
49
|
+
id: "entry-2",
|
|
50
|
+
label: "Resize layer",
|
|
51
|
+
createdAt: 300,
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(withUndo.undo).toHaveLength(1);
|
|
56
|
+
expect(withUndo.redo).toHaveLength(0);
|
|
57
|
+
expect(next.undo.map((item) => item.label)).toEqual(["Move layer", "Resize layer"]);
|
|
58
|
+
expect(next.redo).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("undo returns before contents and moves entry to redo", () => {
|
|
62
|
+
const entry = buildEditHistoryEntry({
|
|
63
|
+
projectId: "project-1",
|
|
64
|
+
label: "Move layer",
|
|
65
|
+
files: {
|
|
66
|
+
"index.html": { before: "before", after: "after" },
|
|
67
|
+
},
|
|
68
|
+
now: 100,
|
|
69
|
+
id: "entry-1",
|
|
70
|
+
});
|
|
71
|
+
const state = pushEditHistoryEntry(createEmptyEditHistory(), entry);
|
|
72
|
+
|
|
73
|
+
const result = undoEditHistory(state, { "index.html": hashEditHistoryContent("after") }, 200);
|
|
74
|
+
|
|
75
|
+
expect(result.ok).toBe(true);
|
|
76
|
+
expect(result.filesToWrite).toEqual({ "index.html": "before" });
|
|
77
|
+
expect(result.state.undo).toHaveLength(0);
|
|
78
|
+
expect(result.state.redo.map((item) => item.id)).toEqual(["entry-1"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("redo returns after contents and moves entry to undo", () => {
|
|
82
|
+
const entry = buildEditHistoryEntry({
|
|
83
|
+
projectId: "project-1",
|
|
84
|
+
label: "Move layer",
|
|
85
|
+
files: {
|
|
86
|
+
"index.html": { before: "before", after: "after" },
|
|
87
|
+
},
|
|
88
|
+
now: 100,
|
|
89
|
+
id: "entry-1",
|
|
90
|
+
});
|
|
91
|
+
const undone = undoEditHistory(
|
|
92
|
+
pushEditHistoryEntry(createEmptyEditHistory(), entry),
|
|
93
|
+
{ "index.html": hashEditHistoryContent("after") },
|
|
94
|
+
200,
|
|
95
|
+
).state;
|
|
96
|
+
|
|
97
|
+
const result = redoEditHistory(undone, { "index.html": hashEditHistoryContent("before") }, 300);
|
|
98
|
+
|
|
99
|
+
expect(result.ok).toBe(true);
|
|
100
|
+
expect(result.filesToWrite).toEqual({ "index.html": "after" });
|
|
101
|
+
expect(result.state.undo.map((item) => item.id)).toEqual(["entry-1"]);
|
|
102
|
+
expect(result.state.redo).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("blocks undo when current content hash does not match the recorded after hash", () => {
|
|
106
|
+
const entry = buildEditHistoryEntry({
|
|
107
|
+
projectId: "project-1",
|
|
108
|
+
label: "Move layer",
|
|
109
|
+
files: {
|
|
110
|
+
"index.html": { before: "before", after: "after" },
|
|
111
|
+
},
|
|
112
|
+
now: 100,
|
|
113
|
+
id: "entry-1",
|
|
114
|
+
});
|
|
115
|
+
const state = pushEditHistoryEntry(createEmptyEditHistory(), entry);
|
|
116
|
+
|
|
117
|
+
const result = undoEditHistory(
|
|
118
|
+
state,
|
|
119
|
+
{ "index.html": hashEditHistoryContent("external") },
|
|
120
|
+
200,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(result.ok).toBe(false);
|
|
124
|
+
expect(result.reason).toBe("content-mismatch");
|
|
125
|
+
expect(result.state).toBe(state);
|
|
126
|
+
expect(result.filesToWrite).toEqual({});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("can validate all files in a multi-file entry before applying", () => {
|
|
130
|
+
const entry = buildEditHistoryEntry({
|
|
131
|
+
projectId: "project-1",
|
|
132
|
+
label: "Update files",
|
|
133
|
+
files: {
|
|
134
|
+
"index.html": { before: "a", after: "b" },
|
|
135
|
+
"compositions/title.html": { before: "c", after: "d" },
|
|
136
|
+
},
|
|
137
|
+
now: 100,
|
|
138
|
+
id: "entry-1",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(
|
|
142
|
+
canApplyEditHistoryEntry(entry, "undo", {
|
|
143
|
+
"index.html": hashEditHistoryContent("b"),
|
|
144
|
+
"compositions/title.html": hashEditHistoryContent("d"),
|
|
145
|
+
}),
|
|
146
|
+
).toEqual({ ok: true });
|
|
147
|
+
expect(
|
|
148
|
+
canApplyEditHistoryEntry(entry, "undo", {
|
|
149
|
+
"index.html": hashEditHistoryContent("b"),
|
|
150
|
+
"compositions/title.html": hashEditHistoryContent("external"),
|
|
151
|
+
}),
|
|
152
|
+
).toEqual({ ok: false, reason: "content-mismatch", path: "compositions/title.html" });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("prunes oldest undo entries when the limit is exceeded", () => {
|
|
156
|
+
let state = createEmptyEditHistory({ maxEntries: 2 });
|
|
157
|
+
for (let index = 1; index <= 3; index += 1) {
|
|
158
|
+
state = pushEditHistoryEntry(
|
|
159
|
+
state,
|
|
160
|
+
buildEditHistoryEntry({
|
|
161
|
+
projectId: "project-1",
|
|
162
|
+
label: `Edit ${index}`,
|
|
163
|
+
files: {
|
|
164
|
+
"index.html": { before: `${index - 1}`, after: `${index}` },
|
|
165
|
+
},
|
|
166
|
+
now: index,
|
|
167
|
+
id: `entry-${index}`,
|
|
168
|
+
}),
|
|
169
|
+
{ maxEntries: 2 },
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
expect(state.undo.map((entry) => entry.id)).toEqual(["entry-2", "entry-3"]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("coalesces source editor edits for the same file inside the coalesce window", () => {
|
|
177
|
+
const first = buildEditHistoryEntry({
|
|
178
|
+
projectId: "project-1",
|
|
179
|
+
label: "Edit source",
|
|
180
|
+
kind: "source",
|
|
181
|
+
coalesceKey: "source:index.html",
|
|
182
|
+
files: {
|
|
183
|
+
"index.html": { before: "a", after: "b" },
|
|
184
|
+
},
|
|
185
|
+
now: 100,
|
|
186
|
+
id: "entry-1",
|
|
187
|
+
});
|
|
188
|
+
const second = buildEditHistoryEntry({
|
|
189
|
+
projectId: "project-1",
|
|
190
|
+
label: "Edit source",
|
|
191
|
+
kind: "source",
|
|
192
|
+
coalesceKey: "source:index.html",
|
|
193
|
+
files: {
|
|
194
|
+
"index.html": { before: "b", after: "c" },
|
|
195
|
+
},
|
|
196
|
+
now: 300,
|
|
197
|
+
id: "entry-2",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const state = pushEditHistoryEntry(
|
|
201
|
+
pushEditHistoryEntry(createEmptyEditHistory(), first),
|
|
202
|
+
second,
|
|
203
|
+
{ coalesceMs: 1000 },
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
expect(state.undo).toHaveLength(1);
|
|
207
|
+
expect(state.undo[0].id).toBe("entry-2");
|
|
208
|
+
expect(state.undo[0].files["index.html"].before).toBe("a");
|
|
209
|
+
expect(state.undo[0].files["index.html"].after).toBe("c");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("does not coalesce source editor edits outside the coalesce window", () => {
|
|
213
|
+
const first = buildEditHistoryEntry({
|
|
214
|
+
projectId: "project-1",
|
|
215
|
+
label: "Edit source",
|
|
216
|
+
kind: "source",
|
|
217
|
+
coalesceKey: "source:index.html",
|
|
218
|
+
files: {
|
|
219
|
+
"index.html": { before: "a", after: "b" },
|
|
220
|
+
},
|
|
221
|
+
now: 100,
|
|
222
|
+
id: "entry-1",
|
|
223
|
+
});
|
|
224
|
+
const second = buildEditHistoryEntry({
|
|
225
|
+
projectId: "project-1",
|
|
226
|
+
label: "Edit source",
|
|
227
|
+
kind: "source",
|
|
228
|
+
coalesceKey: "source:index.html",
|
|
229
|
+
files: {
|
|
230
|
+
"index.html": { before: "b", after: "c" },
|
|
231
|
+
},
|
|
232
|
+
now: 5000,
|
|
233
|
+
id: "entry-2",
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const state = pushEditHistoryEntry(
|
|
237
|
+
pushEditHistoryEntry(createEmptyEditHistory(), first),
|
|
238
|
+
second,
|
|
239
|
+
{ coalesceMs: 1000 },
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(state.undo.map((entry) => entry.id)).toEqual(["entry-1", "entry-2"]);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
export type EditHistoryKind = "manual" | "motion" | "timeline" | "source";
|
|
2
|
+
|
|
3
|
+
export interface EditHistoryFileSnapshot {
|
|
4
|
+
before: string;
|
|
5
|
+
after: string;
|
|
6
|
+
beforeHash: string;
|
|
7
|
+
afterHash: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface EditHistoryEntry {
|
|
11
|
+
id: string;
|
|
12
|
+
projectId: string;
|
|
13
|
+
label: string;
|
|
14
|
+
kind: EditHistoryKind;
|
|
15
|
+
coalesceKey?: string;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
files: Record<string, EditHistoryFileSnapshot>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EditHistoryState {
|
|
21
|
+
version: 1;
|
|
22
|
+
updatedAt: number;
|
|
23
|
+
undo: EditHistoryEntry[];
|
|
24
|
+
redo: EditHistoryEntry[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface EditHistoryOptions {
|
|
28
|
+
maxEntries?: number;
|
|
29
|
+
coalesceMs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BuildEditHistoryEntryInput {
|
|
33
|
+
id: string;
|
|
34
|
+
projectId: string;
|
|
35
|
+
label: string;
|
|
36
|
+
kind?: EditHistoryKind;
|
|
37
|
+
coalesceKey?: string;
|
|
38
|
+
now: number;
|
|
39
|
+
files: Record<string, { before: string; after: string }>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type EditHistoryDirection = "undo" | "redo";
|
|
43
|
+
|
|
44
|
+
export type EditHistoryApplyCheck =
|
|
45
|
+
| { ok: true }
|
|
46
|
+
| { ok: false; reason: "content-mismatch"; path: string };
|
|
47
|
+
|
|
48
|
+
export type EditHistoryTransitionResult =
|
|
49
|
+
| {
|
|
50
|
+
ok: true;
|
|
51
|
+
state: EditHistoryState;
|
|
52
|
+
entry: EditHistoryEntry;
|
|
53
|
+
filesToWrite: Record<string, string>;
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
ok: false;
|
|
57
|
+
reason: "empty" | "content-mismatch";
|
|
58
|
+
state: EditHistoryState;
|
|
59
|
+
filesToWrite: Record<string, string>;
|
|
60
|
+
path?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const DEFAULT_MAX_ENTRIES = 100;
|
|
64
|
+
const DEFAULT_COALESCE_MS = 1500;
|
|
65
|
+
|
|
66
|
+
export function hashEditHistoryContent(content: string): string {
|
|
67
|
+
let hash = 2166136261;
|
|
68
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
69
|
+
hash ^= content.charCodeAt(index);
|
|
70
|
+
hash = Math.imul(hash, 16777619);
|
|
71
|
+
}
|
|
72
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createEmptyEditHistory(_options?: EditHistoryOptions): EditHistoryState {
|
|
76
|
+
return {
|
|
77
|
+
version: 1,
|
|
78
|
+
updatedAt: 0,
|
|
79
|
+
undo: [],
|
|
80
|
+
redo: [],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildEditHistoryEntry(input: BuildEditHistoryEntryInput): EditHistoryEntry {
|
|
85
|
+
const files: Record<string, EditHistoryFileSnapshot> = {};
|
|
86
|
+
for (const [path, snapshot] of Object.entries(input.files)) {
|
|
87
|
+
if (snapshot.before === snapshot.after) continue;
|
|
88
|
+
files[path] = {
|
|
89
|
+
before: snapshot.before,
|
|
90
|
+
after: snapshot.after,
|
|
91
|
+
beforeHash: hashEditHistoryContent(snapshot.before),
|
|
92
|
+
afterHash: hashEditHistoryContent(snapshot.after),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
id: input.id,
|
|
98
|
+
projectId: input.projectId,
|
|
99
|
+
label: input.label,
|
|
100
|
+
kind: input.kind ?? "manual",
|
|
101
|
+
coalesceKey: input.coalesceKey,
|
|
102
|
+
createdAt: input.now,
|
|
103
|
+
files,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function pushEditHistoryEntry(
|
|
108
|
+
state: EditHistoryState,
|
|
109
|
+
entry: EditHistoryEntry,
|
|
110
|
+
options?: EditHistoryOptions,
|
|
111
|
+
): EditHistoryState {
|
|
112
|
+
if (Object.keys(entry.files).length === 0) return state;
|
|
113
|
+
|
|
114
|
+
const coalesceMs = options?.coalesceMs ?? DEFAULT_COALESCE_MS;
|
|
115
|
+
const maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
116
|
+
const previous = state.undo[state.undo.length - 1];
|
|
117
|
+
let undo = state.undo;
|
|
118
|
+
|
|
119
|
+
if (
|
|
120
|
+
previous &&
|
|
121
|
+
previous.coalesceKey &&
|
|
122
|
+
previous.coalesceKey === entry.coalesceKey &&
|
|
123
|
+
entry.createdAt - previous.createdAt <= coalesceMs
|
|
124
|
+
) {
|
|
125
|
+
const files: Record<string, EditHistoryFileSnapshot> = {};
|
|
126
|
+
for (const [path, snapshot] of Object.entries(entry.files)) {
|
|
127
|
+
const previousSnapshot = previous.files[path];
|
|
128
|
+
files[path] = previousSnapshot
|
|
129
|
+
? {
|
|
130
|
+
before: previousSnapshot.before,
|
|
131
|
+
after: snapshot.after,
|
|
132
|
+
beforeHash: previousSnapshot.beforeHash,
|
|
133
|
+
afterHash: snapshot.afterHash,
|
|
134
|
+
}
|
|
135
|
+
: snapshot;
|
|
136
|
+
}
|
|
137
|
+
undo = [...state.undo.slice(0, -1), { ...entry, files }];
|
|
138
|
+
} else {
|
|
139
|
+
undo = [...state.undo, entry];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
version: 1,
|
|
144
|
+
updatedAt: entry.createdAt,
|
|
145
|
+
undo: undo.slice(Math.max(0, undo.length - maxEntries)),
|
|
146
|
+
redo: [],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function canApplyEditHistoryEntry(
|
|
151
|
+
entry: EditHistoryEntry,
|
|
152
|
+
direction: EditHistoryDirection,
|
|
153
|
+
currentHashes: Record<string, string>,
|
|
154
|
+
): EditHistoryApplyCheck {
|
|
155
|
+
for (const [path, snapshot] of Object.entries(entry.files)) {
|
|
156
|
+
const expected = direction === "undo" ? snapshot.afterHash : snapshot.beforeHash;
|
|
157
|
+
if (currentHashes[path] !== expected) {
|
|
158
|
+
return { ok: false, reason: "content-mismatch", path };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { ok: true };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function undoEditHistory(
|
|
165
|
+
state: EditHistoryState,
|
|
166
|
+
currentHashes: Record<string, string>,
|
|
167
|
+
now: number,
|
|
168
|
+
): EditHistoryTransitionResult {
|
|
169
|
+
const entry = state.undo[state.undo.length - 1];
|
|
170
|
+
if (!entry) return { ok: false, reason: "empty", state, filesToWrite: {} };
|
|
171
|
+
|
|
172
|
+
const check = canApplyEditHistoryEntry(entry, "undo", currentHashes);
|
|
173
|
+
if (!check.ok) {
|
|
174
|
+
return { ok: false, reason: check.reason, path: check.path, state, filesToWrite: {} };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
entry,
|
|
180
|
+
filesToWrite: Object.fromEntries(
|
|
181
|
+
Object.entries(entry.files).map(([path, snapshot]) => [path, snapshot.before]),
|
|
182
|
+
),
|
|
183
|
+
state: {
|
|
184
|
+
version: 1,
|
|
185
|
+
updatedAt: now,
|
|
186
|
+
undo: state.undo.slice(0, -1),
|
|
187
|
+
redo: [...state.redo, entry],
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function redoEditHistory(
|
|
193
|
+
state: EditHistoryState,
|
|
194
|
+
currentHashes: Record<string, string>,
|
|
195
|
+
now: number,
|
|
196
|
+
): EditHistoryTransitionResult {
|
|
197
|
+
const entry = state.redo[state.redo.length - 1];
|
|
198
|
+
if (!entry) return { ok: false, reason: "empty", state, filesToWrite: {} };
|
|
199
|
+
|
|
200
|
+
const check = canApplyEditHistoryEntry(entry, "redo", currentHashes);
|
|
201
|
+
if (!check.ok) {
|
|
202
|
+
return { ok: false, reason: check.reason, path: check.path, state, filesToWrite: {} };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
entry,
|
|
208
|
+
filesToWrite: Object.fromEntries(
|
|
209
|
+
Object.entries(entry.files).map(([path, snapshot]) => [path, snapshot.after]),
|
|
210
|
+
),
|
|
211
|
+
state: {
|
|
212
|
+
version: 1,
|
|
213
|
+
updatedAt: now,
|
|
214
|
+
undo: [...state.undo, entry],
|
|
215
|
+
redo: state.redo.slice(0, -1),
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { buildEditHistoryEntry, createEmptyEditHistory, pushEditHistoryEntry } from "./editHistory";
|
|
3
|
+
import {
|
|
4
|
+
createMemoryEditHistoryStorage,
|
|
5
|
+
loadEditHistoryState,
|
|
6
|
+
saveEditHistoryState,
|
|
7
|
+
} from "./editHistoryStorage";
|
|
8
|
+
|
|
9
|
+
describe("edit history storage", () => {
|
|
10
|
+
let storage: ReturnType<typeof createMemoryEditHistoryStorage>;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
storage = createMemoryEditHistoryStorage();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns empty history for projects without persisted state", async () => {
|
|
17
|
+
const state = await loadEditHistoryState(storage, "project-1");
|
|
18
|
+
|
|
19
|
+
expect(state).toEqual(createEmptyEditHistory());
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("saves and loads history per project", async () => {
|
|
23
|
+
const entry = buildEditHistoryEntry({
|
|
24
|
+
id: "entry-1",
|
|
25
|
+
projectId: "project-1",
|
|
26
|
+
label: "Move layer",
|
|
27
|
+
files: { "index.html": { before: "a", after: "b" } },
|
|
28
|
+
now: 100,
|
|
29
|
+
});
|
|
30
|
+
const state = pushEditHistoryEntry(createEmptyEditHistory(), entry);
|
|
31
|
+
|
|
32
|
+
await saveEditHistoryState(storage, "project-1", state);
|
|
33
|
+
|
|
34
|
+
expect(await loadEditHistoryState(storage, "project-1")).toEqual(state);
|
|
35
|
+
expect(await loadEditHistoryState(storage, "project-2")).toEqual(createEmptyEditHistory());
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createEmptyEditHistory, type EditHistoryState } from "./editHistory";
|
|
2
|
+
|
|
3
|
+
export interface EditHistoryStorageAdapter {
|
|
4
|
+
get(projectId: string): Promise<EditHistoryState | null>;
|
|
5
|
+
set(projectId: string, state: EditHistoryState): Promise<void>;
|
|
6
|
+
delete(projectId: string): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const DB_NAME = "hyperframes-studio-edit-history";
|
|
10
|
+
const DB_VERSION = 1;
|
|
11
|
+
const STORE_NAME = "project-history";
|
|
12
|
+
|
|
13
|
+
export function createMemoryEditHistoryStorage(): EditHistoryStorageAdapter {
|
|
14
|
+
const states = new Map<string, EditHistoryState>();
|
|
15
|
+
return {
|
|
16
|
+
async get(projectId) {
|
|
17
|
+
return states.get(projectId) ?? null;
|
|
18
|
+
},
|
|
19
|
+
async set(projectId, state) {
|
|
20
|
+
states.set(projectId, structuredClone(state));
|
|
21
|
+
},
|
|
22
|
+
async delete(projectId) {
|
|
23
|
+
states.delete(projectId);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function openEditHistoryDb(): Promise<IDBDatabase> {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
if (!globalThis.indexedDB) {
|
|
31
|
+
reject(new Error("IndexedDB is not available"));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const request = globalThis.indexedDB.open(DB_NAME, DB_VERSION);
|
|
36
|
+
request.onupgradeneeded = () => {
|
|
37
|
+
const db = request.result;
|
|
38
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
39
|
+
db.createObjectStore(STORE_NAME);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to open edit history db"));
|
|
43
|
+
request.onsuccess = () => resolve(request.result);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function withStore<T>(
|
|
48
|
+
mode: IDBTransactionMode,
|
|
49
|
+
callback: (store: IDBObjectStore) => IDBRequest<T>,
|
|
50
|
+
): Promise<T> {
|
|
51
|
+
return openEditHistoryDb().then(
|
|
52
|
+
(db) =>
|
|
53
|
+
new Promise<T>((resolve, reject) => {
|
|
54
|
+
const tx = db.transaction(STORE_NAME, mode);
|
|
55
|
+
const request = callback(tx.objectStore(STORE_NAME));
|
|
56
|
+
request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
|
|
57
|
+
request.onsuccess = () => resolve(request.result);
|
|
58
|
+
tx.oncomplete = () => db.close();
|
|
59
|
+
tx.onerror = () => {
|
|
60
|
+
db.close();
|
|
61
|
+
reject(tx.error ?? new Error("IndexedDB transaction failed"));
|
|
62
|
+
};
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createIndexedDbEditHistoryStorage(): EditHistoryStorageAdapter {
|
|
68
|
+
return {
|
|
69
|
+
async get(projectId) {
|
|
70
|
+
return (
|
|
71
|
+
(await withStore<EditHistoryState | undefined>("readonly", (store) =>
|
|
72
|
+
store.get(projectId),
|
|
73
|
+
)) ?? null
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
async set(projectId, state) {
|
|
77
|
+
await withStore<IDBValidKey>("readwrite", (store) => store.put(state, projectId));
|
|
78
|
+
},
|
|
79
|
+
async delete(projectId) {
|
|
80
|
+
await withStore<undefined>("readwrite", (store) => store.delete(projectId));
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function loadEditHistoryState(
|
|
86
|
+
storage: EditHistoryStorageAdapter,
|
|
87
|
+
projectId: string,
|
|
88
|
+
): Promise<EditHistoryState> {
|
|
89
|
+
const state = await storage.get(projectId);
|
|
90
|
+
return state ?? createEmptyEditHistory();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function saveEditHistoryState(
|
|
94
|
+
storage: EditHistoryStorageAdapter,
|
|
95
|
+
projectId: string,
|
|
96
|
+
state: EditHistoryState,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
await storage.set(projectId, state);
|
|
99
|
+
}
|
package/src/utils/mediaTypes.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
|
|
2
2
|
export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
|
3
3
|
export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
|
|
4
|
-
export const FONT_EXT = /\.(woff|woff2|ttf|otf|eot)$/i;
|
|
4
|
+
export const FONT_EXT = /\.(woff|woff2|ttf|ttc|otf|eot)$/i;
|
|
5
5
|
export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i;
|
|
6
6
|
|
|
7
7
|
export function isMediaFile(path: string): boolean {
|