From b459b46802a760a4a81ce91fdf24390e07375fb0 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 26 Aug 2025 15:04:25 +0200 Subject: refactor: type-safety & separate concerns --- apps/web/src/components/editor/timeline/index.tsx | 30 ++++++------------ .../editor/timeline/timeline-element.tsx | 10 ++---- .../components/editor/timeline/timeline-track.tsx | 36 +++++++--------------- 3 files changed, 24 insertions(+), 52 deletions(-) (limited to 'apps/web/src/components/editor/timeline') diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 284384eb..49769989 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -82,13 +82,11 @@ export function Timeline() { toggleTrackMute, dragState, } = useTimelineStore(); - const { mediaItems, addMediaItem } = useMediaStore(); + const { mediaFiles, addMediaFile } = useMediaStore(); const { activeProject } = useProjectStore(); - const { currentTime, duration, seek, setDuration, isPlaying, toggle } = - usePlaybackStore(); + const { currentTime, duration, seek, setDuration } = usePlaybackStore(); const [isDragOver, setIsDragOver] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); - const [progress, setProgress] = useState(0); + const { addElementToNewTrack } = useTimelineStore(); const dragCounterRef = useRef(0); const timelineRef = useRef(null); const rulerRef = useRef(null); @@ -456,10 +454,10 @@ export function Timeline() { if (dragData.type === "text") { // Always create new text track to avoid overlaps - useTimelineStore.getState().addTextToNewTrack(dragData); + addElementToNewTrack(dragData); } else { // Handle media items - const mediaItem = mediaItems.find( + const mediaItem = mediaFiles.find( (item: any) => item.id === dragData.id ); if (!mediaItem) { @@ -467,7 +465,7 @@ export function Timeline() { return; } - useTimelineStore.getState().addMediaToNewTrack(mediaItem); + addElementToNewTrack(mediaItem); } } catch (error) { console.error("Error parsing dropped item data:", error); @@ -480,17 +478,12 @@ export function Timeline() { return; } - setIsProcessing(true); - setProgress(0); try { - const processedItems = await processMediaFiles( - e.dataTransfer.files, - (p) => setProgress(p) - ); + const processedItems = await processMediaFiles(e.dataTransfer.files); for (const processedItem of processedItems) { - await addMediaItem(activeProject.id, processedItem); - const currentMediaItems = useMediaStore.getState().mediaItems; - const addedItem = currentMediaItems.find( + await addMediaFile(activeProject.id, processedItem); + const currentMediaFiles = mediaFiles; + const addedItem = currentMediaFiles.find( (item) => item.name === processedItem.name && item.url === processedItem.url ); @@ -516,9 +509,6 @@ export function Timeline() { // Show error if file processing fails console.error("Error processing external files:", error); toast.error("Failed to process dropped files"); - } finally { - setIsProcessing(false); - setProgress(0); } } }; diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index a1a33837..300c4c7b 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -40,10 +40,8 @@ export function TimelineElement({ onElementMouseDown, onElementClick, }: TimelineElementProps) { - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); const { - updateElementTrim, - updateElementDuration, removeElementFromTrack, removeElementFromTrackWithRipple, dragState, @@ -58,7 +56,7 @@ export function TimelineElement({ const mediaItem = element.type === "media" - ? mediaItems.find((item) => item.id === element.mediaId) + ? mediaFiles.find((file) => file.id === element.mediaId) : null; const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video"; @@ -67,8 +65,6 @@ export function TimelineElement({ element, track, zoomLevel, - onUpdateTrim: updateElementTrim, - onUpdateDuration: updateElementDuration, }); const { requestRevealMedia } = useMediaPanelStore.getState(); @@ -190,7 +186,7 @@ export function TimelineElement({ } // Render media element -> - const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + const mediaItem = mediaFiles.find((file) => file.id === element.mediaId); if (!mediaItem) { return ( diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 5a037710..ad3ca9b7 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -12,6 +12,7 @@ import { canElementGoOnTrack, } from "@/types/timeline"; import { usePlaybackStore } from "@/stores/playback-store"; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; import type { TimelineElement as TimelineElementType, DragData, @@ -33,7 +34,7 @@ export function TimelineTrackContent({ zoomLevel: number; onSnapPointChange?: (snapPoint: SnapPoint | null) => void; }) { - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); const { tracks, addTrack, @@ -537,7 +538,7 @@ export function TimelineTrackContent({ }); } else { // Media elements - const mediaItem = mediaItems.find( + const mediaItem = mediaFiles.find( (item) => item.id === dragData.id ); if (mediaItem) { @@ -890,29 +891,14 @@ export function TimelineTrackContent({ } addElementToTrack(targetTrackId, { - type: "text", - name: dragData.name || "Text", - content: dragData.content || "Default Text", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + ...DEFAULT_TEXT_ELEMENT, + name: dragData.name || DEFAULT_TEXT_ELEMENT.name, + content: dragData.content || DEFAULT_TEXT_ELEMENT.content, startTime: textSnappedTime, - trimStart: 0, - trimEnd: 0, - fontSize: 48, - fontFamily: "Arial", - color: "#ffffff", - backgroundColor: "transparent", - textAlign: "center", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - x: 0, - y: 0, - rotation: 0, - opacity: 1, }); } else { // Handle media items - const mediaItem = mediaItems.find((item) => item.id === dragData.id); + const mediaItem = mediaFiles.find((item) => item.id === dragData.id); if (!mediaItem) { toast.error("Media item not found"); @@ -1054,7 +1040,7 @@ export function TimelineTrackContent({ } else if (hasFiles) { // External file drops const { activeProject } = useProjectStore.getState(); - const { addMediaItem } = useMediaStore.getState(); + const { addMediaFile } = useMediaStore.getState(); const { addElementToTrack } = useTimelineStore.getState(); if (!activeProject) { @@ -1066,9 +1052,9 @@ export function TimelineTrackContent({ processMediaFiles(e.dataTransfer.files) .then(async (processedItems) => { for (const processedItem of processedItems) { - await addMediaItem(activeProject.id, processedItem); - const currentMediaItems = useMediaStore.getState().mediaItems; - const addedItem = currentMediaItems.find( + await addMediaFile(activeProject.id, processedItem); + const currentMediaFiles = mediaFiles; + const addedItem = currentMediaFiles.find( (item) => item.name === processedItem.name && item.url === processedItem.url -- cgit v1.3.1 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 --- .../editor/timeline/timeline-element.tsx | 10 +++ apps/web/src/constants/actions.ts | 4 +- apps/web/src/hooks/use-editor-actions.ts | 17 ++++ apps/web/src/hooks/use-keyboard-shortcuts-help.ts | 8 ++ apps/web/src/stores/keybindings-store.ts | 4 +- apps/web/src/stores/timeline-store.ts | 100 +++++++++++++++++++-- 6 files changed, 136 insertions(+), 7 deletions(-) (limited to 'apps/web/src/components/editor/timeline') 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, + }); + } + } + }, }; }); -- cgit v1.3.1