@hyperframes/studio 0.5.0-alpha.9 → 0.5.0

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 (65) hide show
  1. package/dist/assets/hyperframes-player-CoI5h1xv.js +353 -0
  2. package/dist/assets/index-BKjcNNNd.css +1 -0
  3. package/dist/assets/index-CqiisJmo.js +93 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +208 -1438
  7. package/src/captions/generator.test.ts +19 -0
  8. package/src/captions/generator.ts +9 -2
  9. package/src/captions/hooks/useCaptionSync.ts +6 -1
  10. package/src/captions/parser.test.ts +14 -0
  11. package/src/captions/parser.ts +1 -0
  12. package/src/components/LintModal.tsx +4 -3
  13. package/src/components/editor/PropertyPanel.tsx +206 -2466
  14. package/src/components/nle/NLELayout.tsx +47 -17
  15. package/src/components/nle/NLEPreview.tsx +5 -50
  16. package/src/components/sidebar/AssetsTab.tsx +4 -3
  17. package/src/components/sidebar/CompositionsTab.test.ts +1 -16
  18. package/src/components/sidebar/CompositionsTab.tsx +45 -117
  19. package/src/components/sidebar/LeftSidebar.tsx +55 -34
  20. package/src/components/ui/HyperframesLoader.tsx +104 -0
  21. package/src/components/ui/index.ts +2 -0
  22. package/src/icons/SystemIcons.tsx +2 -0
  23. package/src/player/components/CompositionThumbnail.tsx +10 -42
  24. package/src/player/components/EditModal.tsx +20 -5
  25. package/src/player/components/Player.tsx +129 -28
  26. package/src/player/components/PlayerControls.tsx +3 -44
  27. package/src/player/components/Timeline.test.ts +0 -12
  28. package/src/player/components/Timeline.tsx +25 -52
  29. package/src/player/components/TimelineClip.tsx +9 -21
  30. package/src/player/components/timelineEditing.test.ts +4 -2
  31. package/src/player/components/timelineEditing.ts +3 -1
  32. package/src/player/components/timelineTheme.test.ts +19 -0
  33. package/src/player/components/timelineTheme.ts +8 -4
  34. package/src/player/hooks/useTimelinePlayer.test.ts +160 -21
  35. package/src/player/hooks/useTimelinePlayer.ts +206 -93
  36. package/src/player/lib/time.test.ts +11 -1
  37. package/src/player/lib/time.ts +6 -0
  38. package/src/player/store/playerStore.ts +1 -0
  39. package/src/styles/studio.css +112 -0
  40. package/src/utils/frameCapture.test.ts +26 -0
  41. package/src/utils/frameCapture.ts +40 -0
  42. package/src/utils/mediaTypes.ts +1 -1
  43. package/src/utils/projectRouting.test.ts +87 -0
  44. package/src/utils/projectRouting.ts +27 -0
  45. package/src/utils/sourcePatcher.test.ts +1 -128
  46. package/src/utils/sourcePatcher.ts +18 -130
  47. package/src/utils/timelineAssetDrop.test.ts +11 -31
  48. package/src/utils/timelineAssetDrop.ts +2 -22
  49. package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
  50. package/dist/assets/index-DKaNgV2Z.css +0 -1
  51. package/dist/assets/index-peNJzL-4.js +0 -105
  52. package/src/components/editor/DomEditOverlay.tsx +0 -445
  53. package/src/components/editor/colorValue.test.ts +0 -82
  54. package/src/components/editor/colorValue.ts +0 -175
  55. package/src/components/editor/domEditing.test.ts +0 -537
  56. package/src/components/editor/domEditing.ts +0 -762
  57. package/src/components/editor/floatingPanel.test.ts +0 -34
  58. package/src/components/editor/floatingPanel.ts +0 -54
  59. package/src/components/editor/fontAssets.ts +0 -32
  60. package/src/components/editor/fontCatalog.ts +0 -126
  61. package/src/components/editor/gradientValue.test.ts +0 -89
  62. package/src/components/editor/gradientValue.ts +0 -445
  63. package/src/player/components/CompositionThumbnail.test.ts +0 -19
  64. package/src/utils/clipboard.test.ts +0 -88
  65. package/src/utils/clipboard.ts +0 -57
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { buildFrameCaptureUrl } from "./frameCapture";
3
+ import {
4
+ buildProjectApiPath,
5
+ buildProjectHash,
6
+ encodeProjectId,
7
+ parseProjectIdFromHash,
8
+ } from "./projectRouting";
9
+
10
+ describe("project routing utilities", () => {
11
+ it("decodes project ids from hash routes before building capture URLs", () => {
12
+ vi.useFakeTimers();
13
+ vi.setSystemTime(new Date("2026-05-01T12:00:00Z"));
14
+
15
+ const projectId = parseProjectIdFromHash("#project/Notion%20Showcase");
16
+
17
+ expect(projectId).toBe("Notion Showcase");
18
+ expect(
19
+ buildFrameCaptureUrl({
20
+ projectId: projectId ?? "",
21
+ compositionPath: null,
22
+ currentTime: 1.809,
23
+ origin: "http://localhost:3002",
24
+ }),
25
+ ).toBe(
26
+ "http://localhost:3002/api/projects/Notion%20Showcase/thumbnail/index.html?t=1.809&format=png&v=1777636800000",
27
+ );
28
+
29
+ vi.useRealTimers();
30
+ });
31
+
32
+ it("accepts legacy raw-space hash routes", () => {
33
+ expect(parseProjectIdFromHash("#project/Notion Showcase")).toBe("Notion Showcase");
34
+ });
35
+
36
+ it("decodes reserved characters when the hash route is encoded", () => {
37
+ expect(parseProjectIdFromHash("#project/Launch%20%231%3F%20v2")).toBe("Launch #1? v2");
38
+ });
39
+
40
+ it("does not throw on malformed percent escapes in hash routes", () => {
41
+ expect(parseProjectIdFromHash("#project/Broken%ZZName")).toBe("Broken%ZZName");
42
+ });
43
+
44
+ it("ignores non-project hash routes", () => {
45
+ expect(parseProjectIdFromHash("")).toBeNull();
46
+ expect(parseProjectIdFromHash("#settings")).toBeNull();
47
+ expect(parseProjectIdFromHash("#project/")).toBeNull();
48
+ expect(parseProjectIdFromHash("#project/foo/bar")).toBeNull();
49
+ });
50
+
51
+ it("encodes project ids when writing hash routes", () => {
52
+ expect(buildProjectHash("Notion Showcase")).toBe("#project/Notion%20Showcase");
53
+ expect(buildProjectHash("Notion%20Showcase")).toBe("#project/Notion%2520Showcase");
54
+ expect(buildProjectHash("Launch #1? v2")).toBe("#project/Launch%20%231%3F%20v2");
55
+ });
56
+
57
+ it("round-trips unicode project ids through hash routes", () => {
58
+ const hash = buildProjectHash("Mañana demo");
59
+
60
+ expect(hash).toBe("#project/Ma%C3%B1ana%20demo");
61
+ expect(parseProjectIdFromHash(hash)).toBe("Mañana demo");
62
+ });
63
+
64
+ it("encodes project ids as one API path segment", () => {
65
+ expect(encodeProjectId("Notion Showcase")).toBe("Notion%20Showcase");
66
+ expect(encodeProjectId("Notion%20Showcase")).toBe("Notion%2520Showcase");
67
+ expect(encodeProjectId("Launch #1? v2")).toBe("Launch%20%231%3F%20v2");
68
+ });
69
+
70
+ it("builds API paths without double encoding decoded project ids", () => {
71
+ expect(buildProjectApiPath("Notion Showcase", "/thumbnail/index.html")).toBe(
72
+ "/api/projects/Notion%20Showcase/thumbnail/index.html",
73
+ );
74
+ });
75
+
76
+ it("keeps literal percent signs safe in API paths", () => {
77
+ expect(buildProjectApiPath("Percent%20Name", "/preview")).toBe(
78
+ "/api/projects/Percent%2520Name/preview",
79
+ );
80
+ });
81
+
82
+ it("keeps unicode project ids safe in API paths", () => {
83
+ expect(buildProjectApiPath("Mañana demo", "/preview")).toBe(
84
+ "/api/projects/Ma%C3%B1ana%20demo/preview",
85
+ );
86
+ });
87
+ });
@@ -0,0 +1,27 @@
1
+ const PROJECT_HASH_PREFIX = "#project/";
2
+
3
+ export function encodeProjectId(projectId: string): string {
4
+ return encodeURIComponent(projectId);
5
+ }
6
+
7
+ export function buildProjectHash(projectId: string): string {
8
+ return `${PROJECT_HASH_PREFIX}${encodeProjectId(projectId)}`;
9
+ }
10
+
11
+ export function parseProjectIdFromHash(hash: string): string | null {
12
+ if (!hash.startsWith(PROJECT_HASH_PREFIX)) return null;
13
+
14
+ const encodedProjectId = hash.slice(PROJECT_HASH_PREFIX.length);
15
+ if (!encodedProjectId || encodedProjectId.includes("/")) return null;
16
+
17
+ try {
18
+ return decodeURIComponent(encodedProjectId);
19
+ } catch {
20
+ return encodedProjectId;
21
+ }
22
+ }
23
+
24
+ export function buildProjectApiPath(projectId: string, suffix = ""): string {
25
+ const normalizedSuffix = suffix && !suffix.startsWith("/") ? `/${suffix}` : suffix;
26
+ return `/api/projects/${encodeProjectId(projectId)}${normalizedSuffix}`;
27
+ }
@@ -1,11 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import {
3
- applyPatch,
4
- applyPatchByTarget,
5
- readAttributeByTarget,
6
- readTagSnippetByTarget,
7
- type PatchOperation,
8
- } from "./sourcePatcher";
2
+ import { applyPatchByTarget, readAttributeByTarget, type PatchOperation } from "./sourcePatcher";
9
3
 
