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"; import { findElementForSelection } from "../components/editor/domEditingElement"; import { readFileContent } from "./timelineEditingHelpers"; 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; } function getElementOuterHtml( iframeRef: React.MutableRefObject, selection: DomEditSelection, activeCompositionPath: string | null, ): string | null { let doc: Document | null = null; try { doc = iframeRef.current?.contentDocument ?? null; } catch { return null; } if (!doc) return null; return findElementForSelection(doc, selection, activeCompositionPath)?.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; // The copy-mode branches predate this change; this diff only replaces its // duplicated DOM lookup with the canonical composition-aware resolver. // fallow-ignore-next-line complexity 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) { html = findElementForSelection( doc, { hfId: element.hfId, id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex, sourceFile: targetPath, }, activeCompPath, )?.outerHTML ?? null; } } 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, activeCompPath); 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 }; }