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 { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store";
|
||||||
import { useMediaStore } from "@/stores/media-store";
|
import { useMediaStore } from "@/stores/media-store";
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
|
import { useDragClip } from "@/hooks/use-drag-clip";
|
||||||
import { processMediaFiles } from "@/lib/media-processing";
|
import { processMediaFiles } from "@/lib/media-processing";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
@ -1163,9 +1164,19 @@ function TimelineTrackContent({
|
|||||||
deselectClip,
|
deselectClip,
|
||||||
} = useTimelineStore();
|
} = useTimelineStore();
|
||||||
const { currentTime } = usePlaybackStore();
|
const { currentTime } = usePlaybackStore();
|
||||||
|
|
||||||
|
// Mouse-based drag hook
|
||||||
|
const {
|
||||||
|
isDragging,
|
||||||
|
draggedClipId,
|
||||||
|
startDrag,
|
||||||
|
endDrag,
|
||||||
|
getDraggedClipPosition,
|
||||||
|
isValidDropTarget,
|
||||||
|
timelineRef,
|
||||||
|
} = useDragClip(zoomLevel);
|
||||||
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 [wouldOverlap, setWouldOverlap] = useState(false);
|
const [wouldOverlap, setWouldOverlap] = useState(false);
|
||||||
const [resizing, setResizing] = useState<{
|
const [resizing, setResizing] = useState<{
|
||||||
clipId: string;
|
clipId: string;
|
||||||
@ -1240,37 +1251,14 @@ function TimelineTrackContent({
|
|||||||
setResizing(null);
|
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
|
// 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.parentElement as HTMLElement;
|
||||||
const clipRect = clipElement.getBoundingClientRect();
|
const clipRect = clipElement.getBoundingClientRect();
|
||||||
const clickOffsetX = e.clientX - clipRect.left;
|
const clickOffsetX = e.clientX - clipRect.left;
|
||||||
const clickOffsetTime = clickOffsetX / (50 * zoomLevel);
|
const clickOffsetTime = clickOffsetX / (50 * zoomLevel);
|
||||||
|
|
||||||
const dragData = {
|
startDrag(e, clip.id, track.id, clip.startTime, clickOffsetTime);
|
||||||
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 = "";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTrackDragOver = (e: React.DragEvent) => {
|
const handleTrackDragOver = (e: React.DragEvent) => {
|
||||||
@ -1388,14 +1376,12 @@ function TimelineTrackContent({
|
|||||||
|
|
||||||
if (wouldOverlap) {
|
if (wouldOverlap) {
|
||||||
e.dataTransfer.dropEffect = "none";
|
e.dataTransfer.dropEffect = "none";
|
||||||
setIsDraggedOver(true);
|
|
||||||
setWouldOverlap(true);
|
setWouldOverlap(true);
|
||||||
setDropPosition(Math.round(dropTime * 10) / 10);
|
setDropPosition(Math.round(dropTime * 10) / 10);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
|
e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
|
||||||
setIsDraggedOver(true);
|
|
||||||
setWouldOverlap(false);
|
setWouldOverlap(false);
|
||||||
setDropPosition(Math.round(dropTime * 10) / 10);
|
setDropPosition(Math.round(dropTime * 10) / 10);
|
||||||
};
|
};
|
||||||
@ -1414,7 +1400,6 @@ function TimelineTrackContent({
|
|||||||
|
|
||||||
dragCounterRef.current++;
|
dragCounterRef.current++;
|
||||||
setIsDropping(true);
|
setIsDropping(true);
|
||||||
setIsDraggedOver(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTrackDragLeave = (e: React.DragEvent) => {
|
const handleTrackDragLeave = (e: React.DragEvent) => {
|
||||||
@ -1433,7 +1418,6 @@ function TimelineTrackContent({
|
|||||||
|
|
||||||
if (dragCounterRef.current === 0) {
|
if (dragCounterRef.current === 0) {
|
||||||
setIsDropping(false);
|
setIsDropping(false);
|
||||||
setIsDraggedOver(false);
|
|
||||||
setWouldOverlap(false);
|
setWouldOverlap(false);
|
||||||
setDropPosition(null);
|
setDropPosition(null);
|
||||||
}
|
}
|
||||||
@ -1446,7 +1430,6 @@ function TimelineTrackContent({
|
|||||||
// Reset all drag states
|
// Reset all drag states
|
||||||
dragCounterRef.current = 0;
|
dragCounterRef.current = 0;
|
||||||
setIsDropping(false);
|
setIsDropping(false);
|
||||||
setIsDraggedOver(false);
|
|
||||||
setWouldOverlap(false);
|
setWouldOverlap(false);
|
||||||
const currentDropPosition = dropPosition;
|
const currentDropPosition = dropPosition;
|
||||||
setDropPosition(null);
|
setDropPosition(null);
|
||||||
@ -1726,10 +1709,18 @@ function TimelineTrackContent({
|
|||||||
onDragLeave={handleTrackDragLeave}
|
onDragLeave={handleTrackDragLeave}
|
||||||
onDrop={handleTrackDrop}
|
onDrop={handleTrackDrop}
|
||||||
onMouseMove={handleResizeMove}
|
onMouseMove={handleResizeMove}
|
||||||
onMouseUp={handleResizeEnd}
|
onMouseUp={(e) => {
|
||||||
|
handleResizeEnd();
|
||||||
|
if (isDragging) {
|
||||||
|
endDrag(track.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onMouseLeave={handleResizeEnd}
|
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 ? (
|
{track.clips.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
|
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,
|
80,
|
||||||
effectiveDuration * 50 * zoomLevel
|
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(
|
const isSelected = selectedClips.some(
|
||||||
(c) => c.trackId === track.id && c.clipId === clip.id
|
(c) => c.trackId === track.id && c.clipId === clip.id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isBeingDragged = draggedClipId === clip.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={clip.id}
|
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` }}
|
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -1808,10 +1807,8 @@ function TimelineTrackContent({
|
|||||||
/>
|
/>
|
||||||
{/* Clip content */}
|
{/* Clip content */}
|
||||||
<div
|
<div
|
||||||
className="flex-1 cursor-grab active:cursor-grabbing relative"
|
className={`flex-1 relative ${isBeingDragged ? "cursor-grabbing" : "cursor-grab"}`}
|
||||||
draggable={true}
|
onMouseDown={(e) => handleClipMouseDown(e, clip)}
|
||||||
onDragStart={(e) => handleClipDragStart(e, clip)}
|
|
||||||
onDragEnd={handleClipDragEnd}
|
|
||||||
>
|
>
|
||||||
{renderClipContent(clip)}
|
{renderClipContent(clip)}
|
||||||
{/* Clip options menu */}
|
{/* 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