diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 584a45f..31e0dd5 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 0000000..7eb24da --- /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, + }; +}