fix: some timeline issues

This commit is contained in:
Maze Winther
2025-06-26 00:37:35 +02:00
parent 75eede20af
commit e225272ec3
4 changed files with 2586 additions and 2384 deletions

View File

@ -1,277 +1,276 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { MoreVertical, Scissors, Trash2 } from "lucide-react"; import { MoreVertical, Scissors, Trash2 } from "lucide-react";
import { useMediaStore } from "@/stores/media-store"; import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
import { useDragClip } from "@/hooks/use-drag-clip"; import { useDragClip } from "@/hooks/use-drag-clip";
import AudioWaveform from "./audio-waveform"; import AudioWaveform from "./audio-waveform";
import { toast } from "sonner"; import { toast } from "sonner";
import { TimelineClipProps, ResizeState } from "@/types/timeline"; import { TimelineClipProps, ResizeState } from "@/types/timeline";
export function TimelineClip({ export function TimelineClip({
clip, clip,
track, track,
zoomLevel, zoomLevel,
isSelected, isSelected,
onContextMenu, onContextMenu,
onClipMouseDown, onClipMouseDown,
onClipClick, onClipClick,
}: TimelineClipProps) { }: TimelineClipProps) {
const { mediaItems } = useMediaStore(); const { mediaItems } = useMediaStore();
const { updateClipTrim, addClipToTrack, removeClipFromTrack } = const { updateClipTrim, addClipToTrack, removeClipFromTrack, dragState } =
useTimelineStore(); useTimelineStore();
const { currentTime } = usePlaybackStore(); const { currentTime } = usePlaybackStore();
const { draggedClipId, getDraggedClipPosition } =
useDragClip(zoomLevel); const [resizing, setResizing] = useState<ResizeState | null>(null);
const [clipMenuOpen, setClipMenuOpen] = useState(false);
const [resizing, setResizing] = useState<ResizeState | null>(null);
const [clipMenuOpen, setClipMenuOpen] = useState(false); const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); // Use real-time position during drag, otherwise use stored position
const isBeingDragged = dragState.clipId === clip.id;
// Use real-time position during drag, otherwise use stored position const clipStartTime =
const dragPosition = getDraggedClipPosition(clip.id); isBeingDragged && dragState.isDragging
const clipStartTime = dragPosition !== null ? dragPosition : clip.startTime; ? dragState.currentTime
const clipLeft = clipStartTime * 50 * zoomLevel; : clip.startTime;
const clipLeft = clipStartTime * 50 * zoomLevel;
const isBeingDragged = draggedClipId === clip.id;
const getTrackColor = (type: string) => {
const getTrackColor = (type: string) => { switch (type) {
switch (type) { case "video":
case "video": return "bg-blue-500/20 border-blue-500/30";
return "bg-blue-500/20 border-blue-500/30"; case "audio":
case "audio": return "bg-green-500/20 border-green-500/30";
return "bg-green-500/20 border-green-500/30"; case "effects":
case "effects": return "bg-purple-500/20 border-purple-500/30";
return "bg-purple-500/20 border-purple-500/30"; default:
default: return "bg-gray-500/20 border-gray-500/30";
return "bg-gray-500/20 border-gray-500/30"; }
} };
};
const handleResizeStart = (
const handleResizeStart = ( e: React.MouseEvent,
e: React.MouseEvent, clipId: string,
clipId: string, side: "left" | "right"
side: "left" | "right" ) => {
) => { e.stopPropagation();
e.stopPropagation(); e.preventDefault();
e.preventDefault();
setResizing({
setResizing({ clipId,
clipId, side,
side, startX: e.clientX,
startX: e.clientX, initialTrimStart: clip.trimStart,
initialTrimStart: clip.trimStart, initialTrimEnd: clip.trimEnd,
initialTrimEnd: clip.trimEnd, });
}); };
};
const updateTrimFromMouseMove = (e: { clientX: number }) => {
const updateTrimFromMouseMove = (e: { clientX: number }) => { if (!resizing) return;
if (!resizing) return;
const deltaX = e.clientX - resizing.startX;
const deltaX = e.clientX - resizing.startX; const deltaTime = deltaX / (50 * zoomLevel);
const deltaTime = deltaX / (50 * zoomLevel);
if (resizing.side === "left") {
if (resizing.side === "left") { const newTrimStart = Math.max(
const newTrimStart = Math.max( 0,
0, Math.min(
Math.min( clip.duration - clip.trimEnd - 0.1,
clip.duration - clip.trimEnd - 0.1, resizing.initialTrimStart + deltaTime
resizing.initialTrimStart + deltaTime )
) );
); updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd);
updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd); } else {
} else { const newTrimEnd = Math.max(
const newTrimEnd = Math.max( 0,
0, Math.min(
Math.min( clip.duration - clip.trimStart - 0.1,
clip.duration - clip.trimStart - 0.1, resizing.initialTrimEnd - deltaTime
resizing.initialTrimEnd - deltaTime )
) );
); updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd);
updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd); }
} };
};
const handleResizeMove = (e: React.MouseEvent) => {
const handleResizeMove = (e: React.MouseEvent) => { updateTrimFromMouseMove(e);
updateTrimFromMouseMove(e); };
};
const handleResizeEnd = () => {
const handleResizeEnd = () => { setResizing(null);
setResizing(null); };
};
const handleDeleteClip = () => {
const handleDeleteClip = () => { removeClipFromTrack(track.id, clip.id);
removeClipFromTrack(track.id, clip.id); setClipMenuOpen(false);
setClipMenuOpen(false); };
};
const handleSplitClip = () => {
const handleSplitClip = () => { // Use current playback time as split point
// Use current playback time as split point const splitTime = currentTime;
const splitTime = currentTime; // Only split if splitTime is within the clip's effective range
// Only split if splitTime is within the clip's effective range const effectiveStart = clip.startTime;
const effectiveStart = clip.startTime; const effectiveEnd =
const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) {
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) { toast.error("Playhead must be within clip to split");
toast.error("Playhead must be within clip to split"); return;
return; }
}
const firstDuration = splitTime - effectiveStart;
const firstDuration = splitTime - effectiveStart; const secondDuration = effectiveEnd - splitTime;
const secondDuration = effectiveEnd - splitTime;
// First part: adjust original clip
// First part: adjust original clip updateClipTrim(
updateClipTrim( track.id,
track.id, clip.id,
clip.id, clip.trimStart,
clip.trimStart, clip.trimEnd + secondDuration
clip.trimEnd + secondDuration );
);
// Second part: add new clip after split
// Second part: add new clip after split addClipToTrack(track.id, {
addClipToTrack(track.id, { mediaId: clip.mediaId,
mediaId: clip.mediaId, name: clip.name + " (cut)",
name: clip.name + " (cut)", duration: clip.duration,
duration: clip.duration, startTime: splitTime,
startTime: splitTime, trimStart: clip.trimStart + firstDuration,
trimStart: clip.trimStart + firstDuration, trimEnd: clip.trimEnd,
trimEnd: clip.trimEnd, });
});
setClipMenuOpen(false);
setClipMenuOpen(false); toast.success("Clip split successfully");
toast.success("Clip split successfully"); };
};
const renderClipContent = () => {
const renderClipContent = () => { const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
if (!mediaItem) {
if (!mediaItem) { return (
return ( <span className="text-xs text-foreground/80 truncate">{clip.name}</span>
<span className="text-xs text-foreground/80 truncate">{clip.name}</span> );
); }
}
if (mediaItem.type === "image") {
if (mediaItem.type === "image") { return (
return ( <div className="w-full h-full flex items-center justify-center">
<div className="w-full h-full flex items-center justify-center"> <img
<img src={mediaItem.url}
src={mediaItem.url} alt={mediaItem.name}
alt={mediaItem.name} className="w-full h-full object-cover"
className="w-full h-full object-cover" draggable={false}
draggable={false} />
/> </div>
</div> );
); }
}
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { return (
return ( <div className="w-full h-full flex items-center gap-2">
<div className="w-full h-full flex items-center gap-2"> <div className="w-8 h-8 flex-shrink-0">
<div className="w-8 h-8 flex-shrink-0"> <img
<img src={mediaItem.thumbnailUrl}
src={mediaItem.thumbnailUrl} alt={mediaItem.name}
alt={mediaItem.name} className="w-full h-full object-cover rounded-sm"
className="w-full h-full object-cover rounded-sm" draggable={false}
draggable={false} />
/> </div>
</div> <span className="text-xs text-foreground/80 truncate flex-1">
<span className="text-xs text-foreground/80 truncate flex-1"> {clip.name}
{clip.name} </span>
</span> </div>
</div> );
); }
}
if (mediaItem.type === "audio") {
if (mediaItem.type === "audio") { return (
return ( <div className="w-full h-full flex items-center gap-2">
<div className="w-full h-full flex items-center gap-2"> <div className="flex-1 min-w-0">
<div className="flex-1 min-w-0"> <AudioWaveform
<AudioWaveform audioUrl={mediaItem.url}
audioUrl={mediaItem.url} height={24}
height={24} className="w-full"
className="w-full" />
/> </div>
</div> </div>
</div> );
); }
}
// Fallback for videos without thumbnails
// Fallback for videos without thumbnails return (
return ( <span className="text-xs text-foreground/80 truncate">{clip.name}</span>
<span className="text-xs text-foreground/80 truncate">{clip.name}</span> );
); };
};
return (
return ( <div
<div 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 ? "shadow-lg z-20" : ""}`}
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 ? "shadow-lg z-20" : ""} ${isBeingDragged ? "cursor-grabbing" : "cursor-grab"}`} style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }} onMouseDown={(e) => onClipMouseDown(e, clip)}
onMouseDown={(e) => onClipMouseDown(e, clip)} onClick={(e) => onClipClick(e, clip)}
onClick={(e) => onClipClick(e, clip)} onMouseMove={handleResizeMove}
onMouseMove={handleResizeMove} onMouseUp={handleResizeEnd}
onMouseUp={handleResizeEnd} onMouseLeave={handleResizeEnd}
onMouseLeave={handleResizeEnd} tabIndex={0}
tabIndex={0} onContextMenu={(e) => onContextMenu(e, clip.id)}
onContextMenu={(e) => onContextMenu(e, clip.id)} >
> {/* Left trim handle */}
{/* Left trim handle */} <div
<div className={`absolute left-0 top-0 bottom-0 w-2 cursor-w-resize transition-opacity bg-blue-500/50 hover:bg-blue-500 ${isSelected ? "opacity-100" : "opacity-0"}`}
className={`absolute left-0 top-0 bottom-0 w-2 cursor-w-resize transition-opacity bg-blue-500/50 hover:bg-blue-500 ${isSelected ? "opacity-100" : "opacity-0"}`} onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
onMouseDown={(e) => handleResizeStart(e, clip.id, "left")} />
/>
{/* Clip content */}
{/* Clip content */} <div className="flex-1 relative">
<div className="flex-1 relative"> {renderClipContent()}
{renderClipContent()}
{/* Clip options menu */}
{/* Clip options menu */} <div className="absolute top-1 right-1 z-10">
<div className="absolute top-1 right-1 z-10"> <Button
<Button variant="text"
variant="text" size="icon"
size="icon" className="opacity-0 group-hover:opacity-100 transition-opacity"
className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); setClipMenuOpen(!clipMenuOpen);
setClipMenuOpen(!clipMenuOpen); }}
}} onMouseDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} >
> <MoreVertical className="h-4 w-4" />
<MoreVertical className="h-4 w-4" /> </Button>
</Button>
{clipMenuOpen && (
{clipMenuOpen && ( <div
<div className="absolute right-0 mt-2 w-32 bg-white border rounded shadow z-50"
className="absolute right-0 mt-2 w-32 bg-white border rounded shadow z-50" onMouseDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} >
> <button
<button className="flex items-center w-full px-3 py-2 text-sm hover:bg-muted/30"
className="flex items-center w-full px-3 py-2 text-sm hover:bg-muted/30" onClick={handleSplitClip}
onClick={handleSplitClip} >
> <Scissors className="h-4 w-4 mr-2" /> Split
<Scissors className="h-4 w-4 mr-2" /> Split </button>
</button> <button
<button className="flex items-center w-full px-3 py-2 text-sm text-red-600 hover:bg-red-50"
className="flex items-center w-full px-3 py-2 text-sm text-red-600 hover:bg-red-50" onClick={handleDeleteClip}
onClick={handleDeleteClip} >
> <Trash2 className="h-4 w-4 mr-2" /> Delete
<Trash2 className="h-4 w-4 mr-2" /> Delete </button>
</button> </div>
</div> )}
)} </div>
</div> </div>
</div>
{/* Right trim handle */}
{/* Right trim handle */} <div
<div className={`absolute right-0 top-0 bottom-0 w-2 cursor-e-resize transition-opacity bg-blue-500/50 hover:bg-blue-500 ${isSelected ? "opacity-100" : "opacity-0"}`}
className={`absolute right-0 top-0 bottom-0 w-2 cursor-e-resize transition-opacity bg-blue-500/50 hover:bg-blue-500 ${isSelected ? "opacity-100" : "opacity-0"}`} onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
onMouseDown={(e) => handleResizeStart(e, clip.id, "right")} />
/> </div>
</div> );
); }
}

