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
This commit is contained in:
@ -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<number | null>(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}
|
||||
>
|
||||
<div className="h-full relative track-clips-container min-w-full">
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className="h-full relative track-clips-container min-w-full"
|
||||
>
|
||||
{track.clips.length === 0 ? (
|
||||
<div
|
||||
className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
|
||||
@ -1755,14 +1746,22 @@ function TimelineTrackContent({
|
||||
80,
|
||||
effectiveDuration * 50 * zoomLevel
|
||||
);
|
||||
const clipLeft = clip.startTime * 50 * zoomLevel;
|
||||
|
||||
// Use real-time position during drag, otherwise use stored position
|
||||
const dragPosition = getDraggedClipPosition(clip.id);
|
||||
const clipStartTime =
|
||||
dragPosition !== null ? dragPosition : clip.startTime;
|
||||
const clipLeft = clipStartTime * 50 * zoomLevel;
|
||||
|
||||
const isSelected = selectedClips.some(
|
||||
(c) => c.trackId === track.id && c.clipId === clip.id
|
||||
);
|
||||
|
||||
const isBeingDragged = draggedClipId === clip.id;
|
||||
return (
|
||||
<div
|
||||
key={clip.id}
|
||||
className={`timeline-clip absolute h-full border ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg ${isSelected ? "ring-2 ring-blue-500 z-10" : ""}`}
|
||||
className={`timeline-clip absolute h-full border ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg ${isSelected ? "ring-2 ring-blue-500 z-10" : ""} ${isBeingDragged ? "opacity-50 shadow-lg scale-105 z-20" : ""}`}
|
||||
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@ -1808,10 +1807,8 @@ function TimelineTrackContent({
|
||||
/>
|
||||
{/* Clip content */}
|
||||
<div
|
||||
className="flex-1 cursor-grab active:cursor-grabbing relative"
|
||||
draggable={true}
|
||||
onDragStart={(e) => handleClipDragStart(e, clip)}
|
||||
onDragEnd={handleClipDragEnd}
|
||||
className={`flex-1 relative ${isBeingDragged ? "cursor-grabbing" : "cursor-grab"}`}
|
||||
onMouseDown={(e) => handleClipMouseDown(e, clip)}
|
||||
>
|
||||
{renderClipContent(clip)}
|
||||
{/* Clip options menu */}
|
||||
|
213
apps/web/src/hooks/use-drag-clip.ts
Normal file
213
apps/web/src/hooks/use-drag-clip.ts
Normal file
@ -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<DragState>({
|
||||
isDragging: false,
|
||||
clipId: null,
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startClipTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
});
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(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,
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user