@hyperframes/studio 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-hYc4aP7M.js +117 -0
  3. package/dist/index.html +1 -1
  4. package/package.json +4 -4
  5. package/src/App.tsx +2 -13
  6. package/src/captions/components/CaptionOverlay.tsx +13 -246
  7. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  8. package/src/components/StudioPreviewArea.tsx +6 -2
  9. package/src/components/editor/DomEditOverlay.tsx +88 -1007
  10. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  11. package/src/components/editor/FileTree.tsx +13 -621
  12. package/src/components/editor/FileTreeIcons.tsx +128 -0
  13. package/src/components/editor/FileTreeNodes.tsx +496 -0
  14. package/src/components/editor/MotionPanel.tsx +16 -390
  15. package/src/components/editor/MotionPanelFields.tsx +185 -0
  16. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  17. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  19. package/src/components/editor/domEditing.ts +44 -1150
  20. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  21. package/src/components/editor/domEditingDom.ts +266 -0
  22. package/src/components/editor/domEditingElement.ts +329 -0
  23. package/src/components/editor/domEditingLayers.ts +460 -0
  24. package/src/components/editor/domEditingTypes.ts +125 -0
  25. package/src/components/editor/manualEdits.ts +84 -1081
  26. package/src/components/editor/manualEditsDom.ts +436 -0
  27. package/src/components/editor/manualEditsParsing.ts +280 -0
  28. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  29. package/src/components/editor/manualEditsTypes.ts +141 -0
  30. package/src/components/editor/studioMotion.ts +47 -434
  31. package/src/components/editor/studioMotionOps.ts +299 -0
  32. package/src/components/editor/studioMotionTypes.ts +168 -0
  33. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  34. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  35. package/src/components/nle/NLELayout.tsx +60 -144
  36. package/src/components/nle/useCompositionStack.ts +126 -0
  37. package/src/hooks/useToast.ts +20 -0
  38. package/src/player/components/Timeline.tsx +189 -1418
  39. package/src/player/components/TimelineCanvas.tsx +434 -0
  40. package/src/player/components/TimelineEmptyState.tsx +102 -0
  41. package/src/player/components/TimelineRuler.tsx +90 -0
  42. package/src/player/components/timelineIcons.tsx +49 -0
  43. package/src/player/components/timelineLayout.ts +215 -0
  44. package/src/player/components/timelineUtils.ts +211 -0
  45. package/src/player/components/useTimelineClipDrag.ts +388 -0
  46. package/src/player/components/useTimelinePlayhead.ts +200 -0
  47. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  48. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  49. package/src/player/hooks/useTimelinePlayer.ts +69 -1372
  50. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  51. package/src/player/lib/playbackAdapter.ts +145 -0
  52. package/src/player/lib/playbackShortcuts.ts +68 -0
  53. package/src/player/lib/playbackTypes.ts +60 -0
  54. package/src/player/lib/timelineDOM.ts +373 -0
  55. package/src/player/lib/timelineElementHelpers.ts +303 -0
  56. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  57. package/dist/assets/hyperframes-player-DOFETgjy.js +0 -418
  58. package/dist/assets/index-DUqUmaoH.js +0 -117
