refactor: separate timeline track content into new file
This commit is contained in:
660
apps/web/src/components/editor/timeline-track.tsx
Normal file
660
apps/web/src/components/editor/timeline-track.tsx
Normal file
@ -0,0 +1,660 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { toast } from "sonner";
|
||||
import { Copy, Scissors, Trash2 } from "lucide-react";
|
||||
import { TimelineClip } from "./timeline-clip";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
import {
|
||||
TimelineTrack,
|
||||
TimelineClip as TypeTimelineClip,
|
||||
} from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
|
||||
export function TimelineTrackContent({
|
||||
track,
|
||||
zoomLevel,
|
||||
}: {
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
}) {
|
||||
const { mediaItems } = useMediaStore();
|
||||
const {
|
||||
tracks,
|
||||
moveClipToTrack,
|
||||
updateClipStartTime,
|
||||
addClipToTrack,
|
||||
selectedClips,
|
||||
selectClip,
|
||||
deselectClip,
|
||||
dragState,
|
||||
startDrag: startDragAction,
|
||||
updateDragTime,
|
||||
endDrag: endDragAction,
|
||||
} = useTimelineStore();
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const [isDropping, setIsDropping] = useState(false);
|
||||
const [dropPosition, setDropPosition] = useState<number | null>(null);
|
||||
const [wouldOverlap, setWouldOverlap] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const [mouseDownLocation, setMouseDownLocation] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
// Set up mouse event listeners for drag
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!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;
|
||||
|
||||
updateDragTime(snappedTime);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!dragState.clipId || !dragState.trackId) return;
|
||||
|
||||
const finalTime = dragState.currentTime;
|
||||
|
||||
// Check for overlaps and update position
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const movingClip = sourceTrack?.clips.find(
|
||||
(c) => c.id === dragState.clipId
|
||||
);
|
||||
|
||||
if (movingClip) {
|
||||
const movingClipDuration =
|
||||
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
|
||||
const movingClipEnd = finalTime + movingClipDuration;
|
||||
|
||||
const targetTrack = tracks.find((t) => t.id === track.id);
|
||||
const hasOverlap = targetTrack?.clips.some((existingClip) => {
|
||||
if (
|
||||
dragState.trackId === track.id &&
|
||||
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 === track.id) {
|
||||
updateClipStartTime(track.id, dragState.clipId, finalTime);
|
||||
} else {
|
||||
moveClipToTrack(dragState.trackId, track.id, dragState.clipId);
|
||||
requestAnimationFrame(() => {
|
||||
updateClipStartTime(track.id, dragState.clipId!, finalTime);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endDragAction();
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.clickOffsetTime,
|
||||
dragState.clipId,
|
||||
dragState.trackId,
|
||||
dragState.currentTime,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
track.id,
|
||||
updateDragTime,
|
||||
updateClipStartTime,
|
||||
moveClipToTrack,
|
||||
endDragAction,
|
||||
]);
|
||||
|
||||
const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => {
|
||||
setMouseDownLocation({ x: e.clientX, y: e.clientY });
|
||||
// Handle multi-selection only in mousedown
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
selectClip(track.id, clip.id, true);
|
||||
}
|
||||
|
||||
// Calculate the offset from the left edge of the clip to where the user clicked
|
||||
const clipElement = e.currentTarget as HTMLElement;
|
||||
const clipRect = clipElement.getBoundingClientRect();
|
||||
const clickOffsetX = e.clientX - clipRect.left;
|
||||
const clickOffsetTime = clickOffsetX / (50 * zoomLevel);
|
||||
|
||||
startDragAction(
|
||||
clip.id,
|
||||
track.id,
|
||||
e.clientX,
|
||||
clip.startTime,
|
||||
clickOffsetTime
|
||||
);
|
||||
};
|
||||
|
||||
const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if mouse moved significantly
|
||||
if (mouseDownLocation) {
|
||||
const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
|
||||
const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
|
||||
// If it moved more than a few pixels, consider it a drag and not a click.
|
||||
if (deltaX > 5 || deltaY > 5) {
|
||||
setMouseDownLocation(null); // Reset for next interaction
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip selection logic for multi-selection (handled in mousedown)
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle single selection/deselection
|
||||
const isSelected = selectedClips.some(
|
||||
(c) => c.trackId === track.id && c.clipId === clip.id
|
||||
);
|
||||
|
||||
if (isSelected) {
|
||||
// If clip is selected, deselect it
|
||||
deselectClip(track.id, clip.id);
|
||||
} else {
|
||||
// If clip is not selected, select it (replacing other selections)
|
||||
selectClip(track.id, clip.id, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrackDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Handle both timeline clips and media items
|
||||
const hasTimelineClip = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
|
||||
if (!hasTimelineClip && !hasMediaItem) return;
|
||||
|
||||
if (hasMediaItem) {
|
||||
try {
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item"
|
||||
);
|
||||
if (mediaItemData) {
|
||||
const { type } = JSON.parse(mediaItemData);
|
||||
const isCompatible =
|
||||
(track.type === "video" &&
|
||||
(type === "video" || type === "image")) ||
|
||||
(track.type === "audio" && type === "audio");
|
||||
|
||||
if (!isCompatible) {
|
||||
e.dataTransfer.dropEffect = "none";
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing dropped media item:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate drop position for overlap checking
|
||||
const trackContainer = e.currentTarget.querySelector(
|
||||
".track-clips-container"
|
||||
) as HTMLElement;
|
||||
let dropTime = 0;
|
||||
if (trackContainer) {
|
||||
const rect = trackContainer.getBoundingClientRect();
|
||||
const mouseX = Math.max(0, e.clientX - rect.left);
|
||||
dropTime = mouseX / (50 * zoomLevel);
|
||||
}
|
||||
|
||||
// Check for potential overlaps and show appropriate feedback
|
||||
let wouldOverlap = false;
|
||||
|
||||
if (hasMediaItem) {
|
||||
try {
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item"
|
||||
);
|
||||
if (mediaItemData) {
|
||||
const { id } = JSON.parse(mediaItemData);
|
||||
const mediaItem = mediaItems.find((item) => item.id === id);
|
||||
if (mediaItem) {
|
||||
const newClipDuration = mediaItem.duration || 5;
|
||||
const snappedTime = Math.round(dropTime * 10) / 10;
|
||||
const newClipEnd = snappedTime + newClipDuration;
|
||||
|
||||
wouldOverlap = track.clips.some((existingClip) => {
|
||||
const existingStart = existingClip.startTime;
|
||||
const existingEnd =
|
||||
existingClip.startTime +
|
||||
(existingClip.duration -
|
||||
existingClip.trimStart -
|
||||
existingClip.trimEnd);
|
||||
return snappedTime < existingEnd && newClipEnd > existingStart;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue with default behavior
|
||||
}
|
||||
} else if (hasTimelineClip) {
|
||||
try {
|
||||
const timelineClipData = e.dataTransfer.getData(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
if (timelineClipData) {
|
||||
const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData);
|
||||
const sourceTrack = tracks.find(
|
||||
(t: TimelineTrack) => t.id === fromTrackId
|
||||
);
|
||||
const movingClip = sourceTrack?.clips.find(
|
||||
(c: any) => c.id === clipId
|
||||
);
|
||||
|
||||
if (movingClip) {
|
||||
const movingClipDuration =
|
||||
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
|
||||
const snappedTime = Math.round(dropTime * 10) / 10;
|
||||
const movingClipEnd = snappedTime + movingClipDuration;
|
||||
|
||||
wouldOverlap = track.clips.some((existingClip) => {
|
||||
if (fromTrackId === track.id && existingClip.id === clipId)
|
||||
return false;
|
||||
|
||||
const existingStart = existingClip.startTime;
|
||||
const existingEnd =
|
||||
existingClip.startTime +
|
||||
(existingClip.duration -
|
||||
existingClip.trimStart -
|
||||
existingClip.trimEnd);
|
||||
return snappedTime < existingEnd && movingClipEnd > existingStart;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue with default behavior
|
||||
}
|
||||
}
|
||||
|
||||
if (wouldOverlap) {
|
||||
e.dataTransfer.dropEffect = "none";
|
||||
setWouldOverlap(true);
|
||||
setDropPosition(Math.round(dropTime * 10) / 10);
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
|
||||
setWouldOverlap(false);
|
||||
setDropPosition(Math.round(dropTime * 10) / 10);
|
||||
};
|
||||
|
||||
const handleTrackDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const hasTimelineClip = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
|
||||
if (!hasTimelineClip && !hasMediaItem) return;
|
||||
|
||||
dragCounterRef.current++;
|
||||
setIsDropping(true);
|
||||
};
|
||||
|
||||
const handleTrackDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const hasTimelineClip = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
|
||||
if (!hasTimelineClip && !hasMediaItem) return;
|
||||
|
||||
dragCounterRef.current--;
|
||||
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDropping(false);
|
||||
setWouldOverlap(false);
|
||||
setDropPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrackDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Reset all drag states
|
||||
dragCounterRef.current = 0;
|
||||
setIsDropping(false);
|
||||
setWouldOverlap(false);
|
||||
const currentDropPosition = dropPosition;
|
||||
setDropPosition(null);
|
||||
|
||||
const hasTimelineClip = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
|
||||
if (!hasTimelineClip && !hasMediaItem) return;
|
||||
|
||||
const trackContainer = e.currentTarget.querySelector(
|
||||
".track-clips-container"
|
||||
) as HTMLElement;
|
||||
if (!trackContainer) return;
|
||||
|
||||
const rect = trackContainer.getBoundingClientRect();
|
||||
const mouseX = Math.max(0, e.clientX - rect.left);
|
||||
const newStartTime = mouseX / (50 * zoomLevel);
|
||||
const snappedTime = Math.round(newStartTime * 10) / 10;
|
||||
|
||||
try {
|
||||
if (hasTimelineClip) {
|
||||
// Handle timeline clip movement
|
||||
const timelineClipData = e.dataTransfer.getData(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
if (!timelineClipData) return;
|
||||
|
||||
const {
|
||||
clipId,
|
||||
trackId: fromTrackId,
|
||||
clickOffsetTime = 0,
|
||||
} = JSON.parse(timelineClipData);
|
||||
|
||||
// Find the clip being moved
|
||||
const sourceTrack = tracks.find(
|
||||
(t: TimelineTrack) => t.id === fromTrackId
|
||||
);
|
||||
const movingClip = sourceTrack?.clips.find(
|
||||
(c: TypeTimelineClip) => c.id === clipId
|
||||
);
|
||||
|
||||
if (!movingClip) {
|
||||
toast.error("Clip not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Adjust position based on where user clicked on the clip
|
||||
const adjustedStartTime = snappedTime - clickOffsetTime;
|
||||
const finalStartTime = Math.max(
|
||||
0,
|
||||
Math.round(adjustedStartTime * 10) / 10
|
||||
);
|
||||
|
||||
// Check for overlaps with existing clips (excluding the moving clip itself)
|
||||
const movingClipDuration =
|
||||
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
|
||||
const movingClipEnd = finalStartTime + movingClipDuration;
|
||||
|
||||
const hasOverlap = track.clips.some((existingClip) => {
|
||||
// Skip the clip being moved if it's on the same track
|
||||
if (fromTrackId === track.id && existingClip.id === clipId)
|
||||
return false;
|
||||
|
||||
const existingStart = existingClip.startTime;
|
||||
const existingEnd =
|
||||
existingClip.startTime +
|
||||
(existingClip.duration -
|
||||
existingClip.trimStart -
|
||||
existingClip.trimEnd);
|
||||
|
||||
// Check if clips overlap
|
||||
return finalStartTime < existingEnd && movingClipEnd > existingStart;
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
toast.error(
|
||||
"Cannot move clip here - it would overlap with existing clips"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromTrackId === track.id) {
|
||||
// Moving within same track
|
||||
updateClipStartTime(track.id, clipId, finalStartTime);
|
||||
} else {
|
||||
// Moving to different track
|
||||
moveClipToTrack(fromTrackId, track.id, clipId);
|
||||
requestAnimationFrame(() => {
|
||||
updateClipStartTime(track.id, clipId, finalStartTime);
|
||||
});
|
||||
}
|
||||
} else if (hasMediaItem) {
|
||||
// Handle media item drop
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item"
|
||||
);
|
||||
if (!mediaItemData) return;
|
||||
|
||||
const { id, type } = JSON.parse(mediaItemData);
|
||||
const mediaItem = mediaItems.find((item) => item.id === id);
|
||||
|
||||
if (!mediaItem) {
|
||||
toast.error("Media item not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if track type is compatible
|
||||
const isCompatible =
|
||||
(track.type === "video" && (type === "video" || type === "image")) ||
|
||||
(track.type === "audio" && type === "audio");
|
||||
|
||||
if (!isCompatible) {
|
||||
toast.error(`Cannot add ${type} to ${track.type} track`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for overlaps with existing clips
|
||||
const newClipDuration = mediaItem.duration || 5;
|
||||
const newClipEnd = snappedTime + newClipDuration;
|
||||
|
||||
const hasOverlap = track.clips.some((existingClip) => {
|
||||
const existingStart = existingClip.startTime;
|
||||
const existingEnd =
|
||||
existingClip.startTime +
|
||||
(existingClip.duration -
|
||||
existingClip.trimStart -
|
||||
existingClip.trimEnd);
|
||||
|
||||
// Check if clips overlap
|
||||
return snappedTime < existingEnd && newClipEnd > existingStart;
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
toast.error(
|
||||
"Cannot place clip here - it would overlap with existing clips"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
addClipToTrack(track.id, {
|
||||
mediaId: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
duration: mediaItem.duration || 5,
|
||||
startTime: snappedTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
|
||||
toast.success(`Added ${mediaItem.name} to ${track.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling drop:", error);
|
||||
toast.error("Failed to add media to track");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full hover:bg-muted/20"
|
||||
onClick={(e) => {
|
||||
// If clicking empty area (not on a clip), deselect all clips
|
||||
if (!(e.target as HTMLElement).closest(".timeline-clip")) {
|
||||
const { clearSelectedClips } = useTimelineStore.getState();
|
||||
clearSelectedClips();
|
||||
}
|
||||
}}
|
||||
onDragOver={handleTrackDragOver}
|
||||
onDragEnter={handleTrackDragEnter}
|
||||
onDragLeave={handleTrackDragLeave}
|
||||
onDrop={handleTrackDrop}
|
||||
>
|
||||
<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 ${
|
||||
isDropping
|
||||
? wouldOverlap
|
||||
? "border-red-500 bg-red-500/10 text-red-600"
|
||||
: "border-blue-500 bg-blue-500/10 text-blue-600"
|
||||
: "border-muted/30"
|
||||
}`}
|
||||
>
|
||||
{isDropping
|
||||
? wouldOverlap
|
||||
? "Cannot drop - would overlap"
|
||||
: "Drop clip here"
|
||||
: "Drop media here"}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{track.clips.map((clip) => {
|
||||
const isSelected = selectedClips.some(
|
||||
(c) => c.trackId === track.id && c.clipId === clip.id
|
||||
);
|
||||
|
||||
const handleClipSplit = () => {
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const { updateClipTrim, addClipToTrack } = useTimelineStore();
|
||||
const splitTime = currentTime;
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime +
|
||||
(clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
|
||||
updateClipTrim(
|
||||
track.id,
|
||||
clip.id,
|
||||
clip.trimStart,
|
||||
clip.trimEnd + (effectiveEnd - splitTime)
|
||||
);
|
||||
addClipToTrack(track.id, {
|
||||
mediaId: clip.mediaId,
|
||||
name: clip.name + " (split)",
|
||||
duration: clip.duration,
|
||||
startTime: splitTime,
|
||||
trimStart: clip.trimStart + (splitTime - effectiveStart),
|
||||
trimEnd: clip.trimEnd,
|
||||
});
|
||||
toast.success("Clip split successfully");
|
||||
} else {
|
||||
toast.error("Playhead must be within clip to split");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClipDuplicate = () => {
|
||||
const { addClipToTrack } = useTimelineStore.getState();
|
||||
addClipToTrack(track.id, {
|
||||
mediaId: clip.mediaId,
|
||||
name: clip.name + " (copy)",
|
||||
duration: clip.duration,
|
||||
startTime:
|
||||
clip.startTime +
|
||||
(clip.duration - clip.trimStart - clip.trimEnd) +
|
||||
0.1,
|
||||
trimStart: clip.trimStart,
|
||||
trimEnd: clip.trimEnd,
|
||||
});
|
||||
toast.success("Clip duplicated");
|
||||
};
|
||||
|
||||
const handleClipDelete = () => {
|
||||
const { removeClipFromTrack } = useTimelineStore.getState();
|
||||
removeClipFromTrack(track.id, clip.id);
|
||||
toast.success("Clip deleted");
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu key={clip.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div>
|
||||
<TimelineClip
|
||||
clip={clip}
|
||||
track={track}
|
||||
zoomLevel={zoomLevel}
|
||||
isSelected={isSelected}
|
||||
onClipMouseDown={handleClipMouseDown}
|
||||
onClipClick={handleClipClick}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={handleClipSplit}>
|
||||
<Scissors className="h-4 w-4 mr-2" />
|
||||
Split at Playhead
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleClipDuplicate}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Duplicate Clip
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={handleClipDelete}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Clip
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -28,14 +28,9 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
import {
|
||||
useTimelineStore,
|
||||
type TimelineTrack,
|
||||
type TimelineClip as TypeTimelineClip,
|
||||
} from "@/stores/timeline-store";
|
||||
import { useTimelineStore } 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";
|
||||
@ -46,7 +41,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { TimelineClip } from "./timeline-clip";
|
||||
import { TimelineTrackContent } from "./timeline-track";
|
||||
|
||||
export function Timeline() {
|
||||
// Timeline shows all tracks (video, audio, effects) and their clips.
|
||||
@ -1150,643 +1145,3 @@ export function Timeline() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineTrackContent({
|
||||
track,
|
||||
zoomLevel,
|
||||
}: {
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
}) {
|
||||
const { mediaItems } = useMediaStore();
|
||||
const {
|
||||
tracks,
|
||||
moveClipToTrack,
|
||||
updateClipStartTime,
|
||||
addClipToTrack,
|
||||
selectedClips,
|
||||
selectClip,
|
||||
deselectClip,
|
||||
dragState,
|
||||
startDrag: startDragAction,
|
||||
updateDragTime,
|
||||
endDrag: endDragAction,
|
||||
} = useTimelineStore();
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const [isDropping, setIsDropping] = useState(false);
|
||||
const [dropPosition, setDropPosition] = useState<number | null>(null);
|
||||
const [wouldOverlap, setWouldOverlap] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const [mouseDownLocation, setMouseDownLocation] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
// Set up mouse event listeners for drag
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!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;
|
||||
|
||||
updateDragTime(snappedTime);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!dragState.clipId || !dragState.trackId) return;
|
||||
|
||||
const finalTime = dragState.currentTime;
|
||||
|
||||
// Check for overlaps and update position
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const movingClip = sourceTrack?.clips.find(
|
||||
(c) => c.id === dragState.clipId
|
||||
);
|
||||
|
||||
if (movingClip) {
|
||||
const movingClipDuration =
|
||||
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
|
||||
const movingClipEnd = finalTime + movingClipDuration;
|
||||
|
||||
const targetTrack = tracks.find((t) => t.id === track.id);
|
||||
const hasOverlap = targetTrack?.clips.some((existingClip) => {
|
||||
if (
|
||||
dragState.trackId === track.id &&
|
||||
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 === track.id) {
|
||||
updateClipStartTime(track.id, dragState.clipId, finalTime);
|
||||
} else {
|
||||
moveClipToTrack(dragState.trackId, track.id, dragState.clipId);
|
||||
requestAnimationFrame(() => {
|
||||
updateClipStartTime(track.id, dragState.clipId!, finalTime);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endDragAction();
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.clickOffsetTime,
|
||||
dragState.clipId,
|
||||
dragState.trackId,
|
||||
dragState.currentTime,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
track.id,
|
||||
updateDragTime,
|
||||
updateClipStartTime,
|
||||
moveClipToTrack,
|
||||
endDragAction,
|
||||
]);
|
||||
|
||||
const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => {
|
||||
setMouseDownLocation({ x: e.clientX, y: e.clientY });
|
||||
// Handle multi-selection only in mousedown
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
selectClip(track.id, clip.id, true);
|
||||
}
|
||||
|
||||
// Calculate the offset from the left edge of the clip to where the user clicked
|
||||
const clipElement = e.currentTarget as HTMLElement;
|
||||
const clipRect = clipElement.getBoundingClientRect();
|
||||
const clickOffsetX = e.clientX - clipRect.left;
|
||||
const clickOffsetTime = clickOffsetX / (50 * zoomLevel);
|
||||
|
||||
startDragAction(
|
||||
clip.id,
|
||||
track.id,
|
||||
e.clientX,
|
||||
clip.startTime,
|
||||
clickOffsetTime
|
||||
);
|
||||
};
|
||||
|
||||
const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if mouse moved significantly
|
||||
if (mouseDownLocation) {
|
||||
const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
|
||||
const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
|
||||
// If it moved more than a few pixels, consider it a drag and not a click.
|
||||
if (deltaX > 5 || deltaY > 5) {
|
||||
setMouseDownLocation(null); // Reset for next interaction
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip selection logic for multi-selection (handled in mousedown)
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle single selection/deselection
|
||||
const isSelected = selectedClips.some(
|
||||
(c) => c.trackId === track.id && c.clipId === clip.id
|
||||
);
|
||||
|
||||
if (isSelected) {
|
||||
// If clip is selected, deselect it
|
||||
deselectClip(track.id, clip.id);
|
||||
} else {
|
||||
// If clip is not selected, select it (replacing other selections)
|
||||
selectClip(track.id, clip.id, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrackDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Handle both timeline clips and media items
|
||||
const hasTimelineClip = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
|
||||
if (!hasTimelineClip && !hasMediaItem) return;
|
||||
|
||||
if (hasMediaItem) {
|
||||
try {
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item"
|
||||
);
|
||||
if (mediaItemData) {
|
||||
const { type } = JSON.parse(mediaItemData);
|
||||
const isCompatible =
|
||||
(track.type === "video" &&
|
||||
(type === "video" || type === "image")) ||
|
||||
(track.type === "audio" && type === "audio");
|
||||
|
||||
if (!isCompatible) {
|
||||
e.dataTransfer.dropEffect = "none";
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing dropped media item:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate drop position for overlap checking
|
||||
const trackContainer = e.currentTarget.querySelector(
|
||||
".track-clips-container"
|
||||
) as HTMLElement;
|
||||
let dropTime = 0;
|
||||
if (trackContainer) {
|
||||
const rect = trackContainer.getBoundingClientRect();
|
||||
const mouseX = Math.max(0, e.clientX - rect.left);
|
||||
dropTime = mouseX / (50 * zoomLevel);
|
||||
}
|
||||
|
||||
// Check for potential overlaps and show appropriate feedback
|
||||
let wouldOverlap = false;
|
||||
|
||||
if (hasMediaItem) {
|
||||
try {
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item"
|
||||
);
|
||||
if (mediaItemData) {
|
||||
const { id } = JSON.parse(mediaItemData);
|
||||
const mediaItem = mediaItems.find((item) => item.id === id);
|
||||
if (mediaItem) {
|
||||
const newClipDuration = mediaItem.duration || 5;
|
||||
const snappedTime = Math.round(dropTime * 10) / 10;
|
||||
const newClipEnd = snappedTime + newClipDuration;
|
||||
|
||||
wouldOverlap = track.clips.some((existingClip) => {
|
||||
const existingStart = existingClip.startTime;
|
||||
const existingEnd =
|
||||
existingClip.startTime +
|
||||
(existingClip.duration -
|
||||
existingClip.trimStart -
|
||||
existingClip.trimEnd);
|
||||
return snappedTime < existingEnd && newClipEnd > existingStart;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue with default behavior
|
||||
}
|
||||
} else if (hasTimelineClip) {
|
||||
try {
|
||||
const timelineClipData = e.dataTransfer.getData(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
if (timelineClipData) {
|
||||
const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData);
|
||||
const sourceTrack = tracks.find(
|
||||
(t: TimelineTrack) => t.id === fromTrackId
|
||||
);
|
||||
const movingClip = sourceTrack?.clips.find(
|
||||
(c: any) => c.id === clipId
|
||||
);
|
||||
|
||||
if (movingClip) {
|
||||
const movingClipDuration =
|
||||
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
|
||||
const snappedTime = Math.round(dropTime * 10) / 10;
|
||||
const movingClipEnd = snappedTime + movingClipDuration;
|
||||
|
||||
wouldOverlap = track.clips.some((existingClip) => {
|
||||
if (fromTrackId === track.id && existingClip.id === clipId)
|
||||
return false;
|
||||
|
||||
const existingStart = existingClip.startTime;
|
||||
const existingEnd =
|
||||
existingClip.startTime +
|
||||
(existingClip.duration -
|
||||
existingClip.trimStart -
|
||||
existingClip.trimEnd);
|
||||
return snappedTime < existingEnd && movingClipEnd > existingStart;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue with default behavior
|
||||
}
|
||||
}
|
||||
|
||||
if (wouldOverlap) {
|
||||
e.dataTransfer.dropEffect = "none";
|
||||
setWouldOverlap(true);
|
||||
setDropPosition(Math.round(dropTime * 10) / 10);
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
|
||||
setWouldOverlap(false);
|
||||
setDropPosition(Math.round(dropTime * 10) / 10);
|
||||
};
|
||||
|
||||
const handleTrackDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const hasTimelineClip = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
|
||||
if (!hasTimelineClip && !hasMediaItem) return;
|
||||
|
||||
dragCounterRef.current++;
|
||||
setIsDropping(true);
|
||||
};
|
||||
|
||||
const handleTrackDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const hasTimelineClip = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
|
||||
if (!hasTimelineClip && !hasMediaItem) return;
|
||||
|
||||
dragCounterRef.current--;
|
||||
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDropping(false);
|
||||
setWouldOverlap(false);
|
||||
setDropPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrackDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Reset all drag states
|
||||
dragCounterRef.current = 0;
|
||||
setIsDropping(false);
|
||||
setWouldOverlap(false);
|
||||
const currentDropPosition = dropPosition;
|
||||
setDropPosition(null);
|
||||
|
||||
const hasTimelineClip = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
|
||||
if (!hasTimelineClip && !hasMediaItem) return;
|
||||
|
||||
const trackContainer = e.currentTarget.querySelector(
|
||||
".track-clips-container"
|
||||
) as HTMLElement;
|
||||
if (!trackContainer) return;
|
||||
|
||||
const rect = trackContainer.getBoundingClientRect();
|
||||
const mouseX = Math.max(0, e.clientX - rect.left);
|
||||
const newStartTime = mouseX / (50 * zoomLevel);
|
||||
const snappedTime = Math.round(newStartTime * 10) / 10;
|
||||
|
||||
try {
|
||||
if (hasTimelineClip) {
|
||||
// Handle timeline clip movement
|
||||
const timelineClipData = e.dataTransfer.getData(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
if (!timelineClipData) return;
|
||||
|
||||
const {
|
||||
clipId,
|
||||
trackId: fromTrackId,
|
||||
clickOffsetTime = 0,
|
||||
} = JSON.parse(timelineClipData);
|
||||
|
||||
// Find the clip being moved
|
||||
const sourceTrack = tracks.find(
|
||||
(t: TimelineTrack) => t.id === fromTrackId
|
||||
);
|
||||
const movingClip = sourceTrack?.clips.find(
|
||||
(c: TypeTimelineClip) => c.id === clipId
|
||||
);
|
||||
|
||||
if (!movingClip) {
|
||||
toast.error("Clip not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Adjust position based on where user clicked on the clip
|
||||
const adjustedStartTime = snappedTime - clickOffsetTime;
|
||||
const finalStartTime = Math.max(
|
||||
0,
|
||||
Math.round(adjustedStartTime * 10) / 10
|
||||
);
|
||||
|
||||
// Check for overlaps with existing clips (excluding the moving clip itself)
|
||||
const movingClipDuration =
|
||||
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
|
||||
const movingClipEnd = finalStartTime + movingClipDuration;
|
||||
|
||||
const hasOverlap = track.clips.some((existingClip) => {
|
||||
// Skip the clip being moved if it's on the same track
|
||||
if (fromTrackId === track.id && existingClip.id === clipId)
|
||||
return false;
|
||||
|
||||
const existingStart = existingClip.startTime;
|
||||
const existingEnd =
|
||||
existingClip.startTime +
|
||||
(existingClip.duration -
|
||||
existingClip.trimStart -
|
||||
existingClip.trimEnd);
|
||||
|
||||
// Check if clips overlap
|
||||
return finalStartTime < existingEnd && movingClipEnd > existingStart;
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
toast.error(
|
||||
"Cannot move clip here - it would overlap with existing clips"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromTrackId === track.id) {
|
||||
// Moving within same track
|
||||
updateClipStartTime(track.id, clipId, finalStartTime);
|
||||
} else {
|
||||
// Moving to different track
|
||||
moveClipToTrack(fromTrackId, track.id, clipId);
|
||||
requestAnimationFrame(() => {
|
||||
updateClipStartTime(track.id, clipId, finalStartTime);
|
||||
});
|
||||
}
|
||||
} else if (hasMediaItem) {
|
||||
// Handle media item drop
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item"
|
||||
);
|
||||
if (!mediaItemData) return;
|
||||
|
||||
const { id, type } = JSON.parse(mediaItemData);
|
||||
const mediaItem = mediaItems.find((item) => item.id === id);
|
||||
|
||||
if (!mediaItem) {
|
||||
toast.error("Media item not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if track type is compatible
|
||||
const isCompatible =
|
||||
(track.type === "video" && (type === "video" || type === "image")) ||
|
||||
(track.type === "audio" && type === "audio");
|
||||
|
||||
if (!isCompatible) {
|
||||
toast.error(`Cannot add ${type} to ${track.type} track`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for overlaps with existing clips
|
||||
const newClipDuration = mediaItem.duration || 5;
|
||||
const newClipEnd = snappedTime + newClipDuration;
|
||||
|
||||
const hasOverlap = track.clips.some((existingClip) => {
|
||||
const existingStart = existingClip.startTime;
|
||||
const existingEnd =
|
||||
existingClip.startTime +
|
||||
(existingClip.duration -
|
||||
existingClip.trimStart -
|
||||
existingClip.trimEnd);
|
||||
|
||||
// Check if clips overlap
|
||||
return snappedTime < existingEnd && newClipEnd > existingStart;
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
toast.error(
|
||||
"Cannot place clip here - it would overlap with existing clips"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
addClipToTrack(track.id, {
|
||||
mediaId: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
duration: mediaItem.duration || 5,
|
||||
startTime: snappedTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
|
||||
toast.success(`Added ${mediaItem.name} to ${track.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling drop:", error);
|
||||
toast.error("Failed to add media to track");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full hover:bg-muted/20"
|
||||
onClick={(e) => {
|
||||
// If clicking empty area (not on a clip), deselect all clips
|
||||
if (!(e.target as HTMLElement).closest(".timeline-clip")) {
|
||||
const { clearSelectedClips } = useTimelineStore.getState();
|
||||
clearSelectedClips();
|
||||
}
|
||||
}}
|
||||
onDragOver={handleTrackDragOver}
|
||||
onDragEnter={handleTrackDragEnter}
|
||||
onDragLeave={handleTrackDragLeave}
|
||||
onDrop={handleTrackDrop}
|
||||
>
|
||||
<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 ${
|
||||
isDropping
|
||||
? wouldOverlap
|
||||
? "border-red-500 bg-red-500/10 text-red-600"
|
||||
: "border-blue-500 bg-blue-500/10 text-blue-600"
|
||||
: "border-muted/30"
|
||||
}`}
|
||||
>
|
||||
{isDropping
|
||||
? wouldOverlap
|
||||
? "Cannot drop - would overlap"
|
||||
: "Drop clip here"
|
||||
: "Drop media here"}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{track.clips.map((clip) => {
|
||||
const isSelected = selectedClips.some(
|
||||
(c) => c.trackId === track.id && c.clipId === clip.id
|
||||
);
|
||||
|
||||
const handleClipSplit = () => {
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const { updateClipTrim, addClipToTrack } = useTimelineStore();
|
||||
const splitTime = currentTime;
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime +
|
||||
(clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
|
||||
updateClipTrim(
|
||||
track.id,
|
||||
clip.id,
|
||||
clip.trimStart,
|
||||
clip.trimEnd + (effectiveEnd - splitTime)
|
||||
);
|
||||
addClipToTrack(track.id, {
|
||||
mediaId: clip.mediaId,
|
||||
name: clip.name + " (split)",
|
||||
duration: clip.duration,
|
||||
startTime: splitTime,
|
||||
trimStart: clip.trimStart + (splitTime - effectiveStart),
|
||||
trimEnd: clip.trimEnd,
|
||||
});
|
||||
toast.success("Clip split successfully");
|
||||
} else {
|
||||
toast.error("Playhead must be within clip to split");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClipDuplicate = () => {
|
||||
const { addClipToTrack } = useTimelineStore.getState();
|
||||
addClipToTrack(track.id, {
|
||||
mediaId: clip.mediaId,
|
||||
name: clip.name + " (copy)",
|
||||
duration: clip.duration,
|
||||
startTime:
|
||||
clip.startTime +
|
||||
(clip.duration - clip.trimStart - clip.trimEnd) +
|
||||
0.1,
|
||||
trimStart: clip.trimStart,
|
||||
trimEnd: clip.trimEnd,
|
||||
});
|
||||
toast.success("Clip duplicated");
|
||||
};
|
||||
|
||||
const handleClipDelete = () => {
|
||||
const { removeClipFromTrack } = useTimelineStore.getState();
|
||||
removeClipFromTrack(track.id, clip.id);
|
||||
toast.success("Clip deleted");
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu key={clip.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div>
|
||||
<TimelineClip
|
||||
clip={clip}
|
||||
track={track}
|
||||
zoomLevel={zoomLevel}
|
||||
isSelected={isSelected}
|
||||
onClipMouseDown={handleClipMouseDown}
|
||||
onClipClick={handleClipClick}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={handleClipSplit}>
|
||||
<Scissors className="h-4 w-4 mr-2" />
|
||||
Split at Playhead
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleClipDuplicate}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Duplicate Clip
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={handleClipDelete}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Clip
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user