diff options
| author | DevloperAmanSingh <amansinghneh211205@gmail.com> | 2025-06-27 07:56:18 +0530 |
|---|---|---|
| committer | DevloperAmanSingh <amansinghneh211205@gmail.com> | 2025-06-27 07:56:18 +0530 |
| commit | c32daa4f2ec967902f2ce12d291f8034ca8bce38 (patch) | |
| tree | 3be8f01e5095bd0351b09f6ae6e3959f2cf8ce03 /apps/web/src | |
| parent | 0bdbd7e2b3feb698ba35b03ec834dd924d4e21d3 (diff) | |
| download | opencut-c32daa4f2ec967902f2ce12d291f8034ca8bce38.tar.gz | |
refactor: enhance timeline component by improving drag-and-drop functionality and clip selection handling
Diffstat (limited to 'apps/web/src')
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 696 |
1 files changed, 269 insertions, 427 deletions
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index b20006dd..f4c2473c 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1,27 +1,37 @@ "use client";
-import { processMediaFiles } from "@/lib/media-processing";
-import { useMediaStore } from "@/stores/media-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store";
+import { ScrollArea } from "../ui/scroll-area";
+import { Button } from "../ui/button";
import {
+ Scissors,
ArrowLeftToLine,
ArrowRightToLine,
- Copy,
- MoreVertical,
- Pause,
- Play,
- Scissors,
+ Trash2,
Snowflake,
+ Copy,
SplitSquareHorizontal,
- Trash2,
Volume2,
VolumeX,
+ Pause,
+ Play,
} from "lucide-react";
-import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+ TooltipProvider,
+} from "../ui/tooltip";
+import {
+ useTimelineStore,
+ type TimelineTrack,
+ type TimelineClip as TypeTimelineClip,
+} 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 { Button } from "../ui/button";
-import { ScrollArea } from "../ui/scroll-area";
+import { useState, useRef, useEffect, useCallback } from "react";
import {
Select,
SelectContent,
@@ -29,15 +39,8 @@ import { SelectTrigger,
SelectValue,
} from "../ui/select";
-
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "../ui/tooltip";
-
-import AudioWaveform from "./audio-waveform";
+import { TimelineClip } from "./timeline-clip";
+import { ContextMenuState } from "@/types/timeline";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips.
@@ -55,12 +58,12 @@ export function Timeline() { clearSelectedClips,
setSelectedClips,
updateClipTrim,
- undo,
- redo,
splitClip,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
+ undo,
+ redo,
} = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore();
const {
@@ -75,19 +78,14 @@ export function Timeline() { } = usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
const [zoomLevel, setZoomLevel] = useState(1);
const dragCounterRef = useRef(0);
const timelineRef = useRef<HTMLDivElement>(null);
const [isInTimeline, setIsInTimeline] = useState(false);
// Unified context menu state
- const [contextMenu, setContextMenu] = useState<{
- type: "track" | "clip";
- trackId: string;
- clipId?: string;
- x: number;
- y: number;
- } | null>(null);
+ const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
// Marquee selection state
const [marquee, setMarquee] = useState<{
@@ -340,8 +338,12 @@ export function Timeline() { } else if (e.dataTransfer.files?.length > 0) {
// Handle file drops by creating new tracks
setIsProcessing(true);
+ setProgress(0);
try {
- const processedItems = await processMediaFiles(e.dataTransfer.files);
+ const processedItems = await processMediaFiles(
+ e.dataTransfer.files,
+ (p) => setProgress(p)
+ );
for (const processedItem of processedItems) {
addMediaItem(processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems;
@@ -369,6 +371,7 @@ export function Timeline() { toast.error("Failed to process dropped files");
} finally {
setIsProcessing(false);
+ setProgress(0);
}
}
};
@@ -456,7 +459,6 @@ export function Timeline() { toast.error("No clips selected");
return;
}
-
let splitCount = 0;
selectedClips.forEach(({ trackId, clipId }) => {
const track = tracks.find((t) => t.id === trackId);
@@ -472,7 +474,6 @@ export function Timeline() { }
}
});
-
if (splitCount > 0) {
toast.success(`Split ${splitCount} clip(s) at playhead`);
} else {
@@ -527,29 +528,27 @@ export function Timeline() { });
toast.success("Freeze frame added for selected clip(s)");
};
-
const handleSplitAndKeepLeft = () => {
if (selectedClips.length === 0) {
toast.error("No clips selected");
return;
}
-
+
let splitCount = 0;
selectedClips.forEach(({ trackId, clipId }) => {
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
if (clip && track) {
const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
-
+ const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
splitAndKeepLeft(trackId, clipId, currentTime);
splitCount++;
}
}
});
-
+
if (splitCount > 0) {
toast.success(`Split and kept left portion of ${splitCount} clip(s)`);
} else {
@@ -562,23 +561,22 @@ export function Timeline() { toast.error("No clips selected");
return;
}
-
+
let splitCount = 0;
selectedClips.forEach(({ trackId, clipId }) => {
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
if (clip && track) {
const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
-
+ const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
splitAndKeepRight(trackId, clipId, currentTime);
splitCount++;
}
}
});
-
+
if (splitCount > 0) {
toast.success(`Split and kept right portion of ${splitCount} clip(s)`);
} else {
@@ -591,31 +589,25 @@ export function Timeline() { toast.error("No clips selected");
return;
}
-
+
let separatedCount = 0;
selectedClips.forEach(({ trackId, clipId }) => {
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
const mediaItem = mediaItems.find((item) => item.id === clip?.mediaId);
-
- if (
- clip &&
- track &&
- mediaItem?.type === "video" &&
- track.type === "video"
- ) {
+
+ if (clip && track && mediaItem?.type === "video" && track.type === "video") {
const audioClipId = separateAudio(trackId, clipId);
if (audioClipId) separatedCount++;
}
});
-
+
if (separatedCount > 0) {
toast.success(`Separated audio from ${separatedCount} video clip(s)`);
} else {
toast.error("Select video clips to separate audio");
}
};
-
const handleDeleteSelected = () => {
if (selectedClips.length === 0) {
toast.error("No clips selected");
@@ -722,7 +714,6 @@ export function Timeline() { <div className="w-px h-6 bg-border mx-1" />
- {/* Clip editing operations */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleSplitSelected}>
@@ -734,11 +725,7 @@ export function Timeline() { <Tooltip>
<TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepLeft}
- >
+ <Button variant="text" size="icon" onClick={handleSplitAndKeepLeft}>
<ArrowLeftToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
@@ -747,11 +734,7 @@ export function Timeline() { <Tooltip>
<TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepRight}
- >
+ <Button variant="text" size="icon" onClick={handleSplitAndKeepRight}>
<ArrowRightToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
@@ -1021,6 +1004,16 @@ export function Timeline() { y: e.clientY,
});
}}
+ 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();
+ }
+ }}
>
<TimelineTrackContent
track={track}
@@ -1045,14 +1038,12 @@ export function Timeline() { </>
)}
{isDragOver && (
- <div
- className="absolute left-0 right-0 border-2 border-dashed border-accent flex items-center justify-center text-muted-foreground"
- style={{
- top: `${tracks.length * 60}px`,
- height: "60px",
- }}
- >
- <div>Drop media here to add a new track</div>
+ <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg">
+ <div>
+ {isProcessing
+ ? `Processing ${progress}%`
+ : "Drop media here to add to timeline"}
+ </div>
</div>
)}
</div>
@@ -1081,22 +1072,17 @@ export function Timeline() { setContextMenu(null);
}}
>
- {(() => {
- const track = tracks.find(
- (t) => t.id === contextMenu.trackId
- );
- return track?.muted ? (
- <>
- <Volume2 className="h-4 w-4 mr-2" />
- Unmute Track
- </>
- ) : (
- <>
- <VolumeX className="h-4 w-4 mr-2" />
- Mute Track
- </>
- );
- })()}
+ {contextMenu.trackId ? (
+ <div className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left">
+ <Volume2 className="h-4 w-4 mr-2" />
+ Unmute Track
+ </div>
+ ) : (
+ <div className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left">
+ <VolumeX className="h-4 w-4 mr-2" />
+ Mute Track
+ </div>
+ )}
</button>
<div className="h-px bg-border mx-1 my-1" />
<button
@@ -1226,134 +1212,191 @@ function TimelineTrackContent({ }: {
track: TimelineTrack;
zoomLevel: number;
- setContextMenu: (
- menu: {
- type: "track" | "clip";
- trackId: string;
- clipId?: string;
- x: number;
- y: number;
- } | null
- ) => void;
- contextMenu: {
- type: "track" | "clip";
- trackId: string;
- clipId?: string;
- x: number;
- y: number;
- } | null;
+ setContextMenu: (menu: ContextMenuState | null) => void;
+ contextMenu: ContextMenuState | null;
}) {
const { mediaItems } = useMediaStore();
const {
tracks,
moveClipToTrack,
- updateClipTrim,
updateClipStartTime,
addClipToTrack,
- removeClipFromTrack,
- toggleTrackMute,
selectedClips,
selectClip,
deselectClip,
+ dragState,
+ startDrag: startDragAction,
+ updateDragTime,
+ endDrag: endDragAction,
} = useTimelineStore();
- const { currentTime } = usePlaybackStore();
+
+ const timelineRef = useRef<HTMLDivElement>(null);
const [isDropping, setIsDropping] = useState(false);
const [dropPosition, setDropPosition] = useState<number | null>(null);
- const [isDraggedOver, setIsDraggedOver] = useState(false);
const [wouldOverlap, setWouldOverlap] = useState(false);
- const [resizing, setResizing] = useState<{
- clipId: string;
- side: "left" | "right";
- startX: number;
- initialTrimStart: number;
- initialTrimEnd: number;
- } | null>(null);
const dragCounterRef = useRef(0);
- const [clipMenuOpen, setClipMenuOpen] = useState<string | null>(null);
-
- // Handle clip deletion
- const handleDeleteClip = (clipId: string) => {
- removeClipFromTrack(track.id, clipId);
- };
+ const [mouseDownLocation, setMouseDownLocation] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
- const handleResizeStart = (
- e: React.MouseEvent,
- clipId: string,
- side: "left" | "right"
- ) => {
- e.stopPropagation();
- e.preventDefault();
+ // Set up mouse event listeners for drag
+ useEffect(() => {
+ if (!dragState.isDragging) return;
- const clip = track.clips.find((c) => c.id === clipId);
- if (!clip) return;
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!timelineRef.current) return;
- setResizing({
- clipId,
- side,
- startX: e.clientX,
- initialTrimStart: clip.trimStart,
- initialTrimEnd: clip.trimEnd,
- });
- };
+ 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;
- const updateTrimFromMouseMove = (e: { clientX: number }) => {
- if (!resizing) return;
+ updateDragTime(snappedTime);
+ };
- const clip = track.clips.find((c) => c.id === resizing.clipId);
- if (!clip) return;
+ const handleMouseUp = () => {
+ if (!dragState.clipId || !dragState.trackId) return;
- const deltaX = e.clientX - resizing.startX;
- const deltaTime = deltaX / (50 * zoomLevel);
+ const finalTime = dragState.currentTime;
- if (resizing.side === "left") {
- const newTrimStart = Math.max(
- 0,
- Math.min(
- clip.duration - clip.trimEnd - 0.1,
- resizing.initialTrimStart + deltaTime
- )
- );
- updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd);
- } else {
- const newTrimEnd = Math.max(
- 0,
- Math.min(
- clip.duration - clip.trimStart - 0.1,
- resizing.initialTrimEnd - deltaTime
- )
+ // 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
);
- updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd);
+
+ 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);
}
- };
- const handleResizeMove = (e: React.MouseEvent) => {
- updateTrimFromMouseMove(e);
- };
+ // 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);
- const handleResizeEnd = () => {
- setResizing(null);
+ startDragAction(
+ clip.id,
+ track.id,
+ e.clientX,
+ clip.startTime,
+ clickOffsetTime
+ );
};
- const handleClipDragStart = (e: React.DragEvent, clip: any) => {
- const dragData = { clipId: clip.id, trackId: track.id, name: clip.name };
+ 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;
+ }
+ }
+
+ // Close context menu if it's open
+ if (contextMenu) {
+ setContextMenu(null);
+ return; // Don't handle selection when closing context menu
+ }
+
+ // Skip selection logic for multi-selection (handled in mousedown)
+ if (e.metaKey || e.ctrlKey || e.shiftKey) {
+ return;
+ }
- e.dataTransfer.setData(
- "application/x-timeline-clip",
- JSON.stringify(dragData)
+ // Handle single selection/deselection
+ const isSelected = selectedClips.some(
+ (c) => c.trackId === track.id && c.clipId === clip.id
);
- e.dataTransfer.effectAllowed = "move";
- // Add visual feedback to the dragged element
- const target = e.currentTarget.parentElement as HTMLElement;
- target.style.opacity = "0.5";
- target.style.transform = "scale(0.95)";
+ 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 handleClipDragEnd = (e: React.DragEvent) => {
- // Reset visual feedback
- const target = e.currentTarget.parentElement as HTMLElement;
- target.style.opacity = "";
- target.style.transform = "";
+ const handleClipContextMenu = (e: React.MouseEvent, clipId: string) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setContextMenu({
+ type: "clip",
+ trackId: track.id,
+ clipId: clipId,
+ x: e.clientX,
+ y: e.clientY,
+ });
};
const handleTrackDragOver = (e: React.DragEvent) => {
@@ -1473,14 +1516,12 @@ function TimelineTrackContent({ if (wouldOverlap) {
e.dataTransfer.dropEffect = "none";
- setIsDraggedOver(true);
setWouldOverlap(true);
setDropPosition(Math.round(dropTime * 10) / 10);
return;
}
e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
- setIsDraggedOver(true);
setWouldOverlap(false);
setDropPosition(Math.round(dropTime * 10) / 10);
};
@@ -1499,7 +1540,6 @@ function TimelineTrackContent({ dragCounterRef.current++;
setIsDropping(true);
- setIsDraggedOver(true);
};
const handleTrackDragLeave = (e: React.DragEvent) => {
@@ -1518,7 +1558,6 @@ function TimelineTrackContent({ if (dragCounterRef.current === 0) {
setIsDropping(false);
- setIsDraggedOver(false);
setWouldOverlap(false);
setDropPosition(null);
}
@@ -1531,7 +1570,6 @@ function TimelineTrackContent({ // Reset all drag states
dragCounterRef.current = 0;
setIsDropping(false);
- setIsDraggedOver(false);
setWouldOverlap(false);
const currentDropPosition = dropPosition;
setDropPosition(null);
@@ -1563,23 +1601,36 @@ function TimelineTrackContent({ );
if (!timelineClipData) return;
- const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData);
+ 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: any) => c.id === clipId);
+ 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 = snappedTime + movingClipDuration;
+ const movingClipEnd = finalStartTime + movingClipDuration;
const hasOverlap = track.clips.some((existingClip) => {
// Skip the clip being moved if it's on the same track
@@ -1594,7 +1645,7 @@ function TimelineTrackContent({ existingClip.trimEnd);
// Check if clips overlap
- return snappedTime < existingEnd && movingClipEnd > existingStart;
+ return finalStartTime < existingEnd && movingClipEnd > existingStart;
});
if (hasOverlap) {
@@ -1606,12 +1657,12 @@ function TimelineTrackContent({ if (fromTrackId === track.id) {
// Moving within same track
- updateClipStartTime(track.id, clipId, snappedTime);
+ updateClipStartTime(track.id, clipId, finalStartTime);
} else {
// Moving to different track
moveClipToTrack(fromTrackId, track.id, clipId);
requestAnimationFrame(() => {
- updateClipStartTime(track.id, clipId, snappedTime);
+ updateClipStartTime(track.id, clipId, finalStartTime);
});
}
} else if (hasMediaItem) {
@@ -1679,114 +1730,9 @@ function TimelineTrackContent({ }
};
- const getTrackColor = (type: string) => {
- switch (type) {
- case "video":
- return "bg-blue-500/20 border-blue-500/30";
- case "audio":
- return "bg-green-500/20 border-green-500/30";
- case "effects":
- return "bg-purple-500/20 border-purple-500/30";
- default:
- return "bg-gray-500/20 border-gray-500/30";
- }
- };
-
- const renderClipContent = (clip: any) => {
- const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
-
- if (!mediaItem) {
- return (
- <span className="text-xs text-foreground/80 truncate">{clip.name}</span>
- );
- }
-
- if (mediaItem.type === "image") {
- return (
- <div className="w-full h-full flex items-center justify-center">
- <img
- src={mediaItem.url}
- alt={mediaItem.name}
- className="w-full h-full object-cover"
- />
- </div>
- );
- }
-
- if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
- return (
- <div className="w-full h-full flex items-center gap-2">
- <div className="w-8 h-8 flex-shrink-0">
- <img
- src={mediaItem.thumbnailUrl}
- alt={mediaItem.name}
- className="w-full h-full object-cover rounded-sm"
- />
- </div>
- <span className="text-xs text-foreground/80 truncate flex-1">
- {clip.name}
- </span>
- </div>
- );
- }
-
- if (mediaItem.type === "audio") {
- return (
- <div className="w-full h-full flex items-center gap-2">
- <div className="flex-1 min-w-0">
- <AudioWaveform
- audioUrl={mediaItem.url}
- height={24}
- className="w-full"
- />
- </div>
- </div>
- );
- }
-
- // Fallback for videos without thumbnails
- return (
- <span className="text-xs text-foreground/80 truncate">{clip.name}</span>
- );
- };
-
- const handleSplitClip = (clip: any) => {
- // Use current playback time as split point
- const splitTime = currentTime;
- // Only split if splitTime is within the clip's effective range
- const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
- const firstDuration = splitTime - effectiveStart;
- const secondDuration = effectiveEnd - splitTime;
- // First part: adjust original clip
- updateClipTrim(
- track.id,
- clip.id,
- clip.trimStart,
- clip.trimEnd + secondDuration
- );
- // Second part: add new clip after split
- addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (cut)",
- duration: clip.duration,
- startTime: splitTime,
- trimStart: clip.trimStart + firstDuration,
- trimEnd: clip.trimEnd,
- });
- };
-
return (
<div
- className={`w-full h-full transition-all duration-150 ease-out ${
- isDraggedOver
- ? wouldOverlap
- ? "bg-red-500/15 border-2 border-dashed border-red-400 shadow-lg"
- : "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg"
- : "hover:bg-muted/20"
- }`}
+ className="w-full h-full hover:bg-muted/20"
onContextMenu={(e) => {
e.preventDefault();
// Only show track menu if we didn't click on a clip
@@ -1799,15 +1745,22 @@ function TimelineTrackContent({ });
}
}}
+ 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}
- onMouseMove={handleResizeMove}
- onMouseUp={handleResizeEnd}
- onMouseLeave={handleResizeEnd}
>
- <div className="h-full relative track-clips-container min-w-full">
+ <div
+ ref={timelineRef}
+ className="h-full relative track-clips-container min-w-full"
+ >
{track.clips.length === 0 ? (
<div
className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
@@ -1827,134 +1780,23 @@ function TimelineTrackContent({ ) : (
<>
{track.clips.map((clip) => {
- const effectiveDuration =
- clip.duration - clip.trimStart - clip.trimEnd;
- const clipWidth = Math.max(
- 80,
- effectiveDuration * 50 * zoomLevel
- );
- const clipLeft = clip.startTime * 50 * zoomLevel;
const isSelected = selectedClips.some(
(c) => c.trackId === track.id && c.clipId === clip.id
);
+
return (
- <div
+ <TimelineClip
key={clip.id}
- className={`timeline-clip absolute h-full border transition-all-200 ${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" : ""}`}
- style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
- onClick={(e) => {
- e.stopPropagation();
-
- // Close context menu if it's open
- if (contextMenu) {
- setContextMenu(null);
- return; // Don't handle selection when closing context menu
- }
-
- const isSelected = selectedClips.some(
- (c) => c.trackId === track.id && c.clipId === clip.id
- );
-
- if (e.metaKey || e.ctrlKey || e.shiftKey) {
- // Multi-selection mode: toggle the clip
- selectClip(track.id, clip.id, true);
- } else if (isSelected) {
- // If clip is already 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);
- }
- }}
- tabIndex={0}
- onContextMenu={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setContextMenu({
- type: "clip",
- trackId: track.id,
- clipId: clip.id,
- x: e.clientX,
- y: e.clientY,
- });
- }}
- >
- {/* Left trim handle */}
- <div
- className="absolute left-0 top-0 bottom-0 w-2 cursor-w-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
- onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
- />
- {/* Clip content */}
- <div
- className="flex-1 cursor-grab active:cursor-grabbing relative"
- draggable={true}
- onDragStart={(e) => handleClipDragStart(e, clip)}
- onDragEnd={handleClipDragEnd}
- >
- {renderClipContent(clip)}
- {/* Clip options menu */}
- <div className="absolute top-1 right-1 z-10">
- <Button
- variant="text"
- size="icon"
- className="opacity-0 group-hover:opacity-100 transition-opacity"
- onClick={() => setClipMenuOpen(clip.id)}
- >
- <MoreVertical className="h-4 w-4" />
- </Button>
- {clipMenuOpen === clip.id && (
- <div className="absolute right-0 mt-2 w-32 bg-white text-black border rounded shadow z-50">
- <button
- className="flex items-center w-full px-3 py-2 text-sm hover:bg-muted/30"
- onClick={() => {
- handleSplitClip(clip);
- setClipMenuOpen(null);
- }}
- >
- <Scissors className="h-4 w-4 mr-2" /> Split
- </button>
- <button
- className="flex items-center w-full px-3 py-2 text-sm text-red-600 hover:bg-red-50"
- onClick={() => handleDeleteClip(clip.id)}
- >
- <Trash2 className="h-4 w-4 mr-2" /> Delete
- </button>
- </div>
- )}
- </div>
- </div>
- {/* Right trim handle */}
- <div
- className="absolute right-0 top-0 bottom-0 w-2 cursor-e-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
- onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
- />
- </div>
+ clip={clip}
+ track={track}
+ zoomLevel={zoomLevel}
+ isSelected={isSelected}
+ onContextMenu={handleClipContextMenu}
+ onClipMouseDown={handleClipMouseDown}
+ onClipClick={handleClipClick}
+ />
);
})}
-
- {/* Drop position indicator */}
- {isDraggedOver && dropPosition !== null && (
- <div
- className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
- style={{
- left: `${dropPosition * 50 * zoomLevel}px`,
- transform: "translateX(-50%)",
- }}
- >
- <div
- className={`absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
- />
- <div
- className={`absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
- />
- <div
- className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white px-1 py-0.5 rounded whitespace-nowrap ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
- >
- {wouldOverlap ? "⚠️" : ""}
- {dropPosition.toFixed(1)}s
- </div>
- </div>
- )}
</>
)}
</div>
|
