diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index a9a4b570c..5a8553c01 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"; @@ -109,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({ @@ -162,15 +159,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 +192,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..e8fa8aae9 --- /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, + serializeClipboardPayload, + deduplicateIds, + insertAsSibling, +} 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 && "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; + 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, + originSelector: domSelection.selector, + originSelectorIndex: domSelection.selectorIndex, + }; + 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 = 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 () => { + 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 }; +} 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..30b259938 --- /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.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 { + 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}"`; + }); +}