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/7] 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/7] 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/7] 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/7] 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/7] 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/7] =?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/7] =?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 };