File diff suppressed because it is too large Load Diff

View File

@ -1,213 +1,226 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
interface DragState { interface DragState {
isDragging: boolean; isDragging: boolean;
clipId: string | null; clipId: string | null;
trackId: string | null; trackId: string | null;
startMouseX: number; startMouseX: number;
startClipTime: number; startClipTime: number;
clickOffsetTime: number; clickOffsetTime: number;
currentTime: number; currentTime: number;
} }
export function useDragClip(zoomLevel: number) { export function useDragClip(zoomLevel: number) {
const { tracks, updateClipStartTime, moveClipToTrack } = useTimelineStore(); const { tracks, updateClipStartTime, moveClipToTrack } = useTimelineStore();
const [dragState, setDragState] = useState<DragState>({ const [dragState, setDragState] = useState<DragState>({
isDragging: false, isDragging: false,
clipId: null, clipId: null,
trackId: null, trackId: null,
startMouseX: 0, startMouseX: 0,
startClipTime: 0, startClipTime: 0,
clickOffsetTime: 0, clickOffsetTime: 0,
currentTime: 0, currentTime: 0,
}); });
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
const dragStateRef = useRef(dragState);
const startDrag = useCallback(
( // Keep ref in sync with state
e: React.MouseEvent, dragStateRef.current = dragState;
clipId: string,
trackId: string, const startDrag = useCallback(
clipStartTime: number, (
clickOffsetTime: number e: React.MouseEvent,
) => { clipId: string,
e.preventDefault(); trackId: string,
e.stopPropagation(); clipStartTime: number,
clickOffsetTime: number
setDragState({ ) => {
isDragging: true, e.preventDefault();
clipId, e.stopPropagation();
trackId,
startMouseX: e.clientX, setDragState({
startClipTime: clipStartTime, isDragging: true,
clickOffsetTime, clipId,
currentTime: clipStartTime, 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 updateDrag = useCallback(
const mouseX = e.clientX - timelineRect.left; (e: MouseEvent) => {
const mouseTime = Math.max(0, mouseX / (50 * zoomLevel)); if (!dragState.isDragging || !timelineRef.current) {
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); return;
const snappedTime = Math.round(adjustedTime * 10) / 10; }
setDragState((prev) => ({ const timelineRect = timelineRef.current.getBoundingClientRect();
...prev, const mouseX = e.clientX - timelineRect.left;
currentTime: snappedTime, const mouseTime = Math.max(0, mouseX / (50 * zoomLevel));
})); const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
}, const snappedTime = Math.round(adjustedTime * 10) / 10;
[dragState.isDragging, dragState.clickOffsetTime, zoomLevel]
); setDragState((prev) => ({
...prev,
const endDrag = useCallback( currentTime: snappedTime,
(targetTrackId?: string) => { }));
if (!dragState.isDragging || !dragState.clipId || !dragState.trackId) },
return; [dragState.isDragging, dragState.clickOffsetTime, zoomLevel]
);
const finalTrackId = targetTrackId || dragState.trackId;
const finalTime = dragState.currentTime; const endDrag = useCallback(
(targetTrackId?: string) => {
// Check for overlaps if (!dragState.isDragging || !dragState.clipId || !dragState.trackId)
const sourceTrack = tracks.find((t) => t.id === dragState.trackId); return;
const targetTrack = tracks.find((t) => t.id === finalTrackId);
const movingClip = sourceTrack?.clips.find( const finalTrackId = targetTrackId || dragState.trackId;
(c) => c.id === dragState.clipId const finalTime = dragState.currentTime;
);
// Check for overlaps
if (!movingClip || !targetTrack) { const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
setDragState((prev) => ({ ...prev, isDragging: false })); const targetTrack = tracks.find((t) => t.id === finalTrackId);
return; const movingClip = sourceTrack?.clips.find(
} (c) => c.id === dragState.clipId
);
const movingClipDuration =
movingClip.duration - movingClip.trimStart - movingClip.trimEnd; if (!movingClip || !targetTrack) {
const movingClipEnd = finalTime + movingClipDuration; setDragState((prev) => ({ ...prev, isDragging: false }));
return;
const hasOverlap = targetTrack.clips.some((existingClip) => { }
// Skip the clip being moved if it's on the same track
if ( const movingClipDuration =
dragState.trackId === finalTrackId && movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
existingClip.id === dragState.clipId const movingClipEnd = finalTime + movingClipDuration;
) {
return false; const hasOverlap = targetTrack.clips.some((existingClip) => {
} // Skip the clip being moved if it's on the same track
if (
const existingStart = existingClip.startTime; dragState.trackId === finalTrackId &&
const existingEnd = existingClip.id === dragState.clipId
existingClip.startTime + ) {
(existingClip.duration - return false;
existingClip.trimStart - }
existingClip.trimEnd);
const existingStart = existingClip.startTime;
return finalTime < existingEnd && movingClipEnd > existingStart; const existingEnd =
}); existingClip.startTime +
(existingClip.duration -
if (!hasOverlap) { existingClip.trimStart -
if (dragState.trackId === finalTrackId) { existingClip.trimEnd);
// Moving within same track
updateClipStartTime(finalTrackId, dragState.clipId!, finalTime); return finalTime < existingEnd && movingClipEnd > existingStart;
} else { });
// Moving to different track
moveClipToTrack(dragState.trackId!, finalTrackId, dragState.clipId!); if (!hasOverlap) {
requestAnimationFrame(() => { if (dragState.trackId === finalTrackId) {
updateClipStartTime(finalTrackId, dragState.clipId!, finalTime); // Moving within same track
}); updateClipStartTime(finalTrackId, dragState.clipId!, finalTime);
} } else {
} // Moving to different track
moveClipToTrack(dragState.trackId!, finalTrackId, dragState.clipId!);
setDragState({ requestAnimationFrame(() => {
isDragging: false, updateClipStartTime(finalTrackId, dragState.clipId!, finalTime);
clipId: null, });
trackId: null, }
startMouseX: 0, }
startClipTime: 0,
clickOffsetTime: 0, setDragState({
currentTime: 0, isDragging: false,
}); clipId: null,
}, trackId: null,
[dragState, tracks, updateClipStartTime, moveClipToTrack] startMouseX: 0,
); startClipTime: 0,
clickOffsetTime: 0,
const cancelDrag = useCallback(() => { currentTime: 0,
setDragState({ });
isDragging: false, },
clipId: null, [dragState, tracks, updateClipStartTime, moveClipToTrack]
trackId: null, );
startMouseX: 0,
startClipTime: 0, const cancelDrag = useCallback(() => {
clickOffsetTime: 0, setDragState({
currentTime: 0, isDragging: false,
}); clipId: null,
}, []); trackId: null,
startMouseX: 0,
// Global mouse events startClipTime: 0,
useEffect(() => { clickOffsetTime: 0,
if (!dragState.isDragging) return; currentTime: 0,
});
const handleMouseMove = (e: MouseEvent) => updateDrag(e); }, []);
const handleMouseUp = () => endDrag();
const handleEscape = (e: KeyboardEvent) => { // Global mouse events
if (e.key === "Escape") cancelDrag(); useEffect(() => {
}; if (!dragState.isDragging) return;
document.addEventListener("mousemove", handleMouseMove); const handleMouseMove = (e: MouseEvent) => updateDrag(e);
document.addEventListener("mouseup", handleMouseUp); const handleMouseUp = () => endDrag();
document.addEventListener("keydown", handleEscape); const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") cancelDrag();
return () => { };
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.addEventListener("mousemove", handleMouseMove);
document.removeEventListener("keydown", handleEscape); document.addEventListener("mouseup", handleMouseUp);
}; document.addEventListener("keydown", handleEscape);
}, [dragState.isDragging, updateDrag, endDrag, cancelDrag]);
return () => {
const getDraggedClipPosition = useCallback( document.removeEventListener("mousemove", handleMouseMove);
(clipId: string) => { document.removeEventListener("mouseup", handleMouseUp);
if (dragState.isDragging && dragState.clipId === clipId) { document.removeEventListener("keydown", handleEscape);
return dragState.currentTime; };
} }, [dragState.isDragging, updateDrag, endDrag, cancelDrag]);
return null;
}, const getDraggedClipPosition = useCallback(
[dragState] (clipId: string) => {
); // Use ref to get current state, not stale closure
const currentDragState = dragStateRef.current;
const isValidDropTarget = useCallback( const isMatch =
(trackId: string) => { currentDragState.isDragging && currentDragState.clipId === clipId;
if (!dragState.isDragging) return false;
if (isMatch) {
const sourceTrack = tracks.find((t) => t.id === dragState.trackId); return currentDragState.currentTime;
const targetTrack = tracks.find((t) => t.id === trackId); }
return null;
if (!sourceTrack || !targetTrack) return false; },
[] // No dependencies needed since we use ref
// For now, allow drops on same track type );
return sourceTrack.type === targetTrack.type;
}, const isValidDropTarget = useCallback(
[dragState.isDragging, dragState.trackId, tracks] (trackId: string) => {
); if (!dragState.isDragging) return false;
return { const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
// State const targetTrack = tracks.find((t) => t.id === trackId);
isDragging: dragState.isDragging,
draggedClipId: dragState.clipId, if (!sourceTrack || !targetTrack) return false;
// Methods // For now, allow drops on same track type
startDrag, return sourceTrack.type === targetTrack.type;
endDrag, },
cancelDrag, [dragState.isDragging, dragState.trackId, tracks]
getDraggedClipPosition, );
isValidDropTarget,
return {
// Refs // State
timelineRef, isDragging: dragState.isDragging,
}; draggedClipId: dragState.clipId,
} currentDragTime: dragState.currentTime,
clickOffsetTime: dragState.clickOffsetTime,
// Methods
startDrag,
endDrag,
cancelDrag,
getDraggedClipPosition,
isValidDropTarget,
// Refs
timelineRef,
};
}

View File

@ -1,264 +1,346 @@
import { create } from "zustand"; import { create } from "zustand";
export interface TimelineClip { export interface TimelineClip {
id: string; id: string;
mediaId: string; mediaId: string;
name: string; name: string;
duration: number; duration: number;
startTime: number; startTime: number;
trimStart: number; trimStart: number;
trimEnd: number; trimEnd: number;
} }
export interface TimelineTrack { export interface TimelineTrack {
id: string; id: string;
name: string; name: string;
type: "video" | "audio" | "effects"; type: "video" | "audio" | "effects";
clips: TimelineClip[]; clips: TimelineClip[];
muted?: boolean; muted?: boolean;
} }
interface TimelineStore { interface TimelineStore {
tracks: TimelineTrack[]; tracks: TimelineTrack[];
history: TimelineTrack[][]; history: TimelineTrack[][];
redoStack: TimelineTrack[][]; redoStack: TimelineTrack[][];
// Multi-selection // Multi-selection
selectedClips: { trackId: string; clipId: string }[]; selectedClips: { trackId: string; clipId: string }[];
selectClip: (trackId: string, clipId: string, multi?: boolean) => void; selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
deselectClip: (trackId: string, clipId: string) => void; deselectClip: (trackId: string, clipId: string) => void;
clearSelectedClips: () => void; clearSelectedClips: () => void;
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void; setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
// Actions // Drag state
addTrack: (type: "video" | "audio" | "effects") => string; dragState: {
removeTrack: (trackId: string) => void; isDragging: boolean;
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void; clipId: string | null;
removeClipFromTrack: (trackId: string, clipId: string) => void; trackId: string | null;
moveClipToTrack: ( startMouseX: number;
fromTrackId: string, startClipTime: number;
toTrackId: string, clickOffsetTime: number;
clipId: string currentTime: number;
) => void; };
updateClipTrim: ( setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
trackId: string, startDrag: (
clipId: string, clipId: string,
trimStart: number, trackId: string,
trimEnd: number startMouseX: number,
) => void; startClipTime: number,
updateClipStartTime: ( clickOffsetTime: number
trackId: string, ) => void;
clipId: string, updateDragTime: (currentTime: number) => void;
startTime: number endDrag: () => void;
) => void;
toggleTrackMute: (trackId: string) => void; // Actions
addTrack: (type: "video" | "audio" | "effects") => string;
// Computed values removeTrack: (trackId: string) => void;
getTotalDuration: () => number; addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
removeClipFromTrack: (trackId: string, clipId: string) => void;
// New actions moveClipToTrack: (
undo: () => void; fromTrackId: string,
redo: () => void; toTrackId: string,
pushHistory: () => void; clipId: string
} ) => void;
updateClipTrim: (
export const useTimelineStore = create<TimelineStore>((set, get) => ({ trackId: string,
tracks: [], clipId: string,
history: [], trimStart: number,
redoStack: [], trimEnd: number
selectedClips: [], ) => void;
updateClipStartTime: (
pushHistory: () => { trackId: string,
const { tracks, history, redoStack } = get(); clipId: string,
// Deep copy tracks startTime: number
set({ ) => void;
history: [...history, JSON.parse(JSON.stringify(tracks))], toggleTrackMute: (trackId: string) => void;
redoStack: [] // Clear redo stack when new action is performed
}); // Computed values
}, getTotalDuration: () => number;
undo: () => { // New actions
const { history, redoStack, tracks } = get(); undo: () => void;
if (history.length === 0) return; redo: () => void;
const prev = history[history.length - 1]; pushHistory: () => void;
set({ }
tracks: prev,
history: history.slice(0, -1), export const useTimelineStore = create<TimelineStore>((set, get) => ({
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack tracks: [],
}); history: [],
}, redoStack: [],
selectedClips: [],
selectClip: (trackId, clipId, multi = false) => {
set((state) => { pushHistory: () => {
const exists = state.selectedClips.some( const { tracks, history, redoStack } = get();
(c) => c.trackId === trackId && c.clipId === clipId // Deep copy tracks
); set({
if (multi) { history: [...history, JSON.parse(JSON.stringify(tracks))],
// Toggle selection redoStack: [], // Clear redo stack when new action is performed
return exists });
? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) } },
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
} else { undo: () => {
return { selectedClips: [{ trackId, clipId }] }; const { history, redoStack, tracks } = get();
} if (history.length === 0) return;
}); const prev = history[history.length - 1];
}, set({
deselectClip: (trackId, clipId) => { tracks: prev,
set((state) => ({ history: history.slice(0, -1),
selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)), redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], // Add current state to redo stack
})); });
}, },
clearSelectedClips: () => {
set({ selectedClips: [] }); selectClip: (trackId, clipId, multi = false) => {
}, set((state) => {
const exists = state.selectedClips.some(
setSelectedClips: (clips) => set({ selectedClips: clips }), (c) => c.trackId === trackId && c.clipId === clipId
);
addTrack: (type) => { if (multi) {
get().pushHistory(); // Toggle selection
const newTrack: TimelineTrack = { return exists
id: crypto.randomUUID(), ? {
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`, selectedClips: state.selectedClips.filter(
type, (c) => !(c.trackId === trackId && c.clipId === clipId)
clips: [], ),
muted: false, }
}; : { selectedClips: [...state.selectedClips, { trackId, clipId }] };
set((state) => ({ } else {
tracks: [...state.tracks, newTrack], return { selectedClips: [{ trackId, clipId }] };
})); }
return newTrack.id; });
}, },
deselectClip: (trackId, clipId) => {
removeTrack: (trackId) => { set((state) => ({
get().pushHistory(); selectedClips: state.selectedClips.filter(
set((state) => ({ (c) => !(c.trackId === trackId && c.clipId === clipId)
tracks: state.tracks.filter((track) => track.id !== trackId), ),
})); }));
}, },
clearSelectedClips: () => {
addClipToTrack: (trackId, clipData) => { set({ selectedClips: [] });
get().pushHistory(); },
const newClip: TimelineClip = {
...clipData, setSelectedClips: (clips) => set({ selectedClips: clips }),
id: crypto.randomUUID(),
startTime: clipData.startTime || 0, addTrack: (type) => {
trimStart: 0, get().pushHistory();
trimEnd: 0, const newTrack: TimelineTrack = {
}; id: crypto.randomUUID(),
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
set((state) => ({ type,
tracks: state.tracks.map((track) => clips: [],
track.id === trackId muted: false,
? { ...track, clips: [...track.clips, newClip] } };
: track set((state) => ({
), tracks: [...state.tracks, newTrack],
})); }));
}, return newTrack.id;
},
removeClipFromTrack: (trackId, clipId) => {
get().pushHistory(); removeTrack: (trackId) => {
set((state) => ({ get().pushHistory();
tracks: state.tracks set((state) => ({
.map((track) => tracks: state.tracks.filter((track) => track.id !== trackId),
track.id === trackId }));
? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) } },
: track
) addClipToTrack: (trackId, clipData) => {
// Remove track if it becomes empty get().pushHistory();
.filter((track) => track.clips.length > 0), const newClip: TimelineClip = {
})); ...clipData,
}, id: crypto.randomUUID(),
startTime: clipData.startTime || 0,
moveClipToTrack: (fromTrackId, toTrackId, clipId) => { trimStart: 0,
get().pushHistory(); trimEnd: 0,
set((state) => { };
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId); set((state) => ({
tracks: state.tracks.map((track) =>
if (!clipToMove) return state; track.id === trackId
? { ...track, clips: [...track.clips, newClip] }
return { : track
tracks: state.tracks ),
.map((track) => { }));
if (track.id === fromTrackId) { },
return {
...track, removeClipFromTrack: (trackId, clipId) => {
clips: track.clips.filter((clip) => clip.id !== clipId), get().pushHistory();
}; set((state) => ({
} else if (track.id === toTrackId) { tracks: state.tracks
return { .map((track) =>
...track, track.id === trackId
clips: [...track.clips, clipToMove], ? {
}; ...track,
} clips: track.clips.filter((clip) => clip.id !== clipId),
return track; }
}) : track
// Remove track if it becomes empty )
.filter((track) => track.clips.length > 0), // Remove track if it becomes empty
}; .filter((track) => track.clips.length > 0),
}); }));
}, },
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => { moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
get().pushHistory(); get().pushHistory();
set((state) => ({ set((state) => {
tracks: state.tracks.map((track) => const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
track.id === trackId const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
? {
...track, if (!clipToMove) return state;
clips: track.clips.map((clip) =>
clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip return {
), tracks: state.tracks
} .map((track) => {
: track if (track.id === fromTrackId) {
), return {
})); ...track,
}, clips: track.clips.filter((clip) => clip.id !== clipId),
};
updateClipStartTime: (trackId, clipId, startTime) => { } else if (track.id === toTrackId) {
get().pushHistory(); return {
set((state) => ({ ...track,
tracks: state.tracks.map((track) => clips: [...track.clips, clipToMove],
track.id === trackId };
? { }
...track, return track;
clips: track.clips.map((clip) => })
clip.id === clipId ? { ...clip, startTime } : clip // Remove track if it becomes empty
), .filter((track) => track.clips.length > 0),
} };
: track });
), },
}));
}, updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
get().pushHistory();
toggleTrackMute: (trackId) => { set((state) => ({
get().pushHistory(); tracks: state.tracks.map((track) =>
set((state) => ({ track.id === trackId
tracks: state.tracks.map((track) => ? {
track.id === trackId ? { ...track, muted: !track.muted } : track ...track,
), clips: track.clips.map((clip) =>
})); clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip
}, ),
}
getTotalDuration: () => { : track
const { tracks } = get(); ),
if (tracks.length === 0) return 0; }));
},
const trackEndTimes = tracks.map((track) =>
track.clips.reduce((maxEnd, clip) => { updateClipStartTime: (trackId, clipId, startTime) => {
const clipEnd = get().pushHistory();
clip.startTime + clip.duration - clip.trimStart - clip.trimEnd; set((state) => ({
return Math.max(maxEnd, clipEnd); tracks: state.tracks.map((track) =>
}, 0) track.id === trackId
); ? {
...track,
return Math.max(...trackEndTimes, 0); clips: track.clips.map((clip) =>
}, clip.id === clipId ? { ...clip, startTime } : clip
),
redo: () => { }
const { redoStack } = get(); : track
if (redoStack.length === 0) return; ),
const next = redoStack[redoStack.length - 1]; }));
set({ tracks: next, redoStack: redoStack.slice(0, -1) }); },
},
})); toggleTrackMute: (trackId) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId ? { ...track, muted: !track.muted } : track
),
}));
},
getTotalDuration: () => {
const { tracks } = get();
if (tracks.length === 0) return 0;
const trackEndTimes = tracks.map((track) =>
track.clips.reduce((maxEnd, clip) => {
const clipEnd =
clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
return Math.max(maxEnd, clipEnd);
}, 0)
);
return Math.max(...trackEndTimes, 0);
},
redo: () => {
const { redoStack } = get();
if (redoStack.length === 0) return;
const next = redoStack[redoStack.length - 1];
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
},
dragState: {
isDragging: false,
clipId: null,
trackId: null,
startMouseX: 0,
startClipTime: 0,
clickOffsetTime: 0,
currentTime: 0,
},
setDragState: (dragState) =>
set((state) => ({
dragState: { ...state.dragState, ...dragState },
})),
startDrag: (clipId, trackId, startMouseX, startClipTime, clickOffsetTime) => {
set({
dragState: {
isDragging: true,
clipId,
trackId,
startMouseX,
startClipTime,
clickOffsetTime,
currentTime: startClipTime,
},
});
},
updateDragTime: (currentTime) => {
set((state) => ({
dragState: {
...state.dragState,
currentTime,
},
}));
},
endDrag: () => {
set({
dragState: {
isDragging: false,
clipId: null,
trackId: null,
startMouseX: 0,
startClipTime: 0,
clickOffsetTime: 0,
currentTime: 0,
},
});
},
}));