@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.
- package/dist/assets/hyperframes-player-CoI5h1xv.js +353 -0
- package/dist/assets/index-BKjcNNNd.css +1 -0
- package/dist/assets/index-CqiisJmo.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +208 -1438
- package/src/captions/generator.test.ts +19 -0
- package/src/captions/generator.ts +9 -2
- package/src/captions/hooks/useCaptionSync.ts +6 -1
- package/src/captions/parser.test.ts +14 -0
- package/src/captions/parser.ts +1 -0
- package/src/components/LintModal.tsx +4 -3
- package/src/components/editor/PropertyPanel.tsx +206 -2466
- package/src/components/nle/NLELayout.tsx +47 -17
- package/src/components/nle/NLEPreview.tsx +5 -50
- package/src/components/sidebar/AssetsTab.tsx +4 -3
- package/src/components/sidebar/CompositionsTab.test.ts +1 -16
- package/src/components/sidebar/CompositionsTab.tsx +45 -117
- package/src/components/sidebar/LeftSidebar.tsx +55 -34
- package/src/components/ui/HyperframesLoader.tsx +104 -0
- package/src/components/ui/index.ts +2 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.tsx +10 -42
- package/src/player/components/EditModal.tsx +20 -5
- package/src/player/components/Player.tsx +129 -28
- package/src/player/components/PlayerControls.tsx +3 -44
- package/src/player/components/Timeline.test.ts +0 -12
- package/src/player/components/Timeline.tsx +25 -52
- package/src/player/components/TimelineClip.tsx +9 -21
- package/src/player/components/timelineEditing.test.ts +4 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineTheme.test.ts +19 -0
- package/src/player/components/timelineTheme.ts +8 -4
- package/src/player/hooks/useTimelinePlayer.test.ts +160 -21
- package/src/player/hooks/useTimelinePlayer.ts +206 -93
- package/src/player/lib/time.test.ts +11 -1
- package/src/player/lib/time.ts +6 -0
- package/src/player/store/playerStore.ts +1 -0
- package/src/styles/studio.css +112 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +40 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/projectRouting.test.ts +87 -0
- package/src/utils/projectRouting.ts +27 -0
- package/src/utils/sourcePatcher.test.ts +1 -128
- package/src/utils/sourcePatcher.ts +18 -130
- package/src/utils/timelineAssetDrop.test.ts +11 -31
- package/src/utils/timelineAssetDrop.ts +2 -22
- package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
- package/dist/assets/index-DKaNgV2Z.css +0 -1
- package/dist/assets/index-peNJzL-4.js +0 -105
- package/src/components/editor/DomEditOverlay.tsx +0 -445
- package/src/components/editor/colorValue.test.ts +0 -82
- package/src/components/editor/colorValue.ts +0 -175
- package/src/components/editor/domEditing.test.ts +0 -537
- package/src/components/editor/domEditing.ts +0 -762
- package/src/components/editor/floatingPanel.test.ts +0 -34
- package/src/components/editor/floatingPanel.ts +0 -54
- package/src/components/editor/fontAssets.ts +0 -32
- package/src/components/editor/fontCatalog.ts +0 -126
- package/src/components/editor/gradientValue.test.ts +0 -89
- package/src/components/editor/gradientValue.ts +0 -445
- package/src/player/components/CompositionThumbnail.test.ts +0 -19
- package/src/utils/clipboard.test.ts +0 -88
- 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("../ChatGPT Image Apr 22, 2026.png")",
|
|
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, """) : value.replace(/'/g, "'");
|
|
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=
|
|
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=(["
|
|
89
|
+
const styleMatch = /\bstyle="([^"]*)"/.exec(tag);
|
|
151
90
|
if (styleMatch) {
|
|
152
|
-
const existingStyle = styleMatch[
|
|
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
|
|
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}: ${
|
|
105
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
168
106
|
.join("; ");
|
|
169
|
-
const newTag = tag.replace(
|
|
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=
|
|
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=
|
|
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}=
|
|
268
|
-
return valueMatch?.[
|
|
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}=
|
|
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=
|
|
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}=
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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="${
|
|
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="${
|
|
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>`;
|