@@ -1,1150 +1,44 @@
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-style",
20
- "font-weight",
21
- "font-family",
22
- "line-height",
23
- "letter-spacing",
24
- "text-align",
25
- "text-transform",
26
- "color",
27
- "background-color",
28
- "background-image",
29
- "opacity",
30
- "mix-blend-mode",
31
- "border-radius",
32
- "border-width",
33
- "border-style",
34
- "border-color",
35
- "border-top-width",
36
- "border-top-style",
37
- "border-top-color",
38
- "outline-color",
39
- "overflow",
40
- "clip-path",
41
- "box-shadow",
42
- "filter",
43
- "backdrop-filter",
44
- "z-index",
45
- "transform",
46
- ] as const;
47
-
48
- export interface DomEditCapabilities {
49
- canSelect: boolean;
50
- canEditStyles: boolean;
51
- /** Directly editable authored left/top style fields. Canvas drag uses manual edits instead. */
52
- canMove: boolean;
53
- /** Directly editable authored width/height style fields. Canvas resize uses manual edits instead. */
54
- canResize: boolean;
55
- canApplyManualOffset: boolean;
56
- canApplyManualSize: boolean;
57
- canApplyManualRotation: boolean;
58
- reasonIfDisabled?: string;
59
- }
60
-
61
- export interface DomEditTextField {
62
- key: string;
63
- label: string;
64
- value: string;
65
- tagName: string;
66
- attributes: Array<{ name: string; value: string }>;
67
- inlineStyles: Record<string, string>;
68
- computedStyles: Record<string, string>;
69
- source: "self" | "child";
70
- }
71
-
72
- export interface DomEditSelection extends PatchTarget {
73
- element: HTMLElement;
74
- label: string;
75
- tagName: string;
76
- sourceFile: string;
77
- compositionPath: string;
78
- compositionSrc?: string;
79
- isCompositionHost: boolean;
80
- boundingBox: { x: number; y: number; width: number; height: number };
81
- textContent: string | null;
82
- dataAttributes: Record<string, string>;
83
- inlineStyles: Record<string, string>;
84
- computedStyles: Record<string, string>;
85
- textFields: DomEditTextField[];
86
- capabilities: DomEditCapabilities;
87
- }
88
-
89
- export interface DomEditLayerItem {
90
- key: string;
91
- element: HTMLElement;
92
- label: string;
93
- tagName: string;
94
- depth: number;
95
- childCount: number;
96
- id?: string;
97
- selector?: string;
98
- selectorIndex?: number;
99
- sourceFile: string;
100
- }
101
-
102
- export interface DomEditContextOptions {
103
- activeCompositionPath: string | null;
104
- isMasterView: boolean;
105
- preferClipAncestor?: boolean;
106
- }
107
-
108
- export interface DomEditViewport {
109
- width: number;
110
- height: number;
111
- }
112
-
113
- export interface TimelineElementDomTarget {
114
- id?: string;
115
- domId?: string;
116
- selector?: string;
117
- selectorIndex?: number;
118
- sourceFile?: string;
119
- compositionSrc?: string;
120
- }
121
-
122
- export interface TimelineElementDomTargetOptions {
123
- activeCompositionPath: string | null;
124
- compIdToSrc?: ReadonlyMap<string, string>;
125
- isMasterView: boolean;
126
- }
127
-
128
- function isHtmlElement(value: unknown): value is HTMLElement {
129
- return (
130
- typeof value === "object" &&
131
- value !== null &&
132
- "nodeType" in value &&
133
- typeof (value as { nodeType?: unknown }).nodeType === "number" &&
134
- (value as { nodeType: number }).nodeType === 1
135
- );
136
- }
137
-
138
- function parsePx(value: string | undefined): number | null {
139
- if (!value) return null;
140
- const trimmed = value.trim();
141
- if (!trimmed.endsWith("px")) return null;
142
- const parsed = parseFloat(trimmed);
143
- return Number.isFinite(parsed) ? parsed : null;
144
- }
145
-
146
- function isIdentityTransform(value: string | undefined): boolean {
147
- const transform = (value ?? "none").trim();
148
- if (!transform || transform === "none") return true;
149
-
150
- const matrix = transform.match(/^matrix\(([^)]+)\)$/i);
151
- if (matrix) {
152
- const values = matrix[1].split(",").map((part) => Number.parseFloat(part.trim()));
153
- if (values.length !== 6 || values.some((part) => !Number.isFinite(part))) return false;
154
- return (
155
- Math.abs(values[0] - 1) < 0.0001 &&
156
- Math.abs(values[1]) < 0.0001 &&
157
- Math.abs(values[2]) < 0.0001 &&
158
- Math.abs(values[3] - 1) < 0.0001 &&
159
- Math.abs(values[4]) < 0.0001 &&
160
- Math.abs(values[5]) < 0.0001
161
- );
162
- }
163
-
164
- const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i);
165
- if (!matrix3d) return false;
166
- const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim()));
167
- if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false;
168
- const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
169
- return values.every((part, index) => Math.abs(part - identity[index]) < 0.0001);
170
- }
171
-
172
- function isTextBearingTag(tagName: string): boolean {
173
- return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
174
- }
175
-
176
- function getCuratedComputedStyles(el: HTMLElement): Record<string, string> {
177
- const styles: Record<string, string> = {};
178
- const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
179
- if (!computed) return styles;
180
-
181
- for (const prop of CURATED_STYLE_PROPERTIES) {
182
- const value = computed.getPropertyValue(prop);
183
- if (value) styles[prop] = value;
184
- }
185
-
186
- return styles;
187
- }
188
-
189
- function findClosestByAttribute(el: HTMLElement, attributeNames: string[]): HTMLElement | null {
190
- let current: HTMLElement | null = el;
191
- while (current) {
192
- const candidate = current;
193
- if (attributeNames.some((attribute) => candidate.hasAttribute(attribute))) {
194
- return candidate;
195
- }
196
- current = current.parentElement;
197
- }
198
- return null;
199
- }
200
-
201
- function getSourceFileForElement(
202
- el: HTMLElement,
203
- activeCompositionPath: string | null,
204
- ): { sourceFile: string; compositionPath: string } {
205
- const sourceHost = findClosestByAttribute(el, ["data-composition-file", "data-composition-src"]);
206
- const ownerRoot = findClosestByAttribute(el, ["data-composition-id"]);
207
- const sourceFile =
208
- sourceHost?.getAttribute("data-composition-file") ??
209
- sourceHost?.getAttribute("data-composition-src") ??
210
- ownerRoot?.getAttribute("data-composition-file") ??
211
- ownerRoot?.getAttribute("data-composition-src") ??
212
- activeCompositionPath ??
213
- "index.html";
214
-
215
- return {
216
- sourceFile,
217
- compositionPath: sourceFile,
218
- };
219
- }
220
-
221
- function getPreferredClipAncestor(startEl: HTMLElement): HTMLElement | null {
222
- let current: HTMLElement | null = startEl;
223
- while (current) {
224
- if (current.classList.contains("clip")) {
225
- const isCompositionHost =
226
- current.hasAttribute("data-composition-src") ||
227
- current.hasAttribute("data-composition-file");
228
- if (!isCompositionHost || current === startEl) return current;
229
- }
230
- current = current.parentElement;
231
- }
232
- return null;
233
- }
234
-
235
- function getSelectionCandidate(startEl: HTMLElement, options: DomEditContextOptions): HTMLElement {
236
- if (options.preferClipAncestor) {
237
- const clipAncestor = getPreferredClipAncestor(startEl);
238
- if (clipAncestor) {
239
- return clipAncestor;
240
- }
241
- }
242
-
243
- return startEl;
244
- }
245
-
246
- function getPreferredClassSelector(el: HTMLElement): string | undefined {
247
- const classes = Array.from(el.classList)
248
- .map((value) => value.trim())
249
- .filter(Boolean);
250
- if (classes.length === 0) return undefined;
251
- const preferred =
252
- classes.find((value) => value !== "clip" && !value.startsWith("__hf-")) ?? classes[0];
253
- return preferred ? `.${escapeCssIdentifier(preferred)}` : undefined;
254
- }
255
-
256
- function escapeCssIdentifier(value: string): string {
257
- const css = globalThis.CSS as { escape?: (input: string) => string } | undefined;
258
- if (typeof css?.escape === "function") return css.escape(value);
259
-
260
- if (value === "-") return "\\-";
261
-
262
- let escaped = "";
263
- for (let index = 0; index < value.length; index += 1) {
264
- const char = value[index] ?? "";
265
- const code = char.charCodeAt(0);
266
- if (code === 0) {
267
- escaped += "\uFFFD";
268
- continue;
269
- }
270
-
271
- const isDigit = code >= 48 && code <= 57;
272
- const isUpperAlpha = code >= 65 && code <= 90;
273
- const isLowerAlpha = code >= 97 && code <= 122;
274
- const isControl = (code >= 1 && code <= 31) || code === 127;
275
- const isLeadingDigit = index === 0 && isDigit;
276
- const isSecondDigitAfterDash = index === 1 && value.startsWith("-") && isDigit;
277
- if (isControl || isLeadingDigit || isSecondDigitAfterDash) {
278
- escaped += `\\${code.toString(16)} `;
279
- continue;
280
- }
281
- if (isUpperAlpha || isLowerAlpha || isDigit || char === "-" || char === "_" || code >= 128) {
282
- escaped += char;
283
- continue;
284
- }
285
- escaped += `\\${char}`;
286
- }
287
- return escaped;
288
- }
289
-
290
- function escapeCssString(value: string): string {
291
- return value
292
- .replace(/\\/g, "\\\\")
293
- .replace(/"/g, '\\"')
294
- .replace(/\n/g, "\\a ")
295
- .replace(/\r/g, "\\d ")
296
- .replace(/\f/g, "\\c ");
297
- }
298
-
299
- function normalizeTimelineCompositionSource(value: string | undefined): string | undefined {
300
- const trimmed = value?.trim();
301
- if (!trimmed) return undefined;
302
-
303
- let pathname = trimmed;
304
- try {
305
- pathname = new URL(trimmed, "http://studio.local").pathname;
306
- } catch {
307
- pathname = trimmed;
308
- }
309
-
310
- for (const marker of ["/preview/comp/", "/preview/"]) {
311
- const markerIndex = pathname.indexOf(marker);
312
- if (markerIndex < 0) continue;
313
- const sourcePath = pathname.slice(markerIndex + marker.length).replace(/^\/+/, "");
314
- return sourcePath || trimmed;
315
- }
316
-
317
- return trimmed;
318
- }
319
-
320
- function querySelectorAllSafely(doc: Document, selector: string): Element[] {
321
- try {
322
- return Array.from(doc.querySelectorAll(selector));
323
- } catch {
324
- return [];
325
- }
326
- }
327
-
328
- function humanizeIdentifier(value: string): string {
329
- return (
330
- value
331
- .replace(/\.html$/i, "")
332
- .replace(/^compositions\//i, "")
333
- .split("/")
334
- .at(-1)
335
- ?.replace(/[-_]+/g, " ")
336
- .replace(/\b\w/g, (char) => char.toUpperCase()) ?? value
337
- );
338
- }
339
-
340
- function buildStableSelector(el: HTMLElement): string | undefined {
341
- if (el.id) return `#${escapeCssIdentifier(el.id)}`;
342
-
343
- const compositionId = el.getAttribute("data-composition-id");
344
- if (compositionId) return `[data-composition-id="${escapeCssString(compositionId)}"]`;
345
-
346
- return getPreferredClassSelector(el);
347
- }
348
-
349
- function getSelectorIndex(
350
- doc: Document,
351
- el: HTMLElement,
352
- selector: string | undefined,
353
- sourceFile: string,
354
- activeCompositionPath: string | null,
355
- ): number | undefined {
356
- if (!selector?.startsWith(".")) return undefined;
357
-
358
- const candidates = querySelectorAllSafely(doc, selector).filter(
359
- (candidate): candidate is HTMLElement =>
360
- isHtmlElement(candidate) &&
361
- getSourceFileForElement(candidate, activeCompositionPath).sourceFile === sourceFile,
362
- );
363
- const index = candidates.indexOf(el);
364
- return index >= 0 ? index : undefined;
365
- }
366
-
367
- export function getDomEditLayerKey(
368
- target: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
369
- ): string {
370
- const selectorIndex = target.selectorIndex ?? 0;
371
- return `${target.sourceFile}:${target.id ?? target.selector ?? "layer"}:${selectorIndex}`;
372
- }
373
-
374
- function buildElementLabel(el: HTMLElement): string {
375
- const compositionId = el.getAttribute("data-composition-id");
376
- if (compositionId && compositionId !== "main") {
377
- return humanizeIdentifier(compositionId);
378
- }
379
-
380
- const compositionSrc =
381
- el.getAttribute("data-composition-src") ?? el.getAttribute("data-composition-file");
382
- if (compositionSrc) {
383
- return humanizeIdentifier(compositionSrc);
384
- }
385
-
386
- if (el.id) return humanizeIdentifier(el.id);
387
-
388
- const preferredClass = getPreferredClassSelector(el);
389
- if (preferredClass) {
390
- return humanizeIdentifier(preferredClass.replace(/^\./, ""));
391
- }
392
-
393
- const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
394
- if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text;
395
- return el.tagName.toLowerCase();
396
- }
397
-
398
- const DOM_LAYER_IGNORED_TAGS = new Set([
399
- "base",
400
- "br",
401
- "canvas",
402
- "link",
403
- "meta",
404
- "script",
405
- "source",
406
- "style",
407
- "template",
408
- "track",
409
- "wbr",
410
- ]);
411
-
412
- function isInspectableLayerElement(el: HTMLElement): boolean {
413
- const tagName = el.tagName.toLowerCase();
414
- if (DOM_LAYER_IGNORED_TAGS.has(tagName)) return false;
415
-
416
- const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
417
- if (computed?.display === "none" || computed?.visibility === "hidden") return false;
418
-
419
- return true;
420
- }
421
-
422
- function getDomLayerPatchTarget(
423
- el: HTMLElement,
424
- activeCompositionPath: string | null,
425
- ): Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile"> | null {
426
- if (!isInspectableLayerElement(el)) return null;
427
-
428
- const selector = buildStableSelector(el);
429
- if (!selector) return null;
430
-
431
- const { sourceFile } = getSourceFileForElement(el, activeCompositionPath);
432
- return {
433
- id: el.id || undefined,
434
- selector,
435
- selectorIndex: getSelectorIndex(
436
- el.ownerDocument,
437
- el,
438
- selector,
439
- sourceFile,
440
- activeCompositionPath,
441
- ),
442
- sourceFile,
443
- };
444
- }
445
-
446
- function getElementDepth(el: HTMLElement): number {
447
- let depth = 0;
448
- let current = el.parentElement;
449
- while (current) {
450
- depth += 1;
451
- current = current.parentElement;
452
- }
453
- return depth;
454
- }
455
-
456
- const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
457
-
458
- function isElementComputedVisible(el: HTMLElement): boolean {
459
- const win = el.ownerDocument.defaultView;
460
- if (!win) return true;
461
- let current: HTMLElement | null = el;
462
- while (current) {
463
- const computed = win.getComputedStyle(current);
464
- if (computed.display === "none" || computed.visibility === "hidden") return false;
465
- const opacity = Number.parseFloat(computed.opacity);
466
- if (Number.isFinite(opacity) && opacity <= 0.01) return false;
467
- current = current.parentElement;
468
- }
469
- return true;
470
- }
471
-
472
- function isEmptyVisualContainer(el: HTMLElement): boolean {
473
- const tag = el.tagName.toLowerCase();
474
- if (VISUAL_LEAF_TAGS.has(tag)) return false;
475
-
476
- const children = el.children;
477
- if (children.length === 0) {
478
- const text = (el.textContent ?? "").trim();
479
- return text.length === 0;
480
- }
481
-
482
- for (let i = 0; i < children.length; i += 1) {
483
- const child = children[i];
484
- if (!isHtmlElement(child)) continue;
485
- if (VISUAL_LEAF_TAGS.has(child.tagName.toLowerCase())) return false;
486
- if (isElementComputedVisible(child)) return false;
487
- }
488
-
489
- return true;
490
- }
491
-
492
- function hasRenderedBox(el: HTMLElement): boolean {
493
- const rect = el.getBoundingClientRect();
494
- if (rect.width <= 1 || rect.height <= 1) return false;
495
-
496
- if (!isElementComputedVisible(el)) return false;
497
-
498
- if (isEmptyVisualContainer(el)) return false;
499
-
500
- return true;
501
- }
502
-
503
- function getVisualElementScore(el: HTMLElement, pointerStackIndex: number): number {
504
- const tagName = el.tagName.toLowerCase();
505
- const rect = el.getBoundingClientRect();
506
- const area = Math.max(1, rect.width * rect.height);
507
- const smallerElementBonus = Math.max(0, 1_000_000 - Math.min(area, 1_000_000)) / 1_000;
508
- const visualLeafBonus =
509
- isEditableTextLeaf(el) || ["img", "video", "canvas", "svg"].includes(tagName) ? 2_000 : 0;
510
-
511
- return getElementDepth(el) * 10_000 + visualLeafBonus + smallerElementBonus - pointerStackIndex;
512
- }
513
-
514
- export function resolveVisualDomEditSelectionTarget(
515
- elementsFromPoint: Iterable<Element | null | undefined>,
516
- options: Pick<DomEditContextOptions, "activeCompositionPath">,
517
- ): HTMLElement | null {
518
- let best: { element: HTMLElement; score: number } | null = null;
519
- let pointerStackIndex = 0;
520
-
521
- for (const entry of elementsFromPoint) {
522
- if (!isHtmlElement(entry)) {
523
- pointerStackIndex += 1;
524
- continue;
525
- }
526
-
527
- if (hasRenderedBox(entry) && getDomLayerPatchTarget(entry, options.activeCompositionPath)) {
528
- const score = getVisualElementScore(entry, pointerStackIndex);
529
- if (!best || score > best.score) {
530
- best = { element: entry, score };
531
- }
532
- }
533
- pointerStackIndex += 1;
534
- }
535
-
536
- return best?.element ?? null;
537
- }
538
-
539
- function hasRasterBackground(selection: Pick<DomEditSelection, "computedStyles">): boolean {
540
- const backgroundImage = selection.computedStyles["background-image"]?.trim();
541
- return Boolean(backgroundImage && backgroundImage !== "none");
542
- }
543
-
544
- export function isLargeRasterDomEditSelection(
545
- selection: Pick<DomEditSelection, "boundingBox" | "computedStyles" | "tagName">,
546
- viewport?: DomEditViewport | null,
547
- ): boolean {
548
- const tagName = selection.tagName.toLowerCase();
549
- const isRasterLike = tagName === "img" || hasRasterBackground(selection);
550
- if (!isRasterLike) return false;
551
-
552
- const { width, height } = selection.boundingBox;
553
- if (width <= 1 || height <= 1) return false;
554
- if (!viewport || viewport.width <= 1 || viewport.height <= 1) {
555
- return width >= 960 && height >= 540;
556
- }
557
-
558
- const areaRatio = (width * height) / (viewport.width * viewport.height);
559
- const widthRatio = width / viewport.width;
560
- const heightRatio = height / viewport.height;
561
- return areaRatio >= 0.4 || (widthRatio >= 0.7 && heightRatio >= 0.5);
562
- }
563
-
564
- function getDirectLayerChildren(el: HTMLElement, options: DomEditContextOptions): HTMLElement[] {
565
- return Array.from(el.children).filter(
566
- (child): child is HTMLElement =>
567
- isHtmlElement(child) && getDomLayerPatchTarget(child, options.activeCompositionPath) !== null,
568
- );
569
- }
570
-
571
- export function countDomEditChildLayers(
572
- root: HTMLElement | null | undefined,
573
- options: DomEditContextOptions,
574
- maxCount = 99,
575
- ): number {
576
- if (!root) return 0;
577
-
578
- let count = 0;
579
- const visit = (el: HTMLElement) => {
580
- for (const child of Array.from(el.children)) {
581
- if (!isHtmlElement(child)) continue;
582
- if (getDomLayerPatchTarget(child, options.activeCompositionPath)) {
583
- count += 1;
584
- if (count >= maxCount) return;
585
- }
586
- visit(child);
587
- if (count >= maxCount) return;
588
- }
589
- };
590
-
591
- visit(root);
592
- return count;
593
- }
594
-
595
- export function collectDomEditLayerItems(
596
- root: HTMLElement | null | undefined,
597
- options: DomEditContextOptions,
598
- maxItems = 80,
599
- ): DomEditLayerItem[] {
600
- if (!root) return [];
601
-
602
- const items: DomEditLayerItem[] = [];
603
- const visit = (el: HTMLElement, depth: number) => {
604
- if (items.length >= maxItems) return;
605
-
606
- const target = getDomLayerPatchTarget(el, options.activeCompositionPath);
607
- if (target) {
608
- items.push({
609
- key: getDomEditLayerKey(target),
610
- element: el,
611
- label: buildElementLabel(el),
612
- tagName: el.tagName.toLowerCase(),
613
- depth,
614
- childCount: getDirectLayerChildren(el, options).length,
615
- id: target.id ?? undefined,
616
- selector: target.selector ?? undefined,
617
- selectorIndex: target.selectorIndex,
618
- sourceFile: target.sourceFile,
619
- });
620
- }
621
-
622
- const nextDepth = target ? depth + 1 : depth;
623
- for (const child of Array.from(el.children)) {
624
- if (!isHtmlElement(child)) continue;
625
- visit(child, nextDepth);
626
- if (items.length >= maxItems) return;
627
- }
628
- };
629
-
630
- visit(root, 0);
631
- return items;
632
- }
633
-
634
- function getDataAttributes(el: HTMLElement): Record<string, string> {
635
- const attrs: Record<string, string> = {};
636
- for (const attr of el.attributes) {
637
- if (attr.name.startsWith("data-")) {
638
- attrs[attr.name.slice(5)] = attr.value;
639
- }
640
- }
641
- return attrs;
642
- }
643
-
644
- function getInlineStyles(el: HTMLElement): Record<string, string> {
645
- const styles: Record<string, string> = {};
646
- for (const property of CURATED_STYLE_PROPERTIES) {
647
- const value = el.style.getPropertyValue(property);
648
- if (value) styles[property] = value;
649
- }
650
- return styles;
651
- }
652
-
653
- function isEditableTextLeaf(el: HTMLElement): boolean {
654
- return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
655
- }
656
-
657
- function getTextFieldLabel(
658
- _tagName: string,
659
- index: number,
660
- total: number,
661
- source: "self" | "child",
662
- ): string {
663
- if (source === "self" || total === 1) return "Content";
664
- return `Text ${index + 1}`;
665
- }
666
-
667
- function buildTextField(
668
- el: HTMLElement,
669
- index: number,
670
- total: number,
671
- source: "self" | "child",
672
- ): DomEditTextField {
673
- const tagName = el.tagName.toLowerCase();
674
- const key = el.getAttribute("data-hf-text-key") ?? `${source}:${index}:${tagName}`;
675
- return {
676
- key,
677
- label: getTextFieldLabel(tagName, index, total, source),
678
- value: el.textContent ?? "",
679
- tagName,
680
- attributes: Array.from(el.attributes)
681
- .filter((attribute) => attribute.name !== "style")
682
- .map((attribute) => ({
683
- name: attribute.name,
684
- value: attribute.value,
685
- })),
686
- inlineStyles: getInlineStyles(el),
687
- computedStyles: getCuratedComputedStyles(el),
688
- source,
689
- };
690
- }
691
-
692
- function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
693
- const childFields = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf);
694
- if (childFields.length > 0) {
695
- return childFields.map((child, index) =>
696
- buildTextField(child, index, childFields.length, "child"),
697
- );
698
- }
699
-
700
- if (isEditableTextLeaf(el)) {
701
- return [buildTextField(el, 0, 1, "self")];
702
- }
703
-
704
- return [];
705
- }
706
-
707
- function escapeHtmlText(value: string): string {
708
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
709
- }
710
-
711
- function serializeTextFieldStyle(field: DomEditTextField): string {
712
- const entries = Object.entries(field.inlineStyles).filter(([, value]) => Boolean(value));
713
- if (entries.length === 0) return "";
714
- return entries.map(([key, value]) => `${key}: ${value}`).join("; ");
715
- }
716
-
717
- export function serializeDomEditTextFields(fields: DomEditTextField[]): string {
718
- return fields
719
- .filter((field) => field.source === "child")
720
- .map((field) => {
721
- const attrs = [
722
- ...field.attributes.filter((attribute) => attribute.name !== "data-hf-text-key"),
723
- { name: "data-hf-text-key", value: field.key },
724
- ]
725
- .map((attribute) => ` ${attribute.name}="${attribute.value.replace(/"/g, "&quot;")}"`)
726
- .join("");
727
- const style = serializeTextFieldStyle(field);
728
- const styleAttr = style ? ` style="${style.replace(/"/g, "&quot;")}"` : "";
729
- return `<${field.tagName}${attrs}${styleAttr}>${escapeHtmlText(field.value)}</${field.tagName}>`;
730
- })
731
- .join("");
732
- }
733
-
734
- export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>): DomEditTextField {
735
- return {
736
- key: `child:new:${Date.now()}`,
737
- label: "Text",
738
- value: "New text",
739
- tagName: "span",
740
- attributes: [],
741
- inlineStyles: {
742
- "font-family": base?.computedStyles?.["font-family"] ?? "inherit",
743
- "font-size": base?.computedStyles?.["font-size"] ?? "16px",
744
- "font-weight": base?.computedStyles?.["font-weight"] ?? "400",
745
- color: base?.computedStyles?.color ?? "inherit",
746
- },
747
- computedStyles: {},
748
- source: "child",
749
- };
750
- }
751
-
752
- export function resolveDomEditCapabilities(args: {
753
- selector?: string;
754
- tagName?: string;
755
- className?: string;
756
- inlineStyles: Record<string, string>;
757
- computedStyles: Record<string, string>;
758
- isCompositionHost: boolean;
759
- isMasterView: boolean;
760
- }): DomEditCapabilities {
761
- if (!args.selector) {
762
- return {
763
- canSelect: false,
764
- canEditStyles: false,
765
- canMove: false,
766
- canResize: false,
767
- canApplyManualOffset: false,
768
- canApplyManualSize: false,
769
- canApplyManualRotation: false,
770
- reasonIfDisabled: "Studio could not resolve a stable patch target for this element.",
771
- };
772
- }
773
-
774
- const position = args.computedStyles.position;
775
- const left = parsePx(args.inlineStyles.left) ?? parsePx(args.computedStyles.left);
776
- const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
777
- const width = parsePx(args.inlineStyles.width) ?? parsePx(args.computedStyles.width);
778
- const height = parsePx(args.inlineStyles.height) ?? parsePx(args.computedStyles.height);
779
- const hasTransformDrivenGeometry = !isIdentityTransform(args.computedStyles.transform);
780
-
781
- const canMove =
782
- (position === "absolute" || position === "fixed") &&
783
- left != null &&
784
- top != null &&
785
- !hasTransformDrivenGeometry;
786
-
787
- const canResize = canMove && (width != null || height != null);
788
- const canApplyManualGeometry = !args.isCompositionHost;
789
- const canApplyManualOffset = canApplyManualGeometry;
790
- const canApplyManualSize = canApplyManualGeometry;
791
- const canApplyManualRotation = canApplyManualGeometry;
792
- const reasonIfDisabled = canApplyManualGeometry
793
- ? undefined
794
- : "Select an internal layer to transform it.";
795
-
796
- if (args.isCompositionHost && args.isMasterView) {
797
- return {
798
- canSelect: true,
799
- canEditStyles: false,
800
- canMove,
801
- canResize,
802
- canApplyManualOffset,
803
- canApplyManualSize,
804
- canApplyManualRotation,
805
- reasonIfDisabled,
806
- };
807
- }
808
-
809
- return {
810
- canSelect: true,
811
- canEditStyles: true,
812
- canMove,
813
- canResize,
814
- canApplyManualOffset,
815
- canApplyManualSize,
816
- canApplyManualRotation,
817
- reasonIfDisabled,
818
- };
819
- }
820
-
821
- export function resolveDomEditSelection(
822
- startEl: HTMLElement | null,
823
- options: DomEditContextOptions,
824
- ): DomEditSelection | null {
825
- if (!startEl) return null;
826
- const doc = startEl.ownerDocument;
827
-
828
- let current: HTMLElement | null = getSelectionCandidate(startEl, options);
829
- while (current && current !== doc.body && current !== doc.documentElement) {
830
- const selector = buildStableSelector(current);
831
- if (!selector) {
832
- current = current.parentElement;
833
- continue;
834
- }
835
-
836
- const { sourceFile, compositionPath } = getSourceFileForElement(
837
- current,
838
- options.activeCompositionPath,
839
- );
840
- const selectorIndex = getSelectorIndex(
841
- doc,
842
- current,
843
- selector,
844
- sourceFile,
845
- options.activeCompositionPath,
846
- );
847
- const compositionSrc =
848
- current.getAttribute("data-composition-src") ??
849
- current.getAttribute("data-composition-file") ??
850
- undefined;
851
- const inlineStyles = getInlineStyles(current);
852
- const computedStyles = getCuratedComputedStyles(current);
853
- const textFields = collectDomEditTextFields(current);
854
- const capabilities = resolveDomEditCapabilities({
855
- selector,
856
- tagName: current.tagName.toLowerCase(),
857
- className: current.className,
858
- inlineStyles,
859
- computedStyles,
860
- isCompositionHost: Boolean(compositionSrc),
861
- isMasterView: options.isMasterView,
862
- });
863
- const rect = current.getBoundingClientRect();
864
-
865
- return {
866
- element: current,
867
- id: current.id || undefined,
868
- selector,
869
- selectorIndex,
870
- sourceFile,
871
- compositionPath,
872
- compositionSrc,
873
- isCompositionHost: Boolean(compositionSrc),
874
- label: buildElementLabel(current),
875
- tagName: current.tagName.toLowerCase(),
876
- boundingBox: {
877
- x: rect.left,
878
- y: rect.top,
879
- width: rect.width,
880
- height: rect.height,
881
- },
882
- textContent: current.textContent?.trim() || null,
883
- dataAttributes: getDataAttributes(current),
884
- inlineStyles,
885
- computedStyles,
886
- textFields,
887
- capabilities,
888
- };
889
- }
890
-
891
- return null;
892
- }
893
-
894
- export function refreshDomEditSelection(
895
- selection: DomEditSelection,
896
- activeCompositionPath: string | null,
897
- ): DomEditSelection | null {
898
- const doc = selection.element.ownerDocument;
899
- const nextElement = findElementForSelection(doc, selection, activeCompositionPath);
900
- return nextElement
901
- ? resolveDomEditSelection(nextElement, {
902
- activeCompositionPath,
903
- isMasterView: !activeCompositionPath || activeCompositionPath === "index.html",
904
- })
905
- : null;
906
- }
907
-
908
- export function getDomEditTargetKey(
909
- selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
910
- ): string {
911
- return [
912
- selection.sourceFile || "index.html",
913
- selection.id ?? "",
914
- selection.selector ?? "",
915
- selection.selectorIndex ?? "",
916
- ].join("|");
917
- }
918
-
919
- function hasSupportedDirectEdit(capabilities: DomEditCapabilities): boolean {
920
- return (
921
- capabilities.canEditStyles ||
922
- capabilities.canMove ||
923
- capabilities.canResize ||
924
- capabilities.canApplyManualOffset ||
925
- capabilities.canApplyManualSize ||
926
- capabilities.canApplyManualRotation
927
- );
928
- }
929
-
930
- export function getDomEditNonEditableReason(
931
- element: HTMLElement,
932
- selection: DomEditSelection | null,
933
- ): string | null {
934
- if (!selection) {
935
- return "No stable source target";
936
- }
937
-
938
- if (selection.element !== element) {
939
- return selection.isCompositionHost
940
- ? "Nested composition boundary"
941
- : `Selection resolves to ${selection.label}`;
942
- }
943
-
944
- if (!hasSupportedDirectEdit(selection.capabilities)) {
945
- return selection.capabilities.reasonIfDisabled ?? "No supported direct edits";
946
- }
947
-
948
- return null;
949
- }
950
-
951
- export function findElementForSelection(
952
- doc: Document,
953
- selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
954
- activeCompositionPath: string | null = null,
955
- ): HTMLElement | null {
956
- if (selection.id) {
957
- const byId = doc.getElementById(selection.id);
958
- if (
959
- isHtmlElement(byId) &&
960
- (!selection.sourceFile ||
961
- getSourceFileForElement(byId, activeCompositionPath).sourceFile === selection.sourceFile)
962
- ) {
963
- return byId;
964
- }
965
- }
966
-
967
- if (!selection.selector) return null;
968
-
969
- if (selection.selector.startsWith(".") && selection.selectorIndex != null) {
970
- const matches = querySelectorAllSafely(doc, selection.selector).filter(
971
- (candidate): candidate is HTMLElement =>
972
- isHtmlElement(candidate) &&
973
- (!selection.sourceFile ||
974
- getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
975
- selection.sourceFile),
976
- );
977
- return matches[selection.selectorIndex] ?? null;
978
- }
979
-
980
- const matches = querySelectorAllSafely(doc, selection.selector).filter(
981
- (candidate): candidate is HTMLElement =>
982
- isHtmlElement(candidate) &&
983
- (!selection.sourceFile ||
984
- getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
985
- selection.sourceFile),
986
- );
987
- return matches[0] ?? null;
988
- }
989
-
990
- export function findElementForTimelineElement(
991
- doc: Document,
992
- element: TimelineElementDomTarget,
993
- options: TimelineElementDomTargetOptions,
994
- ): HTMLElement | null {
995
- const elementId = typeof element.id === "string" ? element.id : "";
996
- const compositionSource =
997
- normalizeTimelineCompositionSource(element.compositionSrc) ??
998
- options.compIdToSrc?.get(elementId);
999
- const sourceFile =
1000
- compositionSource ??
1001
- normalizeTimelineCompositionSource(element.sourceFile) ??
1002
- options.activeCompositionPath ??
1003
- "index.html";
1004
- const escapedElementId = escapeCssString(elementId);
1005
- const escapedCompositionSource = compositionSource ? escapeCssString(compositionSource) : null;
1006
- const selector =
1007
- element.selector ??
1008
- (compositionSource
1009
- ? `[data-composition-src="${escapedCompositionSource}"],[data-composition-file="${escapedCompositionSource}"],[data-composition-id="${escapedElementId}"]`
1010
- : escapedElementId
1011
- ? `[data-composition-id="${escapedElementId}"]`
1012
- : undefined);
1013
-
1014
- if (selector || element.domId) {
1015
- const targetElement = findElementForSelection(
1016
- doc,
1017
- {
1018
- id: element.domId ?? undefined,
1019
- selector,
1020
- selectorIndex: element.selectorIndex,
1021
- sourceFile,
1022
- },
1023
- options.activeCompositionPath,
1024
- );
1025
- if (targetElement) return targetElement;
1026
- }
1027
-
1028
- const hasExplicitDomTarget = Boolean(element.domId || element.selector || compositionSource);
1029
- if (options.isMasterView || hasExplicitDomTarget || !options.activeCompositionPath) {
1030
- return null;
1031
- }
1032
-
1033
- const root = doc.querySelector("[data-composition-id]");
1034
- if (!isHtmlElement(root)) return null;
1035
- return getSourceFileForElement(root, options.activeCompositionPath).sourceFile === sourceFile
1036
- ? root
1037
- : null;
1038
- }
1039
-
1040
- export function buildDomEditStylePatchOperation(property: string, value: string): PatchOperation {
1041
- return {
1042
- type: "inline-style",
1043
- property,
1044
- value,
1045
- };
1046
- }
1047
-
1048
- export function buildDomEditTextPatchOperation(value: string): PatchOperation {
1049
- return {
1050
- type: "text-content",
1051
- property: "text",
1052
- value,
1053
- };
1054
- }
1055
-
1056
- function formatBoundingBox(bounds: DomEditSelection["boundingBox"]): string {
1057
- return `x=${Math.round(bounds.x)}, y=${Math.round(bounds.y)}, width=${Math.round(bounds.width)}, height=${Math.round(bounds.height)}`;
1058
- }
1059
-
1060
- function formatStyleBlock(styles: Record<string, string>): string {
1061
- return Object.entries(styles)
1062
- .filter(([, value]) => value && value !== "initial")
1063
- .map(([key, value]) => `${key}: ${value}`)
1064
- .join("\n");
1065
- }
1066
-
1067
- function formatTextFields(fields: DomEditTextField[]): string {
1068
- return fields
1069
- .map(
1070
- (field) =>
1071
- `- key=${field.key}; tag=<${field.tagName}>; source=${field.source}; text=${JSON.stringify(field.value)}`,
1072
- )
1073
- .join("\n");
1074
- }
1075
-
1076
- export function buildElementAgentPrompt({
1077
- selection,
1078
- currentTime,
1079
- tagSnippet,
1080
- selectionContext,
1081
- userInstruction,
1082
- sourceFilePath,
1083
- }: {
1084
- selection: DomEditSelection;
1085
- currentTime: number;
1086
- tagSnippet?: string;
1087
- selectionContext?: string;
1088
- userInstruction?: string;
1089
- sourceFilePath?: string;
1090
- }): string {
1091
- const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
1092
- const lines = [
1093
- "## HyperFrames element edit request v1",
1094
- "Schema version: 1",
1095
- "",
1096
- userInstruction?.trim() || "Edit this selected HyperFrames element.",
1097
- "",
1098
- `Composition: ${selection.compositionPath}`,
1099
- `Playback time: ${formatTime(currentTime)}`,
1100
- `Source file: ${displayedSourceFile}`,
1101
- `DOM id: ${selection.id ?? "(none)"}`,
1102
- `Selector: ${selection.selector ?? "(none)"}`,
1103
- `Selector index: ${selection.selectorIndex ?? 0}`,
1104
- `Tag: <${selection.tagName}>`,
1105
- `Bounds: ${formatBoundingBox(selection.boundingBox)}`,
1106
- ];
1107
-
1108
- if (selection.textContent) {
1109
- lines.push(`Text: ${selection.textContent}`);
1110
- }
1111
-
1112
- const trimmedSelectionContext = selectionContext?.trim();
1113
- if (trimmedSelectionContext) {
1114
- lines.push("", "Selection context:", trimmedSelectionContext);
1115
- }
1116
-
1117
- const textFieldsBlock = formatTextFields(selection.textFields);
1118
- if (textFieldsBlock) {
1119
- lines.push("", "Text fields:", textFieldsBlock);
1120
- }
1121
-
1122
- const inlineStyleBlock = formatStyleBlock(selection.inlineStyles);
1123
- if (inlineStyleBlock) {
1124
- lines.push("", "Inline styles:", inlineStyleBlock);
1125
- }
1126
-
1127
- const computedStyleBlock = formatStyleBlock(selection.computedStyles);
1128
- if (computedStyleBlock) {
1129
- lines.push("", "Computed styles (browser-resolved):", computedStyleBlock);
1130
- }
1131
-
1132
- if (tagSnippet) {
1133
- lines.push("", "Target HTML:", tagSnippet);
1134
- }
1135
-
1136
- lines.push(
1137
- "",
1138
- "Guardrails:",
1139
- "- Make a targeted change to this element only.",
1140
- "- Preserve the rest of the composition and its timing.",
1141
- "- Do not modify other elements' data-* attributes or positioning.",
1142
- "- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
1143
- );
1144
-
1145
- return lines.join("\n");
1146
- }
1147
-
1148
- export function isTextEditableSelection(selection: DomEditSelection): boolean {
1149
- return selection.textFields.length > 0 && !selection.isCompositionHost;
1150
- }
1
+ /**
2
+ * Public API for dom editing — re-exports from focused sub-modules.
3
+ * Import from this file to avoid breaking existing import paths.
4
+ */
5
+
6
+ // Types
7
+ export type {
8
+ DomEditCapabilities,
9
+ DomEditContextOptions,
10
+ DomEditLayerItem,
11
+ DomEditSelection,
12
+ DomEditTextField,
13
+ DomEditViewport,
14
+ TimelineElementDomTarget,
15
+ TimelineElementDomTargetOptions,
16
+ } from "./domEditingTypes";
17
+
18
+ // Element finders, visibility, visual scoring, raster detection
19
+ export {
20
+ findElementForSelection,
21
+ findElementForTimelineElement,
22
+ isLargeRasterDomEditSelection,
23
+ resolveVisualDomEditSelectionTarget,
24
+ } from "./domEditingElement";
25
+
26
+ // Layers, text fields, capabilities, selection, patch ops
27
+ export {
28
+ buildDefaultDomEditTextField,
29
+ buildDomEditStylePatchOperation,
30
+ buildDomEditTextPatchOperation,
31
+ collectDomEditLayerItems,
32
+ countDomEditChildLayers,
33
+ getDomEditLayerKey,
34
+ getDomEditNonEditableReason,
35
+ getDomEditTargetKey,
36
+ isTextEditableSelection,
37
+ refreshDomEditSelection,
38
+ resolveDomEditCapabilities,
39
+ resolveDomEditSelection,
40
+ serializeDomEditTextFields,
41
+ } from "./domEditingLayers";
42
+
43
+ // Agent prompt
44
+ export { buildElementAgentPrompt } from "./domEditingAgentPrompt";