diff --git a/packages/studio/src/hooks/useClipboard.source.test.ts b/packages/studio/src/hooks/useClipboard.source.test.ts index 547d06c46b9..b67a5e5032d 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, @@ -253,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(); @@ -339,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 () => { @@ -374,6 +382,78 @@ 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"]); + }); + + // 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]); + 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, + }); + 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 () => { + const { clipboard, writes } = mountSubClip(); + 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 () => { + const view = { path: "index.html" }; + const { clipboard, writes, rerender } = mountSubClip(0.5, 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", () => { 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..da89a9639c3 100644 --- a/packages/studio/src/hooks/useClipboard.ts +++ b/packages/studio/src/hooks/useClipboard.ts @@ -30,6 +30,26 @@ import { } from "../utils/authoredSource"; import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator"; +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; + +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; @@ -313,6 +333,7 @@ export function useClipboard({ clips, sourceFile, projectId: projectIdRef.current ?? undefined, + copiedInComposition: activeCompPath || "index.html", }; }); }, [activeCompPath, findSelectedClips, readClips, showToast]); @@ -369,136 +390,136 @@ 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 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, - 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, - }); - - // 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 handlePaste = useCallback( + async (options?: InPlace) => { + const payload = await clipboardRef.current; + if (!payload) { + showToast("Nothing to paste.", "info"); + return; } - 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]); + 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, + pasteStart(payload, targetPath, currentTime, options), + 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); + }; - // 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 => { - 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]!, - Math.max(...elements.map((el) => el.start + el.duration)), - ); + const { label, toast } = pasteWords(payload); + + 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(toast, "info"); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to paste"; + showToast(message); + } + }, + [activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile], + ); - 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); - }; + // 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( + 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(); 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;