@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
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Agent prompt builder for HyperFrames element edit requests.
3
+ */
4
+ import { formatTime } from "../../player/lib/time";
5
+ import type { DomEditSelection, DomEditTextField } from "./domEditingTypes";
6
+
7
+ function formatBoundingBox(bounds: DomEditSelection["boundingBox"]): string {
8
+ return `x=${Math.round(bounds.x)}, y=${Math.round(bounds.y)}, width=${Math.round(bounds.width)}, height=${Math.round(bounds.height)}`;
9
+ }
10
+
11
+ function formatStyleBlock(styles: Record<string, string>): string {
12
+ return Object.entries(styles)
13
+ .filter(([, value]) => value && value !== "initial")
14
+ .map(([key, value]) => `${key}: ${value}`)
15
+ .join("\n");
16
+ }
17
+
18
+ function formatTextFields(fields: DomEditTextField[]): string {
19
+ return fields
20
+ .map(
21
+ (field) =>
22
+ `- key=${field.key}; tag=<${field.tagName}>; source=${field.source}; text=${JSON.stringify(field.value)}`,
23
+ )
24
+ .join("\n");
25
+ }
26
+
27
+ export function buildElementAgentPrompt({
28
+ selection,
29
+ currentTime,
30
+ tagSnippet,
31
+ selectionContext,
32
+ userInstruction,
33
+ sourceFilePath,
34
+ }: {
35
+ selection: DomEditSelection;
36
+ currentTime: number;
37
+ tagSnippet?: string;
38
+ selectionContext?: string;
39
+ userInstruction?: string;
40
+ sourceFilePath?: string;
41
+ }): string {
42
+ const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
43
+ const lines = [
44
+ "## HyperFrames element edit request v1",
45
+ "Schema version: 1",
46
+ "",
47
+ userInstruction?.trim() || "Edit this selected HyperFrames element.",
48
+ "",
49
+ `Composition: ${selection.compositionPath}`,
50
+ `Playback time: ${formatTime(currentTime)}`,
51
+ `Source file: ${displayedSourceFile}`,
52
+ `DOM id: ${selection.id ?? "(none)"}`,
53
+ `Selector: ${selection.selector ?? "(none)"}`,
54
+ `Selector index: ${selection.selectorIndex ?? 0}`,
55
+ `Tag: <${selection.tagName}>`,
56
+ `Bounds: ${formatBoundingBox(selection.boundingBox)}`,
57
+ ];
58
+
59
+ if (selection.textContent) {
60
+ lines.push(`Text: ${selection.textContent}`);
61
+ }
62
+
63
+ const trimmedSelectionContext = selectionContext?.trim();
64
+ if (trimmedSelectionContext) {
65
+ lines.push("", "Selection context:", trimmedSelectionContext);
66
+ }
67
+
68
+ const textFieldsBlock = formatTextFields(selection.textFields);
69
+ if (textFieldsBlock) {
70
+ lines.push("", "Text fields:", textFieldsBlock);
71
+ }
72
+
73
+ const inlineStyleBlock = formatStyleBlock(selection.inlineStyles);
74
+ if (inlineStyleBlock) {
75
+ lines.push("", "Inline styles:", inlineStyleBlock);
76
+ }
77
+
78
+ const computedStyleBlock = formatStyleBlock(selection.computedStyles);
79
+ if (computedStyleBlock) {
80
+ lines.push("", "Computed styles (browser-resolved):", computedStyleBlock);
81
+ }
82
+
83
+ if (tagSnippet) {
84
+ lines.push("", "Target HTML:", tagSnippet);
85
+ }
86
+
87
+ lines.push(
88
+ "",
89
+ "Guardrails:",
90
+ "- Make a targeted change to this element only.",
91
+ "- Preserve the rest of the composition and its timing.",
92
+ "- Do not modify other elements' data-* attributes or positioning.",
93
+ "- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
94
+ );
95
+
96
+ return lines.join("\n");
97
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Low-level DOM primitives: type guards, style getters, CSS escaping,
3
+ * selector utilities, and composition source resolution.
4
+ * No imports from other domEditing* modules — safe to import from anywhere.
5
+ */
6
+ import { CURATED_STYLE_PROPERTIES } from "./domEditingTypes";
7
+
8
+ // ─── Type guard ───────────────────────────────────────────────────────────────
9
+
10
+ export function isHtmlElement(value: unknown): value is HTMLElement {
11
+ return (
12
+ typeof value === "object" &&
13
+ value !== null &&
14
+ "nodeType" in value &&
15
+ typeof (value as { nodeType?: unknown }).nodeType === "number" &&
16
+ (value as { nodeType: number }).nodeType === 1
17
+ );
18
+ }
19
+
20
+ // ─── Style parsing ────────────────────────────────────────────────────────────
21
+
22
+ export function parsePx(value: string | undefined): number | null {
23
+ if (!value) return null;
24
+ const trimmed = value.trim();
25
+ if (!trimmed.endsWith("px")) return null;
26
+ const parsed = parseFloat(trimmed);
27
+ return Number.isFinite(parsed) ? parsed : null;
28
+ }
29
+
30
+ export function isIdentityTransform(value: string | undefined): boolean {
31
+ const transform = (value ?? "none").trim();
32
+ if (!transform || transform === "none") return true;
33
+
34
+ const matrix = transform.match(/^matrix\(([^)]+)\)$/i);
35
+ if (matrix) {
36
+ const values = matrix[1].split(",").map((part) => Number.parseFloat(part.trim()));
37
+ if (values.length !== 6 || values.some((part) => !Number.isFinite(part))) return false;
38
+ return (
39
+ Math.abs(values[0] - 1) < 0.0001 &&
40
+ Math.abs(values[1]) < 0.0001 &&
41
+ Math.abs(values[2]) < 0.0001 &&
42
+ Math.abs(values[3] - 1) < 0.0001 &&
43
+ Math.abs(values[4]) < 0.0001 &&
44
+ Math.abs(values[5]) < 0.0001
45
+ );
46
+ }
47
+
48
+ const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i);
49
+ if (!matrix3d) return false;
50
+ const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim()));
51
+ if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false;
52
+ const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
53
+ return values.every((part, index) => Math.abs(part - identity[index]) < 0.0001);
54
+ }
55
+
56
+ export function isTextBearingTag(tagName: string): boolean {
57
+ return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
58
+ }
59
+
60
+ // ─── Style accessors ──────────────────────────────────────────────────────────
61
+
62
+ export function getCuratedComputedStyles(el: HTMLElement): Record<string, string> {
63
+ const styles: Record<string, string> = {};
64
+ const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
65
+ if (!computed) return styles;
66
+
67
+ for (const prop of CURATED_STYLE_PROPERTIES) {
68
+ const value = computed.getPropertyValue(prop);
69
+ if (value) styles[prop] = value;
70
+ }
71
+
72
+ return styles;
73
+ }
74
+
75
+ export function getInlineStyles(el: HTMLElement): Record<string, string> {
76
+ const styles: Record<string, string> = {};
77
+ for (const property of CURATED_STYLE_PROPERTIES) {
78
+ const value = el.style.getPropertyValue(property);
79
+ if (value) styles[property] = value;
80
+ }
81
+ return styles;
82
+ }
83
+
84
+ export function getDataAttributes(el: HTMLElement): Record<string, string> {
85
+ const attrs: Record<string, string> = {};
86
+ for (const attr of el.attributes) {
87
+ if (attr.name.startsWith("data-")) {
88
+ attrs[attr.name.slice(5)] = attr.value;
89
+ }
90
+ }
91
+ return attrs;
92
+ }
93
+
94
+ // ─── DOM traversal ────────────────────────────────────────────────────────────
95
+
96
+ export function findClosestByAttribute(
97
+ el: HTMLElement,
98
+ attributeNames: string[],
99
+ ): HTMLElement | null {
100
+ let current: HTMLElement | null = el;
101
+ while (current) {
102
+ const candidate = current;
103
+ if (attributeNames.some((attribute) => candidate.hasAttribute(attribute))) {
104
+ return candidate;
105
+ }
106
+ current = current.parentElement;
107
+ }
108
+ return null;
109
+ }
110
+
111
+ export function getElementDepth(el: HTMLElement): number {
112
+ let depth = 0;
113
+ let current = el.parentElement;
114
+ while (current) {
115
+ depth += 1;
116
+ current = current.parentElement;
117
+ }
118
+ return depth;
119
+ }
120
+
121
+ // ─── Composition source resolution ───────────────────────────────────────────
122
+
123
+ export function getSourceFileForElement(
124
+ el: HTMLElement,
125
+ activeCompositionPath: string | null,
126
+ ): { sourceFile: string; compositionPath: string } {
127
+ const sourceHost = findClosestByAttribute(el, ["data-composition-file", "data-composition-src"]);
128
+ const ownerRoot = findClosestByAttribute(el, ["data-composition-id"]);
129
+ const sourceFile =
130
+ sourceHost?.getAttribute("data-composition-file") ??
131
+ sourceHost?.getAttribute("data-composition-src") ??
132
+ ownerRoot?.getAttribute("data-composition-file") ??
133
+ ownerRoot?.getAttribute("data-composition-src") ??
134
+ activeCompositionPath ??
135
+ "index.html";
136
+
137
+ return {
138
+ sourceFile,
139
+ compositionPath: sourceFile,
140
+ };
141
+ }
142
+
143
+ export function normalizeTimelineCompositionSource(value: string | undefined): string | undefined {
144
+ const trimmed = value?.trim();
145
+ if (!trimmed) return undefined;
146
+
147
+ let pathname = trimmed;
148
+ try {
149
+ pathname = new URL(trimmed, "http://studio.local").pathname;
150
+ } catch {
151
+ pathname = trimmed;
152
+ }
153
+
154
+ for (const marker of ["/preview/comp/", "/preview/"]) {
155
+ const markerIndex = pathname.indexOf(marker);
156
+ if (markerIndex < 0) continue;
157
+ const sourcePath = pathname.slice(markerIndex + marker.length).replace(/^\/+/, "");
158
+ return sourcePath || trimmed;
159
+ }
160
+
161
+ return trimmed;
162
+ }
163
+
164
+ // ─── CSS escaping ─────────────────────────────────────────────────────────────
165
+
166
+ export function escapeCssIdentifier(value: string): string {
167
+ const css = globalThis.CSS as { escape?: (input: string) => string } | undefined;
168
+ if (typeof css?.escape === "function") return css.escape(value);
169
+
170
+ if (value === "-") return "\\-";
171
+
172
+ let escaped = "";
173
+ for (let index = 0; index < value.length; index += 1) {
174
+ const char = value[index] ?? "";
175
+ const code = char.charCodeAt(0);
176
+ if (code === 0) {
177
+ escaped += "�";
178
+ continue;
179
+ }
180
+
181
+ const isDigit = code >= 48 && code <= 57;
182
+ const isUpperAlpha = code >= 65 && code <= 90;
183
+ const isLowerAlpha = code >= 97 && code <= 122;
184
+ const isControl = (code >= 1 && code <= 31) || code === 127;
185
+ const isLeadingDigit = index === 0 && isDigit;
186
+ const isSecondDigitAfterDash = index === 1 && value.startsWith("-") && isDigit;
187
+ if (isControl || isLeadingDigit || isSecondDigitAfterDash) {
188
+ escaped += `\\${code.toString(16)} `;
189
+ continue;
190
+ }
191
+ if (isUpperAlpha || isLowerAlpha || isDigit || char === "-" || char === "_" || code >= 128) {
192
+ escaped += char;
193
+ continue;
194
+ }
195
+ escaped += `\\${char}`;
196
+ }
197
+ return escaped;
198
+ }
199
+
200
+ export function escapeCssString(value: string): string {
201
+ return value
202
+ .replace(/\\/g, "\\\\")
203
+ .replace(/"/g, '\\"')
204
+ .replace(/\n/g, "\\a ")
205
+ .replace(/\r/g, "\\d ")
206
+ .replace(/\f/g, "\\c ");
207
+ }
208
+
209
+ export function querySelectorAllSafely(doc: Document, selector: string): Element[] {
210
+ try {
211
+ return Array.from(doc.querySelectorAll(selector));
212
+ } catch {
213
+ return [];
214
+ }
215
+ }
216
+
217
+ export function humanizeIdentifier(value: string): string {
218
+ return (
219
+ value
220
+ .replace(/\.html$/i, "")
221
+ .replace(/^compositions\//i, "")
222
+ .split("/")
223
+ .at(-1)
224
+ ?.replace(/[-_]+/g, " ")
225
+ .replace(/\b\w/g, (char) => char.toUpperCase()) ?? value
226
+ );
227
+ }
228
+
229
+ // ─── CSS selector building ────────────────────────────────────────────────────
230
+
231
+ export function buildStableSelector(el: HTMLElement): string | undefined {
232
+ if (el.id) return `#${escapeCssIdentifier(el.id)}`;
233
+
234
+ const compositionId = el.getAttribute("data-composition-id");
235
+ if (compositionId) return `[data-composition-id="${escapeCssString(compositionId)}"]`;
236
+
237
+ return getPreferredClassSelector(el);
238
+ }
239
+
240
+ export function getPreferredClassSelector(el: HTMLElement): string | undefined {
241
+ const classes = Array.from(el.classList)
242
+ .map((value) => value.trim())
243
+ .filter(Boolean);
244
+ if (classes.length === 0) return undefined;
245
+ const preferred =
246
+ classes.find((value) => value !== "clip" && !value.startsWith("__hf-")) ?? classes[0];
247
+ return preferred ? `.${escapeCssIdentifier(preferred)}` : undefined;
248
+ }
249
+
250
+ export function getSelectorIndex(
251
+ doc: Document,
252
+ el: HTMLElement,
253
+ selector: string | undefined,
254
+ sourceFile: string,
255
+ activeCompositionPath: string | null,
256
+ ): number | undefined {
257
+ if (!selector?.startsWith(".")) return undefined;
258
+
259
+ const candidates = querySelectorAllSafely(doc, selector).filter(
260
+ (candidate): candidate is HTMLElement =>
261
+ isHtmlElement(candidate) &&
262
+ getSourceFileForElement(candidate, activeCompositionPath).sourceFile === sourceFile,
263
+ );
264
+ const index = candidates.indexOf(el);
265
+ return index >= 0 ? index : undefined;
266
+ }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Element visibility, visual scoring, layer patch targets, element finders,
3
+ * and the `findElementForSelection` / `findElementForTimelineElement` lookups.
4
+ */
5
+ import type {
6
+ DomEditContextOptions,
7
+ DomEditSelection,
8
+ DomEditViewport,
9
+ TimelineElementDomTarget,
10
+ TimelineElementDomTargetOptions,
11
+ } from "./domEditingTypes";
12
+ import {
13
+ buildStableSelector,
14
+ escapeCssString,
15
+ findClosestByAttribute,
16
+ getElementDepth,
17
+ getPreferredClassSelector,
18
+ getSelectorIndex,
19
+ getSourceFileForElement,
20
+ isHtmlElement,
21
+ isTextBearingTag,
22
+ normalizeTimelineCompositionSource,
23
+ querySelectorAllSafely,
24
+ } from "./domEditingDom";
25
+
26
+ // ─── Visibility ──────────────────────────────────────────────────────────────
27
+
28
+ export function isElementComputedVisible(el: HTMLElement): boolean {
29
+ const win = el.ownerDocument.defaultView;
30
+ if (!win) return true;
31
+ let current: HTMLElement | null = el;
32
+ while (current) {
33
+ const computed = win.getComputedStyle(current);
34
+ if (computed.display === "none" || computed.visibility === "hidden") return false;
35
+ const opacity = Number.parseFloat(computed.opacity);
36
+ if (Number.isFinite(opacity) && opacity <= 0.01) return false;
37
+ current = current.parentElement;
38
+ }
39
+ return true;
40
+ }
41
+
42
+ const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
43
+
44
+ function isEmptyVisualContainer(el: HTMLElement): boolean {
45
+ const tag = el.tagName.toLowerCase();
46
+ if (VISUAL_LEAF_TAGS.has(tag)) return false;
47
+
48
+ const { children } = el;
49
+ if (children.length === 0) {
50
+ return (el.textContent ?? "").trim().length === 0;
51
+ }
52
+
53
+ for (let i = 0; i < children.length; i += 1) {
54
+ const child = children[i];
55
+ if (!isHtmlElement(child)) continue;
56
+ if (VISUAL_LEAF_TAGS.has(child.tagName.toLowerCase())) return false;
57
+ if (isElementComputedVisible(child)) return false;
58
+ }
59
+
60
+ return true;
61
+ }
62
+
63
+ export function hasRenderedBox(el: HTMLElement): boolean {
64
+ const rect = el.getBoundingClientRect();
65
+ if (rect.width <= 1 || rect.height <= 1) return false;
66
+ if (!isElementComputedVisible(el)) return false;
67
+ if (isEmptyVisualContainer(el)) return false;
68
+ return true;
69
+ }
70
+
71
+ // ─── Visual scoring ──────────────────────────────────────────────────────────
72
+
73
+ function isEditableTextLeafForScoring(el: HTMLElement): boolean {
74
+ return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
75
+ }
76
+
77
+ function getVisualElementScore(el: HTMLElement, pointerStackIndex: number): number {
78
+ const tagName = el.tagName.toLowerCase();
79
+ const rect = el.getBoundingClientRect();
80
+ const area = Math.max(1, rect.width * rect.height);
81
+ const smallerElementBonus = Math.max(0, 1_000_000 - Math.min(area, 1_000_000)) / 1_000;
82
+ const visualLeafBonus =
83
+ isEditableTextLeafForScoring(el) || ["img", "video", "canvas", "svg"].includes(tagName)
84
+ ? 2_000
85
+ : 0;
86
+
87
+ return getElementDepth(el) * 10_000 + visualLeafBonus + smallerElementBonus - pointerStackIndex;
88
+ }
89
+
90
+ // ─── Layer patch target ──────────────────────────────────────────────────────
91
+
92
+ const DOM_LAYER_IGNORED_TAGS = new Set([
93
+ "base",
94
+ "br",
95
+ "canvas",
96
+ "link",
97
+ "meta",
98
+ "script",
99
+ "source",
100
+ "style",
101
+ "template",
102
+ "track",
103
+ "wbr",
104
+ ]);
105
+
106
+ function isInspectableLayerElement(el: HTMLElement): boolean {
107
+ const tagName = el.tagName.toLowerCase();
108
+ if (DOM_LAYER_IGNORED_TAGS.has(tagName)) return false;
109
+
110
+ const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
111
+ if (computed?.display === "none" || computed?.visibility === "hidden") return false;
112
+
113
+ return true;
114
+ }
115
+
116
+ export function getDomLayerPatchTarget(
117
+ el: HTMLElement,
118
+ activeCompositionPath: string | null,
119
+ ): Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile"> | null {
120
+ if (!isInspectableLayerElement(el)) return null;
121
+
122
+ const selector = buildStableSelector(el);
123
+ if (!selector) return null;
124
+
125
+ const { sourceFile } = getSourceFileForElement(el, activeCompositionPath);
126
+ return {
127
+ id: el.id || undefined,
128
+ selector,
129
+ selectorIndex: getSelectorIndex(
130
+ el.ownerDocument,
131
+ el,
132
+ selector,
133
+ sourceFile,
134
+ activeCompositionPath,
135
+ ),
136
+ sourceFile,
137
+ };
138
+ }
139
+
140
+ // ─── Clip ancestor / selection candidate ─────────────────────────────────────
141
+
142
+ function getPreferredClipAncestor(startEl: HTMLElement): HTMLElement | null {
143
+ let current: HTMLElement | null = startEl;
144
+ while (current) {
145
+ if (current.classList.contains("clip")) {
146
+ const isCompositionHost =
147
+ current.hasAttribute("data-composition-src") ||
148
+ current.hasAttribute("data-composition-file");
149
+ if (!isCompositionHost || current === startEl) return current;
150
+ }
151
+ current = current.parentElement;
152
+ }
153
+ return null;
154
+ }
155
+
156
+ export function getSelectionCandidate(
157
+ startEl: HTMLElement,
158
+ options: DomEditContextOptions,
159
+ ): HTMLElement {
160
+ if (options.preferClipAncestor) {
161
+ const clipAncestor = getPreferredClipAncestor(startEl);
162
+ if (clipAncestor) {
163
+ return clipAncestor;
164
+ }
165
+ }
166
+
167
+ return startEl;
168
+ }
169
+
170
+ // ─── Visual target resolution ─────────────────────────────────────────────────
171
+
172
+ export function resolveVisualDomEditSelectionTarget(
173
+ elementsFromPoint: Iterable<Element | null | undefined>,
174
+ options: Pick<DomEditContextOptions, "activeCompositionPath">,
175
+ ): HTMLElement | null {
176
+ let best: { element: HTMLElement; score: number } | null = null;
177
+ let pointerStackIndex = 0;
178
+
179
+ for (const entry of elementsFromPoint) {
180
+ if (!isHtmlElement(entry)) {
181
+ pointerStackIndex += 1;
182
+ continue;
183
+ }
184
+
185
+ if (hasRenderedBox(entry) && getDomLayerPatchTarget(entry, options.activeCompositionPath)) {
186
+ const score = getVisualElementScore(entry, pointerStackIndex);
187
+ if (!best || score > best.score) {
188
+ best = { element: entry, score };
189
+ }
190
+ }
191
+ pointerStackIndex += 1;
192
+ }
193
+
194
+ return best?.element ?? null;
195
+ }
196
+
197
+ // ─── Raster detection ────────────────────────────────────────────────────────
198
+
199
+ function hasRasterBackground(selection: Pick<DomEditSelection, "computedStyles">): boolean {
200
+ const backgroundImage = selection.computedStyles["background-image"]?.trim();
201
+ return Boolean(backgroundImage && backgroundImage !== "none");
202
+ }
203
+
204
+ export function isLargeRasterDomEditSelection(
205
+ selection: Pick<DomEditSelection, "boundingBox" | "computedStyles" | "tagName">,
206
+ viewport?: DomEditViewport | null,
207
+ ): boolean {
208
+ const tagName = selection.tagName.toLowerCase();
209
+ const isRasterLike = tagName === "img" || hasRasterBackground(selection);
210
+ if (!isRasterLike) return false;
211
+
212
+ const { width, height } = selection.boundingBox;
213
+ if (width <= 1 || height <= 1) return false;
214
+ if (!viewport || viewport.width <= 1 || viewport.height <= 1) {
215
+ return width >= 960 && height >= 540;
216
+ }
217
+
218
+ const areaRatio = (width * height) / (viewport.width * viewport.height);
219
+ const widthRatio = width / viewport.width;
220
+ const heightRatio = height / viewport.height;
221
+ return areaRatio >= 0.4 || (widthRatio >= 0.7 && heightRatio >= 0.5);
222
+ }
223
+
224
+ // ─── Element finders ──────────────────────────────────────────────────────────
225
+
226
+ export function findElementForSelection(
227
+ doc: Document,
228
+ selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
229
+ activeCompositionPath: string | null = null,
230
+ ): HTMLElement | null {
231
+ if (selection.id) {
232
+ const byId = doc.getElementById(selection.id);
233
+ if (
234
+ isHtmlElement(byId) &&
235
+ (!selection.sourceFile ||
236
+ getSourceFileForElement(byId, activeCompositionPath).sourceFile === selection.sourceFile)
237
+ ) {
238
+ return byId;
239
+ }
240
+ }
241
+
242
+ if (!selection.selector) return null;
243
+
244
+ if (selection.selector.startsWith(".") && selection.selectorIndex != null) {
245
+ const matches = querySelectorAllSafely(doc, selection.selector).filter(
246
+ (candidate): candidate is HTMLElement =>
247
+ isHtmlElement(candidate) &&
248
+ (!selection.sourceFile ||
249
+ getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
250
+ selection.sourceFile),
251
+ );
252
+ return matches[selection.selectorIndex] ?? null;
253
+ }
254
+
255
+ const matches = querySelectorAllSafely(doc, selection.selector).filter(
256
+ (candidate): candidate is HTMLElement =>
257
+ isHtmlElement(candidate) &&
258
+ (!selection.sourceFile ||
259
+ getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
260
+ selection.sourceFile),
261
+ );
262
+ return matches[0] ?? null;
263
+ }
264
+
265
+ export function findElementForTimelineElement(
266
+ doc: Document,
267
+ element: TimelineElementDomTarget,
268
+ options: TimelineElementDomTargetOptions,
269
+ ): HTMLElement | null {
270
+ const elementId = typeof element.id === "string" ? element.id : "";
271
+ const compositionSource =
272
+ normalizeTimelineCompositionSource(element.compositionSrc) ??
273
+ options.compIdToSrc?.get(elementId);
274
+ const sourceFile =
275
+ compositionSource ??
276
+ normalizeTimelineCompositionSource(element.sourceFile) ??
277
+ options.activeCompositionPath ??
278
+ "index.html";
279
+ const escapedElementId = escapeCssString(elementId);
280
+ const escapedCompositionSource = compositionSource ? escapeCssString(compositionSource) : null;
281
+ const selector =
282
+ element.selector ??
283
+ (compositionSource
284
+ ? `[data-composition-src="${escapedCompositionSource}"],[data-composition-file="${escapedCompositionSource}"],[data-composition-id="${escapedElementId}"]`
285
+ : escapedElementId
286
+ ? `[data-composition-id="${escapedElementId}"]`
287
+ : undefined);
288
+
289
+ if (selector || element.domId) {
290
+ const targetElement = findElementForSelection(
291
+ doc,
292
+ {
293
+ id: element.domId ?? undefined,
294
+ selector,
295
+ selectorIndex: element.selectorIndex,
296
+ sourceFile,
297
+ },
298
+ options.activeCompositionPath,
299
+ );
300
+ if (targetElement) return targetElement;
301
+ }
302
+
303
+ const hasExplicitDomTarget = Boolean(element.domId || element.selector || compositionSource);
304
+ if (options.isMasterView || hasExplicitDomTarget || !options.activeCompositionPath) {
305
+ return null;
306
+ }
307
+
308
+ const root = doc.querySelector("[data-composition-id]");
309
+ if (!isHtmlElement(root)) return null;
310
+ return getSourceFileForElement(root, options.activeCompositionPath).sourceFile === sourceFile
311
+ ? root
312
+ : null;
313
+ }
314
+
315
+ // ─── Layer children ───────────────────────────────────────────────────────────
316
+
317
+ export function getDirectLayerChildren(
318
+ el: HTMLElement,
319
+ options: DomEditContextOptions,
320
+ ): HTMLElement[] {
321
+ return Array.from(el.children).filter(
322
+ (child): child is HTMLElement =>
323
+ isHtmlElement(child) && getDomLayerPatchTarget(child, options.activeCompositionPath) !== null,
324
+ );
325
+ }
326
+
327
+ // ─── Composition source helpers ───────────────────────────────────────────────
328
+
329
+ export { findClosestByAttribute, getPreferredClassSelector, getSourceFileForElement };