From 50931c487b14ec0b9802e9877bf4f423be19c2cb Mon Sep 17 00:00:00 2001 From: Hyteq Date: Mon, 23 Jun 2025 09:40:31 +0300 Subject: [PATCH] fix: better drag and drop gestures, animation, more robust handling, easier movements --- apps/web/src/components/editor/timeline.tsx | 132 ++++++++++++++------ 1 file changed, 95 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index be53299..b16c47c 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -310,6 +310,7 @@ function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zo const { moveClipToTrack, updateClipTrim, updateClipStartTime } = useTimelineStore(); const [isDropping, setIsDropping] = useState(false); const [dropPosition, setDropPosition] = useState(null); + const [isDraggedOver, setIsDraggedOver] = useState(false); const [resizing, setResizing] = useState<{ clipId: string; side: 'left' | 'right'; @@ -317,6 +318,7 @@ function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zo initialTrimStart: number; initialTrimEnd: number; } | null>(null); + const dragCounterRef = useRef(0); const handleResizeStart = (e: React.MouseEvent, clipId: string, side: 'left' | 'right') => { e.stopPropagation(); @@ -392,37 +394,66 @@ function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zo e.dataTransfer.setData("application/x-timeline-clip", JSON.stringify(dragData)); e.dataTransfer.effectAllowed = "move"; - const target = e.currentTarget as HTMLElement; - e.dataTransfer.setDragImage(target, target.offsetWidth / 2, target.offsetHeight / 2); + // 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 = ""; }; const handleTrackDragOver = (e: React.DragEvent) => { e.preventDefault(); if (!e.dataTransfer.types.includes("application/x-timeline-clip")) return; e.dataTransfer.dropEffect = "move"; + + setIsDraggedOver(true); + + // Calculate and show drop position with better precision + const trackContainer = e.currentTarget.querySelector(".track-clips-container") as HTMLElement; + if (trackContainer) { + const rect = trackContainer.getBoundingClientRect(); + const mouseX = Math.max(0, e.clientX - rect.left); + const dropTime = mouseX / (50 * zoomLevel); + setDropPosition(dropTime); + } }; const handleTrackDragEnter = (e: React.DragEvent) => { e.preventDefault(); if (!e.dataTransfer.types.includes("application/x-timeline-clip")) return; + + dragCounterRef.current++; setIsDropping(true); + setIsDraggedOver(true); }; const handleTrackDragLeave = (e: React.DragEvent) => { e.preventDefault(); if (!e.dataTransfer.types.includes("application/x-timeline-clip")) return; - const rect = e.currentTarget.getBoundingClientRect(); - const { clientX: x, clientY: y } = e; + dragCounterRef.current--; - if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { + if (dragCounterRef.current === 0) { setIsDropping(false); + setIsDraggedOver(false); + setDropPosition(null); } }; const handleTrackDrop = (e: React.DragEvent) => { e.preventDefault(); + + // Reset all drag states + dragCounterRef.current = 0; setIsDropping(false); + setIsDraggedOver(false); + setDropPosition(null); if (!e.dataTransfer.types.includes("application/x-timeline-clip")) return; @@ -436,13 +467,20 @@ function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zo if (!trackContainer) return; const rect = trackContainer.getBoundingClientRect(); - const newStartTime = Math.max(0, (e.clientX - rect.left) / (50 * zoomLevel)); + const mouseX = Math.max(0, e.clientX - rect.left); + const newStartTime = mouseX / (50 * zoomLevel); + + // Snap to grid (optional - every 0.1 seconds) + const snappedTime = Math.round(newStartTime * 10) / 10; if (fromTrackId === track.id) { - updateClipStartTime(track.id, clipId, newStartTime); + updateClipStartTime(track.id, clipId, snappedTime); } else { moveClipToTrack(fromTrackId, track.id, clipId); - setTimeout(() => updateClipStartTime(track.id, clipId, newStartTime), 0); + // Use a small delay to ensure the clip is moved before updating position + requestAnimationFrame(() => { + updateClipStartTime(track.id, clipId, snappedTime); + }); } } catch (error) { console.error("Error moving clip:", error); @@ -514,7 +552,9 @@ function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zo
{track.clips.length === 0 ? ( -
- Drop media here +
+ {isDropping ? "Drop clip here" : "Drop media here"}
) : ( - track.clips.map((clip) => { - const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; - const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); - const clipLeft = clip.startTime * 50 * zoomLevel; + <> + {track.clips.map((clip) => { + const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; + const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); + const clipLeft = clip.startTime * 50 * zoomLevel; - return ( -
+ return (
handleResizeStart(e, clip.id, 'left')} - /> - -
handleClipDragStart(e, clip)} + key={clip.id} + className={`timeline-clip absolute h-full rounded-sm border transition-all duration-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg`} + style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }} > - {renderClipContent(clip)} -
+
handleResizeStart(e, clip.id, 'left')} + /> -
handleResizeStart(e, clip.id, 'right')} - /> +
handleClipDragStart(e, clip)} + onDragEnd={handleClipDragEnd} + > + {renderClipContent(clip)} +
+ +
handleResizeStart(e, clip.id, 'right')} + /> +
+ ); + })} + + {/* Drop position indicator */} + {isDraggedOver && dropPosition !== null && ( +
+
+
+
+ {dropPosition.toFixed(1)}s +
- ); - }) + )} + )}