diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 08aca17..675b398 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -40,7 +40,7 @@ export function Timeline() { // Timeline shows all tracks (video, audio, effects) and their clips. // You can drag media here to add it to your project. // Clips can be trimmed, deleted, and moved. - const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClip, clearSelectedClip } = + const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore(); @@ -59,6 +59,16 @@ export function Timeline() { y: number; } | null>(null); + // Marquee selection state + const [marquee, setMarquee] = useState<{ + startX: number; + startY: number; + endX: number; + endY: number; + active: boolean; + additive: boolean; + } | null>(null); + // Update timeline duration when tracks change useEffect(() => { const totalDuration = getTotalDuration(); @@ -74,17 +84,105 @@ export function Timeline() { } }, [contextMenu]); - // Keyboard event for deleting selected clip + // Keyboard event for deleting selected clips useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.key === "Delete" || e.key === "Backspace") && selectedClip) { - removeClipFromTrack(selectedClip.trackId, selectedClip.clipId); - clearSelectedClip(); + if ((e.key === "Delete" || e.key === "Backspace") && selectedClips.length > 0) { + selectedClips.forEach(({ trackId, clipId }) => { + removeClipFromTrack(trackId, clipId); + }); + clearSelectedClips(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [selectedClip, removeClipFromTrack, clearSelectedClip]); + }, [selectedClips, removeClipFromTrack, clearSelectedClips]); + + // Mouse down on timeline background to start marquee + const handleTimelineMouseDown = (e: React.MouseEvent) => { + if (e.target === e.currentTarget && e.button === 0) { + setMarquee({ + startX: e.clientX, + startY: e.clientY, + endX: e.clientX, + endY: e.clientY, + active: true, + additive: e.metaKey || e.ctrlKey || e.shiftKey, + }); + } + }; + + // Mouse move to update marquee + useEffect(() => { + if (!marquee || !marquee.active) return; + const handleMouseMove = (e: MouseEvent) => { + setMarquee((prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY }); + }; + const handleMouseUp = (e: MouseEvent) => { + setMarquee((prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY, active: false }); + }; + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [marquee]); + + // On marquee end, select clips in box + useEffect(() => { + if (!marquee || marquee.active) return; + const timeline = timelineRef.current; + if (!timeline) return; + const rect = timeline.getBoundingClientRect(); + const x1 = Math.min(marquee.startX, marquee.endX) - rect.left; + const x2 = Math.max(marquee.startX, marquee.endX) - rect.left; + const y1 = Math.min(marquee.startY, marquee.endY) - rect.top; + const y2 = Math.max(marquee.startY, marquee.endY) - rect.top; + // Validation: skip if too small + if (Math.abs(x2 - x1) < 5 || Math.abs(y2 - y1) < 5) { + setMarquee(null); + return; + } + // Clamp to timeline bounds + const clamp = (val: number, min: number, max: number) => Math.max(min, Math.min(max, val)); + const bx1 = clamp(x1, 0, rect.width); + const bx2 = clamp(x2, 0, rect.width); + const by1 = clamp(y1, 0, rect.height); + const by2 = clamp(y2, 0, rect.height); + let newSelection: { trackId: string; clipId: string }[] = []; + tracks.forEach((track, trackIdx) => { + track.clips.forEach((clip) => { + const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; + const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); + const clipLeft = clip.startTime * 50 * zoomLevel; + const clipTop = trackIdx * 60; + const clipBottom = clipTop + 60; + const clipRight = clipLeft + clipWidth; + if ( + bx1 < clipRight && + bx2 > clipLeft && + by1 < clipBottom && + by2 > clipTop + ) { + newSelection.push({ trackId: track.id, clipId: clip.id }); + } + }); + }); + if (newSelection.length > 0) { + if (marquee.additive) { + const selectedSet = new Set(selectedClips.map((c) => c.trackId + ':' + c.clipId)); + newSelection = [ + ...selectedClips, + ...newSelection.filter((c) => !selectedSet.has(c.trackId + ':' + c.clipId)), + ]; + } + setSelectedClips(newSelection); + } else if (!marquee.additive) { + clearSelectedClips(); + } + setMarquee(null); + }, [marquee, tracks, zoomLevel, selectedClips, setSelectedClips, clearSelectedClips]); const handleDragEnter = (e: React.DragEvent) => { // When something is dragged over the timeline, show overlay @@ -192,17 +290,12 @@ export function Timeline() { } }; - const handleTimelineClick = (e: React.MouseEvent) => { - const timeline = timelineRef.current; - if (!timeline || duration === 0) return; - - const rect = timeline.getBoundingClientRect(); - const x = e.clientX - rect.left; - const timelineWidth = rect.width; - const visibleDuration = duration / zoomLevel; - const clickedTime = (x / timelineWidth) * visibleDuration; - - seek(Math.max(0, Math.min(duration, clickedTime))); + // Deselect all clips when clicking empty timeline area + const handleTimelineAreaClick = (e: React.MouseEvent) => { + // Only clear selection if the click target is the timeline background (not a child/clip) + if (e.target === e.currentTarget) { + clearSelectedClips(); + } }; const handleWheel = (e: React.WheelEvent) => { @@ -530,7 +623,8 @@ export function Timeline() { minHeight: tracks.length > 0 ? `${tracks.length * 60}px` : "200px", }} - onClick={handleTimelineClick} + onClick={handleTimelineAreaClick} + onMouseDown={handleTimelineMouseDown} onWheel={handleWheel} > {tracks.length === 0 ? ( @@ -741,8 +835,9 @@ function TimelineTrackContent({ addClipToTrack, removeClipFromTrack, toggleTrackMute, - selectedClip, + selectedClips, selectClip, + deselectClip, } = useTimelineStore(); const { currentTime } = usePlaybackStore(); const [isDropping, setIsDropping] = useState(false); @@ -1304,10 +1399,9 @@ function TimelineTrackContent({ effectiveDuration * 50 * zoomLevel ); const clipLeft = clip.startTime * 50 * zoomLevel; - - // Correctly declare isSelected inside the map - const isSelected = selectedClip && selectedClip.trackId === track.id && selectedClip.clipId === clip.id; - + const isSelected = selectedClips.some( + (c) => c.trackId === track.id && c.clipId === clip.id + ); return (