@hyperframes/studio 0.8.142 → 0.8.144
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-Bw_jIaSX.js +473 -0
- package/dist/assets/{index-B-wffzRH.js → index-BVM9NuWl.js} +153 -153
- package/dist/assets/{index-D3Vij9t1.js → index-Cj7W_c7p.js} +1 -1
- package/dist/assets/{index-D5q2_7Gw.js → index-DyH9dT7b.js} +1 -1
- package/dist/{chunk-TFLU2MGI.js → chunk-3GHTHPZD.js} +8 -1
- package/dist/chunk-3GHTHPZD.js.map +1 -0
- package/dist/{chunk-H2DUOXTF.js → chunk-G3EDKRZH.js} +3 -3
- package/dist/{chunk-UYWON5AX.js → chunk-PEAKCOUW.js} +7 -3
- package/dist/chunk-PEAKCOUW.js.map +1 -0
- package/dist/{chunk-LOVBLSMR.js → chunk-Y2OHHNM2.js} +35 -19
- package/dist/chunk-Y2OHHNM2.js.map +1 -0
- package/dist/{domEditingLayers-KBTPQKGA.js → domEditingLayers-PYST4G45.js} +3 -3
- package/dist/index.html +1 -1
- package/dist/index.js +261 -230
- package/dist/index.js.map +1 -1
- package/dist/player.js +3 -3
- package/dist/ui.d.ts +4 -5
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/components/MediaPreview.tsx +2 -1
- package/src/components/editor/DomEditSelectionChrome.test.tsx +103 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +42 -16
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +36 -0
- package/src/components/editor/domEditOverlayGestures.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -0
- package/src/components/editor/domEditResizeLocal.test.ts +81 -1
- package/src/components/editor/domEditResizeLocal.ts +27 -3
- package/src/components/editor/domEditingLayers.ts +4 -1
- package/src/components/editor/domEditingTextFields.test.ts +18 -0
- package/src/components/editor/manualEditsDom.ts +12 -12
- package/src/components/editor/mediaAudioEdits.test.ts +12 -0
- package/src/components/editor/mediaAudioEdits.ts +5 -5
- package/src/components/editor/previewGestureStarts.ts +14 -5
- package/src/components/editor/propertyPanelFill.tsx +2 -1
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -4
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -4
- package/src/components/editor/resizeDraft.ts +13 -4
- package/src/components/editor/useColorGradingController.ts +2 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +5 -3
- package/src/components/ui/Menu.test.tsx +67 -5
- package/src/components/ui/Menu.tsx +21 -4
- package/src/components/ui/Popover.tsx +11 -5
- package/src/hooks/gsapResizeIntercept.test.ts +46 -11
- package/src/hooks/gsapResizeIntercept.ts +25 -20
- package/src/hooks/useDomEditCommits.ts +2 -1
- package/src/hooks/useFileManager.ts +2 -1
- package/src/hooks/useFileTree.ts +2 -1
- package/src/hooks/useRenderClipContent.ts +15 -3
- package/src/hooks/useThumbnailStripSize.test.tsx +123 -0
- package/src/hooks/useThumbnailStripSize.ts +19 -8
- package/src/player/components/CompositionThumbnail.test.ts +3 -0
- package/src/player/components/ImageThumbnail.test.tsx +4 -1
- package/src/player/components/VideoThumbnail.test.tsx +13 -2
- package/src/player/components/clipMenuNormalize.ts +2 -2
- package/src/player/components/clipPeakMap.ts +2 -2
- package/src/player/components/thumbnailUtils.test.ts +33 -0
- package/src/player/components/thumbnailUtils.ts +5 -0
- package/src/player/hooks/timelineSyncHydration.ts +2 -1
- package/src/player/lib/mediaProbe.test.ts +12 -0
- package/src/player/lib/mediaProbe.ts +4 -2
- package/src/utils/authoredSource.test.ts +25 -0
- package/src/utils/authoredSource.ts +3 -5
- package/src/utils/rounding.ts +7 -0
- package/src/utils/sourcePatcher.test.ts +135 -0
- package/src/utils/sourcePatcher.ts +102 -186
- package/src/utils/studioHelpers.test.ts +23 -0
- package/src/utils/studioHelpers.ts +3 -2
- package/src/utils/studioSelectionSnapshot.test.ts +31 -0
- package/src/utils/timelineAssetDrop.test.ts +14 -0
- package/src/utils/timelineAssetDrop.ts +2 -1
- package/dist/assets/hyperframes-player-sAsRuV73.js +0 -473
- package/dist/chunk-LOVBLSMR.js.map +0 -1
- package/dist/chunk-TFLU2MGI.js.map +0 -1
- package/dist/chunk-UYWON5AX.js.map +0 -1
- /package/dist/{chunk-H2DUOXTF.js.map → chunk-G3EDKRZH.js.map} +0 -0
- /package/dist/{domEditingLayers-KBTPQKGA.js.map → domEditingLayers-PYST4G45.js.map} +0 -0
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
scanHtmlOpeningTags,
|
|
3
|
+
decodeAuthoredAttribute,
|
|
4
|
+
HTML_ATTRIBUTE_ENTITIES,
|
|
5
|
+
} from "@hyperframes/parsers";
|
|
6
|
+
|
|
1
7
|
/**
|
|
2
8
|
* Source Patcher — Maps visual property edits back to source HTML files.
|
|
3
9
|
* Handles inline style updates, attribute changes, and text content.
|
|
@@ -7,26 +13,44 @@ export function escapeRegex(s: string): string {
|
|
|
7
13
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
8
14
|
}
|
|
9
15
|
|
|
16
|
+
function tagAttribute(tag: string, name: string) {
|
|
17
|
+
return scanHtmlOpeningTags(tag)[0]?.attributes.find((attr) => attr.name === name.toLowerCase());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type AttributeEdit = { kind: "remove" } | { kind: "boolean" } | { kind: "value"; value: string };
|
|
21
|
+
|
|
22
|
+
function patchTagAttribute(tag: string, name: string, edit: AttributeEdit): string {
|
|
23
|
+
if (edit.kind === "remove") {
|
|
24
|
+
const attributes =
|
|
25
|
+
scanHtmlOpeningTags(tag)[0]?.attributes.filter((attr) => attr.name === name.toLowerCase()) ??
|
|
26
|
+
[];
|
|
27
|
+
const parts: string[] = [];
|
|
28
|
+
let cursor = 0;
|
|
29
|
+
for (const attribute of attributes) {
|
|
30
|
+
let start = attribute.start;
|
|
31
|
+
while (start > cursor && /[\t\n\f\r ]/.test(tag[start - 1]!)) start--;
|
|
32
|
+
parts.push(tag.slice(cursor, start));
|
|
33
|
+
cursor = attribute.end;
|
|
34
|
+
}
|
|
35
|
+
parts.push(tag.slice(cursor));
|
|
36
|
+
return parts.join("");
|
|
37
|
+
}
|
|
38
|
+
const attr = tagAttribute(tag, name);
|
|
39
|
+
const replacement =
|
|
40
|
+
edit.kind === "boolean" ? name : `${name}="${escapeHtmlAttribute(edit.value)}"`;
|
|
41
|
+
if (attr) return tag.slice(0, attr.start) + replacement + tag.slice(attr.end);
|
|
42
|
+
return tag.endsWith("/")
|
|
43
|
+
? `${tag.slice(0, -1).trimEnd()} ${replacement} /`
|
|
44
|
+
: `${tag} ${replacement}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
10
47
|
function escapeStyleAttributeValue(value: string, quote: string): string {
|
|
11
48
|
return quote === '"' ? value.replace(/"/g, """) : value.replace(/'/g, "'");
|
|
12
49
|
}
|
|
13
50
|
|
|
14
51
|
/** Escape a string for safe use inside a double-quoted HTML attribute. */
|
|
15
52
|
export function escapeHtmlAttribute(value: string): string {
|
|
16
|
-
return value
|
|
17
|
-
.replace(/&/g, "&")
|
|
18
|
-
.replace(/"/g, """)
|
|
19
|
-
.replace(/</g, "<")
|
|
20
|
-
.replace(/>/g, ">");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Reverse escapeHtmlAttribute so callers get the original value. */
|
|
24
|
-
export function unescapeHtmlAttribute(value: string): string {
|
|
25
|
-
return value
|
|
26
|
-
.replace(/"/g, '"')
|
|
27
|
-
.replace(/</g, "<")
|
|
28
|
-
.replace(/>/g, ">")
|
|
29
|
-
.replace(/&/g, "&");
|
|
53
|
+
return value.replace(/[&"<>]/g, (char) => HTML_ATTRIBUTE_ENTITIES[char]![0]!);
|
|
30
54
|
}
|
|
31
55
|
|
|
32
56
|
function splitInlineStyleDeclarations(style: string): string[] {
|
|
@@ -98,8 +122,6 @@ export interface PatchOperation {
|
|
|
98
122
|
childIndex?: number;
|
|
99
123
|
}
|
|
100
124
|
|
|
101
|
-
// Runtime validation for hfId lives in findTagByTarget → execDataAttrPattern (CSS attr-value
|
|
102
|
-
// escape). This type is documentation only; the server's MutationTarget mirrors this shape.
|
|
103
125
|
export interface PatchTarget {
|
|
104
126
|
id?: string | null;
|
|
105
127
|
hfId?: string;
|
|
@@ -120,7 +142,7 @@ export function resolveSourceFile(
|
|
|
120
142
|
// Strategy 1: Search by id attribute
|
|
121
143
|
if (elementId) {
|
|
122
144
|
for (const [path, content] of Object.entries(files)) {
|
|
123
|
-
if (content
|
|
145
|
+
if (findTagByAttribute(content, "id", elementId)) {
|
|
124
146
|
return path;
|
|
125
147
|
}
|
|
126
148
|
}
|
|
@@ -131,7 +153,7 @@ export function resolveSourceFile(
|
|
|
131
153
|
if (compIdMatch) {
|
|
132
154
|
const compId = compIdMatch[1];
|
|
133
155
|
for (const [path, content] of Object.entries(files)) {
|
|
134
|
-
if (content
|
|
156
|
+
if (findTagByAttribute(content, "data-composition-id", compId!)) {
|
|
135
157
|
return path;
|
|
136
158
|
}
|
|
137
159
|
}
|
|
@@ -142,11 +164,7 @@ export function resolveSourceFile(
|
|
|
142
164
|
if (classMatch) {
|
|
143
165
|
const cls = classMatch[1];
|
|
144
166
|
for (const [path, content] of Object.entries(files)) {
|
|
145
|
-
if (
|
|
146
|
-
content.includes(`class="${cls}"`) ||
|
|
147
|
-
content.includes(`class="${cls} `) ||
|
|
148
|
-
content.includes(` ${cls}"`)
|
|
149
|
-
) {
|
|
167
|
+
if (findTagByClass(content, { selector: `.${cls}` })) {
|
|
150
168
|
return path;
|
|
151
169
|
}
|
|
152
170
|
}
|
|
@@ -166,28 +184,17 @@ function patchInlineStyle(
|
|
|
166
184
|
prop: string,
|
|
167
185
|
value: string | null,
|
|
168
186
|
): string {
|
|
169
|
-
|
|
170
|
-
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
|
|
171
|
-
const match = idPattern.exec(html);
|
|
172
|
-
if (!match) return html;
|
|
173
|
-
|
|
174
|
-
const tag = match[1];
|
|
175
|
-
return patchInlineStyleInTag(html, tag, prop, value);
|
|
187
|
+
return patchInlineStyleByTarget(html, { id: elementId }, prop, value);
|
|
176
188
|
}
|
|
177
189
|
|
|
178
|
-
function patchInlineStyleInTag(
|
|
179
|
-
|
|
180
|
-
tag: string,
|
|
181
|
-
prop: string,
|
|
182
|
-
value: string | null,
|
|
183
|
-
): string {
|
|
184
|
-
if (!tag) return html;
|
|
190
|
+
function patchInlineStyleInTag(tag: string, prop: string, value: string | null): string {
|
|
191
|
+
if (!tag) return tag;
|
|
185
192
|
|
|
186
193
|
// Check if there's an existing style attribute
|
|
187
|
-
const styleMatch =
|
|
188
|
-
if (styleMatch) {
|
|
189
|
-
const existingStyle = styleMatch
|
|
190
|
-
const quote = styleMatch
|
|
194
|
+
const styleMatch = tagAttribute(tag, "style");
|
|
195
|
+
if (styleMatch?.kind === "value") {
|
|
196
|
+
const existingStyle = styleMatch.value;
|
|
197
|
+
const quote = styleMatch.quote || '"';
|
|
191
198
|
// Parse existing properties
|
|
192
199
|
const props = new Map<string, string>();
|
|
193
200
|
for (const part of splitInlineStyleDeclarations(existingStyle)) {
|
|
@@ -207,15 +214,18 @@ function patchInlineStyleInTag(
|
|
|
207
214
|
const newStyle = Array.from(props.entries())
|
|
208
215
|
.map(([k, v]) => `${k}: ${escapeStyleAttributeValue(v, quote)}`)
|
|
209
216
|
.join("; ");
|
|
210
|
-
const newTag =
|
|
211
|
-
|
|
217
|
+
const newTag =
|
|
218
|
+
tag.slice(0, styleMatch.start) +
|
|
219
|
+
`style=${quote}${newStyle}${quote}` +
|
|
220
|
+
tag.slice(styleMatch.end);
|
|
221
|
+
return newTag;
|
|
212
222
|
} else {
|
|
213
223
|
// No existing style attribute
|
|
214
|
-
if (value === null) return
|
|
224
|
+
if (value === null) return tag; // nothing to remove
|
|
215
225
|
const selfClosing = tag.endsWith("/");
|
|
216
226
|
const base = selfClosing ? tag.slice(0, -1).trimEnd() : tag;
|
|
217
227
|
const newTag = `${base} style="${prop}: ${escapeStyleAttributeValue(value, '"')}"${selfClosing ? " /" : ""}`;
|
|
218
|
-
return
|
|
228
|
+
return newTag;
|
|
219
229
|
}
|
|
220
230
|
}
|
|
221
231
|
|
|
@@ -227,7 +237,7 @@ function patchInlineStyleByTarget(
|
|
|
227
237
|
): string {
|
|
228
238
|
const match = findTagByTarget(html, target);
|
|
229
239
|
if (!match) return html;
|
|
230
|
-
const newTag = patchInlineStyleInTag(match.tag,
|
|
240
|
+
const newTag = patchInlineStyleInTag(match.tag, prop, value);
|
|
231
241
|
return replaceTagAtMatch(html, match, newTag);
|
|
232
242
|
}
|
|
233
243
|
|
|
@@ -241,45 +251,40 @@ function replaceTagAtMatch(html: string, match: TagMatch, newTag: string): strin
|
|
|
241
251
|
return `${html.slice(0, match.start)}${newTag}${html.slice(match.end)}`;
|
|
242
252
|
}
|
|
243
253
|
|
|
244
|
-
function
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
254
|
+
function findTagByAttribute(html: string, name: string, value: string): TagMatch | null {
|
|
255
|
+
const found = scanHtmlOpeningTags(html).find((tag) => {
|
|
256
|
+
const attr = tag.attributes.find((attr) => attr.name === name);
|
|
257
|
+
return tag.closed && attr?.kind === "value" && decodeAuthoredAttribute(attr.value) === value;
|
|
258
|
+
});
|
|
259
|
+
return found
|
|
260
|
+
? { tag: html.slice(found.start, found.bodyEnd), start: found.start, end: found.bodyEnd }
|
|
261
|
+
: null;
|
|
249
262
|
}
|
|
250
263
|
|
|
251
264
|
function findTagByClass(html: string, target: PatchTarget): TagMatch | null {
|
|
252
265
|
const classMatch = target.selector?.match(/^\.([a-zA-Z0-9_-]+)$/);
|
|
253
266
|
if (!classMatch) return null;
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
tag: match[1],
|
|
266
|
-
start: match.index,
|
|
267
|
-
end: match.index + match[1].length,
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
currentIndex += 1;
|
|
271
|
-
}
|
|
272
|
-
return null;
|
|
267
|
+
const found = scanHtmlOpeningTags(html).filter((tag) => {
|
|
268
|
+
const attr = tag.attributes.find((attr) => attr.name === "class");
|
|
269
|
+
return (
|
|
270
|
+
tag.closed &&
|
|
271
|
+
attr?.kind === "value" &&
|
|
272
|
+
decodeAuthoredAttribute(attr.value).split(/\s+/).includes(classMatch[1]!)
|
|
273
|
+
);
|
|
274
|
+
})[target.selectorIndex ?? 0];
|
|
275
|
+
return found
|
|
276
|
+
? { tag: html.slice(found.start, found.bodyEnd), start: found.start, end: found.bodyEnd }
|
|
277
|
+
: null;
|
|
273
278
|
}
|
|
274
279
|
|
|
275
280
|
export function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
|
|
276
281
|
if (target.hfId) {
|
|
277
|
-
const result =
|
|
282
|
+
const result = findTagByAttribute(html, "data-hf-id", target.hfId);
|
|
278
283
|
if (result) return result;
|
|
279
284
|
}
|
|
280
285
|
|
|
281
286
|
if (target.id) {
|
|
282
|
-
const result =
|
|
287
|
+
const result = findTagByAttribute(html, "id", target.id);
|
|
283
288
|
if (result) return result;
|
|
284
289
|
}
|
|
285
290
|
|
|
@@ -287,7 +292,7 @@ export function findTagByTarget(html: string, target: PatchTarget): TagMatch | n
|
|
|
287
292
|
|
|
288
293
|
const compositionIdMatch = target.selector.match(/^\[data-composition-id="([^"]+)"\]$/);
|
|
289
294
|
if (compositionIdMatch) {
|
|
290
|
-
const result =
|
|
295
|
+
const result = findTagByAttribute(html, "data-composition-id", compositionIdMatch[1]);
|
|
291
296
|
if (result) return result;
|
|
292
297
|
}
|
|
293
298
|
|
|
@@ -302,9 +307,12 @@ export function readAttributeByTarget(
|
|
|
302
307
|
const match = findTagByTarget(html, target);
|
|
303
308
|
if (!match) return undefined;
|
|
304
309
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
310
|
+
return readTagAttribute(match.tag, attr.startsWith("data-") ? attr : `data-${attr}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function readTagAttribute(tag: string, attr: string): string | undefined {
|
|
314
|
+
const value = tagAttribute(tag, attr);
|
|
315
|
+
return value?.kind === "value" ? decodeAuthoredAttribute(value.value) : undefined;
|
|
308
316
|
}
|
|
309
317
|
|
|
310
318
|
export function readTagSnippetByTarget(html: string, target: PatchTarget): string | undefined {
|
|
@@ -320,85 +328,25 @@ function patchAttributeByTarget(
|
|
|
320
328
|
): string {
|
|
321
329
|
const match = findTagByTarget(html, target);
|
|
322
330
|
if (!match) return html;
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
const tag = match.tag;
|
|
327
|
-
|
|
328
|
-
if (value === null) {
|
|
329
|
-
// Remove the attribute if present
|
|
330
|
-
const boolAttrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
|
|
331
|
-
if (!boolAttrPattern.test(tag)) return html;
|
|
332
|
-
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
|
|
333
|
-
const newTag = tag.replace(removePattern, "");
|
|
334
|
-
return replaceTagAtMatch(html, match, newTag);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const escaped = escapeHtmlAttribute(value);
|
|
338
|
-
if (attrPattern.test(tag)) {
|
|
339
|
-
const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
|
|
340
|
-
return replaceTagAtMatch(html, match, newTag);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
const newTag = tag + ` ${fullAttr}="${escaped}"`;
|
|
344
|
-
return replaceTagAtMatch(html, match, newTag);
|
|
331
|
+
const name = attr.startsWith("data-") ? attr : `data-${attr}`;
|
|
332
|
+
const edit: AttributeEdit = value === null ? { kind: "remove" } : { kind: "value", value };
|
|
333
|
+
return replaceTagAtMatch(html, match, patchTagAttribute(match.tag, name, edit));
|
|
345
334
|
}
|
|
346
335
|
|
|
347
|
-
/**
|
|
348
|
-
* Apply an attribute change to an element in the HTML source.
|
|
349
|
-
*/
|
|
350
336
|
function patchAttribute(
|
|
351
337
|
html: string,
|
|
352
338
|
elementId: string,
|
|
353
339
|
attr: string,
|
|
354
340
|
value: string | null,
|
|
355
341
|
): string {
|
|
356
|
-
|
|
357
|
-
const match = idPattern.exec(html);
|
|
358
|
-
if (!match) return html;
|
|
359
|
-
|
|
360
|
-
const tag = match[1];
|
|
361
|
-
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
|
|
362
|
-
const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
|
|
363
|
-
|
|
364
|
-
if (value === null) {
|
|
365
|
-
const boolAttrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
|
|
366
|
-
if (!boolAttrPattern.test(tag)) return html;
|
|
367
|
-
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
|
|
368
|
-
const newTag = tag.replace(removePattern, "");
|
|
369
|
-
return html.replace(tag, newTag);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
const escaped = escapeHtmlAttribute(value);
|
|
373
|
-
if (attrPattern.test(tag)) {
|
|
374
|
-
// Update existing attribute
|
|
375
|
-
const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
|
|
376
|
-
return html.replace(tag, newTag);
|
|
377
|
-
} else {
|
|
378
|
-
// Add new attribute
|
|
379
|
-
const newTag = tag + ` ${fullAttr}="${escaped}"`;
|
|
380
|
-
return html.replace(tag, newTag);
|
|
381
|
-
}
|
|
342
|
+
return patchAttributeByTarget(html, { id: elementId }, attr, value);
|
|
382
343
|
}
|
|
383
344
|
|
|
384
345
|
/**
|
|
385
346
|
* Apply a text content change to an element.
|
|
386
347
|
*/
|
|
387
348
|
function patchTextContent(html: string, elementId: string, value: string): string {
|
|
388
|
-
|
|
389
|
-
`(<([a-z0-9-]+)[^>]*\\bid=(["'])${escapeRegex(elementId)}\\3[^>]*>)`,
|
|
390
|
-
"i",
|
|
391
|
-
);
|
|
392
|
-
const match = openTagPattern.exec(html);
|
|
393
|
-
if (!match || match.index == null) return html;
|
|
394
|
-
|
|
395
|
-
const openingTag = match[1];
|
|
396
|
-
const tagName = match[2];
|
|
397
|
-
const contentStart = match.index + openingTag.length;
|
|
398
|
-
const closingIndex = findMatchingClosingTagIndex(html, tagName, contentStart);
|
|
399
|
-
if (closingIndex < 0) return html;
|
|
400
|
-
|
|
401
|
-
return `${html.slice(0, contentStart)}${value}${html.slice(closingIndex)}`;
|
|
349
|
+
return patchTextContentByTarget(html, { id: elementId }, value);
|
|
402
350
|
}
|
|
403
351
|
|
|
404
352
|
function findMatchingClosingTagIndex(html: string, tagName: string, contentStart: number): number {
|
|
@@ -438,47 +386,18 @@ export const HTML_BOOLEAN_ATTRIBUTES = new Set([
|
|
|
438
386
|
"selected",
|
|
439
387
|
]);
|
|
440
388
|
|
|
441
|
-
function patchHtmlAttributeInTag(
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
attr
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const isBoolean = HTML_BOOLEAN_ATTRIBUTES.has(attr);
|
|
450
|
-
|
|
451
|
-
if (isBoolean) {
|
|
452
|
-
const escapedAttr = escapeRegex(attr);
|
|
453
|
-
const hasBoolAttr = new RegExp(`(?:^|\\s)${escapedAttr}(?:\\s|=|$)`).test(tag);
|
|
454
|
-
|
|
455
|
-
if (value === null || value === "" || value === "false") {
|
|
456
|
-
if (!hasBoolAttr) return html;
|
|
457
|
-
const removePattern = new RegExp(`\\s+${escapedAttr}(?:=(["'])[^"']*\\1)?`);
|
|
458
|
-
const newTag = tag.replace(removePattern, "");
|
|
459
|
-
return html.replace(tag, newTag);
|
|
389
|
+
function patchHtmlAttributeInTag(tag: string, attr: string, value: string | null): string {
|
|
390
|
+
if (!tag) return tag;
|
|
391
|
+
let edit: AttributeEdit;
|
|
392
|
+
if (HTML_BOOLEAN_ATTRIBUTES.has(attr)) {
|
|
393
|
+
if (value === null || value === "" || value === "false") edit = { kind: "remove" };
|
|
394
|
+
else {
|
|
395
|
+
if (tagAttribute(tag, attr)) return tag;
|
|
396
|
+
edit = { kind: "boolean" };
|
|
460
397
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
const attrPattern = new RegExp(`\\b${escapeRegex(attr)}=(["'])([^"']*)\\1`);
|
|
467
|
-
if (value === null) {
|
|
468
|
-
if (!attrPattern.test(tag)) return html;
|
|
469
|
-
const removePattern = new RegExp(`\\s+${escapeRegex(attr)}=(["'])[^"']*\\1`);
|
|
470
|
-
const newTag = tag.replace(removePattern, "");
|
|
471
|
-
return html.replace(tag, newTag);
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
const escaped = escapeHtmlAttribute(value);
|
|
475
|
-
if (attrPattern.test(tag)) {
|
|
476
|
-
const newTag = tag.replace(attrPattern, `${attr}="${escaped}"`);
|
|
477
|
-
return html.replace(tag, newTag);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const newTag = tag + ` ${attr}="${escaped}"`;
|
|
481
|
-
return html.replace(tag, newTag);
|
|
398
|
+
} else edit = value === null ? { kind: "remove" } : { kind: "value", value };
|
|
399
|
+
const newTag = patchTagAttribute(tag, attr, edit);
|
|
400
|
+
return newTag;
|
|
482
401
|
}
|
|
483
402
|
|
|
484
403
|
function patchHtmlAttribute(
|
|
@@ -487,10 +406,7 @@ function patchHtmlAttribute(
|
|
|
487
406
|
attr: string,
|
|
488
407
|
value: string | null,
|
|
489
408
|
): string {
|
|
490
|
-
|
|
491
|
-
const match = idPattern.exec(html);
|
|
492
|
-
if (!match) return html;
|
|
493
|
-
return patchHtmlAttributeInTag(html, match[1], attr, value);
|
|
409
|
+
return patchHtmlAttributeByTarget(html, { id: elementId }, attr, value);
|
|
494
410
|
}
|
|
495
411
|
|
|
496
412
|
function patchHtmlAttributeByTarget(
|
|
@@ -501,7 +417,7 @@ function patchHtmlAttributeByTarget(
|
|
|
501
417
|
): string {
|
|
502
418
|
const match = findTagByTarget(html, target);
|
|
503
419
|
if (!match) return html;
|
|
504
|
-
const newTag = patchHtmlAttributeInTag(match.tag,
|
|
420
|
+
const newTag = patchHtmlAttributeInTag(match.tag, attr, value);
|
|
505
421
|
return replaceTagAtMatch(html, match, newTag);
|
|
506
422
|
}
|
|
507
423
|
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
findMatchingTimelineElementId,
|
|
7
7
|
findTimelineIdByAncestor,
|
|
8
8
|
resolveAssetHasAudio,
|
|
9
|
+
resolveDroppedAssetDuration,
|
|
9
10
|
resolveDroppedAssetDimensions,
|
|
10
11
|
resolveElementTrack,
|
|
11
12
|
resolveTimelineIdForSelection,
|
|
@@ -210,6 +211,28 @@ describe("resolveDroppedAssetDimensions", () => {
|
|
|
210
211
|
});
|
|
211
212
|
});
|
|
212
213
|
|
|
214
|
+
describe("dropped asset probes", () => {
|
|
215
|
+
const NAME = "assets/50% off #1?'s take.mp4";
|
|
216
|
+
const URL = "/api/projects/demo/preview/assets/50%25%20off%20%231%3F%27s%20take.mp4";
|
|
217
|
+
|
|
218
|
+
it("probe a dropped file's length and size at its own URL", async () => {
|
|
219
|
+
vi.useFakeTimers();
|
|
220
|
+
const probes: HTMLVideoElement[] = [];
|
|
221
|
+
const createElement = document.createElement.bind(document);
|
|
222
|
+
vi.spyOn(document, "createElement").mockImplementation((tagName, options) => {
|
|
223
|
+
const el = createElement(tagName, options);
|
|
224
|
+
if (tagName === "video") probes.push(el as HTMLVideoElement);
|
|
225
|
+
return el;
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const duration = resolveDroppedAssetDuration("demo", NAME, "video");
|
|
229
|
+
const size = resolveDroppedAssetDimensions("demo", NAME, "video");
|
|
230
|
+
expect(probes.map((probe) => probe.getAttribute("src"))).toEqual([URL, URL]);
|
|
231
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
232
|
+
await Promise.all([duration, size]);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
213
236
|
describe("resolveElementTrack", () => {
|
|
214
237
|
it("rounds an authored track", () => {
|
|
215
238
|
expect(resolveElementTrack({ authoredTrack: 2.4, track: 0 })).toBe(2);
|
|
@@ -5,6 +5,7 @@ import type { DomEditSelection } from "../components/editor/domEditing";
|
|
|
5
5
|
import type { TimelineAssetKind } from "./timelineAssetDrop";
|
|
6
6
|
import { roundToCenti } from "./rounding";
|
|
7
7
|
import { studioApiFetch } from "./studioApiFetch";
|
|
8
|
+
import { encodeUrlPath } from "@hyperframes/parsers";
|
|
8
9
|
|
|
9
10
|
export interface EditingFile {
|
|
10
11
|
path: string;
|
|
@@ -352,7 +353,7 @@ export async function resolveDroppedAssetDuration(
|
|
|
352
353
|
|
|
353
354
|
const media = document.createElement(kind === "video" ? "video" : "audio");
|
|
354
355
|
media.preload = "metadata";
|
|
355
|
-
media.src = buildProjectApiPath(projectId, `/preview/${assetPath}`);
|
|
356
|
+
media.src = buildProjectApiPath(projectId, `/preview/${encodeUrlPath(assetPath)}`);
|
|
356
357
|
|
|
357
358
|
const duration = await new Promise<number>((resolve) => {
|
|
358
359
|
const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3000);
|
|
@@ -424,7 +425,7 @@ export async function resolveDroppedAssetDimensions(
|
|
|
424
425
|
kind: TimelineAssetKind,
|
|
425
426
|
): Promise<{ width: number; height: number } | null> {
|
|
426
427
|
if (kind === "audio") return null;
|
|
427
|
-
const src = buildProjectApiPath(projectId, `/preview/${assetPath}`);
|
|
428
|
+
const src = buildProjectApiPath(projectId, `/preview/${encodeUrlPath(assetPath)}`);
|
|
428
429
|
|
|
429
430
|
if (kind === "image") {
|
|
430
431
|
return new Promise((resolve) => {
|
|
@@ -44,4 +44,35 @@ describe("buildStudioSelectionSnapshot", () => {
|
|
|
44
44
|
expect(JSON.stringify(snapshot)).not.toContain("HTMLElement");
|
|
45
45
|
expect(snapshot).not.toHaveProperty("element");
|
|
46
46
|
});
|
|
47
|
+
|
|
48
|
+
it("links a selection inside a sub-composition to its own thumbnail", () => {
|
|
49
|
+
const snapshot = buildStudioSelectionSnapshot({
|
|
50
|
+
projectId: "demo",
|
|
51
|
+
selection: {
|
|
52
|
+
element: { tagName: "H1" } as HTMLElement,
|
|
53
|
+
id: null,
|
|
54
|
+
hfId: "intro-title",
|
|
55
|
+
selector: ".title",
|
|
56
|
+
selectorIndex: 0,
|
|
57
|
+
label: "Intro title",
|
|
58
|
+
tagName: "h1",
|
|
59
|
+
sourceFile: "compositions/intro #1.html",
|
|
60
|
+
compositionPath: "compositions/intro #1.html",
|
|
61
|
+
isCompositionHost: false,
|
|
62
|
+
isInsideLockedComposition: false,
|
|
63
|
+
boundingBox: { x: 0, y: 0, width: 10, height: 10 },
|
|
64
|
+
textContent: "Intro",
|
|
65
|
+
dataAttributes: {},
|
|
66
|
+
inlineStyles: {},
|
|
67
|
+
computedStyles: {},
|
|
68
|
+
textFields: [],
|
|
69
|
+
capabilities: { canSelect: true, canEditStyles: true },
|
|
70
|
+
} as DomEditSelection,
|
|
71
|
+
currentTime: 0,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(snapshot.thumbnailUrl).toMatch(
|
|
75
|
+
/^\/api\/projects\/demo\/thumbnail\/compositions%2Fintro%20%231\.html\?/,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
47
78
|
});
|
|
@@ -292,3 +292,17 @@ describe("fitTimelineAssetGeometry", () => {
|
|
|
292
292
|
});
|
|
293
293
|
});
|
|
294
294
|
});
|
|
295
|
+
|
|
296
|
+
it("writes a dropped file's name as a URL, so %, #, ? and apostrophes load", () => {
|
|
297
|
+
const html = buildTimelineAssetInsertHtml({
|
|
298
|
+
id: "clip",
|
|
299
|
+
hfId: "hf-clip",
|
|
300
|
+
assetPath: "../assets/50% off #1?'s take.mp4",
|
|
301
|
+
kind: "video",
|
|
302
|
+
start: 0,
|
|
303
|
+
duration: 2,
|
|
304
|
+
track: 1,
|
|
305
|
+
zIndex: 1,
|
|
306
|
+
});
|
|
307
|
+
expect(html).toContain('src="../assets/50%25%20off%20%231%3F%27s%20take.mp4"');
|
|
308
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { encodeUrlPath } from "@hyperframes/parsers";
|
|
1
2
|
export { buildTimelineAssetId } from "@hyperframes/core/timeline-asset-id";
|
|
2
3
|
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
|
|
3
4
|
import { roundToCenti } from "./rounding";
|
|
@@ -105,7 +106,7 @@ export function buildTimelineAssetInsertHtml(input: {
|
|
|
105
106
|
/** Video only: true inserts `data-has-audio="true"` with no `muted`. Unknown or false stays muted. */
|
|
106
107
|
hasAudio?: boolean;
|
|
107
108
|
}): string {
|
|
108
|
-
const sharedAttrs = `id="${input.id}" data-hf-id="${input.hfId}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
|
|
109
|
+
const sharedAttrs = `id="${input.id}" data-hf-id="${input.hfId}" class="clip" src="${encodeUrlPath(input.assetPath)}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
|
|
109
110
|
const geometry = input.geometry ?? { left: 0, top: 0, width: 640, height: 360 };
|
|
110
111
|
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}`;
|
|
111
112
|
|