fix: some timeline issues
This commit is contained in:
@ -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
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
Reference in New Issue
Block a user