@hyperframes/studio 0.5.4 → 0.6.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/assets/hyperframes-player-Cd8vYWxP.js +198 -0
  2. package/dist/assets/index-D04_ZoMm.js +107 -0
  3. package/dist/assets/index-UWFaHilT.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +2621 -170
  7. package/src/components/LintModal.tsx +3 -4
  8. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  9. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  10. package/src/components/editor/MotionPanel.tsx +651 -0
  11. package/src/components/editor/PropertyPanel.test.ts +67 -0
  12. package/src/components/editor/PropertyPanel.tsx +2891 -207
  13. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  14. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  15. package/src/components/editor/colorValue.test.ts +82 -0
  16. package/src/components/editor/colorValue.ts +175 -0
  17. package/src/components/editor/domEditing.test.ts +872 -0
  18. package/src/components/editor/domEditing.ts +993 -0
  19. package/src/components/editor/floatingPanel.test.ts +34 -0
  20. package/src/components/editor/floatingPanel.ts +54 -0
  21. package/src/components/editor/fontAssets.ts +32 -0
  22. package/src/components/editor/fontCatalog.ts +126 -0
  23. package/src/components/editor/gradientValue.test.ts +89 -0
  24. package/src/components/editor/gradientValue.ts +445 -0
  25. package/src/components/editor/manualEditingAvailability.test.ts +120 -0
  26. package/src/components/editor/manualEditingAvailability.ts +60 -0
  27. package/src/components/editor/manualEdits.test.ts +945 -0
  28. package/src/components/editor/manualEdits.ts +1397 -0
  29. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  30. package/src/components/editor/manualOffsetDrag.ts +307 -0
  31. package/src/components/editor/studioMotion.test.ts +355 -0
  32. package/src/components/editor/studioMotion.ts +632 -0
  33. package/src/components/nle/NLELayout.tsx +27 -4
  34. package/src/components/nle/NLEPreview.tsx +50 -5
  35. package/src/components/renders/RenderQueue.tsx +13 -62
  36. package/src/components/renders/useRenderQueue.ts +6 -30
  37. package/src/components/sidebar/AssetsTab.tsx +3 -4
  38. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  39. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  40. package/src/components/sidebar/LeftSidebar.tsx +140 -125
  41. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  42. package/src/hooks/usePersistentEditHistory.ts +337 -0
  43. package/src/icons/SystemIcons.tsx +2 -0
  44. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  45. package/src/player/components/CompositionThumbnail.tsx +50 -13
  46. package/src/player/components/EditModal.tsx +5 -20
  47. package/src/player/components/Player.tsx +18 -2
  48. package/src/player/components/Timeline.test.ts +20 -0
  49. package/src/player/components/Timeline.tsx +103 -21
  50. package/src/player/components/TimelineClip.test.ts +92 -0
  51. package/src/player/components/TimelineClip.tsx +241 -7
  52. package/src/player/components/timelineEditing.test.ts +16 -3
  53. package/src/player/components/timelineEditing.ts +10 -3
  54. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  55. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  56. package/src/player/store/playerStore.ts +2 -0
  57. package/src/utils/clipboard.test.ts +89 -0
  58. package/src/utils/clipboard.ts +57 -0
  59. package/src/utils/editHistory.test.ts +244 -0
  60. package/src/utils/editHistory.ts +218 -0
  61. package/src/utils/editHistoryStorage.test.ts +37 -0
  62. package/src/utils/editHistoryStorage.ts +99 -0
  63. package/src/utils/mediaTypes.ts +1 -1
  64. package/src/utils/sourcePatcher.test.ts +128 -1
  65. package/src/utils/sourcePatcher.ts +130 -18
  66. package/src/utils/studioFileHistory.test.ts +156 -0
  67. package/src/utils/studioFileHistory.ts +61 -0
  68. package/src/utils/timelineAssetDrop.test.ts +31 -11
  69. package/src/utils/timelineAssetDrop.ts +22 -2
  70. package/src/utils/timelineInspector.test.ts +79 -0
  71. package/src/utils/timelineInspector.ts +116 -0
  72. package/dist/assets/hyperframes-player-CEnWY28J.js +0 -417
  73. package/dist/assets/index-04Mp2wOn.css +0 -1
  74. package/dist/assets/index-960mgQMI.js +0 -93
