From 45ae2a2a4ac3abd55da4dfb13a5506359aa0d3dd Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Tue, 23 Jun 2026 20:40:15 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(timeline):=20copy=20/=20cut=20/=20past?= =?UTF-8?q?e=20clips=20(=E2=8C=98C=20/=20=E2=8C=98X=20/=20=E2=8C=98V)=20(#?= =?UTF-8?q?94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the standard clipboard shortcuts that were completely missing. Only ⌘C/⌘X/⌘V were absent — the unmodified C/V already switch tools (razor / pointer), and the mod-prefixed branch had no handlers. Frontend only: - clipboardStore: new Zustand store holding deep snapshots of the selected clips plus the source first-frame, so a paste can re-place the group relative to the current playhead. UI-only, never persisted. - editActions: copyClips / cutClips / pasteClipsAtPlayhead. - copy: snapshot selected clips + their track index + min startFrame. - cut: copy then deleteSelectedClips. - paste: offset each clip's startFrame by `activeFrame - sourceFirstFrame`, clear addLinkedAudio so the paste stands alone (mirrors upstream `pasteClipsAtPlayhead` link re-reflection), and select the new clips. Clips whose source track no longer exists are skipped. - useKeyboardShortcuts: wire ⌘C / ⌘X / ⌘V inside the existing `if (mod)` block — no conflict with the unmodified C/V tool switches. - i18n: 4 new keys (zh-CN + en) for copy / cut / paste / clipboardEmpty. Closes #94. --- web/src/hooks/useKeyboardShortcuts.ts | 12 +++++ web/src/i18n/dict.ts | 12 +++++ web/src/store/clipboardStore.ts | 40 +++++++++++++++ web/src/store/editActions.ts | 73 ++++++++++++++++++++++++++- 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 web/src/store/clipboardStore.ts diff --git a/web/src/hooks/useKeyboardShortcuts.ts b/web/src/hooks/useKeyboardShortcuts.ts index 8985486..4a535b7 100644 --- a/web/src/hooks/useKeyboardShortcuts.ts +++ b/web/src/hooks/useKeyboardShortcuts.ts @@ -102,6 +102,18 @@ export function useKeyboardShortcuts() { return; } return; + case "KeyC": + e.preventDefault(); + edit.copyClips(); + return; + case "KeyX": + e.preventDefault(); + void edit.cutClips(); + return; + case "KeyV": + e.preventDefault(); + void edit.pasteClipsAtPlayhead(); + return; } return; } diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 7d20385..7df192e 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -182,6 +182,12 @@ const zh: Dict = { // Common "common.cancel": "取消", "common.open": "打开", + + // Edit (copy / cut / paste, Issue #94) + "edit.copy": "复制 (⌘C)", + "edit.cut": "剪切 (⌘X)", + "edit.paste": "粘贴 (⌘V)", + "edit.clipboardEmpty": "剪贴板为空", }; const en: Dict = { @@ -336,6 +342,12 @@ const en: Dict = { "common.cancel": "Cancel", "common.open": "Open", + + // Edit (copy / cut / paste, Issue #94) + "edit.copy": "Copy (⌘C)", + "edit.cut": "Cut (⌘X)", + "edit.paste": "Paste (⌘V)", + "edit.clipboardEmpty": "Clipboard is empty", }; export const DICTS: Record = { diff --git a/web/src/store/clipboardStore.ts b/web/src/store/clipboardStore.ts new file mode 100644 index 0000000..427f705 --- /dev/null +++ b/web/src/store/clipboardStore.ts @@ -0,0 +1,40 @@ +/** + * Front-end clipboard store for copy/cut/paste (Issue #94). Holds snapshots of + * the selected clips at copy time plus the source first-frame, so a paste can + * re-place them relative to the current playhead without touching the original + * clips. `linkGroupId` is cleared on paste so the backend re-assigns new + * groups (mirrors upstream `pasteClipsAtPlayhead` link re-reflection). + * + * The store is UI-only: the authoritative timeline lives in Rust; this is just + * a transient paste buffer, never persisted. + */ + +import { create } from "zustand"; +import type { Clip } from "../lib/types"; + +export interface ClipboardEntry { + /** Deep snapshot of the clip at copy time. */ + clip: Clip; + /** Track index the clip lived on when copied. Used to preserve track + * placement on paste (upstream behavior). */ + sourceTrackIndex: number; +} + +interface ClipboardState { + entries: ClipboardEntry[]; + /** The smallest `startFrame` among copied clips. Paste offsets every clip + * by `activeFrame - sourceFirstFrame` so the group lands at the playhead. */ + sourceFirstFrame: number; + hasContent: boolean; + set: (entries: ClipboardEntry[], sourceFirstFrame: number) => void; + clear: () => void; +} + +export const useClipboardStore = create((set) => ({ + entries: [], + sourceFirstFrame: 0, + hasContent: false, + set: (entries, sourceFirstFrame) => + set({ entries, sourceFirstFrame, hasContent: entries.length > 0 }), + clear: () => set({ entries: [], sourceFirstFrame: 0, hasContent: false }), +})); diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index 7d5fa88..e5445b6 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -10,6 +10,7 @@ import { forceRefresh } from "./sync"; import { useEditorUiStore } from "./uiStore"; import { useProjectStore } from "./projectStore"; import { trimToPlayheadEdits } from "../lib/clip"; +import { useClipboardStore } from "./clipboardStore"; import type { Clip, ClipEntryReq, @@ -36,7 +37,7 @@ async function applyAndRefresh(cmd: Parameters[0]) { export async function addClips(entries: ClipEntryReq[]) { if (entries.length === 0) return; - await applyAndRefresh({ type: "addClips", entries }); + return applyAndRefresh({ type: "addClips", entries }); } export async function moveClips(moves: ClipMoveReq[]) { @@ -350,3 +351,73 @@ export async function addTextClip() { ui.selectClips(new Set(res.affectedClipIds)); } } + +// MARK: - Clipboard (copy / cut / paste, Issue #94) +// +// Front-end paste buffer: copy snapshots the selected clips; paste re-places +// them at the playhead with a fresh `linkGroupId` (cleared so the backend +// re-assigns, mirroring upstream `pasteClipsAtPlayhead` link re-reflection). +// Track placement is preserved (clip stays on its original track index); if +// the target track no longer exists the clip is skipped. + +/** Collect selected clips with their track index into the clipboard store. */ +export function copyClips() { + const ui = useEditorUiStore.getState(); + const tl = useProjectStore.getState().timeline; + const ids = ui.selectedClipIds; + if (ids.size === 0) return; + const entries: { clip: Clip; sourceTrackIndex: number }[] = []; + for (let ti = 0; ti < tl.tracks.length; ti++) { + for (const clip of tl.tracks[ti].clips) { + if (ids.has(clip.id)) entries.push({ clip, sourceTrackIndex: ti }); + } + } + if (entries.length === 0) return; + const sourceFirstFrame = entries.reduce( + (min, e) => Math.min(min, e.clip.startFrame), + Number.POSITIVE_INFINITY, + ); + useClipboardStore.getState().set(entries, sourceFirstFrame); +} + +/** Copy then delete — the standard cut semantics. */ +export async function cutClips() { + copyClips(); + await deleteSelectedClips(); +} + +/** Paste clipboard entries at the current playhead. Each clip's `startFrame` + * is offset by `activeFrame - sourceFirstFrame`; `linkGroupId` is cleared so + * the backend assigns fresh groups. Clips whose source track no longer exists + * are silently skipped (upstream drops them too). */ +export async function pasteClipsAtPlayhead() { + const cb = useClipboardStore.getState(); + if (!cb.hasContent || cb.entries.length === 0) return; + const ui = useEditorUiStore.getState(); + const tl = useProjectStore.getState().timeline; + const offset = ui.activeFrame - cb.sourceFirstFrame; + const entries: ClipEntryReq[] = []; + for (const e of cb.entries) { + if (e.sourceTrackIndex >= tl.tracks.length) continue; + const startFrame = Math.max(0, e.clip.startFrame + offset); + entries.push({ + mediaRef: e.clip.mediaRef, + mediaType: e.clip.mediaType, + sourceClipType: e.clip.sourceClipType, + trackIndex: e.sourceTrackIndex, + startFrame, + durationFrames: e.clip.durationFrames, + trimStartFrame: e.clip.trimStartFrame, + trimEndFrame: e.clip.trimEndFrame, + hasAudio: e.clip.mediaType === "audio" || e.clip.mediaType === "video", + // Don't re-link: clearing addLinkedAudio lets the paste stand alone. + addLinkedAudio: false, + }); + } + if (entries.length === 0) return; + const res = await addClips(entries); + // Select the freshly pasted clips so the user can immediately move/trim them. + if (res && res.affectedClipIds.length > 0) { + ui.selectClips(new Set(res.affectedClipIds)); + } +} From 3192a2eb7d8a0f9be7c6b985722578f6333b3a90 Mon Sep 17 00:00:00 2001 From: Cui Hao Date: Tue, 23 Jun 2026 22:03:39 +0800 Subject: [PATCH 2/2] fix(#94): rebase onto main + linkGroup re-mapping + empty-clipboard toast Address review feedback on PR #105: 1. Rebased onto latest main (resolved import conflict: kept both trimToPlayheadEdits and useClipboardStore). 2. copyClips now expands link groups: if a selected clip has a linkGroupId, all linked companions are included in the clipboard (mirrors upstream copyClips), so a paste reproduces the video+audio pair. 3. pasteClipsAtPlayhead now re-establishes link groups after addClips: clips that shared a linkGroupId in the clipboard are re-linked via linkClips, preserving video+audio linkage. 4. Empty-clipboard paste now shows a toast (edit.clipboardEmpty) instead of silently doing nothing. Added toast mechanism to uiStore + Toast component in App.tsx. --- web/src/App.tsx | 33 ++++++++++++++++ web/src/hooks/useKeyboardShortcuts.ts | 6 +++ web/src/store/editActions.ts | 55 ++++++++++++++++++++++----- web/src/store/uiStore.ts | 9 +++++ 4 files changed, 94 insertions(+), 9 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 235a3b5..8346ab0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,6 +13,38 @@ import { initI18n } from "./i18n"; import { initTheme } from "./store/settingsStore"; import { onGoHome } from "./lib/api"; +function Toast() { + const toast = useEditorUiStore((s) => s.toast); + const clearToast = useEditorUiStore((s) => s.clearToast); + useEffect(() => { + if (!toast) return; + const timer = setTimeout(clearToast, 2000); + return () => clearTimeout(timer); + }, [toast, clearToast]); + if (!toast) return null; + return ( +
+ {toast.message} +
+ ); +} + export default function App() { // Editor-only hooks are safe to keep mounted across views: they only act on // editor state/events and the keyboard handler is a no-op until the editor is @@ -63,6 +95,7 @@ export default function App() {
+ ); } diff --git a/web/src/hooks/useKeyboardShortcuts.ts b/web/src/hooks/useKeyboardShortcuts.ts index 4a535b7..c957716 100644 --- a/web/src/hooks/useKeyboardShortcuts.ts +++ b/web/src/hooks/useKeyboardShortcuts.ts @@ -8,6 +8,8 @@ import { useEffect } from "react"; import { useEditorUiStore } from "../store/uiStore"; import { useProjectStore } from "../store/projectStore"; +import { useClipboardStore } from "../store/clipboardStore"; +import { t } from "../i18n"; import * as edit from "../store/editActions"; import { saveCurrentProject } from "../store/projectActions"; import { ZOOM } from "../lib/theme"; @@ -112,6 +114,10 @@ export function useKeyboardShortcuts() { return; case "KeyV": e.preventDefault(); + if (!useClipboardStore.getState().hasContent) { + useEditorUiStore.getState().pushToast(t("edit.clipboardEmpty")); + return; + } void edit.pasteClipsAtPlayhead(); return; } diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index e5445b6..573f672 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -360,16 +360,32 @@ export async function addTextClip() { // Track placement is preserved (clip stays on its original track index); if // the target track no longer exists the clip is skipped. -/** Collect selected clips with their track index into the clipboard store. */ +/** Collect selected clips with their track index into the clipboard store. + * If any selected clip belongs to a link group, the entire group is copied + * (mirrors upstream `copyClips` which expands the selection to include + * linked companions, so a paste reproduces the video+audio pair). */ export function copyClips() { const ui = useEditorUiStore.getState(); const tl = useProjectStore.getState().timeline; const ids = ui.selectedClipIds; if (ids.size === 0) return; + // Expand selection to include linked companions. + const expanded = new Set(ids); + for (let ti = 0; ti < tl.tracks.length; ti++) { + for (const clip of tl.tracks[ti].clips) { + if (ids.has(clip.id) && clip.linkGroupId) { + for (let tj = 0; tj < tl.tracks.length; tj++) { + for (const c2 of tl.tracks[tj].clips) { + if (c2.linkGroupId === clip.linkGroupId) expanded.add(c2.id); + } + } + } + } + } const entries: { clip: Clip; sourceTrackIndex: number }[] = []; for (let ti = 0; ti < tl.tracks.length; ti++) { for (const clip of tl.tracks[ti].clips) { - if (ids.has(clip.id)) entries.push({ clip, sourceTrackIndex: ti }); + if (expanded.has(clip.id)) entries.push({ clip, sourceTrackIndex: ti }); } } if (entries.length === 0) return; @@ -387,9 +403,11 @@ export async function cutClips() { } /** Paste clipboard entries at the current playhead. Each clip's `startFrame` - * is offset by `activeFrame - sourceFirstFrame`; `linkGroupId` is cleared so - * the backend assigns fresh groups. Clips whose source track no longer exists - * are silently skipped (upstream drops them too). */ + * is offset by `activeFrame - sourceFirstFrame`. After the clips are created, + * link groups are re-established: clips that shared a `linkGroupId` in the + * clipboard are re-linked via `linkClips` so the paste preserves video+audio + * linkage. Clips whose source track no longer exists are silently skipped + * (upstream drops them too). */ export async function pasteClipsAtPlayhead() { const cb = useClipboardStore.getState(); if (!cb.hasContent || cb.entries.length === 0) return; @@ -397,6 +415,7 @@ export async function pasteClipsAtPlayhead() { const tl = useProjectStore.getState().timeline; const offset = ui.activeFrame - cb.sourceFirstFrame; const entries: ClipEntryReq[] = []; + const sourceLinkGroups: (string | undefined)[] = []; for (const e of cb.entries) { if (e.sourceTrackIndex >= tl.tracks.length) continue; const startFrame = Math.max(0, e.clip.startFrame + offset); @@ -410,14 +429,32 @@ export async function pasteClipsAtPlayhead() { trimStartFrame: e.clip.trimStartFrame, trimEndFrame: e.clip.trimEndFrame, hasAudio: e.clip.mediaType === "audio" || e.clip.mediaType === "video", - // Don't re-link: clearing addLinkedAudio lets the paste stand alone. + // Don't auto-create a linked audio: the linked audio clip is already in + // the clipboard (copyClips expands link groups) and will be pasted as + // its own entry; addLinkedAudio=true would create a duplicate. addLinkedAudio: false, }); + sourceLinkGroups.push(e.clip.linkGroupId); } if (entries.length === 0) return; const res = await addClips(entries); - // Select the freshly pasted clips so the user can immediately move/trim them. - if (res && res.affectedClipIds.length > 0) { - ui.selectClips(new Set(res.affectedClipIds)); + if (!res || res.affectedClipIds.length === 0) return; + + // Re-establish link groups: map each old linkGroupId to the set of newly + // created clip ids, then call linkClips for each group. + const newGroupMap = new Map(); + for (let i = 0; i < res.affectedClipIds.length && i < sourceLinkGroups.length; i++) { + const oldGroup = sourceLinkGroups[i]; + if (!oldGroup) continue; + const newId = res.affectedClipIds[i]; + const arr = newGroupMap.get(oldGroup); + if (arr) arr.push(newId); + else newGroupMap.set(oldGroup, [newId]); + } + for (const ids of newGroupMap.values()) { + if (ids.length >= 2) await linkClips(ids); } + + // Select the freshly pasted clips so the user can immediately move/trim them. + ui.selectClips(new Set(res.affectedClipIds)); } diff --git a/web/src/store/uiStore.ts b/web/src/store/uiStore.ts index 262f4a7..e8a01b9 100644 --- a/web/src/store/uiStore.ts +++ b/web/src/store/uiStore.ts @@ -134,6 +134,11 @@ interface UiState { setMediaTab: (tab: MediaTabId) => void; setMediaSubTab: (tab: MediaSubTabId) => void; setInspectorTab: (tab: InspectorTabId) => void; + + // Toast (transient message) + toast: { message: string; id: number } | null; + pushToast: (message: string) => void; + clearToast: () => void; } export const useEditorUiStore = create((set, get) => ({ @@ -245,4 +250,8 @@ export const useEditorUiStore = create((set, get) => ({ setMediaTab: (mediaTab) => set({ mediaTab }), setMediaSubTab: (mediaSubTab) => set({ mediaSubTab }), setInspectorTab: (inspectorTab) => set({ inspectorTab }), + + toast: null, + pushToast: (message) => set({ toast: { message, id: Date.now() } }), + clearToast: () => set({ toast: null }), }));