From 167e2b83dc072f5d80ab0bf0d3f786a993248c70 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 2 Sep 2025 23:07:11 +0200 Subject: feat: add support for deleting/splitting multiple elements instead of single --- .../editor/timeline/timeline-element.tsx | 190 ++++++++++----------- .../editor/timeline/timeline-toolbar.tsx | 35 +--- .../components/editor/timeline/timeline-track.tsx | 15 +- 3 files changed, 100 insertions(+), 140 deletions(-) (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index 677b9c61..ca47d3ca 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -16,7 +16,7 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { usePlaybackStore } from "@/stores/playback-store"; import AudioWaveform from "../audio-waveform"; import { toast } from "sonner"; -import { TimelineElementProps } from "@/types/timeline"; +import { TimelineElementProps, MediaElement } from "@/types/timeline"; import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; import { getTrackElementClasses, @@ -42,16 +42,17 @@ export function TimelineElement({ }: TimelineElementProps) { const { mediaFiles } = useMediaStore(); const { - removeElementFromTrack, - removeElementFromTrackWithRipple, dragState, - splitElement, - addElementToTrack, - replaceElementMedia, - rippleEditingEnabled, - toggleElementHidden, - toggleElementMuted, copySelected, + selectedElements, + deleteSelected, + splitSelected, + toggleSelectedHidden, + toggleSelectedMuted, + duplicateElement, + revealElementInMedia, + replaceElementWithFile, + getContextMenuState, } = useTimelineStore(); const { currentTime } = usePlaybackStore(); @@ -68,7 +69,12 @@ export function TimelineElement({ zoomLevel, }); - const { requestRevealMedia } = useMediaPanelStore.getState(); + const { + isMultipleSelected, + isCurrentElementSelected, + hasAudioElements, + canSplitSelected, + } = getContextMenuState(track.id, element.id); const effectiveDuration = element.duration - element.trimStart - element.trimEnd; @@ -77,44 +83,26 @@ export function TimelineElement({ effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel ); - // Use real-time position during drag, otherwise use stored position const isBeingDragged = dragState.elementId === element.id; const elementStartTime = isBeingDragged && dragState.isDragging ? dragState.currentTime : element.startTime; - // Element should always be positioned at startTime - trimStart only affects content, not position const elementLeft = elementStartTime * 50 * zoomLevel; const handleElementSplitContext = (e: React.MouseEvent) => { e.stopPropagation(); - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const secondElementId = splitElement(track.id, element.id, currentTime); - if (!secondElementId) { - toast.error("Failed to split element"); - } - } else { - toast.error("Playhead must be within element to split"); - } + splitSelected( + currentTime, + isMultipleSelected && isCurrentElementSelected ? undefined : track.id, + isMultipleSelected && isCurrentElementSelected ? undefined : element.id + ); }; const handleElementDuplicateContext = (e: React.MouseEvent) => { e.stopPropagation(); - const { id, ...elementWithoutId } = element; - addElementToTrack(track.id, { - ...elementWithoutId, - name: element.name + " (copy)", - startTime: - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1, - }); + duplicateElement(track.id, element.id); }; const handleElementCopyContext = (e: React.MouseEvent) => { @@ -124,49 +112,38 @@ export function TimelineElement({ const handleElementDeleteContext = (e: React.MouseEvent) => { e.stopPropagation(); - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(track.id, element.id); - } else { - removeElementFromTrack(track.id, element.id); - } + deleteSelected( + isMultipleSelected && isCurrentElementSelected ? undefined : track.id, + isMultipleSelected && isCurrentElementSelected ? undefined : element.id + ); }; const handleToggleElementContext = (e: React.MouseEvent) => { e.stopPropagation(); + if (hasAudio && element.type === "media") { - toggleElementMuted(track.id, element.id); - return; + toggleSelectedMuted( + isMultipleSelected && isCurrentElementSelected ? undefined : track.id, + isMultipleSelected && isCurrentElementSelected ? undefined : element.id + ); + } else { + toggleSelectedHidden( + isMultipleSelected && isCurrentElementSelected ? undefined : track.id, + isMultipleSelected && isCurrentElementSelected ? undefined : element.id + ); } - toggleElementHidden(track.id, element.id); }; const handleReplaceClip = (e: React.MouseEvent) => { e.stopPropagation(); - if (element.type !== "media") { - toast.error("Replace is only available for media clips"); - return; - } - // Create a file input to select replacement media const input = document.createElement("input"); input.type = "file"; input.accept = "video/*,audio/*,image/*"; input.onchange = async (e) => { const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) return; - - try { - const result = await replaceElementMedia(track.id, element.id, file); - if (result.success) { - toast.success("Clip replaced successfully"); - } else { - toast.error(result.error || "Failed to replace clip"); - } - } catch (error) { - console.error("Unexpected error replacing clip:", error); - toast.error( - `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}` - ); + if (file) { + await replaceElementWithFile(track.id, element.id, file); } }; input.click(); @@ -174,12 +151,7 @@ export function TimelineElement({ const handleRevealInMedia = (e: React.MouseEvent) => { e.stopPropagation(); - if (element.type !== "media") { - toast.error("Reveal is only available for media clips"); - return; - } - - requestRevealMedia(element.mediaId); + revealElementInMedia(element.id); }; const renderElementContent = () => { @@ -191,7 +163,6 @@ export function TimelineElement({ ); } - // Render media element -> const mediaItem = mediaFiles.find((file) => file.id === element.mediaId); if (!mediaItem) { return ( @@ -201,20 +172,15 @@ export function TimelineElement({ ); } - const TILE_ASPECT_RATIO = 16 / 9; - if ( mediaItem.type === "image" || (mediaItem.type === "video" && mediaItem.thumbnailUrl) ) { - // Calculate tile size based on 16:9 aspect ratio const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight; - const tileWidth = tileHeight * TILE_ASPECT_RATIO; + const tileWidth = trackHeight * (16 / 9); const imageUrl = mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl; - const isImage = mediaItem.type === "image"; return (
@@ -228,18 +194,17 @@ export function TimelineElement({ style={{ backgroundImage: imageUrl ? `url(${imageUrl})` : "none", backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${tileHeight}px`, + backgroundSize: `${tileWidth}px ${trackHeight}px`, backgroundPosition: "left center", pointerEvents: "none", }} - aria-label={`Tiled ${isImage ? "background" : "thumbnail"} of ${mediaItem.name}`} + aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`} />
); } - // Render audio element -> if (mediaItem.type === "audio") { return (
@@ -267,7 +232,7 @@ export function TimelineElement({ } }; - const isMuted = element.type === "media" ? element.muted === true : false; + const isMuted = element.type === "media" && element.muted; return ( @@ -332,16 +297,33 @@ export function TimelineElement({
- - - Split at playhead - + {(!isMultipleSelected || + (isMultipleSelected && + isCurrentElementSelected && + canSplitSelected)) && ( + + + {isMultipleSelected && isCurrentElementSelected + ? `Split ${selectedElements.length} elements at playhead` + : "Split at playhead"} + + )} + - Copy element + {isMultipleSelected && isCurrentElementSelected + ? `Copy ${selectedElements.length} elements` + : "Copy element"} + - {hasAudio ? ( + {isMultipleSelected && isCurrentElementSelected ? ( + hasAudioElements ? ( + + ) : ( + + ) + ) : hasAudio ? ( isMuted ? ( ) : ( @@ -353,21 +335,29 @@ export function TimelineElement({ )} - {hasAudio - ? isMuted - ? "Unmute" - : "Mute" - : element.hidden - ? "Show" - : "Hide"}{" "} - {element.type === "text" ? "text" : "clip"} + {isMultipleSelected && isCurrentElementSelected + ? hasAudioElements + ? `Toggle mute ${selectedElements.length} elements` + : `Toggle visibility ${selectedElements.length} elements` + : hasAudio + ? isMuted + ? "Unmute" + : "Mute" + : element.hidden + ? "Show" + : "Hide"}{" "} + {!isMultipleSelected && (element.type === "text" ? "text" : "clip")} - - - Duplicate {element.type === "text" ? "text" : "clip"} - - {element.type === "media" && ( + + {!isMultipleSelected && ( + + + Duplicate {element.type === "text" ? "text" : "clip"} + + )} + + {!isMultipleSelected && element.type === "media" && ( <> @@ -379,13 +369,17 @@ export function TimelineElement({ )} + + - Delete {element.type === "text" ? "text" : "clip"} + {isMultipleSelected && isCurrentElementSelected + ? `Delete ${selectedElements.length} elements` + : `Delete ${element.type === "text" ? "text" : "clip"}`} diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx index bec4458c..80c6ce08 100644 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -52,11 +52,10 @@ export function TimelineToolbar({ tracks, addTrack, addElementToTrack, - removeElementFromTrack, - removeElementFromTrackWithRipple, selectedElements, clearSelectedElements, - splitElement, + deleteSelected, + splitSelected, splitAndKeepLeft, splitAndKeepRight, separateAudio, @@ -70,25 +69,7 @@ export function TimelineToolbar({ const { scenes, currentScene } = useSceneStore(); const handleSplitSelected = () => { - if (selectedElements.length === 0) return; - let splitCount = 0; - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (element && track) { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const newElementId = splitElement(trackId, elementId, currentTime); - if (newElementId) splitCount++; - } - } - }); - if (splitCount === 0) { - toast.error("Playhead must be within selected elements to split"); - } + splitSelected(currentTime); }; const handleDuplicateSelected = () => { @@ -173,15 +154,7 @@ export function TimelineToolbar({ }; const handleDeleteSelected = () => { - if (selectedElements.length === 0) return; - selectedElements.forEach(({ trackId, elementId }) => { - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(trackId, elementId); - } else { - removeElementFromTrack(trackId, elementId); - } - }); - clearSelectedElements(); + deleteSelected(); }; const handleZoomIn = () => { diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 42c78aa4..7cfde931 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -1147,7 +1147,7 @@ export function TimelineTrackContent({ const handleElementSplit = () => { const { currentTime } = usePlaybackStore(); - const { splitElement } = useTimelineStore(); + const { splitSelected } = useTimelineStore(); const splitTime = currentTime; const effectiveStart = element.startTime; const effectiveEnd = @@ -1155,14 +1155,7 @@ export function TimelineTrackContent({ (element.duration - element.trimStart - element.trimEnd); if (splitTime > effectiveStart && splitTime < effectiveEnd) { - const secondElementId = splitElement( - track.id, - element.id, - splitTime - ); - if (!secondElementId) { - toast.error("Failed to split element"); - } + splitSelected(splitTime, track.id, element.id); } else { toast.error("Playhead must be within element to split"); } @@ -1182,8 +1175,8 @@ export function TimelineTrackContent({ }; const handleElementDelete = () => { - const { removeElementFromTrack } = useTimelineStore.getState(); - removeElementFromTrack(track.id, element.id); + const { deleteSelected } = useTimelineStore.getState(); + deleteSelected(track.id, element.id); }; return ( -- cgit v1.3.1