From a7d90f7eac90966920c750134d7de9b611b2c38f Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Thu, 28 Aug 2025 22:37:51 +0200 Subject: feat: copy/pasting for elements --- apps/web/src/stores/keybindings-store.ts | 4 +- apps/web/src/stores/timeline-store.ts | 100 +++++++++++++++++++++++++++++-- 2 files changed, 98 insertions(+), 6 deletions(-) (limited to 'apps/web/src/stores') 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, + }); + } + } + }, }; }); -- cgit v1.3.1