fix: better drag and drop gestures, animation, more robust handling, easier movements

This commit is contained in:
Hyteq
2025-06-23 09:40:31 +03:00
parent f688c7ef5d
commit 50931c487b

View File

@ -310,6 +310,7 @@ function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zo
const { moveClipToTrack, updateClipTrim, updateClipStartTime } = useTimelineStore(); const { moveClipToTrack, updateClipTrim, updateClipStartTime } = useTimelineStore();
const [isDropping, setIsDropping] = useState(false); const [isDropping, setIsDropping] = useState(false);
const [dropPosition, setDropPosition] = useState<number | null>(null); const [dropPosition, setDropPosition] = useState<number | null>(null);
const [isDraggedOver, setIsDraggedOver] = useState(false);
const [resizing, setResizing] = useState<{ const [resizing, setResizing] = useState<{
clipId: string; clipId: string;
side: 'left' | 'right'; side: 'left' | 'right';
@ -317,6 +318,7 @@ function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zo
initialTrimStart: number; initialTrimStart: number;
initialTrimEnd: number; initialTrimEnd: number;
} | null>(null); } | null>(null);
const dragCounterRef = useRef(0);
const handleResizeStart = (e: React.MouseEvent, clipId: string, side: 'left' | 'right') => { const handleResizeStart = (e: React.MouseEvent, clipId: string, side: 'left' | 'right') => {
e.stopPropagation(); 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.setData("application/x-timeline-clip", JSON.stringify(dragData));
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "move";
const target = e.currentTarget as HTMLElement; // Add visual feedback to the dragged element
e.dataTransfer.setDragImage(target, target.offsetWidth / 2, target.offsetHeight / 2); 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) => { const handleTrackDragOver = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
if (!e.dataTransfer.types.includes("application/x-timeline-clip")) return; if (!e.dataTransfer.types.includes("application/x-timeline-clip")) return;
e.dataTransfer.dropEffect = "move"; 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) => { const handleTrackDragEnter = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
if (!e.dataTransfer.types.includes("application/x-timeline-clip")) return; if (!e.dataTransfer.types.includes("application/x-timeline-clip")) return;
dragCounterRef.current++;
setIsDropping(true); setIsDropping(true);
setIsDraggedOver(true);
}; };
const handleTrackDragLeave = (e: React.DragEvent) => { const handleTrackDragLeave = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
if (!e.dataTransfer.types.includes("application/x-timeline-clip")) return; if (!e.dataTransfer.types.includes("application/x-timeline-clip")) return;
const rect = e.currentTarget.getBoundingClientRect(); dragCounterRef.current--;
const { clientX: x, clientY: y } = e;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { if (dragCounterRef.current === 0) {
setIsDropping(false); setIsDropping(false);
setIsDraggedOver(false);
setDropPosition(null);
} }
}; };
const handleTrackDrop = (e: React.DragEvent) => { const handleTrackDrop = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
// Reset all drag states
dragCounterRef.current = 0;
setIsDropping(false); setIsDropping(false);
setIsDraggedOver(false);
setDropPosition(null);
if (!e.dataTransfer.types.includes("application/x-timeline-clip")) return; if (!e.dataTransfer.types.includes("application/x-timeline-clip")) return;
@ -436,13 +467,20 @@ function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zo
if (!trackContainer) return; if (!trackContainer) return;
const rect = trackContainer.getBoundingClientRect(); 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) { if (fromTrackId === track.id) {
updateClipStartTime(track.id, clipId, newStartTime); updateClipStartTime(track.id, clipId, snappedTime);
} else { } else {
moveClipToTrack(fromTrackId, track.id, clipId); 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) { } catch (error) {
console.error("Error moving clip:", error); console.error("Error moving clip:", error);
@ -514,7 +552,9 @@ function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zo
</div> </div>
<div <div
className={`flex-1 h-[60px] transition-colors ${isDropping ? "bg-accent/50 border-2 border-dashed border-accent" : "" className={`flex-1 h-[60px] transition-all duration-150 ease-out ${isDraggedOver
? "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg transform scale-[1.02]"
: "border border-transparent hover:bg-muted/30"
}`} }`}
onDragOver={handleTrackDragOver} onDragOver={handleTrackDragOver}
onDragEnter={handleTrackDragEnter} onDragEnter={handleTrackDragEnter}
@ -526,41 +566,59 @@ function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zo
> >
<div className="h-full relative track-clips-container min-w-full"> <div className="h-full relative track-clips-container min-w-full">
{track.clips.length === 0 ? ( {track.clips.length === 0 ? (
<div className="h-full w-full rounded-sm border-2 border-dashed border-muted/30 flex items-center justify-center text-xs text-muted-foreground"> <div className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${isDropping ? "border-blue-500 bg-blue-500/10 text-blue-600" : "border-muted/30"
Drop media here }`}>
{isDropping ? "Drop clip here" : "Drop media here"}
</div> </div>
) : ( ) : (
track.clips.map((clip) => { <>
const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; {track.clips.map((clip) => {
const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
const clipLeft = clip.startTime * 50 * zoomLevel; const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
const clipLeft = clip.startTime * 50 * zoomLevel;
return ( return (
<div
key={clip.id}
className={`timeline-clip absolute h-full rounded-sm border transition-colors ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group`}
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
>
<div <div
className="absolute left-0 top-0 bottom-0 w-2 cursor-w-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500" key={clip.id}
onMouseDown={(e) => handleResizeStart(e, clip.id, 'left')} 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` }}
<div
className="flex-1 cursor-grab active:cursor-grabbing"
draggable={true}
onDragStart={(e) => handleClipDragStart(e, clip)}
> >
{renderClipContent(clip)} <div
</div> className="absolute left-0 top-0 bottom-0 w-2 cursor-w-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
onMouseDown={(e) => handleResizeStart(e, clip.id, 'left')}
/>
<div <div
className="absolute right-0 top-0 bottom-0 w-2 cursor-e-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500" className="flex-1 cursor-grab active:cursor-grabbing"
onMouseDown={(e) => handleResizeStart(e, clip.id, 'right')} draggable={true}
/> onDragStart={(e) => handleClipDragStart(e, clip)}
onDragEnd={handleClipDragEnd}
>
{renderClipContent(clip)}
</div>
<div
className="absolute right-0 top-0 bottom-0 w-2 cursor-e-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
onMouseDown={(e) => handleResizeStart(e, clip.id, 'right')}
/>
</div>
);
})}
{/* Drop position indicator */}
{isDraggedOver && dropPosition !== null && (
<div
className="absolute top-0 bottom-0 w-1 bg-blue-500 pointer-events-none z-30 transition-all duration-75 ease-out"
style={{ left: `${dropPosition * 50 * zoomLevel}px`, transform: 'translateX(-50%)' }}
>
<div className="absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-blue-500 rounded-full border-2 border-white shadow-md" />
<div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-blue-500 rounded-full border-2 border-white shadow-md" />
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white bg-blue-500 px-1 py-0.5 rounded whitespace-nowrap">
{dropPosition.toFixed(1)}s
</div>
</div> </div>
); )}
}) </>
)} )}
</div> </div>
</div> </div>