From fbe2f682b32e751a27da390e4bd5497ded2d6b93 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/9] 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 190d19d218c12c923805faf57f3af9cf6e96c75b 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/9] 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 5ee8b1a524b43f0932a16855b8d197d609fbbd1c 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/9] 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 f3995d64baf3cf04e38ec994165a9dc5ea8ed6a5 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/9] 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 b331a433efb4422ed7e82e53c68eb6a2ff8fcb1c 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/9] 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 { From c9c14ac39265ef695aec0c724e4a08a75a2b41ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 16 May 2026 00:26:13 -0700 Subject: [PATCH 6/9] =?UTF-8?q?fix(studio):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20deduplicateIds,=20native=20copy,=20altKey=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deduplicateIds regex used \b which matched data-composition-id, data-clip-id, etc. Switch to lookbehind (?<=\s) so only standalone id="..." attributes are rewritten. Add test pinning this. - Ctrl+C no longer calls preventDefault() before confirming there's a selected element. Native browser copy (text selections outside inputs) is preserved when nothing is selected in the Studio. - Add !event.altKey guard on C/V/X to avoid intercepting Cmd+Alt+V (paste-as-plain-text) and similar OS gestures. - Remove no-op .replace(/"/g, '"') flagged by CodeQL. --- packages/studio/src/hooks/useAppHotkeys.ts | 28 +++++++++++++++---- .../studio/src/utils/clipboardPayload.test.ts | 8 ++++++ packages/studio/src/utils/clipboardPayload.ts | 4 +-- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 338147b76..b5b40231e 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -210,23 +210,39 @@ export function useAppHotkeys({ return; } - // Cmd/Ctrl+C — copy + // Cmd/Ctrl+C — copy (only preventDefault if we actually have something to copy) const copyPasteKey = event.key.toLowerCase(); - if (copyPasteKey === "c" && !event.shiftKey && !isEditableTarget(event.target)) { - event.preventDefault(); - handleCopyRef.current(); + 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 && !isEditableTarget(event.target)) { + if ( + copyPasteKey === "v" && + !event.shiftKey && + !event.altKey && + !isEditableTarget(event.target) + ) { event.preventDefault(); void handlePasteRef.current(); return; } // Cmd/Ctrl+X — cut - if (copyPasteKey === "x" && !event.shiftKey && !isEditableTarget(event.target)) { + if ( + copyPasteKey === "x" && + !event.shiftKey && + !event.altKey && + !isEditableTarget(event.target) + ) { event.preventDefault(); void handleCutRef.current(); return; diff --git a/packages/studio/src/utils/clipboardPayload.test.ts b/packages/studio/src/utils/clipboardPayload.test.ts index a46073faa..548876b66 100644 --- a/packages/studio/src/utils/clipboardPayload.test.ts +++ b/packages/studio/src/utils/clipboardPayload.test.ts @@ -22,6 +22,14 @@ describe("deduplicateIds", () => { 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", () => { diff --git a/packages/studio/src/utils/clipboardPayload.ts b/packages/studio/src/utils/clipboardPayload.ts index 30b259938..340dac468 100644 --- a/packages/studio/src/utils/clipboardPayload.ts +++ b/packages/studio/src/utils/clipboardPayload.ts @@ -81,7 +81,7 @@ export function insertAsSibling( 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"); + searchPattern = new RegExp(`<[a-z][^>]*\\b${inner}[^>]*>`, "gi"); } if (searchPattern) { @@ -157,7 +157,7 @@ function findClosingTagPosition(html: string, openTagStart: number): number { export function deduplicateIds(html: string, existingIds: string[]): string { const existingSet = new Set(existingIds); - return html.replace(/\bid="([^"]+)"/g, (full, id: string) => { + return html.replace(/(?<=\s)id="([^"]+)"/g, (full, id: string) => { if (!existingSet.has(id)) return full; let counter = 2; while (existingSet.has(`${id}-${counter}`)) counter++; From f670eb9b59108a8d444c6825f977e732895e2ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 16 May 2026 00:35:47 -0700 Subject: [PATCH 7/9] =?UTF-8?q?fix(studio):=20address=20review=20round=202?= =?UTF-8?q?=20=E2=80=94=20Cmd+X=20guard,=20data-start=20scope,=20revert=20?= =?UTF-8?q?drive-by?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cmd+X now pre-checks selection state before preventDefault, mirroring the Cmd+C fix. Native cut preserved when nothing is selected. - handleCut returns Promise so the caller can gate on it. - data-start rewrite scoped to the outermost opening tag only, so nested clip timing is preserved on paste. - Removed system clipboard write (cross-tab paste unsupported, in-memory ref is the only read path). - Reverted the reloadPreview drive-by (setRefreshKey→location.reload); the perf branch (#895) handles this properly via refreshPlayer(). --- packages/studio/src/App.tsx | 6 ++++- packages/studio/src/hooks/useAppHotkeys.ts | 12 ++++++---- packages/studio/src/hooks/useClipboard.ts | 26 +++++++++++----------- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 5a8553c01..1cf24b2c5 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -110,7 +110,11 @@ export function StudioApp() { const editHistory = usePersistentEditHistory({ projectId }); const domEditSaveTimestampRef = useRef(0); const reloadPreview = useCallback(() => { - setRefreshKey((k) => k + 1); + try { + previewIframeRef.current?.contentWindow?.location.reload(); + } catch { + setRefreshKey((k) => k + 1); + } }, []); const fileManager = useFileManager({ diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index b5b40231e..111ecc69a 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -47,7 +47,7 @@ interface UseAppHotkeysParams { leftSidebarRef: React.RefObject; handleCopy: () => boolean; handlePaste: () => Promise; - handleCut: () => Promise; + handleCut: () => Promise; } // ── Hook ── @@ -236,15 +236,19 @@ export function useAppHotkeys({ return; } - // Cmd/Ctrl+X — cut + // Cmd/Ctrl+X — cut (only preventDefault if there's a selected element to cut) if ( copyPasteKey === "x" && !event.shiftKey && !event.altKey && !isEditableTarget(event.target) ) { - event.preventDefault(); - void handleCutRef.current(); + const hasSelection = + !!usePlayerStore.getState().selectedElementId || !!domEditSelectionRef.current; + if (hasSelection) { + event.preventDefault(); + void handleCutRef.current(); + } return; } } diff --git a/packages/studio/src/hooks/useClipboard.ts b/packages/studio/src/hooks/useClipboard.ts index e8fa8aae9..623f4748c 100644 --- a/packages/studio/src/hooks/useClipboard.ts +++ b/packages/studio/src/hooks/useClipboard.ts @@ -2,13 +2,7 @@ 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 { type ClipboardPayload, deduplicateIds, insertAsSibling } from "../utils/clipboardPayload"; import { collectHtmlIds } from "../utils/studioHelpers"; import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; @@ -118,7 +112,6 @@ export function useClipboard({ const payload: ClipboardPayload = { kind: "timeline-clip", html, sourceFile: targetPath }; clipboardRef.current = payload; - void copyTextToClipboard(serializeClipboardPayload(payload)); showToast("Copied clip", "info"); return true; } @@ -140,7 +133,6 @@ export function useClipboard({ originSelectorIndex: domSelection.selectorIndex, }; clipboardRef.current = payload; - void copyTextToClipboard(serializeClipboardPayload(payload)); showToast("Copied element", "info"); return true; } @@ -165,11 +157,17 @@ export function useClipboard({ 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 withNewStart = deduped.replace( + 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( @@ -206,23 +204,25 @@ export function useClipboard({ writeProjectFile, ]); - const handleCut = useCallback(async () => { + const handleCut = useCallback(async (): Promise => { const copied = handleCopy(); - if (!copied) return; + 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; + return true; } } const domSelection = domEditSelectionRef.current; if (domSelection) { await handleDomEditElementDelete(domSelection); + return true; } + return true; }, [handleCopy, domEditSelectionRef, handleTimelineElementDelete, handleDomEditElementDelete]); return { handleCopy, handlePaste, handleCut }; From 9b4bd98759aba19dada82bf890e7b46b04b582ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 15 May 2026 23:24:58 -0700 Subject: [PATCH 8/9] perf(studio): use lightweight iframe.src reload instead of Player teardown Content refreshes (paste, move, resize, delete, asset drop) previously triggered setRefreshKey which changed the Player's React key, causing full web-component destruction + iframe teardown + crossfade animation + re-initialization of all event listeners and asset polling. Now NLELayout intercepts refreshKey changes and calls refreshPlayer() which just appends a cache-busting _t param to the iframe src. The Player web component stays alive, event listeners persist, and the reload is ~10x faster with no "waiting for media" flash. Key-based teardown is preserved for actual structural changes (project switch, composition drill-down via directUrl change). --- .../studio/src/components/nle/NLELayout.tsx | 11 ++-- .../studio/src/components/nle/NLEPreview.tsx | 53 ++++--------------- 2 files changed, 16 insertions(+), 48 deletions(-) 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} /> From 410e2c4ac688334d6831cc32b79a391f01491bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 15 May 2026 23:30:02 -0700 Subject: [PATCH 9/9] perf(studio): skip asset-loading overlay on content refreshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The asset-loading overlay ("Preparing preview assets") polled for video/audio readyState on every iframe load, including content refreshes from paste/move/resize. On reloads the browser serves assets from cache so they resolve near-instantly — the overlay just created a disruptive flash. Now skips the polling on subsequent loads (loadCountRef > 1), only showing it on the initial cold load. --- packages/studio/src/player/components/Player.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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;