From bf2f77e45eaf48fa9fb7fd926688f96aeb138467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 15 May 2026 21:38:01 -0700 Subject: [PATCH 1/5] feat(studio): add clipboard payload types and ID deduplication --- .../studio/src/utils/clipboardPayload.test.ts | 54 +++++++++++++++++++ packages/studio/src/utils/clipboardPayload.ts | 51 ++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 packages/studio/src/utils/clipboardPayload.test.ts create mode 100644 packages/studio/src/utils/clipboardPayload.ts diff --git a/packages/studio/src/utils/clipboardPayload.test.ts b/packages/studio/src/utils/clipboardPayload.test.ts new file mode 100644 index 000000000..a46073faa --- /dev/null +++ b/packages/studio/src/utils/clipboardPayload.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { + deduplicateIds, + serializeClipboardPayload, + deserializeClipboardPayload, + type ClipboardPayload, +} from "./clipboardPayload"; + +describe("deduplicateIds", () => { + it("renames ids that collide with existing ids", () => { + const html = '
'; + const existingIds = ["hero", "other"]; + const result = deduplicateIds(html, existingIds); + expect(result).not.toContain('id="hero"'); + expect(result).toContain('id="photo"'); + expect(result).toMatch(/id="hero-\d+"/); + }); + + it("returns html unchanged when no collisions", () => { + const html = '

hello

'; + const result = deduplicateIds(html, ["other"]); + expect(result).toBe(html); + }); +}); + +describe("serializeClipboardPayload / deserializeClipboardPayload", () => { + it("round-trips a timeline clip payload", () => { + const payload: ClipboardPayload = { + kind: "timeline-clip", + html: '', + sourceFile: "index.html", + }; + const json = serializeClipboardPayload(payload); + const parsed = deserializeClipboardPayload(json); + expect(parsed).toEqual(payload); + }); + + it("round-trips a dom-element payload", () => { + const payload: ClipboardPayload = { + kind: "dom-element", + html: '

Hello

', + sourceFile: "compositions/scene.html", + }; + const json = serializeClipboardPayload(payload); + const parsed = deserializeClipboardPayload(json); + expect(parsed).toEqual(payload); + }); + + it("returns null for invalid JSON", () => { + expect(deserializeClipboardPayload("not json")).toBeNull(); + expect(deserializeClipboardPayload('{"kind":"unknown"}')).toBeNull(); + }); +}); diff --git a/packages/studio/src/utils/clipboardPayload.ts b/packages/studio/src/utils/clipboardPayload.ts new file mode 100644 index 000000000..ed5031913 --- /dev/null +++ b/packages/studio/src/utils/clipboardPayload.ts @@ -0,0 +1,51 @@ +const CLIPBOARD_MARKER = "hyperframes-clipboard:v1"; + +export interface ClipboardPayload { + kind: "timeline-clip" | "dom-element"; + html: string; + sourceFile: string; +} + +interface SerializedPayload { + _marker: string; + kind: "timeline-clip" | "dom-element"; + html: string; + sourceFile: string; +} + +export function serializeClipboardPayload(payload: ClipboardPayload): string { + const data: SerializedPayload = { + _marker: CLIPBOARD_MARKER, + kind: payload.kind, + html: payload.html, + sourceFile: payload.sourceFile, + }; + return JSON.stringify(data); +} + +export function deserializeClipboardPayload(json: string): ClipboardPayload | null { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + const obj = parsed as Record; + if (obj._marker !== CLIPBOARD_MARKER) return null; + if (obj.kind !== "timeline-clip" && obj.kind !== "dom-element") return null; + if (typeof obj.html !== "string" || typeof obj.sourceFile !== "string") return null; + return { kind: obj.kind, html: obj.html, sourceFile: obj.sourceFile }; +} + +export function deduplicateIds(html: string, existingIds: string[]): string { + const existingSet = new Set(existingIds); + return html.replace(/\bid="([^"]+)"/g, (full, id: string) => { + if (!existingSet.has(id)) return full; + let counter = 2; + while (existingSet.has(`${id}-${counter}`)) counter++; + const newId = `${id}-${counter}`; + existingSet.add(newId); + return `id="${newId}"`; + }); +} From e6a3e71d2beac0e6ed4020ae64c9c17b3d349a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 15 May 2026 21:45:06 -0700 Subject: [PATCH 2/5] feat(studio): add Ctrl+C/V/X copy/paste for timeline clips and DOM elements --- packages/studio/src/App.tsx | 25 ++- packages/studio/src/hooks/useAppHotkeys.ts | 34 ++++ packages/studio/src/hooks/useClipboard.ts | 217 +++++++++++++++++++++ 3 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 packages/studio/src/hooks/useClipboard.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index a9a4b570c..1cf24b2c5 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -12,6 +12,7 @@ import { useManifestPersistence } from "./hooks/useManifestPersistence"; import { useTimelineEditing } from "./hooks/useTimelineEditing"; import { useDomEditSession } from "./hooks/useDomEditSession"; import { useAppHotkeys } from "./hooks/useAppHotkeys"; +import { useClipboard } from "./hooks/useClipboard"; import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences"; import { useCaptionDetection } from "./hooks/useCaptionDetection"; import { useRenderClipContent } from "./hooks/useRenderClipContent"; @@ -162,15 +163,28 @@ export function StudioApp() { const clearDomSelectionRef = useRef<() => void>(() => {}); const domEditSelectionBridgeRef = useRef(null); - const handleDomEditElementDeleteRef = useRef<(selection: DomEditSelection) => Promise>( + const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise>( async () => {}, ); - + const domEditDeleteBridge = async (s: DomEditSelection) => + handleDomEditElementDeleteRef.current(s); + const { handleCopy, handlePaste, handleCut } = useClipboard({ + projectId, + activeCompPath, + domEditSelectionRef: domEditSelectionBridgeRef, + showToast, + writeProjectFile: fileManager.writeProjectFile, + recordEdit: editHistory.recordEdit, + domEditSaveTimestampRef, + reloadPreview, + handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete, + handleDomEditElementDelete: domEditDeleteBridge, + previewIframeRef, + }); const appHotkeys = useAppHotkeys({ toggleTimelineVisibility, handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete, - handleDomEditElementDelete: async (s: DomEditSelection) => - handleDomEditElementDeleteRef.current(s), + handleDomEditElementDelete: domEditDeleteBridge, domEditSelectionRef: domEditSelectionBridgeRef, clearDomSelectionRef, editHistory, @@ -182,6 +196,9 @@ export function StudioApp() { syncHistoryPreviewAfterApply: manifestPersistence.syncHistoryPreviewAfterApply, waitForPendingDomEditSaves: manifestPersistence.waitForPendingDomEditSaves, leftSidebarRef, + handleCopy, + handlePaste, + handleCut, }); const domEditSession = useDomEditSession({ diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index b264ce42d..338147b76 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -45,6 +45,9 @@ interface UseAppHotkeysParams { syncHistoryPreviewAfterApply: (paths: string[] | undefined) => Promise; waitForPendingDomEditSaves: () => Promise; leftSidebarRef: React.RefObject; + handleCopy: () => boolean; + handlePaste: () => Promise; + handleCut: () => Promise; } // ── Hook ── @@ -64,6 +67,9 @@ export function useAppHotkeys({ syncHistoryPreviewAfterApply, waitForPendingDomEditSaves, leftSidebarRef, + handleCopy, + handlePaste, + handleCut, }: UseAppHotkeysParams) { const previewHotkeyWindowRef = useRef(null); const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined); @@ -161,6 +167,12 @@ export function useAppHotkeys({ handleUndoRef.current = handleUndo; const handleRedoRef = useRef(handleRedo); handleRedoRef.current = handleRedo; + const handleCopyRef = useRef(handleCopy); + handleCopyRef.current = handleCopy; + const handlePasteRef = useRef(handlePaste); + handlePasteRef.current = handlePaste; + const handleCutRef = useRef(handleCut); + handleCutRef.current = handleCut; // ── Consolidated keydown handler ── @@ -197,6 +209,28 @@ export function useAppHotkeys({ leftSidebarRef.current?.selectTab("assets"); return; } + + // Cmd/Ctrl+C — copy + const copyPasteKey = event.key.toLowerCase(); + if (copyPasteKey === "c" && !event.shiftKey && !isEditableTarget(event.target)) { + event.preventDefault(); + handleCopyRef.current(); + return; + } + + // Cmd/Ctrl+V — paste + if (copyPasteKey === "v" && !event.shiftKey && !isEditableTarget(event.target)) { + event.preventDefault(); + void handlePasteRef.current(); + return; + } + + // Cmd/Ctrl+X — cut + if (copyPasteKey === "x" && !event.shiftKey && !isEditableTarget(event.target)) { + event.preventDefault(); + void handleCutRef.current(); + return; + } } // Delete / Backspace — remove selected element (timeline clip or preview selection) diff --git a/packages/studio/src/hooks/useClipboard.ts b/packages/studio/src/hooks/useClipboard.ts new file mode 100644 index 000000000..8e8442f43 --- /dev/null +++ b/packages/studio/src/hooks/useClipboard.ts @@ -0,0 +1,217 @@ +import { useCallback, useRef } from "react"; +import type { TimelineElement } from "../player"; +import { usePlayerStore } from "../player"; +import type { DomEditSelection } from "../components/editor/domEditing"; +import { + type ClipboardPayload, + serializeClipboardPayload, + deduplicateIds, +} from "../utils/clipboardPayload"; +import { copyTextToClipboard } from "../utils/clipboard"; +import { collectHtmlIds } from "../utils/studioHelpers"; +import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop"; +import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import type { EditHistoryKind } from "../utils/editHistory"; +import { formatTimelineAttributeNumber } from "../player/components/timelineEditing"; + +interface RecordEditInput { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; +} + +interface UseClipboardOptions { + projectId: string | null; + activeCompPath: string | null; + domEditSelectionRef: React.MutableRefObject; + showToast: (message: string, tone?: "error" | "info") => void; + writeProjectFile: (path: string, content: string) => Promise; + recordEdit: (input: RecordEditInput) => Promise; + domEditSaveTimestampRef: React.MutableRefObject; + reloadPreview: () => void; + handleTimelineElementDelete: (element: TimelineElement) => Promise; + handleDomEditElementDelete: (selection: DomEditSelection) => Promise; + previewIframeRef: React.MutableRefObject; +} + +async function readFileContent(projectId: string, targetPath: string): Promise { + const response = await fetch( + `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`, + ); + if (!response.ok) throw new Error(`Failed to read ${targetPath}`); + const data = (await response.json()) as { content?: string }; + if (typeof data.content !== "string") throw new Error(`Missing file contents for ${targetPath}`); + return data.content; +} + +function getElementOuterHtml( + iframeRef: React.MutableRefObject, + selection: DomEditSelection, +): string | null { + let doc: Document | null = null; + try { + doc = iframeRef.current?.contentDocument ?? null; + } catch { + return null; + } + if (!doc) return null; + + let el: Element | null = null; + if (selection.id) { + el = doc.getElementById(selection.id); + } + if (!el && selection.selector) { + const matches = doc.querySelectorAll(selection.selector); + el = matches[selection.selectorIndex ?? 0] ?? null; + } + return el instanceof HTMLElement ? el.outerHTML : null; +} + +export function useClipboard({ + projectId, + activeCompPath, + domEditSelectionRef, + showToast, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + reloadPreview, + handleTimelineElementDelete, + handleDomEditElementDelete, + previewIframeRef, +}: UseClipboardOptions) { + const clipboardRef = useRef(null); + const projectIdRef = useRef(projectId); + projectIdRef.current = projectId; + + const handleCopy = useCallback((): boolean => { + const { selectedElementId, elements } = usePlayerStore.getState(); + + // Timeline clip copy + if (selectedElementId) { + const element = elements.find((el) => (el.key ?? el.id) === selectedElementId); + if (!element) return false; + const targetPath = element.sourceFile || activeCompPath || "index.html"; + + let html: string | null = null; + try { + const doc = previewIframeRef.current?.contentDocument; + if (doc) { + let el: Element | null = null; + if (element.domId) el = doc.getElementById(element.domId); + if (!el && element.selector) { + const matches = doc.querySelectorAll(element.selector); + el = matches[element.selectorIndex ?? 0] ?? null; + } + if (el instanceof HTMLElement) html = el.outerHTML; + } + } catch { + // cross-origin frame + } + + if (!html) { + showToast("Unable to copy this element.", "info"); + return false; + } + + const payload: ClipboardPayload = { kind: "timeline-clip", html, sourceFile: targetPath }; + clipboardRef.current = payload; + void copyTextToClipboard(serializeClipboardPayload(payload)); + showToast("Copied clip", "info"); + return true; + } + + // DOM element copy + const domSelection = domEditSelectionRef.current; + if (domSelection) { + const html = getElementOuterHtml(previewIframeRef, domSelection); + if (!html) { + showToast("Unable to copy this element.", "info"); + return false; + } + const targetPath = domSelection.sourceFile || activeCompPath || "index.html"; + const payload: ClipboardPayload = { kind: "dom-element", html, sourceFile: targetPath }; + clipboardRef.current = payload; + void copyTextToClipboard(serializeClipboardPayload(payload)); + showToast("Copied element", "info"); + return true; + } + + return false; + }, [activeCompPath, domEditSelectionRef, previewIframeRef, showToast]); + + const handlePaste = useCallback(async () => { + const payload = clipboardRef.current; + if (!payload) { + showToast("Nothing to paste.", "info"); + return; + } + const pid = projectIdRef.current; + if (!pid) return; + + const targetPath = activeCompPath || "index.html"; + try { + const originalContent = await readFileContent(pid, targetPath); + const existingIds = collectHtmlIds(originalContent); + const deduped = deduplicateIds(payload.html, existingIds); + + let patchedContent: string; + if (payload.kind === "timeline-clip") { + const { currentTime } = usePlayerStore.getState(); + const withNewStart = deduped.replace( + /data-start="[^"]*"/, + `data-start="${formatTimelineAttributeNumber(currentTime)}"`, + ); + patchedContent = insertTimelineAssetIntoSource(originalContent, withNewStart); + } else { + patchedContent = insertTimelineAssetIntoSource(originalContent, deduped); + } + + domEditSaveTimestampRef.current = Date.now(); + await saveProjectFilesWithHistory({ + projectId: pid, + label: payload.kind === "timeline-clip" ? "Paste clip" : "Paste element", + kind: "timeline" as EditHistoryKind, + files: { [targetPath]: patchedContent }, + readFile: async () => originalContent, + writeFile: writeProjectFile, + recordEdit, + }); + + reloadPreview(); + showToast(payload.kind === "timeline-clip" ? "Pasted clip" : "Pasted element", "info"); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to paste"; + showToast(message); + } + }, [ + activeCompPath, + domEditSaveTimestampRef, + recordEdit, + reloadPreview, + showToast, + writeProjectFile, + ]); + + const handleCut = useCallback(async () => { + const copied = handleCopy(); + if (!copied) return; + + const { selectedElementId, elements } = usePlayerStore.getState(); + if (selectedElementId) { + const element = elements.find((el) => (el.key ?? el.id) === selectedElementId); + if (element) { + await handleTimelineElementDelete(element); + return; + } + } + + const domSelection = domEditSelectionRef.current; + if (domSelection) { + await handleDomEditElementDelete(domSelection); + } + }, [handleCopy, domEditSelectionRef, handleTimelineElementDelete, handleDomEditElementDelete]); + + return { handleCopy, handlePaste, handleCut }; +} From b0210ebc3c1d50b24b0ff8a52e746759119e793b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 15 May 2026 21:52:29 -0700 Subject: [PATCH 3/5] fix(studio): use duck-typing for cross-frame element access in clipboard Elements from the preview iframe are from a different window context, so `el instanceof HTMLElement` always returns false. Use `"outerHTML" in el` instead to correctly detect elements across frame boundaries. --- packages/studio/src/hooks/useClipboard.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/hooks/useClipboard.ts b/packages/studio/src/hooks/useClipboard.ts index 8e8442f43..3412c8a07 100644 --- a/packages/studio/src/hooks/useClipboard.ts +++ b/packages/studio/src/hooks/useClipboard.ts @@ -65,7 +65,7 @@ function getElementOuterHtml( const matches = doc.querySelectorAll(selection.selector); el = matches[selection.selectorIndex ?? 0] ?? null; } - return el instanceof HTMLElement ? el.outerHTML : null; + return el && "outerHTML" in el ? (el as Element).outerHTML : null; } export function useClipboard({ @@ -104,7 +104,7 @@ export function useClipboard({ const matches = doc.querySelectorAll(element.selector); el = matches[element.selectorIndex ?? 0] ?? null; } - if (el instanceof HTMLElement) html = el.outerHTML; + if (el && "outerHTML" in el) html = (el as Element).outerHTML; } } catch { // cross-origin frame From 6bfac98763885874fc080662f64d821e2339e4f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 15 May 2026 22:47:45 -0700 Subject: [PATCH 4/5] fix(studio): preserve playhead position after paste reloadPreview() used location.reload() which bypassed the NLELayout saveSeekPosition effect, causing the playhead to reset to 0:00 after paste. Switch to setRefreshKey which triggers the effect and restores the seek position after the iframe reloads. --- packages/studio/src/App.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 1cf24b2c5..5a8553c01 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -110,11 +110,7 @@ export function StudioApp() { const editHistory = usePersistentEditHistory({ projectId }); const domEditSaveTimestampRef = useRef(0); const reloadPreview = useCallback(() => { - try { - previewIframeRef.current?.contentWindow?.location.reload(); - } catch { - setRefreshKey((k) => k + 1); - } + setRefreshKey((k) => k + 1); }, []); const fileManager = useFileManager({ From 8daa2297c4e596dd98ea2e3bdb01a1f8b15993b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 15 May 2026 23:08:02 -0700 Subject: [PATCH 5/5] fix(studio): paste DOM elements as siblings, not at composition root DOM element paste was inserting at the composition root, losing the parent context that provides CSS styles and positioning. Now stores the origin selector on copy and inserts the paste as a sibling immediately after the original element, preserving style inheritance. Falls back to root insertion if the selector can't be matched. --- packages/studio/src/hooks/useClipboard.ts | 16 ++- packages/studio/src/utils/clipboardPayload.ts | 119 +++++++++++++++++- 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/hooks/useClipboard.ts b/packages/studio/src/hooks/useClipboard.ts index 3412c8a07..e8fa8aae9 100644 --- a/packages/studio/src/hooks/useClipboard.ts +++ b/packages/studio/src/hooks/useClipboard.ts @@ -6,6 +6,7 @@ import { type ClipboardPayload, serializeClipboardPayload, deduplicateIds, + insertAsSibling, } from "../utils/clipboardPayload"; import { copyTextToClipboard } from "../utils/clipboard"; import { collectHtmlIds } from "../utils/studioHelpers"; @@ -131,7 +132,13 @@ export function useClipboard({ return false; } const targetPath = domSelection.sourceFile || activeCompPath || "index.html"; - const payload: ClipboardPayload = { kind: "dom-element", html, sourceFile: targetPath }; + const payload: ClipboardPayload = { + kind: "dom-element", + html, + sourceFile: targetPath, + originSelector: domSelection.selector, + originSelectorIndex: domSelection.selectorIndex, + }; clipboardRef.current = payload; void copyTextToClipboard(serializeClipboardPayload(payload)); showToast("Copied element", "info"); @@ -165,7 +172,12 @@ export function useClipboard({ ); patchedContent = insertTimelineAssetIntoSource(originalContent, withNewStart); } else { - patchedContent = insertTimelineAssetIntoSource(originalContent, deduped); + patchedContent = insertAsSibling( + originalContent, + deduped, + payload.originSelector, + payload.originSelectorIndex, + ); } domEditSaveTimestampRef.current = Date.now(); diff --git a/packages/studio/src/utils/clipboardPayload.ts b/packages/studio/src/utils/clipboardPayload.ts index ed5031913..30b259938 100644 --- a/packages/studio/src/utils/clipboardPayload.ts +++ b/packages/studio/src/utils/clipboardPayload.ts @@ -4,6 +4,8 @@ export interface ClipboardPayload { kind: "timeline-clip" | "dom-element"; html: string; sourceFile: string; + originSelector?: string; + originSelectorIndex?: number; } interface SerializedPayload { @@ -11,6 +13,8 @@ interface SerializedPayload { kind: "timeline-clip" | "dom-element"; html: string; sourceFile: string; + originSelector?: string; + originSelectorIndex?: number; } export function serializeClipboardPayload(payload: ClipboardPayload): string { @@ -19,6 +23,8 @@ export function serializeClipboardPayload(payload: ClipboardPayload): string { kind: payload.kind, html: payload.html, sourceFile: payload.sourceFile, + originSelector: payload.originSelector, + originSelectorIndex: payload.originSelectorIndex, }; return JSON.stringify(data); } @@ -35,7 +41,118 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu if (obj._marker !== CLIPBOARD_MARKER) return null; if (obj.kind !== "timeline-clip" && obj.kind !== "dom-element") return null; if (typeof obj.html !== "string" || typeof obj.sourceFile !== "string") return null; - return { kind: obj.kind, html: obj.html, sourceFile: obj.sourceFile }; + return { + kind: obj.kind, + html: obj.html, + sourceFile: obj.sourceFile, + originSelector: typeof obj.originSelector === "string" ? obj.originSelector : undefined, + originSelectorIndex: + typeof obj.originSelectorIndex === "number" ? obj.originSelectorIndex : undefined, + }; +} + +/** + * Insert `newHtml` as a sibling immediately after the element matched by + * `selector` (at `selectorIndex`) in `source`. Falls back to inserting after + * the composition root if the selector doesn't match — so paste never silently + * drops the content. + */ +export function insertAsSibling( + source: string, + newHtml: string, + selector: string | undefined, + selectorIndex: number | undefined, +): string { + if (selector) { + const idx = selectorIndex ?? 0; + let matchCount = 0; + + // Find the element by searching for its opening tag pattern. + // For id selectors like #foo, search for id="foo". + // For class selectors like .name-text, search for class="...name-text...". + // For attribute selectors like [data-composition-id="x"], search literally. + + let searchPattern: RegExp | null = null; + if (selector.startsWith("#")) { + const id = selector.slice(1); + searchPattern = new RegExp(`<[a-z][^>]*\\bid="${id}"[^>]*>`, "gi"); + } else if (selector.startsWith(".")) { + const cls = selector.slice(1); + searchPattern = new RegExp(`<[a-z][^>]*\\bclass="[^"]*\\b${cls}\\b[^"]*"[^>]*>`, "gi"); + } else if (selector.startsWith("[")) { + const inner = selector.slice(1, -1); + searchPattern = new RegExp(`<[a-z][^>]*\\b${inner.replace(/"/g, '"')}[^>]*>`, "gi"); + } + + if (searchPattern) { + let match: RegExpExecArray | null; + while ((match = searchPattern.exec(source)) !== null) { + if (matchCount === idx) { + const insertPos = findClosingTagPosition(source, match.index); + if (insertPos > 0) { + return source.slice(0, insertPos) + "\n" + newHtml + source.slice(insertPos); + } + } + matchCount++; + } + } + } + + // Fallback: insert after composition root opening tag (same as timeline clips) + const rootOpenTag = /<[^>]*data-composition-id="[^"]+"[^>]*>/i; + const rootMatch = rootOpenTag.exec(source); + if (rootMatch && rootMatch.index != null) { + const insertAt = rootMatch.index + rootMatch[0].length; + return source.slice(0, insertAt) + newHtml + source.slice(insertAt); + } + + return source + newHtml; +} + +function findClosingTagPosition(html: string, openTagStart: number): number { + // Find the end of the opening tag + const openTagEnd = html.indexOf(">", openTagStart); + if (openTagEnd < 0) return -1; + + // Self-closing tag? + if (html[openTagEnd - 1] === "/") return openTagEnd + 1; + + // Extract the tag name + const tagNameMatch = html.slice(openTagStart).match(/^<([a-z][a-z0-9]*)/i); + if (!tagNameMatch) return -1; + const tagName = tagNameMatch[1]!; + + // Walk forward counting open/close tags of the same name + let depth = 1; + let pos = openTagEnd + 1; + const openRe = new RegExp(`<${tagName}(?:\\s|>|/>)`, "gi"); + const closeRe = new RegExp(``, "gi"); + + while (depth > 0 && pos < html.length) { + openRe.lastIndex = pos; + closeRe.lastIndex = pos; + + const nextOpen = openRe.exec(html); + const nextClose = closeRe.exec(html); + + if (!nextClose) return -1; + + if (nextOpen && nextOpen.index < nextClose.index) { + // Check if it's self-closing + const selfCloseCheck = html.lastIndexOf("/", html.indexOf(">", nextOpen.index)); + if (selfCloseCheck > nextOpen.index) { + pos = html.indexOf(">", nextOpen.index) + 1; + } else { + depth++; + pos = html.indexOf(">", nextOpen.index) + 1; + } + } else { + depth--; + if (depth === 0) return nextClose.index + nextClose[0].length; + pos = nextClose.index + nextClose[0].length; + } + } + return -1; } export function deduplicateIds(html: string, existingIds: string[]): string {