summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/web/src/components/editor/timeline.tsx75
-rw-r--r--apps/web/src/hooks/use-drag-clip.ts213
2 files changed, 249 insertions, 39 deletions
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index 584a45f7..31e0dd50 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<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 */}
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 00000000..7eb24daa
--- /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<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,
+ };
+}