@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.
Files changed (75) hide show
  1. package/dist/assets/index-14zH9lqh.css +1 -0
  2. package/dist/assets/index-B-16fRnH.js +108 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +2965 -186
  6. package/src/components/LintModal.tsx +3 -4
  7. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  8. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  9. package/src/components/editor/MotionPanel.tsx +651 -0
  10. package/src/components/editor/PropertyPanel.test.ts +116 -0
  11. package/src/components/editor/PropertyPanel.tsx +2829 -205
  12. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  13. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  14. package/src/components/editor/colorValue.test.ts +82 -0
  15. package/src/components/editor/colorValue.ts +175 -0
  16. package/src/components/editor/domEditing.test.ts +1120 -0
  17. package/src/components/editor/domEditing.ts +1117 -0
  18. package/src/components/editor/floatingPanel.test.ts +34 -0
  19. package/src/components/editor/floatingPanel.ts +54 -0
  20. package/src/components/editor/fontAssets.ts +32 -0
  21. package/src/components/editor/fontCatalog.ts +126 -0
  22. package/src/components/editor/gradientValue.test.ts +89 -0
  23. package/src/components/editor/gradientValue.ts +445 -0
  24. package/src/components/editor/manualEditingAvailability.test.ts +131 -0
  25. package/src/components/editor/manualEditingAvailability.ts +62 -0
  26. package/src/components/editor/manualEdits.test.ts +945 -0
  27. package/src/components/editor/manualEdits.ts +1409 -0
  28. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  29. package/src/components/editor/manualOffsetDrag.ts +307 -0
  30. package/src/components/editor/studioMotion.test.ts +355 -0
  31. package/src/components/editor/studioMotion.ts +632 -0
  32. package/src/components/nle/NLELayout.test.ts +12 -0
  33. package/src/components/nle/NLELayout.tsx +84 -22
  34. package/src/components/nle/NLEPreview.tsx +56 -5
  35. package/src/components/renders/RenderQueue.tsx +24 -11
  36. package/src/components/sidebar/AssetsTab.tsx +3 -4
  37. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  38. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  39. package/src/components/sidebar/LeftSidebar.tsx +194 -179
  40. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  41. package/src/hooks/usePersistentEditHistory.ts +337 -0
  42. package/src/icons/SystemIcons.tsx +2 -0
  43. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  44. package/src/player/components/CompositionThumbnail.tsx +50 -13
  45. package/src/player/components/EditModal.tsx +5 -20
  46. package/src/player/components/Player.test.ts +58 -0
  47. package/src/player/components/Player.tsx +88 -5
  48. package/src/player/components/PlayerControls.tsx +20 -7
  49. package/src/player/components/Timeline.test.ts +20 -0
  50. package/src/player/components/Timeline.tsx +147 -40
  51. package/src/player/components/TimelineClip.test.ts +92 -0
  52. package/src/player/components/TimelineClip.tsx +241 -7
  53. package/src/player/components/timelineEditing.test.ts +16 -3
  54. package/src/player/components/timelineEditing.ts +10 -3
  55. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  56. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  57. package/src/player/store/playerStore.ts +2 -0
  58. package/src/utils/clipboard.test.ts +89 -0
  59. package/src/utils/clipboard.ts +57 -0
  60. package/src/utils/editHistory.test.ts +244 -0
  61. package/src/utils/editHistory.ts +218 -0
  62. package/src/utils/editHistoryStorage.test.ts +37 -0
  63. package/src/utils/editHistoryStorage.ts +99 -0
  64. package/src/utils/mediaTypes.ts +1 -1
  65. package/src/utils/sourcePatcher.test.ts +128 -1
  66. package/src/utils/sourcePatcher.ts +130 -18
  67. package/src/utils/studioFileHistory.test.ts +156 -0
  68. package/src/utils/studioFileHistory.ts +61 -0
  69. package/src/utils/timelineAssetDrop.test.ts +31 -11
  70. package/src/utils/timelineAssetDrop.ts +22 -2
  71. package/src/utils/timelineDiscovery.ts +1 -1
  72. package/src/utils/timelineInspector.test.ts +79 -0
  73. package/src/utils/timelineInspector.ts +116 -0
  74. package/dist/assets/index-04Mp2wOn.css +0 -1
  75. package/dist/assets/index-Dcw3BoVw.js +0 -93
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { applyPatchByTarget, readAttributeByTarget, type PatchOperation } from "./sourcePatcher";
2
+ import {
3
+ applyPatch,
4
+ applyPatchByTarget,
5
+ readAttributeByTarget,
6
+ readTagSnippetByTarget,
7
+ type PatchOperation,
8
+ } from "./sourcePatcher";
3
9
 
