From f9d0be20d0a9b4c5ba5916838b05426fbb652fa7 Mon Sep 17 00:00:00 2001 From: DevloperAmanSingh Date: Wed, 25 Jun 2025 21:41:16 +0530 Subject: feat: implement progress tracking for media file processing in timeline --- apps/web/src/components/editor/timeline.tsx | 53 ++++++++++++++++------------- 1 file changed, 29 insertions(+), 24 deletions(-) (limited to 'apps/web/src/components/editor/timeline.tsx') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index c1b8f3e8..1ecbe545 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -69,6 +69,7 @@ export function Timeline() { } = usePlaybackStore(); const [isDragOver, setIsDragOver] = useState(false); const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); const [zoomLevel, setZoomLevel] = useState(1); const dragCounterRef = useRef(0); const timelineRef = useRef(null); @@ -334,8 +335,12 @@ export function Timeline() { } else if (e.dataTransfer.files?.length > 0) { // Handle file drops by creating new tracks setIsProcessing(true); + setProgress(0); try { - const processedItems = await processMediaFiles(e.dataTransfer.files); + const processedItems = await processMediaFiles( + e.dataTransfer.files, + (p) => setProgress(p) + ); for (const processedItem of processedItems) { addMediaItem(processedItem); const currentMediaItems = useMediaStore.getState().mediaItems; @@ -363,6 +368,7 @@ export function Timeline() { toast.error("Failed to process dropped files"); } finally { setIsProcessing(false); + setProgress(0); } } }; @@ -594,10 +600,11 @@ export function Timeline() {
{/* Time Display */} -
- {currentTime.toFixed(1)}s / {duration.toFixed(1)}s +
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
{/* Test Clip Button - for debugging */} @@ -946,14 +953,12 @@ export function Timeline() { )} {isDragOver && ( -
-
Drop media here to add a new track
+
+
+ {isProcessing + ? `Processing ${progress}%` + : "Drop media here to add to timeline"} +
)}
@@ -1630,18 +1635,18 @@ function TimelineTrackContent({ } if (mediaItem.type === "audio") { - return ( -
-
- + return ( +
+
+ +
-
- ); - } + ); + } // Fallback for videos without thumbnails return ( -- cgit v1.3.1 From baa5c43e7c9b8e6415179f19d104c958553985aa Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 19:51:50 +0200 Subject: feat: improve clips dragging experience --- apps/web/src/components/editor/timeline.tsx | 70 +++++++++++++---------------- 1 file changed, 32 insertions(+), 38 deletions(-) (limited to 'apps/web/src/components/editor/timeline.tsx') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 1ecbe545..584a45f7 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1241,7 +1241,18 @@ function TimelineTrackContent({ }; const handleClipDragStart = (e: React.DragEvent, clip: any) => { - const dragData = { clipId: clip.id, trackId: track.id, name: clip.name }; + // Calculate the offset from the left edge of the clip to where the user clicked + const clipElement = e.currentTarget.parentElement as HTMLElement; + const clipRect = clipElement.getBoundingClientRect(); + const clickOffsetX = e.clientX - clipRect.left; + const clickOffsetTime = clickOffsetX / (50 * zoomLevel); + + const dragData = { + clipId: clip.id, + trackId: track.id, + name: clip.name, + clickOffsetTime: clickOffsetTime, + }; e.dataTransfer.setData( "application/x-timeline-clip", @@ -1467,7 +1478,11 @@ function TimelineTrackContent({ ); if (!timelineClipData) return; - const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData); + const { + clipId, + trackId: fromTrackId, + clickOffsetTime = 0, + } = JSON.parse(timelineClipData); // Find the clip being moved const sourceTrack = tracks.find( @@ -1480,10 +1495,17 @@ function TimelineTrackContent({ return; } + // Adjust position based on where user clicked on the clip + const adjustedStartTime = snappedTime - clickOffsetTime; + const finalStartTime = Math.max( + 0, + Math.round(adjustedStartTime * 10) / 10 + ); + // Check for overlaps with existing clips (excluding the moving clip itself) const movingClipDuration = movingClip.duration - movingClip.trimStart - movingClip.trimEnd; - const movingClipEnd = snappedTime + movingClipDuration; + const movingClipEnd = finalStartTime + movingClipDuration; const hasOverlap = track.clips.some((existingClip) => { // Skip the clip being moved if it's on the same track @@ -1498,7 +1520,7 @@ function TimelineTrackContent({ existingClip.trimEnd); // Check if clips overlap - return snappedTime < existingEnd && movingClipEnd > existingStart; + return finalStartTime < existingEnd && movingClipEnd > existingStart; }); if (hasOverlap) { @@ -1510,12 +1532,12 @@ function TimelineTrackContent({ if (fromTrackId === track.id) { // Moving within same track - updateClipStartTime(track.id, clipId, snappedTime); + updateClipStartTime(track.id, clipId, finalStartTime); } else { // Moving to different track moveClipToTrack(fromTrackId, track.id, clipId); requestAnimationFrame(() => { - updateClipStartTime(track.id, clipId, snappedTime); + updateClipStartTime(track.id, clipId, finalStartTime); }); } } else if (hasMediaItem) { @@ -1612,6 +1634,7 @@ function TimelineTrackContent({ src={mediaItem.url} alt={mediaItem.name} className="w-full h-full object-cover" + draggable={false} />
); @@ -1625,6 +1648,7 @@ function TimelineTrackContent({ src={mediaItem.thumbnailUrl} alt={mediaItem.name} className="w-full h-full object-cover rounded-sm" + draggable={false} />
@@ -1684,13 +1708,7 @@ function TimelineTrackContent({ return (
{ e.preventDefault(); // Only show track menu if we didn't click on a clip @@ -1744,7 +1762,7 @@ function TimelineTrackContent({ return (
{ e.stopPropagation(); @@ -1835,30 +1853,6 @@ function TimelineTrackContent({
); })} - - {/* Drop position indicator */} - {isDraggedOver && dropPosition !== null && ( -
-
-
-
- {wouldOverlap ? "⚠️" : ""} - {dropPosition.toFixed(1)}s -
-
- )} )}
-- cgit v1.3.1 From ebcb898e4e70805b556a25ac0a293d67a800fc59 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 21:02:34 +0200 Subject: feat: use mouse-based dragging for clips fixes an issue where dragging a clip would create a duplicate instead of dragging clip directly, and gives us more control moved the drag to a hook for cleaner code --- apps/web/src/components/editor/timeline.tsx | 75 +++++----- apps/web/src/hooks/use-drag-clip.ts | 213 ++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/hooks/use-drag-clip.ts (limited to 'apps/web/src/components/editor/timeline.tsx') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 584a45f7..31e0dd50 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -25,6 +25,7 @@ import { import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; +import { useDragClip } from "@/hooks/use-drag-clip"; import { processMediaFiles } from "@/lib/media-processing"; import { toast } from "sonner"; import { useState, useRef, useEffect, useCallback } from "react"; @@ -1163,9 +1164,19 @@ function TimelineTrackContent({ deselectClip, } = useTimelineStore(); const { currentTime } = usePlaybackStore(); + + // Mouse-based drag hook + const { + isDragging, + draggedClipId, + startDrag, + endDrag, + getDraggedClipPosition, + isValidDropTarget, + timelineRef, + } = useDragClip(zoomLevel); const [isDropping, setIsDropping] = useState(false); const [dropPosition, setDropPosition] = useState(null); - const [isDraggedOver, setIsDraggedOver] = useState(false); const [wouldOverlap, setWouldOverlap] = useState(false); const [resizing, setResizing] = useState<{ clipId: string; @@ -1240,37 +1251,14 @@ function TimelineTrackContent({ setResizing(null); }; - const handleClipDragStart = (e: React.DragEvent, clip: any) => { + const handleClipMouseDown = (e: React.MouseEvent, clip: any) => { // Calculate the offset from the left edge of the clip to where the user clicked const clipElement = e.currentTarget.parentElement as HTMLElement; const clipRect = clipElement.getBoundingClientRect(); const clickOffsetX = e.clientX - clipRect.left; const clickOffsetTime = clickOffsetX / (50 * zoomLevel); - const dragData = { - clipId: clip.id, - trackId: track.id, - name: clip.name, - clickOffsetTime: clickOffsetTime, - }; - - e.dataTransfer.setData( - "application/x-timeline-clip", - JSON.stringify(dragData) - ); - e.dataTransfer.effectAllowed = "move"; - - // Add visual feedback to the dragged element - const target = e.currentTarget.parentElement as HTMLElement; - target.style.opacity = "0.5"; - target.style.transform = "scale(0.95)"; - }; - - const handleClipDragEnd = (e: React.DragEvent) => { - // Reset visual feedback - const target = e.currentTarget.parentElement as HTMLElement; - target.style.opacity = ""; - target.style.transform = ""; + startDrag(e, clip.id, track.id, clip.startTime, clickOffsetTime); }; const handleTrackDragOver = (e: React.DragEvent) => { @@ -1388,14 +1376,12 @@ function TimelineTrackContent({ if (wouldOverlap) { e.dataTransfer.dropEffect = "none"; - setIsDraggedOver(true); setWouldOverlap(true); setDropPosition(Math.round(dropTime * 10) / 10); return; } e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy"; - setIsDraggedOver(true); setWouldOverlap(false); setDropPosition(Math.round(dropTime * 10) / 10); }; @@ -1414,7 +1400,6 @@ function TimelineTrackContent({ dragCounterRef.current++; setIsDropping(true); - setIsDraggedOver(true); }; const handleTrackDragLeave = (e: React.DragEvent) => { @@ -1433,7 +1418,6 @@ function TimelineTrackContent({ if (dragCounterRef.current === 0) { setIsDropping(false); - setIsDraggedOver(false); setWouldOverlap(false); setDropPosition(null); } @@ -1446,7 +1430,6 @@ function TimelineTrackContent({ // Reset all drag states dragCounterRef.current = 0; setIsDropping(false); - setIsDraggedOver(false); setWouldOverlap(false); const currentDropPosition = dropPosition; setDropPosition(null); @@ -1726,10 +1709,18 @@ function TimelineTrackContent({ onDragLeave={handleTrackDragLeave} onDrop={handleTrackDrop} onMouseMove={handleResizeMove} - onMouseUp={handleResizeEnd} + onMouseUp={(e) => { + handleResizeEnd(); + if (isDragging) { + endDrag(track.id); + } + }} onMouseLeave={handleResizeEnd} > -
+
{track.clips.length === 0 ? (
c.trackId === track.id && c.clipId === clip.id ); + + const isBeingDragged = draggedClipId === clip.id; return (
{ e.stopPropagation(); @@ -1808,10 +1807,8 @@ function TimelineTrackContent({ /> {/* Clip content */}
handleClipDragStart(e, clip)} - onDragEnd={handleClipDragEnd} + className={`flex-1 relative ${isBeingDragged ? "cursor-grabbing" : "cursor-grab"}`} + onMouseDown={(e) => handleClipMouseDown(e, clip)} > {renderClipContent(clip)} {/* Clip options menu */} diff --git a/apps/web/src/hooks/use-drag-clip.ts b/apps/web/src/hooks/use-drag-clip.ts new file mode 100644 index 00000000..7eb24daa --- /dev/null +++ b/apps/web/src/hooks/use-drag-clip.ts @@ -0,0 +1,213 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useTimelineStore } from "@/stores/timeline-store"; + +interface DragState { + isDragging: boolean; + clipId: string | null; + trackId: string | null; + startMouseX: number; + startClipTime: number; + clickOffsetTime: number; + currentTime: number; +} + +export function useDragClip(zoomLevel: number) { + const { tracks, updateClipStartTime, moveClipToTrack } = useTimelineStore(); + + const [dragState, setDragState] = useState({ + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }); + + const timelineRef = useRef(null); + + const startDrag = useCallback( + ( + e: React.MouseEvent, + clipId: string, + trackId: string, + clipStartTime: number, + clickOffsetTime: number + ) => { + e.preventDefault(); + e.stopPropagation(); + + setDragState({ + isDragging: true, + clipId, + trackId, + startMouseX: e.clientX, + startClipTime: clipStartTime, + clickOffsetTime, + currentTime: clipStartTime, + }); + }, + [] + ); + + const updateDrag = useCallback( + (e: MouseEvent) => { + if (!dragState.isDragging || !timelineRef.current) return; + + const timelineRect = timelineRef.current.getBoundingClientRect(); + const mouseX = e.clientX - timelineRect.left; + const mouseTime = Math.max(0, mouseX / (50 * zoomLevel)); + const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); + const snappedTime = Math.round(adjustedTime * 10) / 10; + + setDragState((prev) => ({ + ...prev, + currentTime: snappedTime, + })); + }, + [dragState.isDragging, dragState.clickOffsetTime, zoomLevel] + ); + + const endDrag = useCallback( + (targetTrackId?: string) => { + if (!dragState.isDragging || !dragState.clipId || !dragState.trackId) + return; + + const finalTrackId = targetTrackId || dragState.trackId; + const finalTime = dragState.currentTime; + + // Check for overlaps + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const targetTrack = tracks.find((t) => t.id === finalTrackId); + const movingClip = sourceTrack?.clips.find( + (c) => c.id === dragState.clipId + ); + + if (!movingClip || !targetTrack) { + setDragState((prev) => ({ ...prev, isDragging: false })); + return; + } + + const movingClipDuration = + movingClip.duration - movingClip.trimStart - movingClip.trimEnd; + const movingClipEnd = finalTime + movingClipDuration; + + const hasOverlap = targetTrack.clips.some((existingClip) => { + // Skip the clip being moved if it's on the same track + if ( + dragState.trackId === finalTrackId && + existingClip.id === dragState.clipId + ) { + return false; + } + + const existingStart = existingClip.startTime; + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); + + return finalTime < existingEnd && movingClipEnd > existingStart; + }); + + if (!hasOverlap) { + if (dragState.trackId === finalTrackId) { + // Moving within same track + updateClipStartTime(finalTrackId, dragState.clipId!, finalTime); + } else { + // Moving to different track + moveClipToTrack(dragState.trackId!, finalTrackId, dragState.clipId!); + requestAnimationFrame(() => { + updateClipStartTime(finalTrackId, dragState.clipId!, finalTime); + }); + } + } + + setDragState({ + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }); + }, + [dragState, tracks, updateClipStartTime, moveClipToTrack] + ); + + const cancelDrag = useCallback(() => { + setDragState({ + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }); + }, []); + + // Global mouse events + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseMove = (e: MouseEvent) => updateDrag(e); + const handleMouseUp = () => endDrag(); + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") cancelDrag(); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("keydown", handleEscape); + }; + }, [dragState.isDragging, updateDrag, endDrag, cancelDrag]); + + const getDraggedClipPosition = useCallback( + (clipId: string) => { + if (dragState.isDragging && dragState.clipId === clipId) { + return dragState.currentTime; + } + return null; + }, + [dragState] + ); + + const isValidDropTarget = useCallback( + (trackId: string) => { + if (!dragState.isDragging) return false; + + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const targetTrack = tracks.find((t) => t.id === trackId); + + if (!sourceTrack || !targetTrack) return false; + + // For now, allow drops on same track type + return sourceTrack.type === targetTrack.type; + }, + [dragState.isDragging, dragState.trackId, tracks] + ); + + return { + // State + isDragging: dragState.isDragging, + draggedClipId: dragState.clipId, + + // Methods + startDrag, + endDrag, + cancelDrag, + getDraggedClipPosition, + isValidDropTarget, + + // Refs + timelineRef, + }; +} -- cgit v1.3.1 From d1e313450db64c95237ebcefe928dbde39043fee Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 21:06:34 +0200 Subject: fix: show clip trim handles on focus rather than hover --- apps/web/src/components/editor/timeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'apps/web/src/components/editor/timeline.tsx') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 31e0dd50..b9e78839 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1802,7 +1802,7 @@ function TimelineTrackContent({ > {/* Left trim handle */}
handleResizeStart(e, clip.id, "left")} /> {/* Clip content */} @@ -1844,7 +1844,7 @@ function TimelineTrackContent({
{/* Right trim handle */}
handleResizeStart(e, clip.id, "right")} />
-- cgit v1.3.1 From f5c546d416a84d577f45a1fdb79e5e67e5a9495c Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 21:08:52 +0200 Subject: fix: clip getting deselected after a drag --- apps/web/src/components/editor/timeline.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) (limited to 'apps/web/src/components/editor/timeline.tsx') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index b9e78839..7e2089a7 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1251,6 +1251,8 @@ function TimelineTrackContent({ setResizing(null); }; + const [justFinishedDrag, setJustFinishedDrag] = useState(false); + const handleClipMouseDown = (e: React.MouseEvent, clip: any) => { // Calculate the offset from the left edge of the clip to where the user clicked const clipElement = e.currentTarget.parentElement as HTMLElement; @@ -1261,6 +1263,16 @@ function TimelineTrackContent({ startDrag(e, clip.id, track.id, clip.startTime, clickOffsetTime); }; + // Reset drag flag when drag ends + useEffect(() => { + if (!isDragging && justFinishedDrag) { + const timer = setTimeout(() => setJustFinishedDrag(false), 50); + return () => clearTimeout(timer); + } else if (isDragging && !justFinishedDrag) { + setJustFinishedDrag(true); + } + }, [isDragging, justFinishedDrag]); + const handleTrackDragOver = (e: React.DragEvent) => { e.preventDefault(); @@ -1766,6 +1778,11 @@ function TimelineTrackContent({ onClick={(e) => { e.stopPropagation(); + // Don't handle click if we just finished dragging + if (justFinishedDrag) { + return; + } + // Close context menu if it's open if (contextMenu) { setContextMenu(null); -- cgit v1.3.1 From 2775ac427de8bd9c2bb6323a216f1875038f68d8 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 21:10:40 +0200 Subject: fix: remove hover effects on clip drag --- apps/web/src/components/editor/timeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'apps/web/src/components/editor/timeline.tsx') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 7e2089a7..18e1338e 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1773,7 +1773,7 @@ function TimelineTrackContent({ return (
{ e.stopPropagation(); -- cgit v1.3.1 From 926aebe00423b5b0b2fd0d8cf0ee97c6e85ec1fd Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 21:22:08 +0200 Subject: fix: make whole clip draggable --- apps/web/src/components/editor/timeline.tsx | 53 ++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 17 deletions(-) (limited to 'apps/web/src/components/editor/timeline.tsx') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 18e1338e..210a943d 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1254,8 +1254,24 @@ function TimelineTrackContent({ const [justFinishedDrag, setJustFinishedDrag] = useState(false); const handleClipMouseDown = (e: React.MouseEvent, clip: any) => { + // Handle selection first + if (!justFinishedDrag) { + const isSelected = selectedClips.some( + (c) => c.trackId === track.id && c.clipId === clip.id + ); + + if (e.metaKey || e.ctrlKey || e.shiftKey) { + // Multi-selection mode: toggle the clip + selectClip(track.id, clip.id, true); + } else if (!isSelected) { + // If clip is not selected, select it (replacing other selections) + selectClip(track.id, clip.id, false); + } + // Note: Don't deselect if already selected, as user might want to drag + } + // Calculate the offset from the left edge of the clip to where the user clicked - const clipElement = e.currentTarget.parentElement as HTMLElement; + const clipElement = e.currentTarget as HTMLElement; const clipRect = clipElement.getBoundingClientRect(); const clickOffsetX = e.clientX - clipRect.left; const clickOffsetTime = clickOffsetX / (50 * zoomLevel); @@ -1773,8 +1789,9 @@ function TimelineTrackContent({ return (
handleClipMouseDown(e, clip)} onClick={(e) => { e.stopPropagation(); @@ -1789,19 +1806,14 @@ function TimelineTrackContent({ return; // Don't handle selection when closing context menu } + // Only handle deselection here (selection is handled in mouseDown) const isSelected = selectedClips.some( (c) => c.trackId === track.id && c.clipId === clip.id ); - if (e.metaKey || e.ctrlKey || e.shiftKey) { - // Multi-selection mode: toggle the clip - selectClip(track.id, clip.id, true); - } else if (isSelected) { - // If clip is already selected, deselect it + if (isSelected && !e.metaKey && !e.ctrlKey && !e.shiftKey) { + // If clip is already selected and no modifier keys, deselect it deselectClip(track.id, clip.id); - } else { - // If clip is not selected, select it (replacing other selections) - selectClip(track.id, clip.id, false); } }} tabIndex={0} @@ -1820,13 +1832,13 @@ function TimelineTrackContent({ {/* Left trim handle */}
handleResizeStart(e, clip.id, "left")} + onMouseDown={(e) => { + e.stopPropagation(); // Prevent triggering clip drag + handleResizeStart(e, clip.id, "left"); + }} /> {/* Clip content */} -
handleClipMouseDown(e, clip)} - > +
{renderClipContent(clip)} {/* Clip options menu */}
@@ -1835,11 +1847,15 @@ function TimelineTrackContent({ size="icon" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => setClipMenuOpen(clip.id)} + onMouseDown={(e) => e.stopPropagation()} > {clipMenuOpen === clip.id && ( -
+
e.stopPropagation()} + >
- - {/* Timeline Container */} -
- {/* Timeline Header with Ruler */} -
- {/* Track Labels Header */} -
- - Tracks - -
- {zoomLevel.toFixed(1)}x -
-
- - {/* Timeline Ruler */} -
- -
{ - // Calculate the clicked time position and seek to it - handleSeekToPosition(e); - }} - > - {/* Time markers */} - {(() => { - // Calculate appropriate time interval based on zoom level - const getTimeInterval = (zoom: number) => { - const pixelsPerSecond = 50 * zoom; - if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in - if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in - if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom - if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out - if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out - if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out - return 30; // Every 30s when extremely zoomed out - }; - - const interval = getTimeInterval(zoomLevel); - const markerCount = Math.ceil(duration / interval) + 1; - - return Array.from({ length: markerCount }, (_, i) => { - const time = i * interval; - if (time > duration) return null; - - const isMainMarker = - time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; - - return ( -
- - {(() => { - const formatTime = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (minutes > 0) { - return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (interval >= 1) { - return `${Math.floor(secs)}s`; - } else { - return `${secs.toFixed(1)}s`; - } - }; - return formatTime(time); - })()} - -
- ); - }).filter(Boolean); - })()} - - {/* Playhead in ruler (scrubbable) */} -
-
-
-
- -
-
- - {/* Tracks Area */} -
- {/* Track Labels */} - {tracks.length > 0 && ( -
-
- {tracks.map((track) => ( -
{ - e.preventDefault(); - setContextMenu({ - type: "track", - trackId: track.id, - x: e.clientX, - y: e.clientY, - }); - }} - > -
-
- - {track.name} - -
- {track.muted && ( - - Muted - - )} -
- ))} -
-
- )} - - {/* Timeline Tracks Content */} -
-
- {/* Timeline grid and clips area (with left margin for sifdebar) */} -
- {tracks.length === 0 ? ( -
-
-
- -
-

- Drop media here to start -

-
-
- ) : ( - <> - {tracks.map((track, index) => ( -
{ - e.preventDefault(); - setContextMenu({ - type: "track", - trackId: track.id, - x: e.clientX, - y: e.clientY, - }); - }} - > - -
- ))} - - {/* Playhead for tracks area (scrubbable) */} - {tracks.length > 0 && ( -
- )} - - )} - {isDragOver && ( -
-
- {isProcessing - ? `Processing ${progress}%` - : "Drop media here to add to timeline"} -
-
- )} -
-
-
-
-
- - {/* Clean Unified Context Menu */} - {contextMenu && ( -
e.preventDefault()} - > - {contextMenu.type === "track" ? ( - // Track context menu - <> - -
- - - ) : ( - // Clip context menu - <> - - -
- - - )} -
- )} -
- ); -} - -function TimelineTrackContent({ - track, - zoomLevel, - setContextMenu, - contextMenu, -}: { - track: TimelineTrack; - zoomLevel: number; - setContextMenu: (menu: ContextMenuState | null) => void; - contextMenu: ContextMenuState | null; -}) { - const { mediaItems } = useMediaStore(); - const { - tracks, - moveClipToTrack, - updateClipStartTime, - addClipToTrack, - selectedClips, - selectClip, - deselectClip, - } = useTimelineStore(); - - // Mouse-based drag hook - const { isDragging, startDrag, endDrag, timelineRef } = - useDragClip(zoomLevel); - const [isDropping, setIsDropping] = useState(false); - const [dropPosition, setDropPosition] = useState(null); - const [wouldOverlap, setWouldOverlap] = useState(false); - const dragCounterRef = useRef(0); - - const [justFinishedDrag, setJustFinishedDrag] = useState(false); - - const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => { - // Handle selection first - if (!justFinishedDrag) { - const isSelected = selectedClips.some( - (c) => c.trackId === track.id && c.clipId === clip.id - ); - - if (e.metaKey || e.ctrlKey || e.shiftKey) { - // Multi-selection mode: toggle the clip - selectClip(track.id, clip.id, true); - } else if (!isSelected) { - // If clip is not selected, select it (replacing other selections) - selectClip(track.id, clip.id, false); - } - // Note: Don't deselect if already selected, as user might want to drag - } - - // Calculate the offset from the left edge of the clip to where the user clicked - const clipElement = e.currentTarget as HTMLElement; - const clipRect = clipElement.getBoundingClientRect(); - const clickOffsetX = e.clientX - clipRect.left; - const clickOffsetTime = clickOffsetX / (50 * zoomLevel); - - startDrag(e, clip.id, track.id, clip.startTime, clickOffsetTime); - }; - - const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => { - e.stopPropagation(); - - // Don't handle click if we just finished dragging - if (justFinishedDrag) { - return; - } - - // Close context menu if it's open - if (contextMenu) { - setContextMenu(null); - return; // Don't handle selection when closing context menu - } - - // Only handle deselection here (selection is handled in mouseDown) - const isSelected = selectedClips.some( - (c) => c.trackId === track.id && c.clipId === clip.id - ); - - if (isSelected && !e.metaKey && !e.ctrlKey && !e.shiftKey) { - // If clip is already selected and no modifier keys, deselect it - deselectClip(track.id, clip.id); - } - }; - - const handleClipContextMenu = (e: React.MouseEvent, clipId: string) => { - e.preventDefault(); - e.stopPropagation(); - setContextMenu({ - type: "clip", - trackId: track.id, - clipId: clipId, - x: e.clientX, - y: e.clientY, - }); - }; - - // Reset drag flag when drag ends - useEffect(() => { - if (!isDragging && justFinishedDrag) { - const timer = setTimeout(() => setJustFinishedDrag(false), 50); - return () => clearTimeout(timer); - } else if (isDragging && !justFinishedDrag) { - setJustFinishedDrag(true); - } - }, [isDragging, justFinishedDrag]); - - const handleTrackDragOver = (e: React.DragEvent) => { - e.preventDefault(); - - // Handle both timeline clips and media items - const hasTimelineClip = e.dataTransfer.types.includes( - "application/x-timeline-clip" - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item" - ); - - if (!hasTimelineClip && !hasMediaItem) return; - - if (hasMediaItem) { - try { - const mediaItemData = e.dataTransfer.getData( - "application/x-media-item" - ); - if (mediaItemData) { - const { type } = JSON.parse(mediaItemData); - const isCompatible = - (track.type === "video" && - (type === "video" || type === "image")) || - (track.type === "audio" && type === "audio"); - - if (!isCompatible) { - e.dataTransfer.dropEffect = "none"; - return; - } - } - } catch (error) {} - } - - // Calculate drop position for overlap checking - const trackContainer = e.currentTarget.querySelector( - ".track-clips-container" - ) as HTMLElement; - let dropTime = 0; - if (trackContainer) { - const rect = trackContainer.getBoundingClientRect(); - const mouseX = Math.max(0, e.clientX - rect.left); - dropTime = mouseX / (50 * zoomLevel); - } - - // Check for potential overlaps and show appropriate feedback - let wouldOverlap = false; - - if (hasMediaItem) { - try { - const mediaItemData = e.dataTransfer.getData( - "application/x-media-item" - ); - if (mediaItemData) { - const { id } = JSON.parse(mediaItemData); - const mediaItem = mediaItems.find((item) => item.id === id); - if (mediaItem) { - const newClipDuration = mediaItem.duration || 5; - const snappedTime = Math.round(dropTime * 10) / 10; - const newClipEnd = snappedTime + newClipDuration; - - wouldOverlap = track.clips.some((existingClip) => { - const existingStart = existingClip.startTime; - const existingEnd = - existingClip.startTime + - (existingClip.duration - - existingClip.trimStart - - existingClip.trimEnd); - return snappedTime < existingEnd && newClipEnd > existingStart; - }); - } - } - } catch (error) { - // Continue with default behavior - } - } else if (hasTimelineClip) { - try { - const timelineClipData = e.dataTransfer.getData( - "application/x-timeline-clip" - ); - if (timelineClipData) { - const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData); - const sourceTrack = tracks.find( - (t: TimelineTrack) => t.id === fromTrackId - ); - const movingClip = sourceTrack?.clips.find( - (c: TypeTimelineClip) => c.id === clipId - ); - - if (movingClip) { - const movingClipDuration = - movingClip.duration - movingClip.trimStart - movingClip.trimEnd; - const snappedTime = Math.round(dropTime * 10) / 10; - const movingClipEnd = snappedTime + movingClipDuration; - - wouldOverlap = track.clips.some((existingClip) => { - if (fromTrackId === track.id && existingClip.id === clipId) - return false; - - const existingStart = existingClip.startTime; - const existingEnd = - existingClip.startTime + - (existingClip.duration - - existingClip.trimStart - - existingClip.trimEnd); - return snappedTime < existingEnd && movingClipEnd > existingStart; - }); - } - } - } catch (error) { - // Continue with default behavior - } - } - - if (wouldOverlap) { - e.dataTransfer.dropEffect = "none"; - setWouldOverlap(true); - setDropPosition(Math.round(dropTime * 10) / 10); - return; - } - - e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy"; - setWouldOverlap(false); - setDropPosition(Math.round(dropTime * 10) / 10); - }; - - const handleTrackDragEnter = (e: React.DragEvent) => { - e.preventDefault(); - - const hasTimelineClip = e.dataTransfer.types.includes( - "application/x-timeline-clip" - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item" - ); - - if (!hasTimelineClip && !hasMediaItem) return; - - dragCounterRef.current++; - setIsDropping(true); - }; - - const handleTrackDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - - const hasTimelineClip = e.dataTransfer.types.includes( - "application/x-timeline-clip" - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item" - ); - - if (!hasTimelineClip && !hasMediaItem) return; - - dragCounterRef.current--; - - if (dragCounterRef.current === 0) { - setIsDropping(false); - setWouldOverlap(false); - setDropPosition(null); - } - }; - - const handleTrackDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - - // Reset all drag states - dragCounterRef.current = 0; - setIsDropping(false); - setWouldOverlap(false); - const currentDropPosition = dropPosition; - setDropPosition(null); - - const hasTimelineClip = e.dataTransfer.types.includes( - "application/x-timeline-clip" - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item" - ); - - if (!hasTimelineClip && !hasMediaItem) return; - - const trackContainer = e.currentTarget.querySelector( - ".track-clips-container" - ) as HTMLElement; - if (!trackContainer) return; - - const rect = trackContainer.getBoundingClientRect(); - const mouseX = Math.max(0, e.clientX - rect.left); - const newStartTime = mouseX / (50 * zoomLevel); - const snappedTime = Math.round(newStartTime * 10) / 10; - - try { - if (hasTimelineClip) { - // Handle timeline clip movement - const timelineClipData = e.dataTransfer.getData( - "application/x-timeline-clip" - ); - if (!timelineClipData) return; - - const { - clipId, - trackId: fromTrackId, - clickOffsetTime = 0, - } = JSON.parse(timelineClipData); - - // Find the clip being moved - const sourceTrack = tracks.find( - (t: TimelineTrack) => t.id === fromTrackId - ); - const movingClip = sourceTrack?.clips.find( - (c: TypeTimelineClip) => c.id === clipId - ); - - if (!movingClip) { - toast.error("Clip not found"); - return; - } - - // Adjust position based on where user clicked on the clip - const adjustedStartTime = snappedTime - clickOffsetTime; - const finalStartTime = Math.max( - 0, - Math.round(adjustedStartTime * 10) / 10 - ); - - // Check for overlaps with existing clips (excluding the moving clip itself) - const movingClipDuration = - movingClip.duration - movingClip.trimStart - movingClip.trimEnd; - const movingClipEnd = finalStartTime + movingClipDuration; - - const hasOverlap = track.clips.some((existingClip) => { - // Skip the clip being moved if it's on the same track - if (fromTrackId === track.id && existingClip.id === clipId) - return false; - - const existingStart = existingClip.startTime; - const existingEnd = - existingClip.startTime + - (existingClip.duration - - existingClip.trimStart - - existingClip.trimEnd); - - // Check if clips overlap - return finalStartTime < existingEnd && movingClipEnd > existingStart; - }); - - if (hasOverlap) { - toast.error( - "Cannot move clip here - it would overlap with existing clips" - ); - return; - } - - if (fromTrackId === track.id) { - // Moving within same track - updateClipStartTime(track.id, clipId, finalStartTime); - } else { - // Moving to different track - moveClipToTrack(fromTrackId, track.id, clipId); - requestAnimationFrame(() => { - updateClipStartTime(track.id, clipId, finalStartTime); - }); - } - } else if (hasMediaItem) { - // Handle media item drop - const mediaItemData = e.dataTransfer.getData( - "application/x-media-item" - ); - if (!mediaItemData) return; - - const { id, type } = JSON.parse(mediaItemData); - const mediaItem = mediaItems.find((item) => item.id === id); - - if (!mediaItem) { - toast.error("Media item not found"); - return; - } - - // Check if track type is compatible - const isCompatible = - (track.type === "video" && (type === "video" || type === "image")) || - (track.type === "audio" && type === "audio"); - - if (!isCompatible) { - toast.error(`Cannot add ${type} to ${track.type} track`); - return; - } - - // Check for overlaps with existing clips - const newClipDuration = mediaItem.duration || 5; - const newClipEnd = snappedTime + newClipDuration; - - const hasOverlap = track.clips.some((existingClip) => { - const existingStart = existingClip.startTime; - const existingEnd = - existingClip.startTime + - (existingClip.duration - - existingClip.trimStart - - existingClip.trimEnd); - - // Check if clips overlap - return snappedTime < existingEnd && newClipEnd > existingStart; - }); - - if (hasOverlap) { - toast.error( - "Cannot place clip here - it would overlap with existing clips" - ); - return; - } - - addClipToTrack(track.id, { - mediaId: mediaItem.id, - name: mediaItem.name, - duration: mediaItem.duration || 5, - startTime: snappedTime, - trimStart: 0, - trimEnd: 0, - }); - - toast.success(`Added ${mediaItem.name} to ${track.name}`); - } - } catch (error) { - console.error("Error handling drop:", error); - toast.error("Failed to add media to track"); - } - }; - - return ( -
{ - e.preventDefault(); - // Only show track menu if we didn't click on a clip - if (!(e.target as HTMLElement).closest(".timeline-clip")) { - setContextMenu({ - type: "track", - trackId: track.id, - x: e.clientX, - y: e.clientY, - }); - } - }} - onDragOver={handleTrackDragOver} - onDragEnter={handleTrackDragEnter} - onDragLeave={handleTrackDragLeave} - onDrop={handleTrackDrop} - onMouseUp={(e) => { - if (isDragging) { - endDrag(track.id); - } - }} - > -
- {track.clips.length === 0 ? ( -
- {isDropping - ? wouldOverlap - ? "Cannot drop - would overlap" - : "Drop clip here" - : "Drop media here"} -
- ) : ( - <> - {track.clips.map((clip) => { - const isSelected = selectedClips.some( - (c) => c.trackId === track.id && c.clipId === clip.id - ); - - return ( - - ); - })} - - )} -
-
- ); -} +"use client"; + +import { ScrollArea } from "../ui/scroll-area"; +import { Button } from "../ui/button"; +import { + Scissors, + ArrowLeftToLine, + ArrowRightToLine, + Trash2, + Snowflake, + Copy, + SplitSquareHorizontal, + Volume2, + VolumeX, + Pause, + Play, +} from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider, +} from "../ui/tooltip"; +import { + useTimelineStore, + type TimelineTrack, + type TimelineClip as TypeTimelineClip, +} from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { useDragClip } from "@/hooks/use-drag-clip"; +import { processMediaFiles } from "@/lib/media-processing"; +import { toast } from "sonner"; +import { useState, useRef, useEffect, useCallback } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { TimelineClip } from "./timeline-clip"; +import { ContextMenuState } from "@/types/timeline"; + +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, + getTotalDuration, + selectedClips, + clearSelectedClips, + setSelectedClips, + updateClipTrim, + undo, + redo, + moveClipToTrack, + updateClipStartTime, + selectClip, + deselectClip, + dragState, + startDrag: startDragAction, + updateDragTime, + endDrag: endDragAction, + } = useTimelineStore(); + const { mediaItems, addMediaItem } = useMediaStore(); + const { + currentTime, + duration, + seek, + setDuration, + isPlaying, + toggle, + setSpeed, + speed, + } = usePlaybackStore(); + const [isDragOver, setIsDragOver] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); + const [zoomLevel, setZoomLevel] = useState(1); + const dragCounterRef = useRef(0); + const timelineRef = useRef(null); + const [isInTimeline, setIsInTimeline] = useState(false); + + // Unified context menu state + const [contextMenu, setContextMenu] = useState(null); + + // Marquee selection state + const [marquee, setMarquee] = useState<{ + startX: number; + startY: number; + endX: number; + endY: number; + active: boolean; + additive: boolean; + } | null>(null); + + // Playhead scrubbing state + const [isScrubbing, setIsScrubbing] = useState(false); + const [scrubTime, setScrubTime] = useState(null); + + // Update timeline duration when tracks change + useEffect(() => { + const totalDuration = getTotalDuration(); + setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline + }, [tracks, setDuration, getTotalDuration]); + + // Close context menu on click elsewhere + useEffect(() => { + const handleClick = () => setContextMenu(null); + if (contextMenu) { + window.addEventListener("click", handleClick); + return () => window.removeEventListener("click", handleClick); + } + }, [contextMenu]); + + // Keyboard event for deleting selected clips + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + 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); + }, [selectedClips, removeClipFromTrack, clearSelectedClips]); + + // Keyboard event for undo (Cmd+Z) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) { + e.preventDefault(); + undo(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [undo]); + + // Keyboard event for redo (Cmd+Shift+Z or Cmd+Y) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) { + e.preventDefault(); + redo(); + } else if ((e.metaKey || e.ctrlKey) && e.key === "y") { + e.preventDefault(); + redo(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [redo]); + + // 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 + 60; // Set a fixed width for time display + 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 + e.preventDefault(); + // Don't show overlay for timeline clips - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + dragCounterRef.current += 1; + if (!isDragOver) { + setIsDragOver(true); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + + // Don't update state for timeline clips - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + + dragCounterRef.current -= 1; + if (dragCounterRef.current === 0) { + setIsDragOver(false); + } + }; + + const handleDrop = async (e: React.DragEvent) => { + // When media is dropped, add it as a new track/clip + e.preventDefault(); + setIsDragOver(false); + dragCounterRef.current = 0; + + // Ignore timeline clip drags - they're handled by track-specific handlers + const hasTimelineClip = e.dataTransfer.types.includes( + "application/x-timeline-clip" + ); + if (hasTimelineClip) { + return; + } + + const mediaItemData = e.dataTransfer.getData("application/x-media-item"); + if (mediaItemData) { + // Handle media item drops by creating new tracks + try { + const { id, type } = JSON.parse(mediaItemData); + const mediaItem = mediaItems.find((item) => item.id === id); + if (!mediaItem) { + toast.error("Media item not found"); + return; + } + // Add to video or audio track depending on type + const trackType = type === "audio" ? "audio" : "video"; + const newTrackId = addTrack(trackType); + addClipToTrack(newTrackId, { + mediaId: mediaItem.id, + name: mediaItem.name, + duration: mediaItem.duration || 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); + toast.success(`Added ${mediaItem.name} to new ${trackType} track`); + } catch (error) { + // Show error if parsing fails + console.error("Error parsing media item data:", error); + toast.error("Failed to add media to timeline"); + } + } else if (e.dataTransfer.files?.length > 0) { + // Handle file drops by creating new tracks + setIsProcessing(true); + setProgress(0); + try { + const processedItems = await processMediaFiles( + e.dataTransfer.files, + (p) => setProgress(p) + ); + for (const processedItem of processedItems) { + addMediaItem(processedItem); + const currentMediaItems = useMediaStore.getState().mediaItems; + const addedItem = currentMediaItems.find( + (item) => + item.name === processedItem.name && item.url === processedItem.url + ); + if (addedItem) { + const trackType = + processedItem.type === "audio" ? "audio" : "video"; + const newTrackId = addTrack(trackType); + addClipToTrack(newTrackId, { + mediaId: addedItem.id, + name: addedItem.name, + duration: addedItem.duration || 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); + } + } + } catch (error) { + // 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); + } + } + }; + + const handleSeekToPosition = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const clickedTime = clickX / (50 * zoomLevel); + const clampedTime = Math.max(0, Math.min(duration, clickedTime)); + + seek(clampedTime); + }; + + const handleTimelineAreaClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + clearSelectedClips(); + + // Calculate the clicked time position and seek to it + handleSeekToPosition(e); + } + }; + + const handleWheel = (e: React.WheelEvent) => { + // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true) + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.05 : 0.05; + setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); + } + // Otherwise, allow normal scrolling + }; + + // --- Playhead Scrubbing Handlers --- + const handlePlayheadMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setIsScrubbing(true); + handleScrub(e); + }, + [duration, zoomLevel] + ); + + const handleScrub = useCallback( + (e: MouseEvent | React.MouseEvent) => { + const timeline = timelineRef.current; + if (!timeline) return; + const rect = timeline.getBoundingClientRect(); + const x = e.clientX - rect.left; + const time = Math.max(0, Math.min(duration, x / (50 * zoomLevel))); + setScrubTime(time); + seek(time); // update video preview in real time + }, + [duration, zoomLevel, seek] + ); + + useEffect(() => { + if (!isScrubbing) return; + const onMouseMove = (e: MouseEvent) => handleScrub(e); + const onMouseUp = (e: MouseEvent) => { + setIsScrubbing(false); + if (scrubTime !== null) seek(scrubTime); // finalize seek + setScrubTime(null); + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [isScrubbing, scrubTime, seek, handleScrub]); + + const playheadPosition = + isScrubbing && scrubTime !== null ? scrubTime : currentTime; + + const dragProps = { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }; + + // Action handlers for toolbar + const handleSplitSelected = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + selectedClips.forEach(({ trackId, clipId }) => { + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + if (clip && track) { + const splitTime = currentTime; + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + if (splitTime > effectiveStart && splitTime < effectiveEnd) { + updateClipTrim( + track.id, + clip.id, + clip.trimStart, + clip.trimEnd + (effectiveEnd - splitTime) + ); + addClipToTrack(track.id, { + mediaId: clip.mediaId, + name: clip.name + " (split)", + duration: clip.duration, + startTime: splitTime, + trimStart: clip.trimStart + (splitTime - effectiveStart), + trimEnd: clip.trimEnd, + }); + } + } + }); + toast.success("Split selected clip(s)"); + }; + + const handleDuplicateSelected = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + selectedClips.forEach(({ trackId, clipId }) => { + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + if (clip && track) { + addClipToTrack(track.id, { + mediaId: clip.mediaId, + name: clip.name + " (copy)", + duration: clip.duration, + startTime: + clip.startTime + + (clip.duration - clip.trimStart - clip.trimEnd) + + 0.1, + trimStart: clip.trimStart, + trimEnd: clip.trimEnd, + }); + } + }); + toast.success("Duplicated selected clip(s)"); + }; + + const handleFreezeSelected = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + selectedClips.forEach(({ trackId, clipId }) => { + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + if (clip && track) { + // Add a new freeze frame clip at the playhead + addClipToTrack(track.id, { + mediaId: clip.mediaId, + name: clip.name + " (freeze)", + duration: 1, // 1 second freeze frame + startTime: currentTime, + trimStart: 0, + trimEnd: clip.duration - 1, + }); + } + }); + toast.success("Freeze frame added for selected clip(s)"); + }; + + const handleDeleteSelected = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + selectedClips.forEach(({ trackId, clipId }) => { + removeClipFromTrack(trackId, clipId); + }); + clearSelectedClips(); + toast.success("Deleted selected clip(s)"); + }; + + // Prevent explorer zooming in/out when in timeline + useEffect(() => { + const preventZoom = (e: WheelEvent) => { + // if (isInTimeline && (e.ctrlKey || e.metaKey)) { + if ( + isInTimeline && + (e.ctrlKey || e.metaKey) && + timelineRef.current?.contains(e.target as Node) + ) { + e.preventDefault(); + } + }; + + document.addEventListener("wheel", preventZoom, { passive: false }); + + return () => { + document.removeEventListener("wheel", preventZoom); + }; + }, [isInTimeline]); + + return ( +
setIsInTimeline(true)} + onMouseLeave={() => setIsInTimeline(false)} + onWheel={handleWheel} + > + {/* Toolbar */} +
+ + {/* Play/Pause Button */} + + + + + + {isPlaying ? "Pause (Space)" : "Play (Space)"} + + + +
+ + {/* Time Display */} +
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s +
+ + {/* Test Clip Button - for debugging */} + {tracks.length === 0 && ( + <> +
+ + + + + Add a test clip to try playback + + + )} + +
+ + + + + + Split clip (S) + + + + + + + Split and keep left (A) + + + + + + + Split and keep right (D) + + + + + + + Separate audio (E) + + + + + + + Duplicate clip (Ctrl+D) + + + + + + + Freeze frame (F) + + + + + + + Delete clip (Delete) + + +
+ + {/* Speed Control */} + + + + + Playback Speed + + +
+ + {/* Timeline Container */} +
+ {/* Timeline Header with Ruler */} +
+ {/* Track Labels Header */} +
+ + Tracks + +
+ {zoomLevel.toFixed(1)}x +
+
+ + {/* Timeline Ruler */} +
+ +
{ + // Calculate the clicked time position and seek to it + handleSeekToPosition(e); + }} + > + {/* Time markers */} + {(() => { + // Calculate appropriate time interval based on zoom level + const getTimeInterval = (zoom: number) => { + const pixelsPerSecond = 50 * zoom; + if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in + if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in + if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom + if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out + if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out + if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out + return 30; // Every 30s when extremely zoomed out + }; + + const interval = getTimeInterval(zoomLevel); + const markerCount = Math.ceil(duration / interval) + 1; + + return Array.from({ length: markerCount }, (_, i) => { + const time = i * interval; + if (time > duration) return null; + + const isMainMarker = + time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; + + return ( +
+ + {(() => { + const formatTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; + } else if (minutes > 0) { + return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; + } else if (interval >= 1) { + return `${Math.floor(secs)}s`; + } else { + return `${secs.toFixed(1)}s`; + } + }; + return formatTime(time); + })()} + +
+ ); + }).filter(Boolean); + })()} + + {/* Playhead in ruler (scrubbable) */} +
+
+
+
+ +
+
+ + {/* Tracks Area */} +
+ {/* Track Labels */} + {tracks.length > 0 && ( +
+
+ {tracks.map((track) => ( +
{ + e.preventDefault(); + setContextMenu({ + type: "track", + trackId: track.id, + x: e.clientX, + y: e.clientY, + }); + }} + > +
+
+ + {track.name} + +
+ {track.muted && ( + + Muted + + )} +
+ ))} +
+
+ )} + + {/* Timeline Tracks Content */} +
+
+ {/* Timeline grid and clips area (with left margin for sifdebar) */} +
+ {tracks.length === 0 ? ( +
+
+
+ +
+

+ Drop media here to start +

+
+
+ ) : ( + <> + {tracks.map((track, index) => ( +
{ + e.preventDefault(); + setContextMenu({ + type: "track", + trackId: track.id, + x: e.clientX, + y: e.clientY, + }); + }} + > + +
+ ))} + + {/* Playhead for tracks area (scrubbable) */} + {tracks.length > 0 && ( +
+ )} + + )} + {isDragOver && ( +
+
+ {isProcessing + ? `Processing ${progress}%` + : "Drop media here to add to timeline"} +
+
+ )} +
+
+
+
+
+ + {/* Clean Unified Context Menu */} + {contextMenu && ( +
e.preventDefault()} + > + {contextMenu.type === "track" ? ( + // Track context menu + <> + +
+ + + ) : ( + // Clip context menu + <> + + +
+ + + )} +
+ )} +
+ ); +} + +function TimelineTrackContent({ + track, + zoomLevel, + setContextMenu, + contextMenu, +}: { + track: TimelineTrack; + zoomLevel: number; + setContextMenu: (menu: ContextMenuState | null) => void; + contextMenu: ContextMenuState | null; +}) { + const { mediaItems } = useMediaStore(); + const { + tracks, + moveClipToTrack, + updateClipStartTime, + addClipToTrack, + selectedClips, + selectClip, + deselectClip, + dragState, + startDrag: startDragAction, + updateDragTime, + endDrag: endDragAction, + } = useTimelineStore(); + + const timelineRef = useRef(null); + const [isDropping, setIsDropping] = useState(false); + const [dropPosition, setDropPosition] = useState(null); + const [wouldOverlap, setWouldOverlap] = useState(false); + const dragCounterRef = useRef(0); + + const [justFinishedDrag, setJustFinishedDrag] = useState(false); + + // Set up mouse event listeners for drag + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!timelineRef.current) return; + + const timelineRect = timelineRef.current.getBoundingClientRect(); + const mouseX = e.clientX - timelineRect.left; + const mouseTime = Math.max(0, mouseX / (50 * zoomLevel)); + const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); + const snappedTime = Math.round(adjustedTime * 10) / 10; + + updateDragTime(snappedTime); + }; + + const handleMouseUp = () => { + if (!dragState.clipId || !dragState.trackId) return; + + const finalTime = dragState.currentTime; + + // Check for overlaps and update position + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const movingClip = sourceTrack?.clips.find( + (c) => c.id === dragState.clipId + ); + + if (movingClip) { + const movingClipDuration = + movingClip.duration - movingClip.trimStart - movingClip.trimEnd; + const movingClipEnd = finalTime + movingClipDuration; + + const targetTrack = tracks.find((t) => t.id === track.id); + const hasOverlap = targetTrack?.clips.some((existingClip) => { + if ( + dragState.trackId === track.id && + existingClip.id === dragState.clipId + ) { + return false; + } + const existingStart = existingClip.startTime; + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); + return finalTime < existingEnd && movingClipEnd > existingStart; + }); + + if (!hasOverlap) { + if (dragState.trackId === track.id) { + updateClipStartTime(track.id, dragState.clipId, finalTime); + } else { + moveClipToTrack(dragState.trackId, track.id, dragState.clipId); + requestAnimationFrame(() => { + updateClipStartTime(track.id, dragState.clipId!, finalTime); + }); + } + } + } + + endDragAction(); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [ + dragState.isDragging, + dragState.clickOffsetTime, + dragState.clipId, + dragState.trackId, + dragState.currentTime, + zoomLevel, + tracks, + track.id, + updateDragTime, + updateClipStartTime, + moveClipToTrack, + endDragAction, + ]); + + const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => { + // Handle selection first + if (!justFinishedDrag) { + const isSelected = selectedClips.some( + (c) => c.trackId === track.id && c.clipId === clip.id + ); + + if (e.metaKey || e.ctrlKey || e.shiftKey) { + // Multi-selection mode: toggle the clip + selectClip(track.id, clip.id, true); + } else if (!isSelected) { + // If clip is not selected, select it (replacing other selections) + selectClip(track.id, clip.id, false); + } + // Note: Don't deselect if already selected, as user might want to drag + } + + // Calculate the offset from the left edge of the clip to where the user clicked + const clipElement = e.currentTarget as HTMLElement; + const clipRect = clipElement.getBoundingClientRect(); + const clickOffsetX = e.clientX - clipRect.left; + const clickOffsetTime = clickOffsetX / (50 * zoomLevel); + + startDragAction( + clip.id, + track.id, + e.clientX, + clip.startTime, + clickOffsetTime + ); + }; + + const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => { + e.stopPropagation(); + + console.log( + "handleClipClick called, contextMenu:", + JSON.stringify(contextMenu) + ); + console.log("Boolean check:", !!contextMenu, "Type:", typeof contextMenu); + + // Don't handle click if we just finished dragging + if (justFinishedDrag) { + console.log("Skipping because justFinishedDrag"); + return; + } + + // Close context menu if it's open + if (contextMenu) { + console.log("Closing context menu"); + setContextMenu(null); + return; // Don't handle selection when closing context menu + } + + console.log("Proceeding to selection logic"); + + // Only handle deselection here (selection is handled in mouseDown) + const isSelected = selectedClips.some( + (c) => c.trackId === track.id && c.clipId === clip.id + ); + + if (isSelected && !e.metaKey && !e.ctrlKey && !e.shiftKey) { + // If clip is already selected and no modifier keys, deselect it + deselectClip(track.id, clip.id); + } + }; + + const handleClipContextMenu = (e: React.MouseEvent, clipId: string) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ + type: "clip", + trackId: track.id, + clipId: clipId, + x: e.clientX, + y: e.clientY, + }); + }; + + // Reset drag flag when drag ends + useEffect(() => { + if (!dragState.isDragging && justFinishedDrag) { + const timer = setTimeout(() => setJustFinishedDrag(false), 50); + return () => clearTimeout(timer); + } else if (!dragState.isDragging && dragState.clipId && !justFinishedDrag) { + // Only set justFinishedDrag when a drag actually ends (not when it starts) + setJustFinishedDrag(true); + } + }, [dragState.isDragging, justFinishedDrag, dragState.clipId]); + + const handleTrackDragOver = (e: React.DragEvent) => { + e.preventDefault(); + + // Handle both timeline clips and media items + const hasTimelineClip = e.dataTransfer.types.includes( + "application/x-timeline-clip" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineClip && !hasMediaItem) return; + + if (hasMediaItem) { + try { + const mediaItemData = e.dataTransfer.getData( + "application/x-media-item" + ); + if (mediaItemData) { + const { type } = JSON.parse(mediaItemData); + const isCompatible = + (track.type === "video" && + (type === "video" || type === "image")) || + (track.type === "audio" && type === "audio"); + + if (!isCompatible) { + e.dataTransfer.dropEffect = "none"; + return; + } + } + } catch (error) {} + } + + // Calculate drop position for overlap checking + const trackContainer = e.currentTarget.querySelector( + ".track-clips-container" + ) as HTMLElement; + let dropTime = 0; + if (trackContainer) { + const rect = trackContainer.getBoundingClientRect(); + const mouseX = Math.max(0, e.clientX - rect.left); + dropTime = mouseX / (50 * zoomLevel); + } + + // Check for potential overlaps and show appropriate feedback + let wouldOverlap = false; + + if (hasMediaItem) { + try { + const mediaItemData = e.dataTransfer.getData( + "application/x-media-item" + ); + if (mediaItemData) { + const { id } = JSON.parse(mediaItemData); + const mediaItem = mediaItems.find((item) => item.id === id); + if (mediaItem) { + const newClipDuration = mediaItem.duration || 5; + const snappedTime = Math.round(dropTime * 10) / 10; + const newClipEnd = snappedTime + newClipDuration; + + wouldOverlap = track.clips.some((existingClip) => { + const existingStart = existingClip.startTime; + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); + return snappedTime < existingEnd && newClipEnd > existingStart; + }); + } + } + } catch (error) { + // Continue with default behavior + } + } else if (hasTimelineClip) { + try { + const timelineClipData = e.dataTransfer.getData( + "application/x-timeline-clip" + ); + if (timelineClipData) { + const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData); + const sourceTrack = tracks.find( + (t: TimelineTrack) => t.id === fromTrackId + ); + const movingClip = sourceTrack?.clips.find( + (c: TypeTimelineClip) => c.id === clipId + ); + + if (movingClip) { + const movingClipDuration = + movingClip.duration - movingClip.trimStart - movingClip.trimEnd; + const snappedTime = Math.round(dropTime * 10) / 10; + const movingClipEnd = snappedTime + movingClipDuration; + + wouldOverlap = track.clips.some((existingClip) => { + if (fromTrackId === track.id && existingClip.id === clipId) + return false; + + const existingStart = existingClip.startTime; + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); + return snappedTime < existingEnd && movingClipEnd > existingStart; + }); + } + } + } catch (error) { + // Continue with default behavior + } + } + + if (wouldOverlap) { + e.dataTransfer.dropEffect = "none"; + setWouldOverlap(true); + setDropPosition(Math.round(dropTime * 10) / 10); + return; + } + + e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy"; + setWouldOverlap(false); + setDropPosition(Math.round(dropTime * 10) / 10); + }; + + const handleTrackDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + + const hasTimelineClip = e.dataTransfer.types.includes( + "application/x-timeline-clip" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineClip && !hasMediaItem) return; + + dragCounterRef.current++; + setIsDropping(true); + }; + + const handleTrackDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + + const hasTimelineClip = e.dataTransfer.types.includes( + "application/x-timeline-clip" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineClip && !hasMediaItem) return; + + dragCounterRef.current--; + + if (dragCounterRef.current === 0) { + setIsDropping(false); + setWouldOverlap(false); + setDropPosition(null); + } + }; + + const handleTrackDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Reset all drag states + dragCounterRef.current = 0; + setIsDropping(false); + setWouldOverlap(false); + const currentDropPosition = dropPosition; + setDropPosition(null); + + const hasTimelineClip = e.dataTransfer.types.includes( + "application/x-timeline-clip" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineClip && !hasMediaItem) return; + + const trackContainer = e.currentTarget.querySelector( + ".track-clips-container" + ) as HTMLElement; + if (!trackContainer) return; + + const rect = trackContainer.getBoundingClientRect(); + const mouseX = Math.max(0, e.clientX - rect.left); + const newStartTime = mouseX / (50 * zoomLevel); + const snappedTime = Math.round(newStartTime * 10) / 10; + + try { + if (hasTimelineClip) { + // Handle timeline clip movement + const timelineClipData = e.dataTransfer.getData( + "application/x-timeline-clip" + ); + if (!timelineClipData) return; + + const { + clipId, + trackId: fromTrackId, + clickOffsetTime = 0, + } = JSON.parse(timelineClipData); + + // Find the clip being moved + const sourceTrack = tracks.find( + (t: TimelineTrack) => t.id === fromTrackId + ); + const movingClip = sourceTrack?.clips.find( + (c: TypeTimelineClip) => c.id === clipId + ); + + if (!movingClip) { + toast.error("Clip not found"); + return; + } + + // Adjust position based on where user clicked on the clip + const adjustedStartTime = snappedTime - clickOffsetTime; + const finalStartTime = Math.max( + 0, + Math.round(adjustedStartTime * 10) / 10 + ); + + // Check for overlaps with existing clips (excluding the moving clip itself) + const movingClipDuration = + movingClip.duration - movingClip.trimStart - movingClip.trimEnd; + const movingClipEnd = finalStartTime + movingClipDuration; + + const hasOverlap = track.clips.some((existingClip) => { + // Skip the clip being moved if it's on the same track + if (fromTrackId === track.id && existingClip.id === clipId) + return false; + + const existingStart = existingClip.startTime; + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); + + // Check if clips overlap + return finalStartTime < existingEnd && movingClipEnd > existingStart; + }); + + if (hasOverlap) { + toast.error( + "Cannot move clip here - it would overlap with existing clips" + ); + return; + } + + if (fromTrackId === track.id) { + // Moving within same track + updateClipStartTime(track.id, clipId, finalStartTime); + } else { + // Moving to different track + moveClipToTrack(fromTrackId, track.id, clipId); + requestAnimationFrame(() => { + updateClipStartTime(track.id, clipId, finalStartTime); + }); + } + } else if (hasMediaItem) { + // Handle media item drop + const mediaItemData = e.dataTransfer.getData( + "application/x-media-item" + ); + if (!mediaItemData) return; + + const { id, type } = JSON.parse(mediaItemData); + const mediaItem = mediaItems.find((item) => item.id === id); + + if (!mediaItem) { + toast.error("Media item not found"); + return; + } + + // Check if track type is compatible + const isCompatible = + (track.type === "video" && (type === "video" || type === "image")) || + (track.type === "audio" && type === "audio"); + + if (!isCompatible) { + toast.error(`Cannot add ${type} to ${track.type} track`); + return; + } + + // Check for overlaps with existing clips + const newClipDuration = mediaItem.duration || 5; + const newClipEnd = snappedTime + newClipDuration; + + const hasOverlap = track.clips.some((existingClip) => { + const existingStart = existingClip.startTime; + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); + + // Check if clips overlap + return snappedTime < existingEnd && newClipEnd > existingStart; + }); + + if (hasOverlap) { + toast.error( + "Cannot place clip here - it would overlap with existing clips" + ); + return; + } + + addClipToTrack(track.id, { + mediaId: mediaItem.id, + name: mediaItem.name, + duration: mediaItem.duration || 5, + startTime: snappedTime, + trimStart: 0, + trimEnd: 0, + }); + + toast.success(`Added ${mediaItem.name} to ${track.name}`); + } + } catch (error) { + console.error("Error handling drop:", error); + toast.error("Failed to add media to track"); + } + }; + + return ( +
{ + e.preventDefault(); + // Only show track menu if we didn't click on a clip + if (!(e.target as HTMLElement).closest(".timeline-clip")) { + setContextMenu({ + type: "track", + trackId: track.id, + x: e.clientX, + y: e.clientY, + }); + } + }} + onDragOver={handleTrackDragOver} + onDragEnter={handleTrackDragEnter} + onDragLeave={handleTrackDragLeave} + onDrop={handleTrackDrop} + > +
+ {track.clips.length === 0 ? ( +
+ {isDropping + ? wouldOverlap + ? "Cannot drop - would overlap" + : "Drop clip here" + : "Drop media here"} +
+ ) : ( + <> + {track.clips.map((clip) => { + const isSelected = selectedClips.some( + (c) => c.trackId === track.id && c.clipId === clip.id + ); + + return ( + + ); + })} + + )} +
+
+ ); +} diff --git a/apps/web/src/hooks/use-drag-clip.ts b/apps/web/src/hooks/use-drag-clip.ts index 7eb24daa..eb4d7435 100644 --- a/apps/web/src/hooks/use-drag-clip.ts +++ b/apps/web/src/hooks/use-drag-clip.ts @@ -1,213 +1,226 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import { useTimelineStore } from "@/stores/timeline-store"; - -interface DragState { - isDragging: boolean; - clipId: string | null; - trackId: string | null; - startMouseX: number; - startClipTime: number; - clickOffsetTime: number; - currentTime: number; -} - -export function useDragClip(zoomLevel: number) { - const { tracks, updateClipStartTime, moveClipToTrack } = useTimelineStore(); - - const [dragState, setDragState] = useState({ - isDragging: false, - clipId: null, - trackId: null, - startMouseX: 0, - startClipTime: 0, - clickOffsetTime: 0, - currentTime: 0, - }); - - const timelineRef = useRef(null); - - const startDrag = useCallback( - ( - e: React.MouseEvent, - clipId: string, - trackId: string, - clipStartTime: number, - clickOffsetTime: number - ) => { - e.preventDefault(); - e.stopPropagation(); - - setDragState({ - isDragging: true, - clipId, - trackId, - startMouseX: e.clientX, - startClipTime: clipStartTime, - clickOffsetTime, - currentTime: clipStartTime, - }); - }, - [] - ); - - const updateDrag = useCallback( - (e: MouseEvent) => { - if (!dragState.isDragging || !timelineRef.current) return; - - const timelineRect = timelineRef.current.getBoundingClientRect(); - const mouseX = e.clientX - timelineRect.left; - const mouseTime = Math.max(0, mouseX / (50 * zoomLevel)); - const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - const snappedTime = Math.round(adjustedTime * 10) / 10; - - setDragState((prev) => ({ - ...prev, - currentTime: snappedTime, - })); - }, - [dragState.isDragging, dragState.clickOffsetTime, zoomLevel] - ); - - const endDrag = useCallback( - (targetTrackId?: string) => { - if (!dragState.isDragging || !dragState.clipId || !dragState.trackId) - return; - - const finalTrackId = targetTrackId || dragState.trackId; - const finalTime = dragState.currentTime; - - // Check for overlaps - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const targetTrack = tracks.find((t) => t.id === finalTrackId); - const movingClip = sourceTrack?.clips.find( - (c) => c.id === dragState.clipId - ); - - if (!movingClip || !targetTrack) { - setDragState((prev) => ({ ...prev, isDragging: false })); - return; - } - - const movingClipDuration = - movingClip.duration - movingClip.trimStart - movingClip.trimEnd; - const movingClipEnd = finalTime + movingClipDuration; - - const hasOverlap = targetTrack.clips.some((existingClip) => { - // Skip the clip being moved if it's on the same track - if ( - dragState.trackId === finalTrackId && - existingClip.id === dragState.clipId - ) { - return false; - } - - const existingStart = existingClip.startTime; - const existingEnd = - existingClip.startTime + - (existingClip.duration - - existingClip.trimStart - - existingClip.trimEnd); - - return finalTime < existingEnd && movingClipEnd > existingStart; - }); - - if (!hasOverlap) { - if (dragState.trackId === finalTrackId) { - // Moving within same track - updateClipStartTime(finalTrackId, dragState.clipId!, finalTime); - } else { - // Moving to different track - moveClipToTrack(dragState.trackId!, finalTrackId, dragState.clipId!); - requestAnimationFrame(() => { - updateClipStartTime(finalTrackId, dragState.clipId!, finalTime); - }); - } - } - - setDragState({ - isDragging: false, - clipId: null, - trackId: null, - startMouseX: 0, - startClipTime: 0, - clickOffsetTime: 0, - currentTime: 0, - }); - }, - [dragState, tracks, updateClipStartTime, moveClipToTrack] - ); - - const cancelDrag = useCallback(() => { - setDragState({ - isDragging: false, - clipId: null, - trackId: null, - startMouseX: 0, - startClipTime: 0, - clickOffsetTime: 0, - currentTime: 0, - }); - }, []); - - // Global mouse events - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseMove = (e: MouseEvent) => updateDrag(e); - const handleMouseUp = () => endDrag(); - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape") cancelDrag(); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - document.addEventListener("keydown", handleEscape); - - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - document.removeEventListener("keydown", handleEscape); - }; - }, [dragState.isDragging, updateDrag, endDrag, cancelDrag]); - - const getDraggedClipPosition = useCallback( - (clipId: string) => { - if (dragState.isDragging && dragState.clipId === clipId) { - return dragState.currentTime; - } - return null; - }, - [dragState] - ); - - const isValidDropTarget = useCallback( - (trackId: string) => { - if (!dragState.isDragging) return false; - - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const targetTrack = tracks.find((t) => t.id === trackId); - - if (!sourceTrack || !targetTrack) return false; - - // For now, allow drops on same track type - return sourceTrack.type === targetTrack.type; - }, - [dragState.isDragging, dragState.trackId, tracks] - ); - - return { - // State - isDragging: dragState.isDragging, - draggedClipId: dragState.clipId, - - // Methods - startDrag, - endDrag, - cancelDrag, - getDraggedClipPosition, - isValidDropTarget, - - // Refs - timelineRef, - }; -} +import { useState, useEffect, useCallback, useRef } from "react"; +import { useTimelineStore } from "@/stores/timeline-store"; + +interface DragState { + isDragging: boolean; + clipId: string | null; + trackId: string | null; + startMouseX: number; + startClipTime: number; + clickOffsetTime: number; + currentTime: number; +} + +export function useDragClip(zoomLevel: number) { + const { tracks, updateClipStartTime, moveClipToTrack } = useTimelineStore(); + + const [dragState, setDragState] = useState({ + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }); + + const timelineRef = useRef(null); + const dragStateRef = useRef(dragState); + + // Keep ref in sync with state + dragStateRef.current = dragState; + + const startDrag = useCallback( + ( + e: React.MouseEvent, + clipId: string, + trackId: string, + clipStartTime: number, + clickOffsetTime: number + ) => { + e.preventDefault(); + e.stopPropagation(); + + setDragState({ + isDragging: true, + clipId, + trackId, + startMouseX: e.clientX, + startClipTime: clipStartTime, + clickOffsetTime, + currentTime: clipStartTime, + }); + }, + [] + ); + + const updateDrag = useCallback( + (e: MouseEvent) => { + if (!dragState.isDragging || !timelineRef.current) { + return; + } + + const timelineRect = timelineRef.current.getBoundingClientRect(); + const mouseX = e.clientX - timelineRect.left; + const mouseTime = Math.max(0, mouseX / (50 * zoomLevel)); + const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); + const snappedTime = Math.round(adjustedTime * 10) / 10; + + setDragState((prev) => ({ + ...prev, + currentTime: snappedTime, + })); + }, + [dragState.isDragging, dragState.clickOffsetTime, zoomLevel] + ); + + const endDrag = useCallback( + (targetTrackId?: string) => { + if (!dragState.isDragging || !dragState.clipId || !dragState.trackId) + return; + + const finalTrackId = targetTrackId || dragState.trackId; + const finalTime = dragState.currentTime; + + // Check for overlaps + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const targetTrack = tracks.find((t) => t.id === finalTrackId); + const movingClip = sourceTrack?.clips.find( + (c) => c.id === dragState.clipId + ); + + if (!movingClip || !targetTrack) { + setDragState((prev) => ({ ...prev, isDragging: false })); + return; + } + + const movingClipDuration = + movingClip.duration - movingClip.trimStart - movingClip.trimEnd; + const movingClipEnd = finalTime + movingClipDuration; + + const hasOverlap = targetTrack.clips.some((existingClip) => { + // Skip the clip being moved if it's on the same track + if ( + dragState.trackId === finalTrackId && + existingClip.id === dragState.clipId + ) { + return false; + } + + const existingStart = existingClip.startTime; + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); + + return finalTime < existingEnd && movingClipEnd > existingStart; + }); + + if (!hasOverlap) { + if (dragState.trackId === finalTrackId) { + // Moving within same track + updateClipStartTime(finalTrackId, dragState.clipId!, finalTime); + } else { + // Moving to different track + moveClipToTrack(dragState.trackId!, finalTrackId, dragState.clipId!); + requestAnimationFrame(() => { + updateClipStartTime(finalTrackId, dragState.clipId!, finalTime); + }); + } + } + + setDragState({ + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }); + }, + [dragState, tracks, updateClipStartTime, moveClipToTrack] + ); + + const cancelDrag = useCallback(() => { + setDragState({ + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }); + }, []); + + // Global mouse events + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseMove = (e: MouseEvent) => updateDrag(e); + const handleMouseUp = () => endDrag(); + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") cancelDrag(); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("keydown", handleEscape); + }; + }, [dragState.isDragging, updateDrag, endDrag, cancelDrag]); + + const getDraggedClipPosition = useCallback( + (clipId: string) => { + // Use ref to get current state, not stale closure + const currentDragState = dragStateRef.current; + const isMatch = + currentDragState.isDragging && currentDragState.clipId === clipId; + + if (isMatch) { + return currentDragState.currentTime; + } + return null; + }, + [] // No dependencies needed since we use ref + ); + + const isValidDropTarget = useCallback( + (trackId: string) => { + if (!dragState.isDragging) return false; + + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const targetTrack = tracks.find((t) => t.id === trackId); + + if (!sourceTrack || !targetTrack) return false; + + // For now, allow drops on same track type + return sourceTrack.type === targetTrack.type; + }, + [dragState.isDragging, dragState.trackId, tracks] + ); + + return { + // State + isDragging: dragState.isDragging, + draggedClipId: dragState.clipId, + currentDragTime: dragState.currentTime, + clickOffsetTime: dragState.clickOffsetTime, + + // Methods + startDrag, + endDrag, + cancelDrag, + getDraggedClipPosition, + isValidDropTarget, + + // Refs + timelineRef, + }; +} diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index e1474bae..3b068d7c 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -1,264 +1,346 @@ -import { create } from "zustand"; - -export interface TimelineClip { - id: string; - mediaId: string; - name: string; - duration: number; - startTime: number; - trimStart: number; - trimEnd: number; -} - -export interface TimelineTrack { - id: string; - name: string; - type: "video" | "audio" | "effects"; - clips: TimelineClip[]; - muted?: boolean; -} - -interface TimelineStore { - tracks: TimelineTrack[]; - history: TimelineTrack[][]; - redoStack: TimelineTrack[][]; - - // Multi-selection - selectedClips: { trackId: string; clipId: string }[]; - selectClip: (trackId: string, clipId: string, multi?: boolean) => void; - deselectClip: (trackId: string, clipId: string) => void; - clearSelectedClips: () => void; - setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void; - - // Actions - addTrack: (type: "video" | "audio" | "effects") => string; - removeTrack: (trackId: string) => void; - addClipToTrack: (trackId: string, clip: Omit) => void; - removeClipFromTrack: (trackId: string, clipId: string) => void; - moveClipToTrack: ( - fromTrackId: string, - toTrackId: string, - clipId: string - ) => void; - updateClipTrim: ( - trackId: string, - clipId: string, - trimStart: number, - trimEnd: number - ) => void; - updateClipStartTime: ( - trackId: string, - clipId: string, - startTime: number - ) => void; - toggleTrackMute: (trackId: string) => void; - - // Computed values - getTotalDuration: () => number; - - // New actions - undo: () => void; - redo: () => void; - pushHistory: () => void; -} - -export const useTimelineStore = create((set, get) => ({ - tracks: [], - history: [], - redoStack: [], - selectedClips: [], - - pushHistory: () => { - const { tracks, history, redoStack } = get(); - // Deep copy tracks - set({ - history: [...history, JSON.parse(JSON.stringify(tracks))], - redoStack: [] // Clear redo stack when new action is performed - }); - }, - - undo: () => { - const { history, redoStack, tracks } = get(); - if (history.length === 0) return; - const prev = history[history.length - 1]; - set({ - tracks: prev, - history: history.slice(0, -1), - redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack - }); - }, - - selectClip: (trackId, clipId, multi = false) => { - set((state) => { - const exists = state.selectedClips.some( - (c) => c.trackId === trackId && c.clipId === clipId - ); - if (multi) { - // Toggle selection - return exists - ? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) } - : { selectedClips: [...state.selectedClips, { trackId, clipId }] }; - } else { - return { selectedClips: [{ trackId, clipId }] }; - } - }); - }, - deselectClip: (trackId, clipId) => { - set((state) => ({ - selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)), - })); - }, - clearSelectedClips: () => { - set({ selectedClips: [] }); - }, - - setSelectedClips: (clips) => set({ selectedClips: clips }), - - addTrack: (type) => { - get().pushHistory(); - const newTrack: TimelineTrack = { - id: crypto.randomUUID(), - name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`, - type, - clips: [], - muted: false, - }; - set((state) => ({ - tracks: [...state.tracks, newTrack], - })); - return newTrack.id; - }, - - removeTrack: (trackId) => { - get().pushHistory(); - set((state) => ({ - tracks: state.tracks.filter((track) => track.id !== trackId), - })); - }, - - addClipToTrack: (trackId, clipData) => { - get().pushHistory(); - const newClip: TimelineClip = { - ...clipData, - id: crypto.randomUUID(), - startTime: clipData.startTime || 0, - trimStart: 0, - trimEnd: 0, - }; - - set((state) => ({ - tracks: state.tracks.map((track) => - track.id === trackId - ? { ...track, clips: [...track.clips, newClip] } - : track - ), - })); - }, - - removeClipFromTrack: (trackId, clipId) => { - get().pushHistory(); - set((state) => ({ - tracks: state.tracks - .map((track) => - track.id === trackId - ? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) } - : track - ) - // Remove track if it becomes empty - .filter((track) => track.clips.length > 0), - })); - }, - - moveClipToTrack: (fromTrackId, toTrackId, clipId) => { - get().pushHistory(); - set((state) => { - const fromTrack = state.tracks.find((track) => track.id === fromTrackId); - const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId); - - if (!clipToMove) return state; - - return { - tracks: state.tracks - .map((track) => { - if (track.id === fromTrackId) { - return { - ...track, - clips: track.clips.filter((clip) => clip.id !== clipId), - }; - } else if (track.id === toTrackId) { - return { - ...track, - clips: [...track.clips, clipToMove], - }; - } - return track; - }) - // Remove track if it becomes empty - .filter((track) => track.clips.length > 0), - }; - }); - }, - - updateClipTrim: (trackId, clipId, trimStart, trimEnd) => { - get().pushHistory(); - set((state) => ({ - tracks: state.tracks.map((track) => - track.id === trackId - ? { - ...track, - clips: track.clips.map((clip) => - clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip - ), - } - : track - ), - })); - }, - - updateClipStartTime: (trackId, clipId, startTime) => { - get().pushHistory(); - set((state) => ({ - tracks: state.tracks.map((track) => - track.id === trackId - ? { - ...track, - clips: track.clips.map((clip) => - clip.id === clipId ? { ...clip, startTime } : clip - ), - } - : track - ), - })); - }, - - toggleTrackMute: (trackId) => { - get().pushHistory(); - set((state) => ({ - tracks: state.tracks.map((track) => - track.id === trackId ? { ...track, muted: !track.muted } : track - ), - })); - }, - - getTotalDuration: () => { - const { tracks } = get(); - if (tracks.length === 0) return 0; - - const trackEndTimes = tracks.map((track) => - track.clips.reduce((maxEnd, clip) => { - const clipEnd = - clip.startTime + clip.duration - clip.trimStart - clip.trimEnd; - return Math.max(maxEnd, clipEnd); - }, 0) - ); - - return Math.max(...trackEndTimes, 0); - }, - - redo: () => { - const { redoStack } = get(); - if (redoStack.length === 0) return; - const next = redoStack[redoStack.length - 1]; - set({ tracks: next, redoStack: redoStack.slice(0, -1) }); - }, -})); +import { create } from "zustand"; + +export interface TimelineClip { + id: string; + mediaId: string; + name: string; + duration: number; + startTime: number; + trimStart: number; + trimEnd: number; +} + +export interface TimelineTrack { + id: string; + name: string; + type: "video" | "audio" | "effects"; + clips: TimelineClip[]; + muted?: boolean; +} + +interface TimelineStore { + tracks: TimelineTrack[]; + history: TimelineTrack[][]; + redoStack: TimelineTrack[][]; + + // Multi-selection + selectedClips: { trackId: string; clipId: string }[]; + selectClip: (trackId: string, clipId: string, multi?: boolean) => void; + deselectClip: (trackId: string, clipId: string) => void; + clearSelectedClips: () => void; + setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void; + + // Drag state + dragState: { + isDragging: boolean; + clipId: string | null; + trackId: string | null; + startMouseX: number; + startClipTime: number; + clickOffsetTime: number; + currentTime: number; + }; + setDragState: (dragState: Partial) => void; + startDrag: ( + clipId: string, + trackId: string, + startMouseX: number, + startClipTime: number, + clickOffsetTime: number + ) => void; + updateDragTime: (currentTime: number) => void; + endDrag: () => void; + + // Actions + addTrack: (type: "video" | "audio" | "effects") => string; + removeTrack: (trackId: string) => void; + addClipToTrack: (trackId: string, clip: Omit) => void; + removeClipFromTrack: (trackId: string, clipId: string) => void; + moveClipToTrack: ( + fromTrackId: string, + toTrackId: string, + clipId: string + ) => void; + updateClipTrim: ( + trackId: string, + clipId: string, + trimStart: number, + trimEnd: number + ) => void; + updateClipStartTime: ( + trackId: string, + clipId: string, + startTime: number + ) => void; + toggleTrackMute: (trackId: string) => void; + + // Computed values + getTotalDuration: () => number; + + // New actions + undo: () => void; + redo: () => void; + pushHistory: () => void; +} + +export const useTimelineStore = create((set, get) => ({ + tracks: [], + history: [], + redoStack: [], + selectedClips: [], + + pushHistory: () => { + const { tracks, history, redoStack } = get(); + // Deep copy tracks + set({ + history: [...history, JSON.parse(JSON.stringify(tracks))], + redoStack: [], // Clear redo stack when new action is performed + }); + }, + + undo: () => { + const { history, redoStack, tracks } = get(); + if (history.length === 0) return; + const prev = history[history.length - 1]; + set({ + tracks: prev, + history: history.slice(0, -1), + redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], // Add current state to redo stack + }); + }, + + selectClip: (trackId, clipId, multi = false) => { + set((state) => { + const exists = state.selectedClips.some( + (c) => c.trackId === trackId && c.clipId === clipId + ); + if (multi) { + // Toggle selection + return exists + ? { + selectedClips: state.selectedClips.filter( + (c) => !(c.trackId === trackId && c.clipId === clipId) + ), + } + : { selectedClips: [...state.selectedClips, { trackId, clipId }] }; + } else { + return { selectedClips: [{ trackId, clipId }] }; + } + }); + }, + deselectClip: (trackId, clipId) => { + set((state) => ({ + selectedClips: state.selectedClips.filter( + (c) => !(c.trackId === trackId && c.clipId === clipId) + ), + })); + }, + clearSelectedClips: () => { + set({ selectedClips: [] }); + }, + + setSelectedClips: (clips) => set({ selectedClips: clips }), + + addTrack: (type) => { + get().pushHistory(); + const newTrack: TimelineTrack = { + id: crypto.randomUUID(), + name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`, + type, + clips: [], + muted: false, + }; + set((state) => ({ + tracks: [...state.tracks, newTrack], + })); + return newTrack.id; + }, + + removeTrack: (trackId) => { + get().pushHistory(); + set((state) => ({ + tracks: state.tracks.filter((track) => track.id !== trackId), + })); + }, + + addClipToTrack: (trackId, clipData) => { + get().pushHistory(); + const newClip: TimelineClip = { + ...clipData, + id: crypto.randomUUID(), + startTime: clipData.startTime || 0, + trimStart: 0, + trimEnd: 0, + }; + + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { ...track, clips: [...track.clips, newClip] } + : track + ), + })); + }, + + removeClipFromTrack: (trackId, clipId) => { + get().pushHistory(); + set((state) => ({ + tracks: state.tracks + .map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.filter((clip) => clip.id !== clipId), + } + : track + ) + // Remove track if it becomes empty + .filter((track) => track.clips.length > 0), + })); + }, + + moveClipToTrack: (fromTrackId, toTrackId, clipId) => { + get().pushHistory(); + set((state) => { + const fromTrack = state.tracks.find((track) => track.id === fromTrackId); + const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId); + + if (!clipToMove) return state; + + return { + tracks: state.tracks + .map((track) => { + if (track.id === fromTrackId) { + return { + ...track, + clips: track.clips.filter((clip) => clip.id !== clipId), + }; + } else if (track.id === toTrackId) { + return { + ...track, + clips: [...track.clips, clipToMove], + }; + } + return track; + }) + // Remove track if it becomes empty + .filter((track) => track.clips.length > 0), + }; + }); + }, + + updateClipTrim: (trackId, clipId, trimStart, trimEnd) => { + get().pushHistory(); + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.map((clip) => + clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip + ), + } + : track + ), + })); + }, + + updateClipStartTime: (trackId, clipId, startTime) => { + get().pushHistory(); + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.map((clip) => + clip.id === clipId ? { ...clip, startTime } : clip + ), + } + : track + ), + })); + }, + + toggleTrackMute: (trackId) => { + get().pushHistory(); + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId ? { ...track, muted: !track.muted } : track + ), + })); + }, + + getTotalDuration: () => { + const { tracks } = get(); + if (tracks.length === 0) return 0; + + const trackEndTimes = tracks.map((track) => + track.clips.reduce((maxEnd, clip) => { + const clipEnd = + clip.startTime + clip.duration - clip.trimStart - clip.trimEnd; + return Math.max(maxEnd, clipEnd); + }, 0) + ); + + return Math.max(...trackEndTimes, 0); + }, + + redo: () => { + const { redoStack } = get(); + if (redoStack.length === 0) return; + const next = redoStack[redoStack.length - 1]; + set({ tracks: next, redoStack: redoStack.slice(0, -1) }); + }, + + dragState: { + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }, + + setDragState: (dragState) => + set((state) => ({ + dragState: { ...state.dragState, ...dragState }, + })), + + startDrag: (clipId, trackId, startMouseX, startClipTime, clickOffsetTime) => { + set({ + dragState: { + isDragging: true, + clipId, + trackId, + startMouseX, + startClipTime, + clickOffsetTime, + currentTime: startClipTime, + }, + }); + }, + + updateDragTime: (currentTime) => { + set((state) => ({ + dragState: { + ...state.dragState, + currentTime, + }, + })); + }, + + endDrag: () => { + set({ + dragState: { + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }, + }); + }, +})); -- cgit v1.3.1 From 0f470ba7a7a8c3c51ed6fffed46cbc404139f617 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Thu, 26 Jun 2025 01:36:49 +0200 Subject: fix: clip being deselected after dragging it --- apps/web/src/components/editor/timeline.tsx | 86 +++++++++++++++-------------- 1 file changed, 44 insertions(+), 42 deletions(-) (limited to 'apps/web/src/components/editor/timeline.tsx') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 765e2aa1..18e1e915 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -936,6 +936,16 @@ export function Timeline() { y: e.clientY, }); }} + onClick={(e) => { + // If clicking empty area (not on a clip), deselect all clips + if ( + !(e.target as HTMLElement).closest(".timeline-clip") + ) { + const { clearSelectedClips } = + useTimelineStore.getState(); + clearSelectedClips(); + } + }} > (null); const [wouldOverlap, setWouldOverlap] = useState(false); const dragCounterRef = useRef(0); - - const [justFinishedDrag, setJustFinishedDrag] = useState(false); + const [mouseDownLocation, setMouseDownLocation] = useState<{ + x: number; + y: number; + } | null>(null); // Set up mouse event listeners for drag useEffect(() => { @@ -1252,20 +1264,10 @@ function TimelineTrackContent({ ]); const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => { - // Handle selection first - if (!justFinishedDrag) { - const isSelected = selectedClips.some( - (c) => c.trackId === track.id && c.clipId === clip.id - ); - - if (e.metaKey || e.ctrlKey || e.shiftKey) { - // Multi-selection mode: toggle the clip - selectClip(track.id, clip.id, true); - } else if (!isSelected) { - // If clip is not selected, select it (replacing other selections) - selectClip(track.id, clip.id, false); - } - // Note: Don't deselect if already selected, as user might want to drag + setMouseDownLocation({ x: e.clientX, y: e.clientY }); + // Handle multi-selection only in mousedown + if (e.metaKey || e.ctrlKey || e.shiftKey) { + selectClip(track.id, clip.id, true); } // Calculate the offset from the left edge of the clip to where the user clicked @@ -1286,35 +1288,39 @@ function TimelineTrackContent({ const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => { e.stopPropagation(); - console.log( - "handleClipClick called, contextMenu:", - JSON.stringify(contextMenu) - ); - console.log("Boolean check:", !!contextMenu, "Type:", typeof contextMenu); - - // Don't handle click if we just finished dragging - if (justFinishedDrag) { - console.log("Skipping because justFinishedDrag"); - return; + // Check if mouse moved significantly + if (mouseDownLocation) { + const deltaX = Math.abs(e.clientX - mouseDownLocation.x); + const deltaY = Math.abs(e.clientY - mouseDownLocation.y); + // If it moved more than a few pixels, consider it a drag and not a click. + if (deltaX > 5 || deltaY > 5) { + setMouseDownLocation(null); // Reset for next interaction + return; + } } // Close context menu if it's open if (contextMenu) { - console.log("Closing context menu"); setContextMenu(null); return; // Don't handle selection when closing context menu } - console.log("Proceeding to selection logic"); + // Skip selection logic for multi-selection (handled in mousedown) + if (e.metaKey || e.ctrlKey || e.shiftKey) { + return; + } - // Only handle deselection here (selection is handled in mouseDown) + // Handle single selection/deselection const isSelected = selectedClips.some( (c) => c.trackId === track.id && c.clipId === clip.id ); - if (isSelected && !e.metaKey && !e.ctrlKey && !e.shiftKey) { - // If clip is already selected and no modifier keys, deselect it + if (isSelected) { + // If clip is selected, deselect it deselectClip(track.id, clip.id); + } else { + // If clip is not selected, select it (replacing other selections) + selectClip(track.id, clip.id, false); } }; @@ -1330,17 +1336,6 @@ function TimelineTrackContent({ }); }; - // Reset drag flag when drag ends - useEffect(() => { - if (!dragState.isDragging && justFinishedDrag) { - const timer = setTimeout(() => setJustFinishedDrag(false), 50); - return () => clearTimeout(timer); - } else if (!dragState.isDragging && dragState.clipId && !justFinishedDrag) { - // Only set justFinishedDrag when a drag actually ends (not when it starts) - setJustFinishedDrag(true); - } - }, [dragState.isDragging, justFinishedDrag, dragState.clipId]); - const handleTrackDragOver = (e: React.DragEvent) => { e.preventDefault(); @@ -1685,6 +1680,13 @@ function TimelineTrackContent({ }); } }} + onClick={(e) => { + // If clicking empty area (not on a clip), deselect all clips + if (!(e.target as HTMLElement).closest(".timeline-clip")) { + const { clearSelectedClips } = useTimelineStore.getState(); + clearSelectedClips(); + } + }} onDragOver={handleTrackDragOver} onDragEnter={handleTrackDragEnter} onDragLeave={handleTrackDragLeave} -- cgit v1.3.1