@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
|
@@ -1,762 +0,0 @@
|
|
|
1
|
-
import { formatTime } from "../../player/lib/time";
|
|
2
|
-
import type { PatchOperation, PatchTarget } from "../../utils/sourcePatcher";
|
|
3
|
-
|
|
4
|
-
const CURATED_STYLE_PROPERTIES = [
|
|
5
|
-
"position",
|
|
6
|
-
"display",
|
|
7
|
-
"top",
|
|
8
|
-
"left",
|
|
9
|
-
"right",
|
|
10
|
-
"bottom",
|
|
11
|
-
"inset",
|
|
12
|
-
"width",
|
|
13
|
-
"height",
|
|
14
|
-
"gap",
|
|
15
|
-
"justify-content",
|
|
16
|
-
"align-items",
|
|
17
|
-
"flex-direction",
|
|
18
|
-
"font-size",
|
|
19
|
-
"font-weight",
|
|
20
|
-
"font-family",
|
|
21
|
-
"color",
|
|
22
|
-
"background-color",
|
|
23
|
-
"background-image",
|
|
24
|
-
"opacity",
|
|
25
|
-
"mix-blend-mode",
|
|
26
|
-
"border-radius",
|
|
27
|
-
"border-color",
|
|
28
|
-
"outline-color",
|
|
29
|
-
"overflow",
|
|
30
|
-
"box-shadow",
|
|
31
|
-
"z-index",
|
|
32
|
-
"transform",
|
|
33
|
-
] as const;
|
|
34
|
-
|
|
35
|
-
export interface DomEditCapabilities {
|
|
36
|
-
canSelect: boolean;
|
|
37
|
-
canEditStyles: boolean;
|
|
38
|
-
canMove: boolean;
|
|
39
|
-
canResize: boolean;
|
|
40
|
-
canDetachFromLayout: boolean;
|
|
41
|
-
reasonIfDisabled?: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface DomEditTextField {
|
|
45
|
-
key: string;
|
|
46
|
-
label: string;
|
|
47
|
-
value: string;
|
|
48
|
-
tagName: string;
|
|
49
|
-
attributes: Array<{ name: string; value: string }>;
|
|
50
|
-
inlineStyles: Record<string, string>;
|
|
51
|
-
computedStyles: Record<string, string>;
|
|
52
|
-
source: "self" | "child";
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface DomEditSelection extends PatchTarget {
|
|
56
|
-
element: HTMLElement;
|
|
57
|
-
label: string;
|
|
58
|
-
tagName: string;
|
|
59
|
-
sourceFile: string;
|
|
60
|
-
compositionPath: string;
|
|
61
|
-
compositionSrc?: string;
|
|
62
|
-
isCompositionHost: boolean;
|
|
63
|
-
boundingBox: { x: number; y: number; width: number; height: number };
|
|
64
|
-
textContent: string | null;
|
|
65
|
-
dataAttributes: Record<string, string>;
|
|
66
|
-
inlineStyles: Record<string, string>;
|
|
67
|
-
computedStyles: Record<string, string>;
|
|
68
|
-
textFields: DomEditTextField[];
|
|
69
|
-
capabilities: DomEditCapabilities;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface DomEditContextOptions {
|
|
73
|
-
activeCompositionPath: string | null;
|
|
74
|
-
isMasterView: boolean;
|
|
75
|
-
preferClipAncestor?: boolean;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function isHtmlElement(value: unknown): value is HTMLElement {
|
|
79
|
-
return (
|
|
80
|
-
typeof value === "object" &&
|
|
81
|
-
value !== null &&
|
|
82
|
-
"nodeType" in value &&
|
|
83
|
-
typeof (value as { nodeType?: unknown }).nodeType === "number" &&
|
|
84
|
-
(value as { nodeType: number }).nodeType === 1
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function parsePx(value: string | undefined): number | null {
|
|
89
|
-
if (!value) return null;
|
|
90
|
-
const trimmed = value.trim();
|
|
91
|
-
if (!trimmed.endsWith("px")) return null;
|
|
92
|
-
const parsed = parseFloat(trimmed);
|
|
93
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function isIdentityTransform(value: string | undefined): boolean {
|
|
97
|
-
const transform = (value ?? "none").trim();
|
|
98
|
-
if (!transform || transform === "none") return true;
|
|
99
|
-
|
|
100
|
-
const matrix = transform.match(/^matrix\(([^)]+)\)$/i);
|
|
101
|
-
if (matrix) {
|
|
102
|
-
const values = matrix[1].split(",").map((part) => Number.parseFloat(part.trim()));
|
|
103
|
-
if (values.length !== 6 || values.some((part) => !Number.isFinite(part))) return false;
|
|
104
|
-
return (
|
|
105
|
-
Math.abs(values[0] - 1) < 0.0001 &&
|
|
106
|
-
Math.abs(values[1]) < 0.0001 &&
|
|
107
|
-
Math.abs(values[2]) < 0.0001 &&
|
|
108
|
-
Math.abs(values[3] - 1) < 0.0001 &&
|
|
109
|
-
Math.abs(values[4]) < 0.0001 &&
|
|
110
|
-
Math.abs(values[5]) < 0.0001
|
|
111
|
-
);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i);
|
|
115
|
-
if (!matrix3d) return false;
|
|
116
|
-
const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim()));
|
|
117
|
-
if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false;
|
|
118
|
-
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
119
|
-
return values.every((part, index) => Math.abs(part - identity[index]) < 0.0001);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function isClipClassName(className: string | undefined): boolean {
|
|
123
|
-
return Boolean(className?.split(/\s+/).includes("clip"));
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function isInlineTextTag(tagName: string | undefined): boolean {
|
|
127
|
-
return Boolean(
|
|
128
|
-
tagName &&
|
|
129
|
-
["a", "b", "em", "i", "small", "span", "strong", "sub", "sup"].includes(tagName.toLowerCase()),
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function isBlockishTag(tagName: string | undefined): boolean {
|
|
134
|
-
return Boolean(
|
|
135
|
-
tagName &&
|
|
136
|
-
[
|
|
137
|
-
"article",
|
|
138
|
-
"aside",
|
|
139
|
-
"canvas",
|
|
140
|
-
"div",
|
|
141
|
-
"figure",
|
|
142
|
-
"footer",
|
|
143
|
-
"header",
|
|
144
|
-
"img",
|
|
145
|
-
"main",
|
|
146
|
-
"section",
|
|
147
|
-
"svg",
|
|
148
|
-
"video",
|
|
149
|
-
].includes(tagName.toLowerCase()),
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function isBlockishDisplay(display: string | undefined): boolean {
|
|
154
|
-
return Boolean(
|
|
155
|
-
display &&
|
|
156
|
-
[
|
|
157
|
-
"block",
|
|
158
|
-
"flex",
|
|
159
|
-
"flow-root",
|
|
160
|
-
"grid",
|
|
161
|
-
"inline-block",
|
|
162
|
-
"inline-flex",
|
|
163
|
-
"inline-grid",
|
|
164
|
-
"list-item",
|
|
165
|
-
"table",
|
|
166
|
-
].includes(display),
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function isTextBearingTag(tagName: string): boolean {
|
|
171
|
-
return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function getCuratedComputedStyles(el: HTMLElement): Record<string, string> {
|
|
175
|
-
const styles: Record<string, string> = {};
|
|
176
|
-
const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
177
|
-
if (!computed) return styles;
|
|
178
|
-
|
|
179
|
-
for (const prop of CURATED_STYLE_PROPERTIES) {
|
|
180
|
-
const value = computed.getPropertyValue(prop);
|
|
181
|
-
if (value) styles[prop] = value;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return styles;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function findClosestByAttribute(el: HTMLElement, attributeNames: string[]): HTMLElement | null {
|
|
188
|
-
let current: HTMLElement | null = el;
|
|
189
|
-
while (current) {
|
|
190
|
-
const candidate = current;
|
|
191
|
-
if (attributeNames.some((attribute) => candidate.hasAttribute(attribute))) {
|
|
192
|
-
return candidate;
|
|
193
|
-
}
|
|
194
|
-
current = current.parentElement;
|
|
195
|
-
}
|
|
196
|
-
return null;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function getCompositionHost(el: HTMLElement): HTMLElement | null {
|
|
200
|
-
return findClosestByAttribute(el, ["data-composition-src", "data-composition-file"]);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function getSourceFileForElement(
|
|
204
|
-
el: HTMLElement,
|
|
205
|
-
activeCompositionPath: string | null,
|
|
206
|
-
): { sourceFile: string; compositionPath: string } {
|
|
207
|
-
const ownerRoot = findClosestByAttribute(el, ["data-composition-id"]);
|
|
208
|
-
const sourceFile =
|
|
209
|
-
ownerRoot?.getAttribute("data-composition-file") ??
|
|
210
|
-
ownerRoot?.getAttribute("data-composition-src") ??
|
|
211
|
-
activeCompositionPath ??
|
|
212
|
-
"index.html";
|
|
213
|
-
|
|
214
|
-
return {
|
|
215
|
-
sourceFile,
|
|
216
|
-
compositionPath: sourceFile,
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function getSelectionCandidate(startEl: HTMLElement, options: DomEditContextOptions): HTMLElement {
|
|
221
|
-
if (options.preferClipAncestor) {
|
|
222
|
-
const clipAncestor = startEl.closest(".clip");
|
|
223
|
-
if (isHtmlElement(clipAncestor)) {
|
|
224
|
-
return clipAncestor;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if (!options.isMasterView) return startEl;
|
|
229
|
-
|
|
230
|
-
const compositionHost = getCompositionHost(startEl);
|
|
231
|
-
if (compositionHost && compositionHost !== startEl) {
|
|
232
|
-
return compositionHost;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
return startEl;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function getPreferredClassSelector(el: HTMLElement): string | undefined {
|
|
239
|
-
const classes = el.className
|
|
240
|
-
.split(/\s+/)
|
|
241
|
-
.map((value) => value.trim())
|
|
242
|
-
.filter(Boolean);
|
|
243
|
-
if (classes.length === 0) return undefined;
|
|
244
|
-
const preferred =
|
|
245
|
-
classes.find((value) => value !== "clip" && !value.startsWith("__hf-")) ?? classes[0];
|
|
246
|
-
return preferred ? `.${preferred}` : undefined;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function humanizeIdentifier(value: string): string {
|
|
250
|
-
return (
|
|
251
|
-
value
|
|
252
|
-
.replace(/\.html$/i, "")
|
|
253
|
-
.replace(/^compositions\//i, "")
|
|
254
|
-
.split("/")
|
|
255
|
-
.at(-1)
|
|
256
|
-
?.replace(/[-_]+/g, " ")
|
|
257
|
-
.replace(/\b\w/g, (char) => char.toUpperCase()) ?? value
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function buildStableSelector(el: HTMLElement): string | undefined {
|
|
262
|
-
if (el.id) return `#${el.id}`;
|
|
263
|
-
|
|
264
|
-
const compositionId = el.getAttribute("data-composition-id");
|
|
265
|
-
if (compositionId) return `[data-composition-id="${compositionId}"]`;
|
|
266
|
-
|
|
267
|
-
return getPreferredClassSelector(el);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function getSelectorIndex(
|
|
271
|
-
doc: Document,
|
|
272
|
-
el: HTMLElement,
|
|
273
|
-
selector: string | undefined,
|
|
274
|
-
sourceFile: string,
|
|
275
|
-
activeCompositionPath: string | null,
|
|
276
|
-
): number | undefined {
|
|
277
|
-
if (!selector?.startsWith(".")) return undefined;
|
|
278
|
-
|
|
279
|
-
const candidates = Array.from(doc.querySelectorAll(selector)).filter(
|
|
280
|
-
(candidate): candidate is HTMLElement =>
|
|
281
|
-
isHtmlElement(candidate) &&
|
|
282
|
-
getSourceFileForElement(candidate, activeCompositionPath).sourceFile === sourceFile,
|
|
283
|
-
);
|
|
284
|
-
const index = candidates.indexOf(el);
|
|
285
|
-
return index >= 0 ? index : undefined;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function buildElementLabel(el: HTMLElement): string {
|
|
289
|
-
const compositionId = el.getAttribute("data-composition-id");
|
|
290
|
-
if (compositionId && compositionId !== "main") {
|
|
291
|
-
return humanizeIdentifier(compositionId);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const compositionSrc =
|
|
295
|
-
el.getAttribute("data-composition-src") ?? el.getAttribute("data-composition-file");
|
|
296
|
-
if (compositionSrc) {
|
|
297
|
-
return humanizeIdentifier(compositionSrc);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
if (el.id) return humanizeIdentifier(el.id);
|
|
301
|
-
|
|
302
|
-
const preferredClass = getPreferredClassSelector(el);
|
|
303
|
-
if (preferredClass) {
|
|
304
|
-
return humanizeIdentifier(preferredClass.replace(/^\./, ""));
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
|
|
308
|
-
if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text;
|
|
309
|
-
return el.tagName.toLowerCase();
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function getDataAttributes(el: HTMLElement): Record<string, string> {
|
|
313
|
-
const attrs: Record<string, string> = {};
|
|
314
|
-
for (const attr of el.attributes) {
|
|
315
|
-
if (attr.name.startsWith("data-")) {
|
|
316
|
-
attrs[attr.name.slice(5)] = attr.value;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
return attrs;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function getInlineStyles(el: HTMLElement): Record<string, string> {
|
|
323
|
-
const styles: Record<string, string> = {};
|
|
324
|
-
for (const property of CURATED_STYLE_PROPERTIES) {
|
|
325
|
-
const value = el.style.getPropertyValue(property);
|
|
326
|
-
if (value) styles[property] = value;
|
|
327
|
-
}
|
|
328
|
-
return styles;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function isEditableTextLeaf(el: HTMLElement): boolean {
|
|
332
|
-
return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function getTextFieldLabel(
|
|
336
|
-
_tagName: string,
|
|
337
|
-
index: number,
|
|
338
|
-
total: number,
|
|
339
|
-
source: "self" | "child",
|
|
340
|
-
): string {
|
|
341
|
-
if (source === "self" || total === 1) return "Content";
|
|
342
|
-
return `Text ${index + 1}`;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function buildTextField(
|
|
346
|
-
el: HTMLElement,
|
|
347
|
-
index: number,
|
|
348
|
-
total: number,
|
|
349
|
-
source: "self" | "child",
|
|
350
|
-
): DomEditTextField {
|
|
351
|
-
const tagName = el.tagName.toLowerCase();
|
|
352
|
-
const key = el.getAttribute("data-hf-text-key") ?? `${source}:${index}:${tagName}`;
|
|
353
|
-
return {
|
|
354
|
-
key,
|
|
355
|
-
label: getTextFieldLabel(tagName, index, total, source),
|
|
356
|
-
value: el.textContent ?? "",
|
|
357
|
-
tagName,
|
|
358
|
-
attributes: Array.from(el.attributes)
|
|
359
|
-
.filter((attribute) => attribute.name !== "style")
|
|
360
|
-
.map((attribute) => ({
|
|
361
|
-
name: attribute.name,
|
|
362
|
-
value: attribute.value,
|
|
363
|
-
})),
|
|
364
|
-
inlineStyles: getInlineStyles(el),
|
|
365
|
-
computedStyles: getCuratedComputedStyles(el),
|
|
366
|
-
source,
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
|
|
371
|
-
const childFields = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf);
|
|
372
|
-
if (childFields.length > 0) {
|
|
373
|
-
return childFields.map((child, index) =>
|
|
374
|
-
buildTextField(child, index, childFields.length, "child"),
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
if (isEditableTextLeaf(el)) {
|
|
379
|
-
return [buildTextField(el, 0, 1, "self")];
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
return [];
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function escapeHtmlText(value: string): string {
|
|
386
|
-
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function serializeTextFieldStyle(field: DomEditTextField): string {
|
|
390
|
-
const entries = Object.entries(field.inlineStyles).filter(([, value]) => Boolean(value));
|
|
391
|
-
if (entries.length === 0) return "";
|
|
392
|
-
return entries.map(([key, value]) => `${key}: ${value}`).join("; ");
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
export function serializeDomEditTextFields(fields: DomEditTextField[]): string {
|
|
396
|
-
return fields
|
|
397
|
-
.filter((field) => field.source === "child")
|
|
398
|
-
.map((field) => {
|
|
399
|
-
const attrs = [
|
|
400
|
-
...field.attributes.filter((attribute) => attribute.name !== "data-hf-text-key"),
|
|
401
|
-
{ name: "data-hf-text-key", value: field.key },
|
|
402
|
-
]
|
|
403
|
-
.map((attribute) => ` ${attribute.name}="${attribute.value.replace(/"/g, """)}"`)
|
|
404
|
-
.join("");
|
|
405
|
-
const style = serializeTextFieldStyle(field);
|
|
406
|
-
const styleAttr = style ? ` style="${style.replace(/"/g, """)}"` : "";
|
|
407
|
-
return `<${field.tagName}${attrs}${styleAttr}>${escapeHtmlText(field.value)}</${field.tagName}>`;
|
|
408
|
-
})
|
|
409
|
-
.join("");
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>): DomEditTextField {
|
|
413
|
-
return {
|
|
414
|
-
key: `child:new:${Date.now()}`,
|
|
415
|
-
label: "Text",
|
|
416
|
-
value: "New text",
|
|
417
|
-
tagName: "span",
|
|
418
|
-
attributes: [],
|
|
419
|
-
inlineStyles: {
|
|
420
|
-
"font-family": base?.computedStyles?.["font-family"] ?? "inherit",
|
|
421
|
-
"font-size": base?.computedStyles?.["font-size"] ?? "16px",
|
|
422
|
-
"font-weight": base?.computedStyles?.["font-weight"] ?? "400",
|
|
423
|
-
color: base?.computedStyles?.color ?? "inherit",
|
|
424
|
-
},
|
|
425
|
-
computedStyles: {},
|
|
426
|
-
source: "child",
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
export function resolveDomEditCapabilities(args: {
|
|
431
|
-
selector?: string;
|
|
432
|
-
tagName?: string;
|
|
433
|
-
className?: string;
|
|
434
|
-
inlineStyles: Record<string, string>;
|
|
435
|
-
computedStyles: Record<string, string>;
|
|
436
|
-
isCompositionHost: boolean;
|
|
437
|
-
isMasterView: boolean;
|
|
438
|
-
}): DomEditCapabilities {
|
|
439
|
-
if (!args.selector) {
|
|
440
|
-
return {
|
|
441
|
-
canSelect: false,
|
|
442
|
-
canEditStyles: false,
|
|
443
|
-
canMove: false,
|
|
444
|
-
canResize: false,
|
|
445
|
-
canDetachFromLayout: false,
|
|
446
|
-
reasonIfDisabled: "Studio could not resolve a stable patch target for this element.",
|
|
447
|
-
};
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const position = args.computedStyles.position;
|
|
451
|
-
const left = parsePx(args.inlineStyles.left) ?? parsePx(args.computedStyles.left);
|
|
452
|
-
const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
|
|
453
|
-
const width = parsePx(args.inlineStyles.width) ?? parsePx(args.computedStyles.width);
|
|
454
|
-
const height = parsePx(args.inlineStyles.height) ?? parsePx(args.computedStyles.height);
|
|
455
|
-
const hasTransformDrivenGeometry = !isIdentityTransform(args.computedStyles.transform);
|
|
456
|
-
|
|
457
|
-
const canMove =
|
|
458
|
-
(position === "absolute" || position === "fixed") &&
|
|
459
|
-
left != null &&
|
|
460
|
-
top != null &&
|
|
461
|
-
!hasTransformDrivenGeometry;
|
|
462
|
-
|
|
463
|
-
const canResize = canMove && (width != null || height != null);
|
|
464
|
-
const isBlockishLayer =
|
|
465
|
-
args.isCompositionHost ||
|
|
466
|
-
isClipClassName(args.className) ||
|
|
467
|
-
isBlockishTag(args.tagName) ||
|
|
468
|
-
isBlockishDisplay(args.computedStyles.display);
|
|
469
|
-
const canDetachFromLayout =
|
|
470
|
-
!canMove &&
|
|
471
|
-
!hasTransformDrivenGeometry &&
|
|
472
|
-
isBlockishLayer &&
|
|
473
|
-
(!isInlineTextTag(args.tagName) || isClipClassName(args.className));
|
|
474
|
-
const reasonIfDisabled = !canMove
|
|
475
|
-
? canDetachFromLayout
|
|
476
|
-
? "This layer is controlled by layout."
|
|
477
|
-
: "Direct move/resize is limited to absolute or fixed elements with px geometry and no transform-driven layout."
|
|
478
|
-
: undefined;
|
|
479
|
-
|
|
480
|
-
if (args.isCompositionHost && args.isMasterView) {
|
|
481
|
-
return {
|
|
482
|
-
canSelect: true,
|
|
483
|
-
canEditStyles: false,
|
|
484
|
-
canMove,
|
|
485
|
-
canResize,
|
|
486
|
-
canDetachFromLayout,
|
|
487
|
-
reasonIfDisabled,
|
|
488
|
-
};
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
return {
|
|
492
|
-
canSelect: true,
|
|
493
|
-
canEditStyles: true,
|
|
494
|
-
canMove,
|
|
495
|
-
canResize,
|
|
496
|
-
canDetachFromLayout,
|
|
497
|
-
reasonIfDisabled,
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
export function resolveDomEditSelection(
|
|
502
|
-
startEl: HTMLElement | null,
|
|
503
|
-
options: DomEditContextOptions,
|
|
504
|
-
): DomEditSelection | null {
|
|
505
|
-
if (!startEl) return null;
|
|
506
|
-
const doc = startEl.ownerDocument;
|
|
507
|
-
|
|
508
|
-
let current: HTMLElement | null = getSelectionCandidate(startEl, options);
|
|
509
|
-
while (current && current !== doc.body && current !== doc.documentElement) {
|
|
510
|
-
const selector = buildStableSelector(current);
|
|
511
|
-
if (!selector) {
|
|
512
|
-
current = current.parentElement;
|
|
513
|
-
continue;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
const { sourceFile, compositionPath } = getSourceFileForElement(
|
|
517
|
-
current,
|
|
518
|
-
options.activeCompositionPath,
|
|
519
|
-
);
|
|
520
|
-
const selectorIndex = getSelectorIndex(
|
|
521
|
-
doc,
|
|
522
|
-
current,
|
|
523
|
-
selector,
|
|
524
|
-
sourceFile,
|
|
525
|
-
options.activeCompositionPath,
|
|
526
|
-
);
|
|
527
|
-
const compositionSrc =
|
|
528
|
-
current.getAttribute("data-composition-src") ??
|
|
529
|
-
current.getAttribute("data-composition-file") ??
|
|
530
|
-
undefined;
|
|
531
|
-
const inlineStyles = getInlineStyles(current);
|
|
532
|
-
const computedStyles = getCuratedComputedStyles(current);
|
|
533
|
-
const textFields = collectDomEditTextFields(current);
|
|
534
|
-
const capabilities = resolveDomEditCapabilities({
|
|
535
|
-
selector,
|
|
536
|
-
tagName: current.tagName.toLowerCase(),
|
|
537
|
-
className: current.className,
|
|
538
|
-
inlineStyles,
|
|
539
|
-
computedStyles,
|
|
540
|
-
isCompositionHost: Boolean(compositionSrc),
|
|
541
|
-
isMasterView: options.isMasterView,
|
|
542
|
-
});
|
|
543
|
-
const rect = current.getBoundingClientRect();
|
|
544
|
-
|
|
545
|
-
return {
|
|
546
|
-
element: current,
|
|
547
|
-
id: current.id || undefined,
|
|
548
|
-
selector,
|
|
549
|
-
selectorIndex,
|
|
550
|
-
sourceFile,
|
|
551
|
-
compositionPath,
|
|
552
|
-
compositionSrc,
|
|
553
|
-
isCompositionHost: Boolean(compositionSrc),
|
|
554
|
-
label: buildElementLabel(current),
|
|
555
|
-
tagName: current.tagName.toLowerCase(),
|
|
556
|
-
boundingBox: {
|
|
557
|
-
x: rect.left,
|
|
558
|
-
y: rect.top,
|
|
559
|
-
width: rect.width,
|
|
560
|
-
height: rect.height,
|
|
561
|
-
},
|
|
562
|
-
textContent: current.textContent?.trim() || null,
|
|
563
|
-
dataAttributes: getDataAttributes(current),
|
|
564
|
-
inlineStyles,
|
|
565
|
-
computedStyles,
|
|
566
|
-
textFields,
|
|
567
|
-
capabilities,
|
|
568
|
-
};
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
return null;
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
export function refreshDomEditSelection(
|
|
575
|
-
selection: DomEditSelection,
|
|
576
|
-
activeCompositionPath: string | null,
|
|
577
|
-
): DomEditSelection | null {
|
|
578
|
-
const doc = selection.element.ownerDocument;
|
|
579
|
-
const nextElement = findElementForSelection(doc, selection, activeCompositionPath);
|
|
580
|
-
return nextElement
|
|
581
|
-
? resolveDomEditSelection(nextElement, {
|
|
582
|
-
activeCompositionPath,
|
|
583
|
-
isMasterView: !activeCompositionPath || activeCompositionPath === "index.html",
|
|
584
|
-
})
|
|
585
|
-
: null;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
export function findElementForSelection(
|
|
589
|
-
doc: Document,
|
|
590
|
-
selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
|
|
591
|
-
activeCompositionPath: string | null = null,
|
|
592
|
-
): HTMLElement | null {
|
|
593
|
-
if (selection.id) {
|
|
594
|
-
const byId = doc.getElementById(selection.id);
|
|
595
|
-
if (
|
|
596
|
-
isHtmlElement(byId) &&
|
|
597
|
-
(!selection.sourceFile ||
|
|
598
|
-
getSourceFileForElement(byId, activeCompositionPath).sourceFile === selection.sourceFile)
|
|
599
|
-
) {
|
|
600
|
-
return byId;
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
if (!selection.selector) return null;
|
|
605
|
-
|
|
606
|
-
if (selection.selector.startsWith(".") && selection.selectorIndex != null) {
|
|
607
|
-
const matches = Array.from(doc.querySelectorAll(selection.selector)).filter(
|
|
608
|
-
(candidate): candidate is HTMLElement =>
|
|
609
|
-
isHtmlElement(candidate) &&
|
|
610
|
-
(!selection.sourceFile ||
|
|
611
|
-
getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
|
|
612
|
-
selection.sourceFile),
|
|
613
|
-
);
|
|
614
|
-
return matches[selection.selectorIndex] ?? null;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
const matches = Array.from(doc.querySelectorAll(selection.selector)).filter(
|
|
618
|
-
(candidate): candidate is HTMLElement =>
|
|
619
|
-
isHtmlElement(candidate) &&
|
|
620
|
-
(!selection.sourceFile ||
|
|
621
|
-
getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
|
|
622
|
-
selection.sourceFile),
|
|
623
|
-
);
|
|
624
|
-
return matches[0] ?? null;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
export function buildDomEditMovePatchOperations(left: number, top: number): PatchOperation[] {
|
|
628
|
-
return [
|
|
629
|
-
{ type: "inline-style", property: "left", value: `${Math.round(left)}px` },
|
|
630
|
-
{ type: "inline-style", property: "top", value: `${Math.round(top)}px` },
|
|
631
|
-
];
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
export function buildDomEditResizePatchOperations(width: number, height: number): PatchOperation[] {
|
|
635
|
-
return [
|
|
636
|
-
{ type: "inline-style", property: "width", value: `${Math.round(width)}px` },
|
|
637
|
-
{ type: "inline-style", property: "height", value: `${Math.round(height)}px` },
|
|
638
|
-
];
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
export function buildDomEditDetachPatchOperations(rect: {
|
|
642
|
-
left: number;
|
|
643
|
-
top: number;
|
|
644
|
-
width: number;
|
|
645
|
-
height: number;
|
|
646
|
-
}): PatchOperation[] {
|
|
647
|
-
return [
|
|
648
|
-
{ type: "inline-style", property: "position", value: "absolute" },
|
|
649
|
-
{ type: "inline-style", property: "left", value: `${Math.round(rect.left)}px` },
|
|
650
|
-
{ type: "inline-style", property: "top", value: `${Math.round(rect.top)}px` },
|
|
651
|
-
{ type: "inline-style", property: "width", value: `${Math.round(rect.width)}px` },
|
|
652
|
-
{ type: "inline-style", property: "height", value: `${Math.round(rect.height)}px` },
|
|
653
|
-
{ type: "inline-style", property: "margin", value: "0" },
|
|
654
|
-
{ type: "inline-style", property: "right", value: "auto" },
|
|
655
|
-
{ type: "inline-style", property: "bottom", value: "auto" },
|
|
656
|
-
];
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
export function buildDomEditStylePatchOperation(property: string, value: string): PatchOperation {
|
|
660
|
-
return {
|
|
661
|
-
type: "inline-style",
|
|
662
|
-
property,
|
|
663
|
-
value,
|
|
664
|
-
};
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
export function buildDomEditTextPatchOperation(value: string): PatchOperation {
|
|
668
|
-
return {
|
|
669
|
-
type: "text-content",
|
|
670
|
-
property: "text",
|
|
671
|
-
value,
|
|
672
|
-
};
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
function formatBoundingBox(bounds: DomEditSelection["boundingBox"]): string {
|
|
676
|
-
return `x=${Math.round(bounds.x)}, y=${Math.round(bounds.y)}, width=${Math.round(bounds.width)}, height=${Math.round(bounds.height)}`;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
function formatStyleBlock(styles: Record<string, string>): string {
|
|
680
|
-
return Object.entries(styles)
|
|
681
|
-
.filter(([, value]) => value && value !== "initial")
|
|
682
|
-
.map(([key, value]) => `${key}: ${value}`)
|
|
683
|
-
.join("\n");
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
function formatTextFields(fields: DomEditTextField[]): string {
|
|
687
|
-
return fields
|
|
688
|
-
.map(
|
|
689
|
-
(field) =>
|
|
690
|
-
`- key=${field.key}; tag=<${field.tagName}>; source=${field.source}; text="${field.value.replace(/"/g, '\\"')}"`,
|
|
691
|
-
)
|
|
692
|
-
.join("\n");
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
export function buildElementAgentPrompt({
|
|
696
|
-
selection,
|
|
697
|
-
currentTime,
|
|
698
|
-
tagSnippet,
|
|
699
|
-
userInstruction,
|
|
700
|
-
sourceFilePath,
|
|
701
|
-
}: {
|
|
702
|
-
selection: DomEditSelection;
|
|
703
|
-
currentTime: number;
|
|
704
|
-
tagSnippet?: string;
|
|
705
|
-
userInstruction?: string;
|
|
706
|
-
sourceFilePath?: string;
|
|
707
|
-
}): string {
|
|
708
|
-
const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
|
|
709
|
-
const lines = [
|
|
710
|
-
"## HyperFrames element edit request v1",
|
|
711
|
-
"Schema version: 1",
|
|
712
|
-
"",
|
|
713
|
-
userInstruction?.trim() || "Edit this selected HyperFrames element.",
|
|
714
|
-
"",
|
|
715
|
-
`Composition: ${selection.compositionPath}`,
|
|
716
|
-
`Playback time: ${formatTime(currentTime)}`,
|
|
717
|
-
`Source file: ${displayedSourceFile}`,
|
|
718
|
-
`DOM id: ${selection.id ?? "(none)"}`,
|
|
719
|
-
`Selector: ${selection.selector ?? "(none)"}`,
|
|
720
|
-
`Selector index: ${selection.selectorIndex ?? 0}`,
|
|
721
|
-
`Tag: <${selection.tagName}>`,
|
|
722
|
-
`Bounds: ${formatBoundingBox(selection.boundingBox)}`,
|
|
723
|
-
];
|
|
724
|
-
|
|
725
|
-
if (selection.textContent) {
|
|
726
|
-
lines.push(`Text: ${selection.textContent}`);
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
const textFieldsBlock = formatTextFields(selection.textFields);
|
|
730
|
-
if (textFieldsBlock) {
|
|
731
|
-
lines.push("", "Text fields:", textFieldsBlock);
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
const inlineStyleBlock = formatStyleBlock(selection.inlineStyles);
|
|
735
|
-
if (inlineStyleBlock) {
|
|
736
|
-
lines.push("", "Inline styles:", inlineStyleBlock);
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
const computedStyleBlock = formatStyleBlock(selection.computedStyles);
|
|
740
|
-
if (computedStyleBlock) {
|
|
741
|
-
lines.push("", "Computed styles (browser-resolved):", computedStyleBlock);
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
if (tagSnippet) {
|
|
745
|
-
lines.push("", "Target HTML:", tagSnippet);
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
lines.push(
|
|
749
|
-
"",
|
|
750
|
-
"Guardrails:",
|
|
751
|
-
"- Make a targeted change to this element only.",
|
|
752
|
-
"- Preserve the rest of the composition and its timing.",
|
|
753
|
-
"- Do not modify other elements' data-* attributes or positioning.",
|
|
754
|
-
"- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
|
|
755
|
-
);
|
|
756
|
-
|
|
757
|
-
return lines.join("\n");
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
export function isTextEditableSelection(selection: DomEditSelection): boolean {
|
|
761
|
-
return selection.textFields.length > 0 && !selection.isCompositionHost;
|
|
762
|
-
}
|