4
10
  describe("applyPatchByTarget", () => {
5
11
  it("updates a composition host by data-composition-id selector", () => {
@@ -29,6 +35,64 @@ describe("applyPatchByTarget", () => {
29
35
  );
30
36
  });
31
37
 
38
+ it("patches inline move styles by target", () => {
39
+ const html = `<div id="card" style="position: absolute; left: 108px; top: 112px"></div>`;
40
+
41
+ const withLeft = applyPatchByTarget(
42
+ html,
43
+ { id: "card" },
44
+ { type: "inline-style", property: "left", value: "160px" },
45
+ );
46
+ const withTop = applyPatchByTarget(
47
+ withLeft,
48
+ { id: "card" },
49
+ { type: "inline-style", property: "top", value: "140px" },
50
+ );
51
+
52
+ expect(withTop).toContain('style="position: absolute; left: 160px; top: 140px"');
53
+ });
54
+
55
+ it("patches inline resize styles by target", () => {
56
+ const html = `<div id="card" style="position: absolute; width: 380px; height: 196px"></div>`;
57
+
58
+ const withWidth = applyPatchByTarget(
59
+ html,
60
+ { id: "card" },
61
+ { type: "inline-style", property: "width", value: "420px" },
62
+ );
63
+ const withHeight = applyPatchByTarget(
64
+ withWidth,
65
+ { id: "card" },
66
+ { type: "inline-style", property: "height", value: "220px" },
67
+ );
68
+
69
+ expect(withHeight).toContain('style="position: absolute; width: 420px; height: 220px"');
70
+ });
71
+
72
+ it("escapes quoted CSS urls inside double-quoted style attributes", () => {
73
+ const html = `<div id="card" style="position: absolute; opacity: 1"></div>`;
74
+
75
+ const withBackground = applyPatchByTarget(
76
+ html,
77
+ { id: "card" },
78
+ {
79
+ type: "inline-style",
80
+ property: "background-image",
81
+ value: `url("../ChatGPT Image Apr 22, 2026.png")`,
82
+ },
83
+ );
84
+ const withRadius = applyPatchByTarget(
85
+ withBackground,
86
+ { id: "card" },
87
+ { type: "inline-style", property: "border-radius", value: "12px" },
88
+ );
89
+
90
+ expect(withRadius).toContain(
91
+ "background-image: url(&quot;../ChatGPT Image Apr 22, 2026.png&quot;)",
92
+ );
93
+ expect(withRadius).toContain("border-radius: 12px");
94
+ });
95
+
32
96
  it("updates media timing attributes by selector", () => {
33
97
  const html = `<video class="hero clip" data-start="0.2" data-duration="1.4" data-media-start="0.4"></video>`;
34
98
 
@@ -62,6 +126,69 @@ describe("applyPatchByTarget", () => {
62
126
  expect(readAttributeByTarget(html, { selector: ".hero" }, "duration")).toBe("1.4");
63
127
  });
64
128
 
129
+ it("reads the matched tag snippet by target", () => {
130
+ const html = `<section id="hero" class="card clip" style="left: 120px; top: 180px"></section>`;
131
+
132
+ expect(readTagSnippetByTarget(html, { id: "hero" })).toBe(
133
+ `<section id="hero" class="card clip" style="left: 120px; top: 180px"`,
134
+ );
135
+ });
136
+
137
+ it("patches and reads single-quoted attributes and styles", () => {
138
+ const html =
139
+ "<section id='hero' class='card clip' data-start='0.2' style='left: 120px; top: 180px'></section>";
140
+
141
+ const moved = applyPatchByTarget(
142
+ html,
143
+ { id: "hero" },
144
+ { type: "inline-style", property: "left", value: "160px" },
145
+ );
146
+ const updated = applyPatchByTarget(
147
+ moved,
148
+ { id: "hero" },
149
+ { type: "attribute", property: "start", value: "0.4" },
150
+ );
151
+
152
+ expect(updated).toContain(`style='left: 160px; top: 180px'`);
153
+ expect(updated).toContain(`data-start="0.4"`);
154
+ expect(readAttributeByTarget(updated, { id: "hero" }, "start")).toBe("0.4");
155
+ });
156
+
157
+ it("replaces the full text body of a nested element by id", () => {
158
+ const html =
159
+ '<div id="panel"><strong>Headline</strong><span>Supporting copy</span></div><p>Outside</p>';
160
+
161
+ const patched = applyPatch(html, "panel", {
162
+ type: "text-content",
163
+ property: "text",
164
+ value: "<strong>New headline</strong><span>New supporting copy</span>",
165
+ });
166
+
167
+ expect(patched).toContain(
168
+ '<div id="panel"><strong>New headline</strong><span>New supporting copy</span></div>',
169
+ );
170
+ expect(patched).toContain("<p>Outside</p>");
171
+ });
172
+
173
+ it("does not stop at the first child closing tag when patching nested text", () => {
174
+ const html =
175
+ '<section id="card"><div><strong>Headline</strong></div><div>Copy</div></section><p>Outside</p>';
176
+
177
+ const patched = applyPatchByTarget(
178
+ html,
179
+ { id: "card" },
180
+ {
181
+ type: "text-content",
182
+ property: "text",
183
+ value: "<strong>New headline</strong>",
184
+ },
185
+ );
186
+
187
+ expect(patched).toBe(
188
+ '<section id="card"><strong>New headline</strong></section><p>Outside</p>',
189
+ );
190
+ });
191
+
65
192
  it("patches the correct duplicate selector occurrence", () => {
66
193
  const html = [
67
194
  `<div class="headline clip" data-start="0"></div>`,
@@ -7,6 +7,67 @@ function escapeRegex(s: string): string {
7
7
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8
8
  }
9
9
 
10
+ function escapeStyleAttributeValue(value: string, quote: string): string {
11
+ return quote === '"' ? value.replace(/"/g, "&quot;") : value.replace(/'/g, "&#39;");
12
+ }
13
+
14
+ function splitInlineStyleDeclarations(style: string): string[] {
15
+ const declarations: string[] = [];
16
+ let current = "";
17
+ let quote: string | null = null;
18
+ let entity = false;
19
+ let parenDepth = 0;
20
+
21
+ for (const char of style) {
22
+ if (entity) {
23
+ current += char;
24
+ if (char === ";") entity = false;
25
+ continue;
26
+ }
27
+
28
+ if (char === "&") {
29
+ entity = true;
30
+ current += char;
31
+ continue;
32
+ }
33
+
34
+ if (quote) {
35
+ current += char;
36
+ if (char === quote) quote = null;
37
+ continue;
38
+ }
39
+
40
+ if (char === '"' || char === "'") {
41
+ quote = char;
42
+ current += char;
43
+ continue;
44
+ }
45
+
46
+ if (char === "(") {
47
+ parenDepth += 1;
48
+ current += char;
49
+ continue;
50
+ }
51
+
52
+ if (char === ")") {
53
+ parenDepth = Math.max(0, parenDepth - 1);
54
+ current += char;
55
+ continue;
56
+ }
57
+
58
+ if (char === ";" && parenDepth === 0) {
59
+ declarations.push(current);
60
+ current = "";
61
+ continue;
62
+ }
63
+
64
+ current += char;
65
+ }
66
+
67
+ if (current) declarations.push(current);
68
+ return declarations;
69
+ }
70
+
10
71
  export interface PatchOperation {
11
72
  type: "inline-style" | "attribute" | "text-content";
12
73
  property: string;
@@ -74,7 +135,7 @@ export function resolveSourceFile(
74
135
  */
75
136
  function patchInlineStyle(html: string, elementId: string, prop: string, value: string): string {
76
137
  // Find the element tag with this id
77
- const idPattern = new RegExp(`(<[^>]*\\bid="${escapeRegex(elementId)}"[^>]*)>`, "i");
138
+ const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
78
139
  const match = idPattern.exec(html);
79
140
  if (!match) return html;
80
141
 
@@ -86,12 +147,13 @@ function patchInlineStyleInTag(html: string, tag: string, prop: string, value: s
86
147
  if (!tag) return html;
87
148
 
88
149
  // Check if there's an existing style attribute
89
- const styleMatch = /\bstyle="([^"]*)"/.exec(tag);
150
+ const styleMatch = /\bstyle=(["'])([\s\S]*?)\1/.exec(tag);
90
151
  if (styleMatch) {
91
- const existingStyle = styleMatch[1];
152
+ const existingStyle = styleMatch[2];
153
+ const quote = styleMatch[1];
92
154
  // Parse existing properties
93
155
  const props = new Map<string, string>();
94
- for (const part of existingStyle.split(";")) {
156
+ for (const part of splitInlineStyleDeclarations(existingStyle)) {
95
157
  const colon = part.indexOf(":");
96
158
  if (colon < 0) continue;
97
159
  const key = part.slice(0, colon).trim();
@@ -102,13 +164,14 @@ function patchInlineStyleInTag(html: string, tag: string, prop: string, value: s
102
164
  props.set(prop, value);
103
165
  // Rebuild style string
104
166
  const newStyle = Array.from(props.entries())
105
- .map(([k, v]) => `${k}: ${v}`)
167
+ .map(([k, v]) => `${k}: ${escapeStyleAttributeValue(v, quote)}`)
106
168
  .join("; ");
107
- const newTag = tag.replace(/\bstyle="[^"]*"/, `style="${newStyle}"`);
169
+ const newTag = tag.replace(styleMatch[0], `style=${quote}${newStyle}${quote}`);
108
170
  return html.replace(tag, newTag);
109
171
  } else {
110
172
  // No existing style — add one
111
- const newTag = tag.replace(/>$/, "") + ` style="${prop}: ${value}"`;
173
+ const newTag =
174
+ tag.replace(/>$/, "") + ` style="${prop}: ${escapeStyleAttributeValue(value, '"')}"`;
112
175
  return html.replace(tag, newTag);
113
176
  }
114
177
  }
@@ -137,7 +200,7 @@ function replaceTagAtMatch(html: string, match: TagMatch, newTag: string): strin
137
200
 
138
201
  function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
139
202
  if (target.id) {
140
- const idPattern = new RegExp(`(<[^>]*\\bid="${escapeRegex(target.id)}"[^>]*)>`, "i");
203
+ const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(target.id)}\\2[^>]*)>`, "i");
141
204
  const match = idPattern.exec(html);
142
205
  if (match?.index != null) {
143
206
  return {
@@ -154,7 +217,7 @@ function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
154
217
  if (compositionIdMatch) {
155
218
  const compId = compositionIdMatch[1];
156
219
  const pattern = new RegExp(
157
- `(<[^>]*\\bdata-composition-id="${escapeRegex(compId)}"[^>]*)>`,
220
+ `(<[^>]*\\bdata-composition-id=(["'])${escapeRegex(compId)}\\2[^>]*)>`,
158
221
  "i",
159
222
  );
160
223
  const match = pattern.exec(html);
@@ -201,8 +264,13 @@ export function readAttributeByTarget(
201
264
  if (!match) return undefined;
202
265
 
203
266
  const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
204
- const valueMatch = new RegExp(`\\b${fullAttr}="([^"]*)"`).exec(match.tag);
205
- return valueMatch?.[1];
267
+ const valueMatch = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`).exec(match.tag);
268
+ return valueMatch?.[2];
269
+ }
270
+
271
+ export function readTagSnippetByTarget(html: string, target: PatchTarget): string | undefined {
272
+ const match = findTagByTarget(html, target);
273
+ return match?.tag;
206
274
  }
207
275
 
208
276
  function patchAttributeByTarget(
@@ -215,7 +283,7 @@ function patchAttributeByTarget(
215
283
  if (!match) return html;
216
284
 
217
285
  const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
218
- const attrPattern = new RegExp(`\\b${fullAttr}="[^"]*"`);
286
+ const attrPattern = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`);
219
287
  const tag = match.tag;
220
288
 
221
289
  if (attrPattern.test(tag)) {
@@ -231,13 +299,13 @@ function patchAttributeByTarget(
231
299
  * Apply an attribute change to an element in the HTML source.
232
300
  */
233
301
  function patchAttribute(html: string, elementId: string, attr: string, value: string): string {
234
- const idPattern = new RegExp(`(<[^>]*\\bid="${escapeRegex(elementId)}"[^>]*)>`, "i");
302
+ const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
235
303
  const match = idPattern.exec(html);
236
304
  if (!match) return html;
237
305
 
238
306
  const tag = match[1];
239
307
  const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
240
- const attrPattern = new RegExp(`\\b${fullAttr}="[^"]*"`);
308
+ const attrPattern = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`);
241
309
 
242
310
  if (attrPattern.test(tag)) {
243
311
  // Update existing attribute
@@ -254,11 +322,53 @@ function patchAttribute(html: string, elementId: string, attr: string, value: st
254
322
  * Apply a text content change to an element.
255
323
  */
256
324
  function patchTextContent(html: string, elementId: string, value: string): string {
257
- // Match the element and its content: <tagname id="elementId"...>content</tagname>
258
- const pattern = new RegExp(`(<[^>]*\\bid="${elementId}"[^>]*>)([\\s\\S]*?)(<\\/[a-z]+>)`, "i");
259
- const match = pattern.exec(html);
325
+ const openTagPattern = new RegExp(
326
+ `(<([a-z0-9-]+)[^>]*\\bid=(["'])${escapeRegex(elementId)}\\3[^>]*>)`,
327
+ "i",
328
+ );
329
+ const match = openTagPattern.exec(html);
330
+ if (!match || match.index == null) return html;
331
+
332
+ const openingTag = match[1];
333
+ const tagName = match[2];
334
+ const contentStart = match.index + openingTag.length;
335
+ const closingIndex = findMatchingClosingTagIndex(html, tagName, contentStart);
336
+ if (closingIndex < 0) return html;
337
+
338
+ return `${html.slice(0, contentStart)}${value}${html.slice(closingIndex)}`;
339
+ }
340
+
341
+ function findMatchingClosingTagIndex(html: string, tagName: string, contentStart: number): number {
342
+ const tagPattern = new RegExp(`</?${escapeRegex(tagName)}\\b[^>]*>`, "gi");
343
+ tagPattern.lastIndex = contentStart;
344
+ let depth = 1;
345
+ let match: RegExpExecArray | null;
346
+
347
+ while ((match = tagPattern.exec(html)) !== null) {
348
+ const tag = match[0];
349
+ if (tag.startsWith("</")) {
350
+ depth -= 1;
351
+ if (depth === 0) return match.index;
352
+ continue;
353
+ }
354
+ if (!tag.endsWith("/>")) depth += 1;
355
+ }
356
+
357
+ return -1;
358
+ }
359
+
360
+ function patchTextContentByTarget(html: string, target: PatchTarget, value: string): string {
361
+ const match = findTagByTarget(html, target);
260
362
  if (!match) return html;
261
- return html.replace(pattern, `${match[1]}${value}${match[3]}`);
363
+
364
+ const tagNameMatch = /^<([a-z0-9-]+)/i.exec(match.tag);
365
+ const tagName = tagNameMatch?.[1];
366
+ if (!tagName) return html;
367
+
368
+ const closingIndex = findMatchingClosingTagIndex(html, tagName, match.end + 1);
369
+ if (closingIndex < 0) return html;
370
+
371
+ return `${html.slice(0, match.end + 1)}${value}${html.slice(closingIndex)}`;
262
372
  }
263
373
 
264
374
  /**
@@ -290,6 +400,8 @@ export function applyPatchByTarget(html: string, target: PatchTarget, op: PatchO
290
400
  return patchInlineStyleByTarget(html, target, op.property, op.value);
291
401
  case "attribute":
292
402
  return patchAttributeByTarget(html, target, op.property, op.value);
403
+ case "text-content":
404
+ return patchTextContentByTarget(html, target, op.value);
293
405
  default:
294
406
  return html;
295
407
  }
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { saveProjectFilesWithHistory } from "./studioFileHistory";
3
+
4
+ describe("saveProjectFilesWithHistory", () => {
5
+ it("reads before content, writes after content, and records a history entry", async () => {
6
+ const reads: Record<string, string> = { "index.html": "before" };
7
+ const writes: Record<string, string> = {};
8
+ const recordEdit = vi.fn();
9
+
10
+ await saveProjectFilesWithHistory({
11
+ projectId: "project-1",
12
+ label: "Move layer",
13
+ kind: "manual",
14
+ files: { "index.html": "after" },
15
+ readFile: async (path) => reads[path],
16
+ writeFile: async (path, content) => {
17
+ writes[path] = content;
18
+ },
19
+ recordEdit,
20
+ });
21
+
22
+ expect(writes).toEqual({ "index.html": "after" });
23
+ expect(recordEdit).toHaveBeenCalledWith({
24
+ label: "Move layer",
25
+ kind: "manual",
26
+ coalesceKey: undefined,
27
+ files: { "index.html": { before: "before", after: "after" } },
28
+ });
29
+ });
30
+
31
+ it("skips writes and history for unchanged content", async () => {
32
+ const writeFile = vi.fn();
33
+ const recordEdit = vi.fn();
34
+
35
+ const changedPaths = await saveProjectFilesWithHistory({
36
+ projectId: "project-1",
37
+ label: "Edit layer",
38
+ kind: "manual",
39
+ files: { "index.html": "same" },
40
+ readFile: async () => "same",
41
+ writeFile,
42
+ recordEdit,
43
+ });
44
+
45
+ expect(changedPaths).toEqual([]);
46
+ expect(writeFile).not.toHaveBeenCalled();
47
+ expect(recordEdit).not.toHaveBeenCalled();
48
+ });
49
+
50
+ it("rolls back files already written when a later file write fails", async () => {
51
+ const reads: Record<string, string> = {
52
+ "index.html": "index-before",
53
+ "scene.html": "scene-before",
54
+ };
55
+ const writes: Array<[string, string]> = [];
56
+ const recordEdit = vi.fn();
57
+
58
+ await expect(
59
+ saveProjectFilesWithHistory({
60
+ projectId: "project-1",
61
+ label: "Move layer",
62
+ kind: "manual",
63
+ files: {
64
+ "index.html": "index-after",
65
+ "scene.html": "scene-after",
66
+ },
67
+ readFile: async (path) => reads[path],
68
+ writeFile: async (path, content) => {
69
+ writes.push([path, content]);
70
+ if (path === "scene.html") {
71
+ throw new Error("disk full");
72
+ }
73
+ },
74
+ recordEdit,
75
+ }),
76
+ ).rejects.toThrow("disk full");
77
+
78
+ expect(writes).toEqual([
79
+ ["index.html", "index-after"],
80
+ ["scene.html", "scene-after"],
81
+ ["index.html", "index-before"],
82
+ ]);
83
+ expect(recordEdit).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it("rolls back written files when the injected history recorder throws", async () => {
87
+ const reads: Record<string, string> = {
88
+ "index.html": "index-before",
89
+ "scene.html": "scene-before",
90
+ };
91
+ const writes: Array<[string, string]> = [];
92
+
93
+ await expect(
94
+ saveProjectFilesWithHistory({
95
+ projectId: "project-1",
96
+ label: "Move layer",
97
+ kind: "manual",
98
+ files: {
99
+ "index.html": "index-after",
100
+ "scene.html": "scene-after",
101
+ },
102
+ readFile: async (path) => reads[path],
103
+ writeFile: async (path, content) => {
104
+ writes.push([path, content]);
105
+ },
106
+ recordEdit: async () => {
107
+ throw new Error("history unavailable");
108
+ },
109
+ }),
110
+ ).rejects.toThrow("history unavailable");
111
+
112
+ expect(writes).toEqual([
113
+ ["index.html", "index-after"],
114
+ ["scene.html", "scene-after"],
115
+ ["scene.html", "scene-before"],
116
+ ["index.html", "index-before"],
117
+ ]);
118
+ });
119
+
120
+ it("reports rollback failure with the original write failure", async () => {
121
+ const reads: Record<string, string> = {
122
+ "index.html": "index-before",
123
+ "scene.html": "scene-before",
124
+ };
125
+ const writes: Array<[string, string]> = [];
126
+
127
+ await expect(
128
+ saveProjectFilesWithHistory({
129
+ projectId: "project-1",
130
+ label: "Move layer",
131
+ kind: "manual",
132
+ files: {
133
+ "index.html": "index-after",
134
+ "scene.html": "scene-after",
135
+ },
136
+ readFile: async (path) => reads[path],
137
+ writeFile: async (path, content) => {
138
+ writes.push([path, content]);
139
+ if (path === "scene.html" && content === "scene-after") {
140
+ throw new Error("write denied");
141
+ }
142
+ if (path === "index.html" && content === "index-before") {
143
+ throw new Error("rollback denied");
144
+ }
145
+ },
146
+ recordEdit: vi.fn(),
147
+ }),
148
+ ).rejects.toThrow("rollback did not complete");
149
+
150
+ expect(writes).toEqual([
151
+ ["index.html", "index-after"],
152
+ ["scene.html", "scene-after"],
153
+ ["index.html", "index-before"],
154
+ ]);
155
+ });
156
+ });
@@ -0,0 +1,61 @@
1
+ import type { EditHistoryKind } from "./editHistory";
2
+
3
+ interface SaveProjectFilesWithHistoryInput {
4
+ projectId: string;
5
+ label: string;
6
+ kind: EditHistoryKind;
7
+ coalesceKey?: string;
8
+ files: Record<string, string>;
9
+ readFile: (path: string) => Promise<string>;
10
+ writeFile: (path: string, content: string) => Promise<void>;
11
+ recordEdit: (entry: {
12
+ label: string;
13
+ kind: EditHistoryKind;
14
+ coalesceKey?: string;
15
+ files: Record<string, { before: string; after: string }>;
16
+ }) => Promise<void>;
17
+ }
18
+
19
+ export async function saveProjectFilesWithHistory({
20
+ label,
21
+ kind,
22
+ coalesceKey,
23
+ files,
24
+ readFile,
25
+ writeFile,
26
+ recordEdit,
27
+ }: SaveProjectFilesWithHistoryInput): Promise<string[]> {
28
+ const snapshots: Record<string, { before: string; after: string }> = {};
29
+ for (const [path, after] of Object.entries(files)) {
30
+ const before = await readFile(path);
31
+ if (before !== after) {
32
+ snapshots[path] = { before, after };
33
+ }
34
+ }
35
+
36
+ const changedPaths = Object.keys(snapshots);
37
+ if (changedPaths.length === 0) return [];
38
+
39
+ const writtenPaths: string[] = [];
40
+ try {
41
+ for (const path of changedPaths) {
42
+ await writeFile(path, snapshots[path].after);
43
+ writtenPaths.push(path);
44
+ }
45
+
46
+ await recordEdit({ label, kind, coalesceKey, files: snapshots });
47
+ } catch (error) {
48
+ try {
49
+ for (const path of writtenPaths.reverse()) {
50
+ await writeFile(path, snapshots[path].before);
51
+ }
52
+ } catch (rollbackError) {
53
+ throw new AggregateError(
54
+ [error, rollbackError],
55
+ "Failed to save project files and rollback did not complete",
56
+ );
57
+ }
58
+ throw error;
59
+ }
60
+ return changedPaths;
61
+ }
@@ -4,6 +4,7 @@ import {
4
4
  buildTimelineAssetInsertHtml,
5
5
  getTimelineAssetKind,
6
6
  insertTimelineAssetIntoSource,
7
+ resolveTimelineAssetInitialGeometry,
7
8
  resolveTimelineAssetSrc,
8
9
  } from "./timelineAssetDrop";
9
10
 
@@ -19,17 +20,21 @@ describe("getTimelineAssetKind", () => {
19
20
 
20
21
  describe("buildTimelineAssetInsertHtml", () => {
21
22
  it("builds an image clip with explicit timing and track", () => {
22
- expect(
23
- buildTimelineAssetInsertHtml({
24
- id: "photo_asset",
25
- assetPath: "assets/photo.png",
26
- kind: "image",
27
- start: 1.25,
28
- duration: 3,
29
- track: 2,
30
- zIndex: 4,
31
- }),
32
- ).toContain('img id="photo_asset"');
23
+ const html = buildTimelineAssetInsertHtml({
24
+ id: "photo_asset",
25
+ assetPath: "assets/photo.png",
26
+ kind: "image",
27
+ start: 1.25,
28
+ duration: 3,
29
+ track: 2,
30
+ zIndex: 4,
31
+ geometry: { left: 0, top: 0, width: 1280, height: 720 },
32
+ });
33
+
34
+ expect(html).toContain('img id="photo_asset"');
35
+ expect(html).toContain("left: 0px");
36
+ expect(html).toContain("width: 1280px");
37
+ expect(html).not.toContain("inset:");
33
38
  });
34
39
 
35
40
  it("builds an audio clip without visual layout styles", () => {
@@ -47,6 +52,21 @@ describe("buildTimelineAssetInsertHtml", () => {
47
52
  });
48
53
  });
49
54
 
55
+ describe("resolveTimelineAssetInitialGeometry", () => {
56
+ it("uses the target composition dimensions for visual media", () => {
57
+ expect(
58
+ resolveTimelineAssetInitialGeometry(
59
+ `<div data-composition-id="main" data-width="330" data-height="228"></div>`,
60
+ ),
61
+ ).toEqual({
62
+ left: 0,
63
+ top: 0,
64
+ width: 330,
65
+ height: 228,
66
+ });
67
+ });
68
+ });
69
+
50
70
  describe("resolveTimelineAssetSrc", () => {
51
71
  it("keeps project-root asset paths for index.html", () => {
52
72
  expect(resolveTimelineAssetSrc("index.html", "assets/photo.png")).toBe("assets/photo.png");