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/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index e76b23dd2..e211eab0e 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -99,7 +99,7 @@ export const NLELayout = memo(function NLELayout({ togglePlay, seek, onIframeLoad: baseOnIframeLoad, - saveSeekPosition, + refreshPlayer, } = useTimelinePlayer(); // Reset timeline state when the project changes @@ -109,13 +109,16 @@ export const NLELayout = memo(function NLELayout({ usePlayerStore.getState().reset(); } - // Save seek position before refresh + // Lightweight reload: change iframe src instead of destroying the Player. + // refreshPlayer() saves the seek position and appends a cache-busting _t + // param, avoiding the full web-component teardown + crossfade that the + // key-based path uses. const prevRefreshKeyRef = useRef(refreshKey); useEffect(() => { if (refreshKey === prevRefreshKeyRef.current) return; prevRefreshKeyRef.current = refreshKey; - saveSeekPosition(); - }, [refreshKey, saveSeekPosition]); + refreshPlayer(); + }, [refreshKey, refreshPlayer]); const onIframeLoad = useCallback(() => { baseOnIframeLoad(); diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 8be285035..c655f6e9b 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react"; +import { memo, useCallback, useEffect, useRef, type Ref } from "react"; import { Player } from "../../player"; import { DEFAULT_PREVIEW_ZOOM, @@ -53,15 +53,14 @@ export const NLEPreview = memo(function NLEPreview({ onCompositionLoadingChange, portrait, directUrl, - refreshKey, suppressLoadingOverlay, }: NLEPreviewProps) { - const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey }); - const prevRefreshKeyRef = useRef(refreshKey); + // Player key only changes for structural changes (project switch, composition + // drill-down), NOT for content refreshes. Content refreshes use the lighter + // iframe.src reload path handled by NLELayout → refreshPlayer(). + const activeKey = getPreviewPlayerKey({ projectId, directUrl }); const viewportRef = useRef(null); const stageRef = useRef(null); - const [retiringKey, setRetiringKey] = useState(null); - const retiringTimerRef = useRef | null>(null); const zoomRef = useRef(loadInitialZoom()); const hudRef = useRef(null); @@ -80,7 +79,6 @@ export const NLEPreview = memo(function NLEPreview({ return () => { if (settleTimerRef.current) clearTimeout(settleTimerRef.current); if (hudTimerRef.current) clearTimeout(hudTimerRef.current); - if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current); }; }, []); @@ -130,14 +128,6 @@ export const NLEPreview = memo(function NLEPreview({ [writeTransform], ); - if (refreshKey !== prevRefreshKeyRef.current) { - const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`; - prevRefreshKeyRef.current = refreshKey; - setRetiringKey(oldKey); - } - - const activeKey = `${baseKey}:${refreshKey ?? 0}`; - const applyInitialZoom = useCallback(() => { const z = zoomRef.current; if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) { @@ -145,16 +135,6 @@ export const NLEPreview = memo(function NLEPreview({ } }, [writeTransform]); - const handleNewPlayerLoad = () => { - onIframeLoad(); - applyInitialZoom(); - if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current); - retiringTimerRef.current = setTimeout(() => { - setRetiringKey(null); - retiringTimerRef.current = null; - }, 160); - }; - useEffect(() => { const viewport = viewportRef.current; if (!viewport) return; @@ -282,32 +262,17 @@ export const NLEPreview = memo(function NLEPreview({ }} data-testid="preview-zoom-stage" > - {retiringKey && ( - {}} - portrait={portrait} - style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }} - /> - )} { - onIframeLoad(); - applyInitialZoom(); - } - } + onLoad={() => { + onIframeLoad(); + applyInitialZoom(); + }} onCompositionLoadingChange={onCompositionLoadingChange} portrait={portrait} - style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined} suppressLoadingOverlay={suppressLoadingOverlay} /> diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index b264ce42d..111ecc69a 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,48 @@ export function useAppHotkeys({ leftSidebarRef.current?.selectTab("assets"); return; } + + // Cmd/Ctrl+C — copy (only preventDefault if we actually have something to copy) + const copyPasteKey = event.key.toLowerCase(); + if ( + copyPasteKey === "c" && + !event.shiftKey && + !event.altKey && + !isEditableTarget(event.target) + ) { + if (handleCopyRef.current()) { + event.preventDefault(); + } + return; + } + + // Cmd/Ctrl+V — paste + if ( + copyPasteKey === "v" && + !event.shiftKey && + !event.altKey && + !isEditableTarget(event.target) + ) { + event.preventDefault(); + void handlePasteRef.current(); + return; + } + + // Cmd/Ctrl+X — cut (only preventDefault if there's a selected element to cut) + if ( + copyPasteKey === "x" && + !event.shiftKey && + !event.altKey && + !isEditableTarget(event.target) + ) { + const hasSelection = + !!usePlayerStore.getState().selectedElementId || !!domEditSelectionRef.current; + if (hasSelection) { + 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..623f4748c --- /dev/null +++ b/packages/studio/src/hooks/useClipboard.ts @@ -0,0 +1,229 @@ +import { useCallback, useRef } from "react"; +import type { TimelineElement } from "../player"; +import { usePlayerStore } from "../player"; +import type { DomEditSelection } from "../components/editor/domEditing"; +import { type ClipboardPayload, deduplicateIds, insertAsSibling } from "../utils/clipboardPayload"; +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 && "outerHTML" in el ? (el as Element).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 && "outerHTML" in el) html = (el as Element).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; + 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, + originSelector: domSelection.selector, + originSelectorIndex: domSelection.selectorIndex, + }; + clipboardRef.current = 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") { + // Only rewrite data-start on the outermost opening tag. The non-global + // regex matches the first occurrence, which is always in the root tag + // since outerHTML starts with it. Nested clips keep their own timing. + const { currentTime } = usePlayerStore.getState(); + const rootTagEnd = deduped.indexOf(">"); + const rootTag = rootTagEnd >= 0 ? deduped.slice(0, rootTagEnd + 1) : deduped; + const patchedRootTag = rootTag.replace( + /data-start="[^"]*"/, + `data-start="${formatTimelineAttributeNumber(currentTime)}"`, + ); + const withNewStart = patchedRootTag + deduped.slice(rootTagEnd + 1); + patchedContent = insertTimelineAssetIntoSource(originalContent, withNewStart); + } else { + patchedContent = insertAsSibling( + originalContent, + deduped, + payload.originSelector, + payload.originSelectorIndex, + ); + } + + 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 (): Promise => { + const copied = handleCopy(); + if (!copied) return false; + + const { selectedElementId, elements } = usePlayerStore.getState(); + if (selectedElementId) { + const element = elements.find((el) => (el.key ?? el.id) === selectedElementId); + if (element) { + await handleTimelineElementDelete(element); + return true; + } + } + + const domSelection = domEditSelectionRef.current; + if (domSelection) { + await handleDomEditElementDelete(domSelection); + return true; + } + return true; + }, [handleCopy, domEditSelectionRef, handleTimelineElementDelete, handleDomEditElementDelete]); + + return { handleCopy, handlePaste, handleCut }; +} diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index 2ecadc0b4..47d967371 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -229,13 +229,19 @@ export const Player = forwardRef( // data arrives), but the overlay communicates why the first frame // or first audio beat may lag. // + // Skip the overlay on subsequent loads (content refreshes via + // refreshPlayer). The browser has already cached the assets from + // the first load, so they resolve near-instantly and the overlay + // just creates a disruptive flash. + // // Poll with a 10 s safety cap (100 ticks × 100 ms). If the cap // trips we hide the overlay so the UI doesn't appear stuck forever, // but we log a debug warning so the case is diagnosable — a long // cold video or a broken asset can legitimately exceed 10 s on a // slow network. if (assetPollRef.current) clearInterval(assetPollRef.current); - let lastUnloaded = hasUnloadedAssets(iframe, false); + const isContentRefresh = loadCountRef.current > 1; + let lastUnloaded = isContentRefresh ? false : hasUnloadedAssets(iframe, false); if (lastUnloaded) { setAssetsLoading(true); let attempts = 0; diff --git a/packages/studio/src/utils/clipboardPayload.test.ts b/packages/studio/src/utils/clipboardPayload.test.ts new file mode 100644 index 000000000..548876b66 --- /dev/null +++ b/packages/studio/src/utils/clipboardPayload.test.ts @@ -0,0 +1,62 @@ +// @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); + }); + + it("does not rewrite data-composition-id or other data-*-id attributes", () => { + const html = '
content
'; + const result = deduplicateIds(html, ["hero"]); + expect(result).toContain('data-composition-id="hero"'); + expect(result).toContain('data-clip-id="hero"'); + expect(result).toMatch(/\sid="hero-\d+"/); + }); +}); + +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..340dac468 --- /dev/null +++ b/packages/studio/src/utils/clipboardPayload.ts @@ -0,0 +1,168 @@ +const CLIPBOARD_MARKER = "hyperframes-clipboard:v1"; + +export interface ClipboardPayload { + kind: "timeline-clip" | "dom-element"; + html: string; + sourceFile: string; + originSelector?: string; + originSelectorIndex?: number; +} + +interface SerializedPayload { + _marker: string; + kind: "timeline-clip" | "dom-element"; + html: string; + sourceFile: string; + originSelector?: string; + originSelectorIndex?: number; +} + +export function serializeClipboardPayload(payload: ClipboardPayload): string { + const data: SerializedPayload = { + _marker: CLIPBOARD_MARKER, + kind: payload.kind, + html: payload.html, + sourceFile: payload.sourceFile, + originSelector: payload.originSelector, + originSelectorIndex: payload.originSelectorIndex, + }; + 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, + 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}[^>]*>`, "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 { + const existingSet = new Set(existingIds); + return html.replace(/(?<=\s)id="([^"]+)"/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}"`; + }); +}