diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index 300c4c7b..677b9c61 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -51,6 +51,7 @@ export function TimelineElement({ rippleEditingEnabled, toggleElementHidden, toggleElementMuted, + copySelected, } = useTimelineStore(); const { currentTime } = usePlaybackStore(); @@ -116,6 +117,11 @@ export function TimelineElement({ }); }; + const handleElementCopyContext = (e: React.MouseEvent) => { + e.stopPropagation(); + copySelected(); + }; + const handleElementDeleteContext = (e: React.MouseEvent) => { e.stopPropagation(); if (rippleEditingEnabled) { @@ -330,6 +336,10 @@ export function TimelineElement({ Split at playhead + + + Copy element + {hasAudio ? ( isMuted ? ( diff --git a/apps/web/src/constants/actions.ts b/apps/web/src/constants/actions.ts index 4278ce7e..50e8ae5d 100644 --- a/apps/web/src/constants/actions.ts +++ b/apps/web/src/constants/actions.ts @@ -45,7 +45,9 @@ export type Action = | "duplicate-selected" // Duplicate selected element | "toggle-snapping" // Toggle snapping | "undo" // Undo last action - | "redo"; // Redo last undone action + | "redo" // Redo last undone action + | "copy-selected" // Copy selected elements to clipboard + | "paste-selected"; // Paste elements from clipboard at playhead /** * Defines the arguments, if present for a given type that is required to be passed on diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts index 81500416..974f9db5 100644 --- a/apps/web/src/hooks/use-editor-actions.ts +++ b/apps/web/src/hooks/use-editor-actions.ts @@ -202,6 +202,23 @@ export function useEditorActions() { undefined ); + useActionHandler( + "copy-selected", + () => { + if (selectedElements.length === 0) return; + useTimelineStore.getState().copySelected(); + }, + undefined + ); + + useActionHandler( + "paste-selected", + () => { + useTimelineStore.getState().pasteAtTime(currentTime); + }, + undefined + ); + useActionHandler( "toggle-snapping", () => { diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts index e2cde3b4..29e3c09b 100644 --- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts +++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts @@ -63,6 +63,14 @@ const actionDescriptions: Record< "toggle-snapping": { description: "Toggle snapping", category: "Editing" }, undo: { description: "Undo", category: "History" }, redo: { description: "Redo", category: "History" }, + "copy-selected": { + description: "Copy selected elements", + category: "Editing", + }, + "paste-selected": { + description: "Paste elements at playhead", + category: "Editing", + }, }; // Convert key binding format to display format diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index 4c11cfde..7fa0da5e 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -23,6 +23,8 @@ export const defaultKeybindings: KeybindingConfig = { n: "toggle-snapping", "ctrl+a": "select-all", "ctrl+d": "duplicate-selected", + "ctrl+c": "copy-selected", + "ctrl+v": "paste-selected", "ctrl+z": "undo", "ctrl+shift+z": "redo", "ctrl+y": "redo", @@ -162,7 +164,7 @@ export const useKeybindingsStore = create()( }), { name: "opencut-keybindings", - version: 1, + version: 2, } ) ); diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 884db23e..f0bae00c 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -41,6 +41,11 @@ interface TimelineStore { history: TimelineTrack[][]; redoStack: TimelineTrack[][]; + // Clipboard buffer + clipboard: { + items: Array<{ trackType: TrackType; element: CreateTimelineElement }>; + } | null; + // Always returns properly ordered tracks with main track ensured tracks: TimelineTrack[]; @@ -176,6 +181,10 @@ interface TimelineStore { loadProjectTimeline: (projectId: string) => Promise; saveProjectTimeline: (projectId: string) => Promise; clearTimeline: () => void; + + // Clipboard actions + copySelected: () => void; + pasteAtTime: (time: number) => void; updateTextElement: ( trackId: string, elementId: string, @@ -253,6 +262,7 @@ export const useTimelineStore = create((set, get) => { redoStack: [], selectedElements: [], rippleEditingEnabled: false, + clipboard: null, // Snapping settings defaults snappingEnabled: true, @@ -493,10 +503,12 @@ export const useTimelineStore = create((set, get) => { const newElement: TimelineElement = { ...elementData, id: generateUUID(), - startTime: elementData.startTime || 0, - trimStart: 0, - trimEnd: 0, - ...(elementData.type === "media" ? { muted: false } : {}), + startTime: elementData.startTime, + trimStart: elementData.trimStart ?? 0, + trimEnd: elementData.trimEnd ?? 0, + ...(elementData.type === "media" + ? { muted: elementData.muted ?? false } + : {}), } as TimelineElement; if (isFirstElement && newElement.type === "media") { @@ -1347,7 +1359,12 @@ export const useTimelineStore = create((set, get) => { clearTimeline: () => { const defaultTracks = ensureMainTrack([]); updateTracks(defaultTracks); - set({ history: [], redoStack: [], selectedElements: [] }); + set({ + history: [], + redoStack: [], + selectedElements: [], + clipboard: null, + }); }, // Snapping actions @@ -1451,6 +1468,79 @@ export const useTimelineStore = create((set, get) => { }); return true; }, + + copySelected: () => { + const { selectedElements, _tracks } = get(); + if (selectedElements.length === 0) return; + + const items: Array<{ + trackType: TrackType; + element: CreateTimelineElement; + }> = []; + + for (const { trackId, elementId } of selectedElements) { + const track = _tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); + if (!track || !element) continue; + + // Prepare a creation-friendly copy without id + const { id: _id, ...rest } = element as TimelineElement; + items.push({ + trackType: track.type, + element: rest as CreateTimelineElement, + }); + } + + set({ clipboard: { items } }); + }, + + pasteAtTime: (time) => { + const { clipboard } = get(); + if (!clipboard || clipboard.items.length === 0) return; + + // Determine reference start time offset based on earliest element in clipboard + const minStart = Math.min( + ...clipboard.items.map((x) => x.element.startTime) + ); + + get().pushHistory(); + + for (const item of clipboard.items) { + const targetTrackId = get().findOrCreateTrack(item.trackType); + const relativeOffset = item.element.startTime - minStart; + const startTime = Math.max(0, time + relativeOffset); + + // Ensure no overlap on target track + const duration = + item.element.duration - item.element.trimStart - item.element.trimEnd; + const hasOverlap = get().checkElementOverlap( + targetTrackId, + startTime, + duration + ); + if (hasOverlap) { + // If overlap, nudge forward slightly until free (simple resolve) + let candidate = startTime; + let safety = 0; + while ( + get().checkElementOverlap(targetTrackId, candidate, duration) && + safety < 1000 + ) { + candidate += 0.01; + safety += 1; + } + get().addElementToTrack(targetTrackId, { + ...item.element, + startTime: candidate, + }); + } else { + get().addElementToTrack(targetTrackId, { + ...item.element, + startTime, + }); + } + } + }, }; });