10
4
  describe("applyPatchByTarget", () => {
11
5
  it("updates a composition host by data-composition-id selector", () => {
@@ -35,64 +29,6 @@ describe("applyPatchByTarget", () => {
35
29
  );
36
30
  });
37
31
 
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
-
96
32
  it("updates media timing attributes by selector", () => {
97
33
  const html = `<video class="hero clip" data-start="0.2" data-duration="1.4" data-media-start="0.4"></video>`;
98
34
 
@@ -126,69 +62,6 @@ describe("applyPatchByTarget", () => {
126
62
  expect(readAttributeByTarget(html, { selector: ".hero" }, "duration")).toBe("1.4");
127
63
  });
128
64
 
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
-
192
65
  it("patches the correct duplicate selector occurrence", () => {
193
66
  const html = [
194
67
  `<div class="headline clip" data-start="0"></div>`,
@@ -7,67 +7,6 @@ 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
-
71
10
  export interface PatchOperation {
72
11
  type: "inline-style" | "attribute" | "text-content";
73
12
  property: string;
@@ -135,7 +74,7 @@ export function resolveSourceFile(
135
74
  */
136
75
  function patchInlineStyle(html: string, elementId: string, prop: string, value: string): string {
137
76
  // Find the element tag with this id
138
- const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
77
+ const idPattern = new RegExp(`(<[^>]*\\bid="${escapeRegex(elementId)}"[^>]*)>`, "i");
139
78
  const match = idPattern.exec(html);
140
79
  if (!match) return html;
141
80
 
@@ -147,13 +86,12 @@ function patchInlineStyleInTag(html: string, tag: string, prop: string, value: s
147
86
  if (!tag) return html;
148
87
 
149
88
  // Check if there's an existing style attribute
150
- const styleMatch = /\bstyle=(["'])([\s\S]*?)\1/.exec(tag);
89
+ const styleMatch = /\bstyle="([^"]*)"/.exec(tag);
151
90
  if (styleMatch) {
152
- const existingStyle = styleMatch[2];
153
- const quote = styleMatch[1];
91
+ const existingStyle = styleMatch[1];
154
92
  // Parse existing properties
155
93
  const props = new Map<string, string>();
156
- for (const part of splitInlineStyleDeclarations(existingStyle)) {
94
+ for (const part of existingStyle.split(";")) {
157
95
  const colon = part.indexOf(":");
158
96
  if (colon < 0) continue;
159
97
  const key = part.slice(0, colon).trim();
@@ -164,14 +102,13 @@ function patchInlineStyleInTag(html: string, tag: string, prop: string, value: s
164
102
  props.set(prop, value);
165
103
  // Rebuild style string
166
104
  const newStyle = Array.from(props.entries())
167
- .map(([k, v]) => `${k}: ${escapeStyleAttributeValue(v, quote)}`)
105
+ .map(([k, v]) => `${k}: ${v}`)
168
106
  .join("; ");
169
- const newTag = tag.replace(styleMatch[0], `style=${quote}${newStyle}${quote}`);
107
+ const newTag = tag.replace(/\bstyle="[^"]*"/, `style="${newStyle}"`);
170
108
  return html.replace(tag, newTag);
171
109
  } else {
172
110
  // No existing style — add one
173
- const newTag =
174
- tag.replace(/>$/, "") + ` style="${prop}: ${escapeStyleAttributeValue(value, '"')}"`;
111
+ const newTag = tag.replace(/>$/, "") + ` style="${prop}: ${value}"`;
175
112
  return html.replace(tag, newTag);
176
113
  }
177
114
  }
@@ -200,7 +137,7 @@ function replaceTagAtMatch(html: string, match: TagMatch, newTag: string): strin
200
137
 
201
138
  function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
202
139
  if (target.id) {
203
- const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(target.id)}\\2[^>]*)>`, "i");
140
+ const idPattern = new RegExp(`(<[^>]*\\bid="${escapeRegex(target.id)}"[^>]*)>`, "i");
204
141
  const match = idPattern.exec(html);
205
142
  if (match?.index != null) {
206
143
  return {
@@ -217,7 +154,7 @@ function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
217
154
  if (compositionIdMatch) {
218
155
  const compId = compositionIdMatch[1];
219
156
  const pattern = new RegExp(
220
- `(<[^>]*\\bdata-composition-id=(["'])${escapeRegex(compId)}\\2[^>]*)>`,
157
+ `(<[^>]*\\bdata-composition-id="${escapeRegex(compId)}"[^>]*)>`,
221
158
  "i",
222
159
  );
223
160
  const match = pattern.exec(html);
@@ -264,13 +201,8 @@ export function readAttributeByTarget(
264
201
  if (!match) return undefined;
265
202
 
266
203
  const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
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;
204
+ const valueMatch = new RegExp(`\\b${fullAttr}="([^"]*)"`).exec(match.tag);
205
+ return valueMatch?.[1];
274
206
  }
275
207
 
276
208
  function patchAttributeByTarget(
@@ -283,7 +215,7 @@ function patchAttributeByTarget(
283
215
  if (!match) return html;
284
216
 
285
217
  const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
286
- const attrPattern = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`);
218
+ const attrPattern = new RegExp(`\\b${fullAttr}="[^"]*"`);
287
219
  const tag = match.tag;
288
220
 
289
221
  if (attrPattern.test(tag)) {
@@ -299,13 +231,13 @@ function patchAttributeByTarget(
299
231
  * Apply an attribute change to an element in the HTML source.
300
232
  */
301
233
  function patchAttribute(html: string, elementId: string, attr: string, value: string): string {
302
- const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
234
+ const idPattern = new RegExp(`(<[^>]*\\bid="${escapeRegex(elementId)}"[^>]*)>`, "i");
303
235
  const match = idPattern.exec(html);
304
236
  if (!match) return html;
305
237
 
306
238
  const tag = match[1];
307
239
  const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
308
- const attrPattern = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`);
240
+ const attrPattern = new RegExp(`\\b${fullAttr}="[^"]*"`);
309
241
 
310
242
  if (attrPattern.test(tag)) {
311
243
  // Update existing attribute
@@ -322,53 +254,11 @@ function patchAttribute(html: string, elementId: string, attr: string, value: st
322
254
  * Apply a text content change to an element.
323
255
  */
324
256
  function patchTextContent(html: string, elementId: string, value: string): string {
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);
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);
362
260
  if (!match) return html;
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)}`;
261
+ return html.replace(pattern, `${match[1]}${value}${match[3]}`);
372
262
  }
373
263
 
374
264
  /**
@@ -400,8 +290,6 @@ export function applyPatchByTarget(html: string, target: PatchTarget, op: PatchO
400
290
  return patchInlineStyleByTarget(html, target, op.property, op.value);
401
291
  case "attribute":
402
292
  return patchAttributeByTarget(html, target, op.property, op.value);
403
- case "text-content":
404
- return patchTextContentByTarget(html, target, op.value);
405
293
  default:
406
294
  return html;
407
295
  }
@@ -4,7 +4,6 @@ import {
4
4
  buildTimelineAssetInsertHtml,
5
5
  getTimelineAssetKind,
6
6
  insertTimelineAssetIntoSource,
7
- resolveTimelineAssetInitialGeometry,
8
7
  resolveTimelineAssetSrc,
9
8
  } from "./timelineAssetDrop";
10
9
 
@@ -20,21 +19,17 @@ describe("getTimelineAssetKind", () => {
20
19
 
21
20
  describe("buildTimelineAssetInsertHtml", () => {
22
21
  it("builds an image clip with explicit timing and track", () => {
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:");
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"');
38
33
  });
39
34
 
40
35
  it("builds an audio clip without visual layout styles", () => {
@@ -52,21 +47,6 @@ describe("buildTimelineAssetInsertHtml", () => {
52
47
  });
53
48
  });
54
49
 
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
-
70
50
  describe("resolveTimelineAssetSrc", () => {
71
51
  it("keeps project-root asset paths for index.html", () => {
72
52
  expect(resolveTimelineAssetSrc("index.html", "assets/photo.png")).toBe("assets/photo.png");
@@ -76,23 +76,6 @@ export function buildTimelineFileDropPlacements(
76
76
  });
77
77
  }
78
78
 
79
- export function resolveTimelineAssetInitialGeometry(source: string): {
80
- left: number;
81
- top: number;
82
- width: number;
83
- height: number;
84
- } {
85
- const width = Number.parseFloat(source.match(/\bdata-width=(["'])([^"']+)\1/i)?.[2] ?? "");
86
- const height = Number.parseFloat(source.match(/\bdata-height=(["'])([^"']+)\1/i)?.[2] ?? "");
87
-
88
- return {
89
- left: 0,
90
- top: 0,
91
- width: Number.isFinite(width) && width > 0 ? Math.round(width) : 640,
92
- height: Number.isFinite(height) && height > 0 ? Math.round(height) : 360,
93
- };
94
- }
95
-
96
79
  export function buildTimelineAssetInsertHtml(input: {
97
80
  id: string;
98
81
  assetPath: string;
@@ -101,18 +84,15 @@ export function buildTimelineAssetInsertHtml(input: {
101
84
  duration: number;
102
85
  track: number;
103
86
  zIndex: number;
104
- geometry?: { left: number; top: number; width: number; height: number };
105
87
  }): string {
106
88
  const sharedAttrs = `id="${input.id}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
107
- const geometry = input.geometry ?? { left: 0, top: 0, width: 640, height: 360 };
108
- const visualStyles = `position: absolute; left: ${geometry.left}px; top: ${geometry.top}px; width: ${geometry.width}px; height: ${geometry.height}px; object-fit: contain; z-index: ${input.zIndex}`;
109
89
 
110
90
  if (input.kind === "image") {
111
- return `<img ${sharedAttrs} style="${visualStyles}" />`;
91
+ return `<img ${sharedAttrs} style="position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; z-index: ${input.zIndex}" />`;
112
92
  }
113
93
 
114
94
  if (input.kind === "video") {
115
- return `<video ${sharedAttrs} muted playsinline style="${visualStyles}"></video>`;
95
+ return `<video ${sharedAttrs} muted playsinline style="position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; z-index: ${input.zIndex}"></video>`;
116
96
  }
117
97
 
118
98
  return `<audio ${sharedAttrs} style="z-index: ${input.zIndex}"></audio>`;