From 9b7d1eded64136952df0056fdb855506c238c032 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 1 Oct 2026 07:32:12 -0700 Subject: [PATCH 1/5] feat(studio): paste and duplicate can keep a clip's own time, on the next free track --- .../src/hooks/useClipboard.source.test.ts | 23 +++++++++++++++++++ packages/studio/src/hooks/useClipboard.ts | 20 +++++++++------- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/studio/src/hooks/useClipboard.source.test.ts b/packages/studio/src/hooks/useClipboard.source.test.ts index 547d06c46b9..69cac3e53d2 100644 --- a/packages/studio/src/hooks/useClipboard.source.test.ts +++ b/packages/studio/src/hooks/useClipboard.source.test.ts @@ -374,6 +374,29 @@ describe("copy order", () => { }); }); +describe("a copy in place, as the canvas makes one", () => { + // The copy's start and track: the saved original has no data-hf-id, its copy is minted one. + const copyAt = (html: string | undefined) => { + const copy = html?.match(/]*data-hf-id[^>]*>Title<\/h1>/)?.[0] ?? ""; + return [copy.match(/data-start="([^"]*)"/)?.[1], copy.match(/data-track-index="([^"]*)"/)?.[1]]; + }; + + it("duplicates at the clip's own time, on the next free track", async () => { + selectTitle(); + const { clipboard, writes } = mountClipboard(); + await clipboard().handleDuplicate({ inPlace: true }); + expect(copyAt(writes[0])).toEqual(["2", "1"]); + }); + + it("pastes at the copied clip's time, not the playhead's", async () => { + selectTitle(); + const { clipboard, writes } = mountClipboard(); + clipboard().handleCopy(); + await clipboard().handlePaste({ inPlace: true }); + expect(copyAt(writes[0])).toEqual(["2", "1"]); + }); +}); + describe("duplicate of a clip inside a sub-composition", () => { it("writes the copy at local time right after the original", async () => { // Host at 2 s, so the clip's local 1-3 s shows as a 3-5 s master row. diff --git a/packages/studio/src/hooks/useClipboard.ts b/packages/studio/src/hooks/useClipboard.ts index c20fc8570a4..11a8cefa5f2 100644 --- a/packages/studio/src/hooks/useClipboard.ts +++ b/packages/studio/src/hooks/useClipboard.ts @@ -30,6 +30,8 @@ import { } from "../utils/authoredSource"; import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator"; +type InPlace = { inPlace?: boolean }; + interface RecordEditInput { label: string; coalesceKey?: string; @@ -369,7 +371,7 @@ export function useClipboard({ // Two independent paste modes (timeline clip vs DOM element) behind one guarded save. // fallow-ignore-next-line complexity - const handlePaste = useCallback(async () => { + const handlePaste = useCallback(async ({ inPlace = false }: InPlace = {}) => { const payload = await clipboardRef.current; if (!payload) { showToast("Nothing to paste.", "info"); @@ -389,7 +391,7 @@ export function useClipboard({ const pasted = pasteTimelineClips( originalContent, payload.clips, - currentTime, + inPlace ? Math.min(...payload.clips.map((clip) => clip.start)) : currentTime, elements, fromThisFile, ); @@ -434,11 +436,11 @@ export function useClipboard({ } }, [activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile]); - // Duplicates the current selection in place, immediately after it, without - // touching the clipboard — a pending copy must survive a Cmd+D. Shares - // pasteTimelineClips with handlePaste; only the anchor and clip source - // differ (the selection's own end here, the playhead there). - const handleDuplicate = useCallback(async (): Promise => { + // Duplicates the selection right after it, or with `inPlace` at its own time on the next free + // track, without touching the clipboard — a pending copy must survive a Cmd+D. Shares + // pasteTimelineClips with handlePaste; only the anchor and clip source differ (the + // selection's end or start here, the playhead or the copy's own time there). + const handleDuplicate = useCallback(async ({ inPlace = false }: InPlace = {}): Promise => { const targets = findSelectedClips(); if (!targets) return false; const pid = projectIdRef.current; @@ -450,7 +452,9 @@ export function useClipboard({ // The copy lands in targetPath's own clock, so anchor and lane check are local to it. const anchorTime = toAuthoredStart( elements[0]!, - Math.max(...elements.map((el) => el.start + el.duration)), + inPlace + ? Math.min(...elements.map((el) => el.start)) + : Math.max(...elements.map((el) => el.start + el.duration)), ); try { From 714cf6b1cabe78dffb03faa124cf7c03dd47dd36 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 1 Oct 2026 07:43:23 -0700 Subject: [PATCH 2/5] fix(studio): an in-place paste uses the copied time only in the composition it was copied in --- .../src/hooks/useClipboard.source.test.ts | 57 ++++++++++++++++++- packages/studio/src/hooks/useClipboard.ts | 5 +- packages/studio/src/utils/clipboardPayload.ts | 11 +++- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/hooks/useClipboard.source.test.ts b/packages/studio/src/hooks/useClipboard.source.test.ts index 69cac3e53d2..51f1c8e3bc6 100644 --- a/packages/studio/src/hooks/useClipboard.source.test.ts +++ b/packages/studio/src/hooks/useClipboard.source.test.ts @@ -98,6 +98,7 @@ function mountClipboard( sub = SUB, bundle: (host: Element) => void = () => {}, saved = SAVED, + view = { path: "index.html" }, ) { const files: Record = { "index.html": saved, "compositions/sub.html": sub }; const fail = { on: false }; @@ -115,7 +116,7 @@ function mountClipboard( function Harness() { api = useClipboard({ projectId: "p", - activeCompPath: "index.html", + activeCompPath: view.path, domEditSelectionRef: domSelectionRef, showToast: () => {}, writeProjectFile, @@ -134,7 +135,9 @@ function mountClipboard( root = createRoot(document.createElement("div")); act(() => root?.render(React.createElement(Harness))); const clipboard = () => api as ReturnType; + const rerender = () => act(() => root?.render(React.createElement(Harness))); return { + rerender, clipboard, writes, deleted, @@ -395,6 +398,58 @@ describe("a copy in place, as the canvas makes one", () => { await clipboard().handlePaste({ inPlace: true }); expect(copyAt(writes[0])).toEqual(["2", "1"]); }); + + // Host at 2 s: the clip's local 1-3 s shows as a 3-5 s master row, the clock the film's own timeline has. + const SUB_CLIP: TimelineElement = { + id: SUB_HF_ID, + hfId: SUB_HF_ID, + tag: "h2", + start: 3, + duration: 2, + track: 0, + authoredTrack: 0, + sourceFile: "compositions/sub.html", + parentCompositionStart: 2, + }; + const pastedSubStarts = (html: string | undefined) => + [...(html ?? "").matchAll(/]*data-hf-id[^>]*data-start="([^"]+)"/g)].map((m) => m[1]); + + it("pastes a sub-composition's clip into the film at the moment the film shows it", async () => { + usePlayerStore.setState({ + elements: [TITLE, SUB_CLIP], + selectedElementId: SUB_CLIP.id, + selectedElementIds: new Set([SUB_CLIP.id]), + currentTime: 7, + }); + const { clipboard, writes } = mountClipboard(null, SUB, (host) => + host.setAttribute("data-start", "2"), + ); + clipboard().handleCopy(); + await clipboard().handlePaste({ inPlace: true }); + expect(pastedSubStarts(writes[0])).toEqual(["3"]); + }); + + it("pastes at the playhead once the edited composition is another, whose clock differs", async () => { + usePlayerStore.setState({ + elements: [TITLE, SUB_CLIP], + selectedElementId: SUB_CLIP.id, + selectedElementIds: new Set([SUB_CLIP.id]), + currentTime: 0.5, + }); + const view = { path: "index.html" }; + const { clipboard, writes, rerender } = mountClipboard( + null, + SUB, + (host) => host.setAttribute("data-start", "2"), + SAVED, + view, + ); + clipboard().handleCopy(); + view.path = "compositions/sub.html"; + rerender(); + await clipboard().handlePaste({ inPlace: true }); + expect(pastedSubStarts(writes[0])).toEqual(["0.5"]); + }); }); describe("duplicate of a clip inside a sub-composition", () => { diff --git a/packages/studio/src/hooks/useClipboard.ts b/packages/studio/src/hooks/useClipboard.ts index 11a8cefa5f2..140058130e0 100644 --- a/packages/studio/src/hooks/useClipboard.ts +++ b/packages/studio/src/hooks/useClipboard.ts @@ -315,6 +315,7 @@ export function useClipboard({ clips, sourceFile, projectId: projectIdRef.current ?? undefined, + copiedInComposition: activeCompPath || "index.html", }; }); }, [activeCompPath, findSelectedClips, readClips, showToast]); @@ -391,7 +392,9 @@ export function useClipboard({ const pasted = pasteTimelineClips( originalContent, payload.clips, - inPlace ? Math.min(...payload.clips.map((clip) => clip.start)) : currentTime, + inPlace && payload.copiedInComposition === targetPath + ? Math.min(...payload.clips.map((clip) => clip.start)) + : currentTime, elements, fromThisFile, ); diff --git a/packages/studio/src/utils/clipboardPayload.ts b/packages/studio/src/utils/clipboardPayload.ts index 438cab14871..49c5198883b 100644 --- a/packages/studio/src/utils/clipboardPayload.ts +++ b/packages/studio/src/utils/clipboardPayload.ts @@ -17,6 +17,7 @@ export type ClipboardPayload = clips: TimelineClipboardClip[]; sourceFile: string; projectId?: string; + copiedInComposition?: string; } | { kind: "dom-element"; @@ -63,7 +64,15 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu }); if (clips.length === 0) return null; const projectId = typeof obj.projectId === "string" ? obj.projectId : undefined; - return { kind: "timeline-clip", clips, sourceFile: obj.sourceFile, projectId }; + const copiedInComposition = + typeof obj.copiedInComposition === "string" ? obj.copiedInComposition : undefined; + return { + kind: "timeline-clip", + clips, + sourceFile: obj.sourceFile, + projectId, + copiedInComposition, + }; } if (obj.kind === "dom-element") { if (typeof obj.html !== "string") return null; From df437f776e6a52ccb8f2d9127c6709b96327f74d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 1 Oct 2026 08:06:33 -0700 Subject: [PATCH 3/5] test(studio): an in-place duplicate inside a sub-composition keeps its local time --- .../src/hooks/useClipboard.source.test.ts | 15 ++ packages/studio/src/hooks/useClipboard.ts | 254 +++++++++--------- 2 files changed, 145 insertions(+), 124 deletions(-) diff --git a/packages/studio/src/hooks/useClipboard.source.test.ts b/packages/studio/src/hooks/useClipboard.source.test.ts index 51f1c8e3bc6..ca747bc686c 100644 --- a/packages/studio/src/hooks/useClipboard.source.test.ts +++ b/packages/studio/src/hooks/useClipboard.source.test.ts @@ -429,6 +429,21 @@ describe("a copy in place, as the canvas makes one", () => { expect(pastedSubStarts(writes[0])).toEqual(["3"]); }); + it("duplicates a sub-composition's clip at its own local time, on another track", async () => { + usePlayerStore.setState({ + elements: [TITLE, SUB_CLIP], + selectedElementId: SUB_CLIP.id, + selectedElementIds: new Set([SUB_CLIP.id]), + }); + const { clipboard, writes } = mountClipboard(null, SUB, (host) => + host.setAttribute("data-start", "2"), + ); + await clipboard().handleDuplicate({ inPlace: true }); + const copy = /]*data-hf-id[^>]*>/.exec(writes[0] ?? "")?.[0] ?? ""; + expect(copy).toContain('data-start="1"'); + expect(copy).toContain('data-track-index="1"'); + }); + it("pastes at the playhead once the edited composition is another, whose clock differs", async () => { usePlayerStore.setState({ elements: [TITLE, SUB_CLIP], diff --git a/packages/studio/src/hooks/useClipboard.ts b/packages/studio/src/hooks/useClipboard.ts index 140058130e0..7759149089e 100644 --- a/packages/studio/src/hooks/useClipboard.ts +++ b/packages/studio/src/hooks/useClipboard.ts @@ -372,140 +372,146 @@ export function useClipboard({ // Two independent paste modes (timeline clip vs DOM element) behind one guarded save. // fallow-ignore-next-line complexity - const handlePaste = useCallback(async ({ inPlace = false }: InPlace = {}) => { - const payload = await clipboardRef.current; - if (!payload) { - showToast("Nothing to paste.", "info"); - return; - } - const pid = projectIdRef.current; - if (!pid) return; - - const targetPath = activeCompPath || "index.html"; - try { - let pastedIds: string[] = []; - const fromThisFile = payload.sourceFile === targetPath && payload.projectId === pid; - const paste = (originalContent: string) => { - if (payload.kind !== "timeline-clip") - return pasteElementHtml(originalContent, payload, fromThisFile); - const { currentTime, elements } = usePlayerStore.getState(); - const pasted = pasteTimelineClips( - originalContent, - payload.clips, - inPlace && payload.copiedInComposition === targetPath - ? Math.min(...payload.clips.map((clip) => clip.start)) - : currentTime, - elements, - fromThisFile, - ); - pastedIds = pasted.ids; - // A clip pasted past the current composition end would exist in the - // file but never appear on the timeline or in playback/export (the - // root's data-duration is what actually bounds the render). - return extendRootDurationInSource(pasted.content, pasted.requiredEnd); - }; - - const label = - payload.kind === "timeline-clip" - ? clipLabel("Paste", payload.clips.length) - : "Paste element"; - - await saveProjectFilesWithHistory({ - projectId: pid, - label, - files: { [targetPath]: paste }, - readFile: (path) => readFileContent(pid, path), - writeFile: writeProjectFile, - recordEdit, - }); + const handlePaste = useCallback( + async (options?: InPlace) => { + const payload = await clipboardRef.current; + if (!payload) { + showToast("Nothing to paste.", "info"); + return; + } + const pid = projectIdRef.current; + if (!pid) return; + + const targetPath = activeCompPath || "index.html"; + try { + let pastedIds: string[] = []; + const fromThisFile = payload.sourceFile === targetPath && payload.projectId === pid; + const paste = (originalContent: string) => { + if (payload.kind !== "timeline-clip") + return pasteElementHtml(originalContent, payload, fromThisFile); + const { currentTime, elements } = usePlayerStore.getState(); + const pasted = pasteTimelineClips( + originalContent, + payload.clips, + options?.inPlace && payload.copiedInComposition === targetPath + ? Math.min(...payload.clips.map((clip) => clip.start)) + : currentTime, + elements, + fromThisFile, + ); + pastedIds = pasted.ids; + // A clip pasted past the current composition end would exist in the + // file but never appear on the timeline or in playback/export (the + // root's data-duration is what actually bounds the render). + return extendRootDurationInSource(pasted.content, pasted.requiredEnd); + }; - // CapCut: the pasted clip(s) become the selection; the playhead does not - // move. reloadPreview is a bare refresh-key bump with no selection - // snapshot of its own; calling setSelection before it is what makes the - // post-reload store state land on the pasted ids instead of stale ones. - if (pastedIds.length > 0) { - usePlayerStore.getState().setSelection(pastedIds.map((id) => elementKey(id, targetPath))); + const label = + payload.kind === "timeline-clip" + ? clipLabel("Paste", payload.clips.length) + : "Paste element"; + + await saveProjectFilesWithHistory({ + projectId: pid, + label, + files: { [targetPath]: paste }, + readFile: (path) => readFileContent(pid, path), + writeFile: writeProjectFile, + recordEdit, + }); + + // CapCut: the pasted clip(s) become the selection; the playhead does not + // move. reloadPreview is a bare refresh-key bump with no selection + // snapshot of its own; calling setSelection before it is what makes the + // post-reload store state land on the pasted ids instead of stale ones. + if (pastedIds.length > 0) { + usePlayerStore.getState().setSelection(pastedIds.map((id) => elementKey(id, targetPath))); + } + reloadPreview(); + showToast( + payload.kind === "timeline-clip" + ? clipToast("Pasted", payload.clips.length) + : "Pasted element", + "info", + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to paste"; + showToast(message); } - reloadPreview(); - showToast( - payload.kind === "timeline-clip" - ? clipToast("Pasted", payload.clips.length) - : "Pasted element", - "info", - ); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to paste"; - showToast(message); - } - }, [activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile]); + }, + [activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile], + ); // Duplicates the selection right after it, or with `inPlace` at its own time on the next free // track, without touching the clipboard — a pending copy must survive a Cmd+D. Shares // pasteTimelineClips with handlePaste; only the anchor and clip source differ (the // selection's end or start here, the playhead or the copy's own time there). - const handleDuplicate = useCallback(async ({ inPlace = false }: InPlace = {}): Promise => { - const targets = findSelectedClips(); - if (!targets) return false; - const pid = projectIdRef.current; - if (!pid) return false; - - const { elements } = targets; - const pathOf = (el: TimelineElement) => el.sourceFile || activeCompPath || "index.html"; - const targetPath = pathOf(elements[0]!); - // The copy lands in targetPath's own clock, so anchor and lane check are local to it. - const anchorTime = toAuthoredStart( - elements[0]!, - inPlace - ? Math.min(...elements.map((el) => el.start)) - : Math.max(...elements.map((el) => el.start + el.duration)), - ); - - try { - const clips = await readClips(targets); - let ids: string[] = []; - const duplicate = (originalContent: string) => { - const liveElements = usePlayerStore - .getState() - .elements.filter((el) => pathOf(el) === targetPath) - .map((el) => ({ ...el, start: toAuthoredStart(el, el.start) })); - const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements, true); - ids = pasted.ids; - return extendRootDurationInSource(pasted.content, pasted.requiredEnd); - }; + const handleDuplicate = useCallback( + async (options?: InPlace): Promise => { + const targets = findSelectedClips(); + if (!targets) return false; + const pid = projectIdRef.current; + if (!pid) return false; + + const { elements } = targets; + const pathOf = (el: TimelineElement) => el.sourceFile || activeCompPath || "index.html"; + const targetPath = pathOf(elements[0]!); + // The copy lands in targetPath's own clock, so anchor and lane check are local to it. + const anchorTime = toAuthoredStart( + elements[0]!, + options?.inPlace + ? Math.min(...elements.map((el) => el.start)) + : Math.max(...elements.map((el) => el.start + el.duration)), + ); - await saveProjectFilesWithHistory({ - projectId: pid, - label: clipLabel("Duplicate", clips.length), - files: { [targetPath]: duplicate }, - readFile: (path) => readFileContent(pid, path), - writeFile: writeProjectFile, - recordEdit, - }); + try { + const clips = await readClips(targets); + let ids: string[] = []; + const duplicate = (originalContent: string) => { + const liveElements = usePlayerStore + .getState() + .elements.filter((el) => pathOf(el) === targetPath) + .map((el) => ({ ...el, start: toAuthoredStart(el, el.start) })); + const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements, true); + ids = pasted.ids; + return extendRootDurationInSource(pasted.content, pasted.requiredEnd); + }; - // The duplicate becomes the selection, mirroring CapCut's own paste - // convention — there is no CapCut Duplicate to match directly (Cmd+D is - // a no-op there); this is our own choice for consistency with paste. - // Select before reloading, same reason as handlePaste above. - if (ids.length > 0) { - usePlayerStore.getState().setSelection(ids.map((id) => elementKey(id, targetPath))); + await saveProjectFilesWithHistory({ + projectId: pid, + label: clipLabel("Duplicate", clips.length), + files: { [targetPath]: duplicate }, + readFile: (path) => readFileContent(pid, path), + writeFile: writeProjectFile, + recordEdit, + }); + + // The duplicate becomes the selection, mirroring CapCut's own paste + // convention — there is no CapCut Duplicate to match directly (Cmd+D is + // a no-op there); this is our own choice for consistency with paste. + // Select before reloading, same reason as handlePaste above. + if (ids.length > 0) { + usePlayerStore.getState().setSelection(ids.map((id) => elementKey(id, targetPath))); + } + reloadPreview(); + showToast(clipToast("Duplicated", clips.length), "info"); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to duplicate"; + showToast(message); + return false; } - reloadPreview(); - showToast(clipToast("Duplicated", clips.length), "info"); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to duplicate"; - showToast(message); - return false; - } - }, [ - activeCompPath, - findSelectedClips, - readClips, - recordEdit, - reloadPreview, - showToast, - writeProjectFile, - ]); + }, + [ + activeCompPath, + findSelectedClips, + readClips, + recordEdit, + reloadPreview, + showToast, + writeProjectFile, + ], + ); const handleCut = useCallback(async (): Promise => { const selected = getSelectedElements(); From 118f60cc956ff8a1bf583164c2f0c5123ee87557 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 1 Oct 2026 08:37:19 -0700 Subject: [PATCH 4/5] refactor(studio): the paste anchor is its own function, and the in-place tests share their setup --- .../src/hooks/useClipboard.source.test.ts | 36 +++++-------------- packages/studio/src/hooks/useClipboard.ts | 18 +++++++--- 2 files changed, 22 insertions(+), 32 deletions(-) diff --git a/packages/studio/src/hooks/useClipboard.source.test.ts b/packages/studio/src/hooks/useClipboard.source.test.ts index ca747bc686c..d6493217a3e 100644 --- a/packages/studio/src/hooks/useClipboard.source.test.ts +++ b/packages/studio/src/hooks/useClipboard.source.test.ts @@ -413,31 +413,25 @@ describe("a copy in place, as the canvas makes one", () => { }; const pastedSubStarts = (html: string | undefined) => [...(html ?? "").matchAll(/]*data-hf-id[^>]*data-start="([^"]+)"/g)].map((m) => m[1]); - - it("pastes a sub-composition's clip into the film at the moment the film shows it", async () => { + const mountSubClip = (currentTime = 0, view = { path: "index.html" }) => { usePlayerStore.setState({ elements: [TITLE, SUB_CLIP], selectedElementId: SUB_CLIP.id, selectedElementIds: new Set([SUB_CLIP.id]), - currentTime: 7, + currentTime, }); - const { clipboard, writes } = mountClipboard(null, SUB, (host) => - host.setAttribute("data-start", "2"), - ); + return mountClipboard(null, SUB, (host) => host.setAttribute("data-start", "2"), SAVED, view); + }; + + it("pastes a sub-composition's clip into the film at the moment the film shows it", async () => { + const { clipboard, writes } = mountSubClip(7); clipboard().handleCopy(); await clipboard().handlePaste({ inPlace: true }); expect(pastedSubStarts(writes[0])).toEqual(["3"]); }); it("duplicates a sub-composition's clip at its own local time, on another track", async () => { - usePlayerStore.setState({ - elements: [TITLE, SUB_CLIP], - selectedElementId: SUB_CLIP.id, - selectedElementIds: new Set([SUB_CLIP.id]), - }); - const { clipboard, writes } = mountClipboard(null, SUB, (host) => - host.setAttribute("data-start", "2"), - ); + const { clipboard, writes } = mountSubClip(); await clipboard().handleDuplicate({ inPlace: true }); const copy = /]*data-hf-id[^>]*>/.exec(writes[0] ?? "")?.[0] ?? ""; expect(copy).toContain('data-start="1"'); @@ -445,20 +439,8 @@ describe("a copy in place, as the canvas makes one", () => { }); it("pastes at the playhead once the edited composition is another, whose clock differs", async () => { - usePlayerStore.setState({ - elements: [TITLE, SUB_CLIP], - selectedElementId: SUB_CLIP.id, - selectedElementIds: new Set([SUB_CLIP.id]), - currentTime: 0.5, - }); const view = { path: "index.html" }; - const { clipboard, writes, rerender } = mountClipboard( - null, - SUB, - (host) => host.setAttribute("data-start", "2"), - SAVED, - view, - ); + const { clipboard, writes, rerender } = mountSubClip(0.5, view); clipboard().handleCopy(); view.path = "compositions/sub.html"; rerender(); diff --git a/packages/studio/src/hooks/useClipboard.ts b/packages/studio/src/hooks/useClipboard.ts index 7759149089e..364579e5fa0 100644 --- a/packages/studio/src/hooks/useClipboard.ts +++ b/packages/studio/src/hooks/useClipboard.ts @@ -32,6 +32,16 @@ import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordin type InPlace = { inPlace?: boolean }; +const pasteStart = ( + payload: Extract, + targetPath: string, + playhead: number, + options?: InPlace, +) => + options?.inPlace && payload.copiedInComposition === targetPath + ? Math.min(...payload.clips.map((clip) => clip.start)) + : playhead; + interface RecordEditInput { label: string; coalesceKey?: string; @@ -393,9 +403,7 @@ export function useClipboard({ const pasted = pasteTimelineClips( originalContent, payload.clips, - options?.inPlace && payload.copiedInComposition === targetPath - ? Math.min(...payload.clips.map((clip) => clip.start)) - : currentTime, + pasteStart(payload, targetPath, currentTime, options), elements, fromThisFile, ); @@ -442,8 +450,8 @@ export function useClipboard({ [activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile], ); - // Duplicates the selection right after it, or with `inPlace` at its own time on the next free - // track, without touching the clipboard — a pending copy must survive a Cmd+D. Shares + // Duplicates the selection right after it, or with `inPlace` at its own time on a track of its own, + // without touching the clipboard — a pending copy must survive a Cmd+D. Shares // pasteTimelineClips with handlePaste; only the anchor and clip source differ (the // selection's end or start here, the playhead or the copy's own time there). const handleDuplicate = useCallback( From cfa432ceeaf7e59d5413f355a3c5dab82bf46850 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 1 Oct 2026 09:15:09 -0700 Subject: [PATCH 5/5] refactor(studio): paste names its undo step and note in one place, and two paste tests share their steps --- .../src/hooks/useClipboard.source.test.ts | 41 +++++++++++-------- packages/studio/src/hooks/useClipboard.ts | 20 ++++----- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/packages/studio/src/hooks/useClipboard.source.test.ts b/packages/studio/src/hooks/useClipboard.source.test.ts index d6493217a3e..b67a5e5032d 100644 --- a/packages/studio/src/hooks/useClipboard.source.test.ts +++ b/packages/studio/src/hooks/useClipboard.source.test.ts @@ -256,6 +256,17 @@ const SUB_SELECTION = { sourceFile: "compositions/sub.html", } as DomEditSelection; +const copySubNext = ({ clipboard, domSelectionRef }: ReturnType) => { + clearSelection(); + domSelectionRef.current = SUB_SELECTION; + clipboard().handleCopy(); +}; + +const expectSubPastedAlone = (writes: string[]) => { + expect(writes[0]).toContain(">Sub"); + expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1); +}; + describe("paste of an element styled by its id", () => { it("gives the copy the look its original has", async () => { clearSelection(); @@ -342,30 +353,24 @@ describe("copy order", () => { it("pastes the second of two copies", async () => { selectTitle(); - const { clipboard, writes, domSelectionRef } = mountClipboard(); - clipboard().handleCopy(); + const view = mountClipboard(); + view.clipboard().handleCopy(); await new Promise((resolve) => setTimeout(resolve, 10)); - clearSelection(); - domSelectionRef.current = SUB_SELECTION; - clipboard().handleCopy(); - await clipboard().handlePaste(); - expect(writes[0]).toContain(">Sub"); - expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1); + copySubNext(view); + await view.clipboard().handlePaste(); + expectSubPastedAlone(view.writes); }); it("pastes the later copy even when the earlier copy's read lands last", async () => { selectTitle(); - const { clipboard, writes, delayMs, domSelectionRef } = mountClipboard(); - delayMs["index.html"] = 20; - clipboard().handleCopy(); - clearSelection(); - domSelectionRef.current = SUB_SELECTION; - clipboard().handleCopy(); + const view = mountClipboard(); + view.delayMs["index.html"] = 20; + view.clipboard().handleCopy(); + copySubNext(view); await new Promise((resolve) => setTimeout(resolve, 40)); - delayMs["index.html"] = 0; - await clipboard().handlePaste(); - expect(writes[0]).toContain(">Sub"); - expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1); + view.delayMs["index.html"] = 0; + await view.clipboard().handlePaste(); + expectSubPastedAlone(view.writes); }); it("duplicates a hidden clip with its saved markup", async () => { diff --git a/packages/studio/src/hooks/useClipboard.ts b/packages/studio/src/hooks/useClipboard.ts index 364579e5fa0..da89a9639c3 100644 --- a/packages/studio/src/hooks/useClipboard.ts +++ b/packages/studio/src/hooks/useClipboard.ts @@ -42,6 +42,14 @@ const pasteStart = ( ? Math.min(...payload.clips.map((clip) => clip.start)) : playhead; +const pasteWords = (payload: ClipboardPayload) => + payload.kind === "timeline-clip" + ? { + label: clipLabel("Paste", payload.clips.length), + toast: clipToast("Pasted", payload.clips.length), + } + : { label: "Paste element", toast: "Pasted element" }; + interface RecordEditInput { label: string; coalesceKey?: string; @@ -414,10 +422,7 @@ export function useClipboard({ return extendRootDurationInSource(pasted.content, pasted.requiredEnd); }; - const label = - payload.kind === "timeline-clip" - ? clipLabel("Paste", payload.clips.length) - : "Paste element"; + const { label, toast } = pasteWords(payload); await saveProjectFilesWithHistory({ projectId: pid, @@ -436,12 +441,7 @@ export function useClipboard({ usePlayerStore.getState().setSelection(pastedIds.map((id) => elementKey(id, targetPath))); } reloadPreview(); - showToast( - payload.kind === "timeline-clip" - ? clipToast("Pasted", payload.clips.length) - : "Pasted element", - "info", - ); + showToast(toast, "info"); } catch (error) { const message = error instanceof Error ? error.message : "Failed to paste"; showToast(message);