@@ -0,0 +1,89 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { Window } from "happy-dom";
3
+ import { copyTextToClipboard } from "./clipboard";
4
+
5
+ function installDocument(execCommand: (command: string) => boolean): void {
6
+ const window = new Window();
7
+ Object.assign(window, { SyntaxError });
8
+ Object.defineProperty(window.document, "execCommand", {
9
+ configurable: true,
10
+ value: execCommand,
11
+ });
12
+ vi.stubGlobal("document", window.document);
13
+ }
14
+
15
+ function installNavigator(
16
+ writeText: (text: string) => Promise<void>,
17
+ userAgent = "Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36",
18
+ ): void {
19
+ vi.stubGlobal("navigator", {
20
+ clipboard: { writeText },
21
+ userAgent,
22
+ });
23
+ }
24
+
25
+ describe("copyTextToClipboard", () => {
26
+ afterEach(() => {
27
+ vi.unstubAllGlobals();
28
+ });
29
+
30
+ it("uses the synchronous selection copy path first in Safari", async () => {
31
+ const execCommand = vi.fn((command: string) => command === "copy");
32
+ const writeText = vi.fn((_text: string) => Promise.resolve());
33
+
34
+ installDocument(execCommand);
35
+ installNavigator(
36
+ writeText,
37
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
38
+ );
39
+
40
+ await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
41
+
42
+ expect(execCommand).toHaveBeenCalledWith("copy");
43
+ expect(writeText).not.toHaveBeenCalled();
44
+ expect(document.querySelector("textarea")).toBeNull();
45
+ });
46
+
47
+ it("uses navigator.clipboard first outside Safari", async () => {
48
+ const execCommand = vi.fn((command: string) => command === "copy");
49
+ const writeText = vi.fn((_text: string) => Promise.resolve());
50
+
51
+ installDocument(execCommand);
52
+ installNavigator(writeText);
53
+
54
+ await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
55
+
56
+ expect(writeText).toHaveBeenCalledWith("copy me");
57
+ expect(execCommand).not.toHaveBeenCalled();
58
+ });
59
+
60
+ it("falls back to selection copy outside Safari when navigator.clipboard fails", async () => {
61
+ const execCommand = vi.fn((command: string) => command === "copy");
62
+ const writeText = vi.fn((_text: string) => Promise.reject(new Error("blocked")));
63
+
64
+ installDocument(execCommand);
65
+ installNavigator(writeText);
66
+
67
+ await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
68
+
69
+ expect(writeText).toHaveBeenCalledWith("copy me");
70
+ expect(execCommand).toHaveBeenCalledWith("copy");
71
+ });
72
+
73
+ it("reports failure when both copy paths fail", async () => {
74
+ const execCommand = vi.fn(() => false);
75
+ const writeText = vi.fn((_text: string) => Promise.reject(new Error("blocked")));
76
+
77
+ installDocument(execCommand);
78
+ installNavigator(
79
+ writeText,
80
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
81
+ );
82
+
83
+ await expect(copyTextToClipboard("copy me")).resolves.toBe(false);
84
+
85
+ expect(execCommand).toHaveBeenCalledWith("copy");
86
+ expect(writeText).toHaveBeenCalledWith("copy me");
87
+ expect(document.querySelector("textarea")).toBeNull();
88
+ });
89
+ });
@@ -0,0 +1,57 @@
1
+ function copyWithSelection(text: string): boolean {
2
+ if (typeof document === "undefined" || !document.body || !document.execCommand) {
3
+ return false;
4
+ }
5
+
6
+ const textarea = document.createElement("textarea");
7
+ textarea.value = text;
8
+ textarea.setAttribute("readonly", "true");
9
+ textarea.style.position = "fixed";
10
+ textarea.style.top = "0";
11
+ textarea.style.left = "0";
12
+ textarea.style.width = "1px";
13
+ textarea.style.height = "1px";
14
+ textarea.style.padding = "0";
15
+ textarea.style.border = "0";
16
+ textarea.style.opacity = "0";
17
+ textarea.style.pointerEvents = "none";
18
+
19
+ document.body.appendChild(textarea);
20
+ textarea.focus({ preventScroll: true });
21
+ textarea.select();
22
+ textarea.setSelectionRange(0, text.length);
23
+
24
+ try {
25
+ return document.execCommand("copy");
26
+ } catch {
27
+ return false;
28
+ } finally {
29
+ document.body.removeChild(textarea);
30
+ }
31
+ }
32
+
33
+ function shouldCopyWithSelectionFirst(): boolean {
34
+ if (typeof navigator === "undefined") return false;
35
+
36
+ const userAgent = navigator.userAgent;
37
+ return /Safari/i.test(userAgent) && !/Chrome|Chromium|CriOS|FxiOS|Edg|OPR/i.test(userAgent);
38
+ }
39
+
40
+ export async function copyTextToClipboard(text: string): Promise<boolean> {
41
+ const useSelectionFirst = shouldCopyWithSelectionFirst();
42
+ if (useSelectionFirst && copyWithSelection(text)) {
43
+ return true;
44
+ }
45
+
46
+ const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
47
+ if (clipboard?.writeText) {
48
+ try {
49
+ await clipboard.writeText(text);
50
+ return true;
51
+ } catch {
52
+ // Fall back below when the browser still allows synchronous copy.
53
+ }
54
+ }
55
+
56
+ return !useSelectionFirst && copyWithSelection(text);
57
+ }
@@ -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
+ });