diff --git a/apps/web/src/components/editor/timeline-clip.tsx b/apps/web/src/components/editor/timeline-clip.tsx new file mode 100644 index 0000000..1bb2b77 --- /dev/null +++ b/apps/web/src/components/editor/timeline-clip.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "../ui/button"; +import { MoreVertical, Scissors, Trash2 } from "lucide-react"; +import { useMediaStore } from "@/stores/media-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { useDragClip } from "@/hooks/use-drag-clip"; +import AudioWaveform from "./audio-waveform"; +import { toast } from "sonner"; +import { TimelineClipProps, ResizeState } from "@/types/timeline"; + +export function TimelineClip({ + clip, + track, + zoomLevel, + isSelected, + onContextMenu, + onClipMouseDown, + onClipClick, +}: TimelineClipProps) { + const { mediaItems } = useMediaStore(); + const { updateClipTrim, addClipToTrack, removeClipFromTrack } = + useTimelineStore(); + const { currentTime } = usePlaybackStore(); + const { draggedClipId, getDraggedClipPosition } = + useDragClip(zoomLevel); + + const [resizing, setResizing] = useState(null); + const [clipMenuOpen, setClipMenuOpen] = useState(false); + + 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 dragPosition = getDraggedClipPosition(clip.id); + const clipStartTime = dragPosition !== null ? dragPosition : clip.startTime; + const clipLeft = clipStartTime * 50 * zoomLevel; + + const isBeingDragged = draggedClipId === clip.id; + + 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 handleResizeStart = ( + e: React.MouseEvent, + clipId: string, + side: "left" | "right" + ) => { + e.stopPropagation(); + e.preventDefault(); + + setResizing({ + clipId, + side, + startX: e.clientX, + initialTrimStart: clip.trimStart, + initialTrimEnd: clip.trimEnd, + }); + }; + + const updateTrimFromMouseMove = (e: { clientX: number }) => { + if (!resizing) return; + + const deltaX = e.clientX - resizing.startX; + const deltaTime = deltaX / (50 * zoomLevel); + + 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 + ) + ); + updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd); + } + }; + + const handleResizeMove = (e: React.MouseEvent) => { + updateTrimFromMouseMove(e); + }; + + const handleResizeEnd = () => { + setResizing(null); + }; + + const handleDeleteClip = () => { + removeClipFromTrack(track.id, clip.id); + setClipMenuOpen(false); + }; + + const handleSplitClip = () => { + // 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) { + toast.error("Playhead must be within clip to split"); + 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, + }); + + setClipMenuOpen(false); + toast.success("Clip split successfully"); + }; + + const renderClipContent = () => { + const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + + if (!mediaItem) { + return ( + {clip.name} + ); + } + + if (mediaItem.type === "image") { + return ( +
+ {mediaItem.name} +
+ ); + } + + if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { + return ( +
+
+ {mediaItem.name} +
+ + {clip.name} + +
+ ); + } + + if (mediaItem.type === "audio") { + return ( +
+
+ +
+
+ ); + } + + // Fallback for videos without thumbnails + return ( + {clip.name} + ); + }; + + return ( +
onClipMouseDown(e, clip)} + onClick={(e) => onClipClick(e, clip)} + onMouseMove={handleResizeMove} + onMouseUp={handleResizeEnd} + onMouseLeave={handleResizeEnd} + tabIndex={0} + onContextMenu={(e) => onContextMenu(e, clip.id)} + > + {/* Left trim handle */} +
handleResizeStart(e, clip.id, "left")} + /> + + {/* Clip content */} +
+ {renderClipContent()} + + {/* Clip options menu */} +
+ + + {clipMenuOpen && ( +
e.stopPropagation()} + > + + +
+ )} +
+
+ + {/* Right trim handle */} +
handleResizeStart(e, clip.id, "right")} + /> +
+ ); +} diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index de2740a..ef45eb5 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -10,7 +10,6 @@ import { Snowflake, Copy, SplitSquareHorizontal, - MoreVertical, Volume2, VolumeX, Pause, @@ -25,7 +24,7 @@ import { import { useTimelineStore, type TimelineTrack, - type TimelineClip, + type TimelineClip as TypeTimelineClip, } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; @@ -40,7 +39,8 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; -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. @@ -81,13 +81,7 @@ export function Timeline() { 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(null); // Marquee selection state const [marquee, setMarquee] = useState<{ @@ -1137,127 +1131,31 @@ 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, } = useTimelineStore(); - const { currentTime } = usePlaybackStore(); // Mouse-based drag hook - const { - isDragging, - draggedClipId, - startDrag, - endDrag, - getDraggedClipPosition, - isValidDropTarget, - timelineRef, - } = useDragClip(zoomLevel); + const { isDragging, startDrag, endDrag, timelineRef } = + useDragClip(zoomLevel); const [isDropping, setIsDropping] = useState(false); const [dropPosition, setDropPosition] = useState(null); 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(null); - - // Handle clip deletion - const handleDeleteClip = (clipId: string) => { - removeClipFromTrack(track.id, clipId); - }; - - const handleResizeStart = ( - e: React.MouseEvent, - clipId: string, - side: "left" | "right" - ) => { - e.stopPropagation(); - e.preventDefault(); - - const clip = track.clips.find((c) => c.id === clipId); - if (!clip) return; - - setResizing({ - clipId, - side, - startX: e.clientX, - initialTrimStart: clip.trimStart, - initialTrimEnd: clip.trimEnd, - }); - }; - - const updateTrimFromMouseMove = (e: { clientX: number }) => { - if (!resizing) return; - - const clip = track.clips.find((c) => c.id === resizing.clipId); - if (!clip) return; - - const deltaX = e.clientX - resizing.startX; - const deltaTime = deltaX / (50 * zoomLevel); - - 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 - ) - ); - updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd); - } - }; - - const handleResizeMove = (e: React.MouseEvent) => { - updateTrimFromMouseMove(e); - }; - - const handleResizeEnd = () => { - setResizing(null); - }; const [justFinishedDrag, setJustFinishedDrag] = useState(false); - const handleClipMouseDown = (e: React.MouseEvent, clip: TimelineClip) => { + const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => { // Handle selection first if (!justFinishedDrag) { const isSelected = selectedClips.some( @@ -1283,6 +1181,43 @@ function TimelineTrackContent({ startDrag(e, clip.id, track.id, clip.startTime, clickOffsetTime); }; + const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => { + e.stopPropagation(); + + // Don't handle click if we just finished dragging + if (justFinishedDrag) { + return; + } + + // Close context menu if it's open + if (contextMenu) { + setContextMenu(null); + return; // Don't handle selection when closing context menu + } + + // Only handle deselection here (selection is handled in mouseDown) + const isSelected = selectedClips.some( + (c) => c.trackId === track.id && c.clipId === clip.id + ); + + if (isSelected && !e.metaKey && !e.ctrlKey && !e.shiftKey) { + // If clip is already selected and no modifier keys, deselect it + deselectClip(track.id, clip.id); + } + }; + + 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, + }); + }; + // Reset drag flag when drag ends useEffect(() => { if (!isDragging && justFinishedDrag) { @@ -1378,7 +1313,7 @@ function TimelineTrackContent({ (t: TimelineTrack) => t.id === fromTrackId ); const movingClip = sourceTrack?.clips.find( - (c: TimelineClip) => c.id === clipId + (c: TypeTimelineClip) => c.id === clipId ); if (movingClip) { @@ -1504,7 +1439,7 @@ function TimelineTrackContent({ (t: TimelineTrack) => t.id === fromTrackId ); const movingClip = sourceTrack?.clips.find( - (c: TimelineClip) => c.id === clipId + (c: TypeTimelineClip) => c.id === clipId ); if (!movingClip) { @@ -1622,107 +1557,6 @@ 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: TimelineClip) => { - const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); - - if (!mediaItem) { - return ( - {clip.name} - ); - } - - if (mediaItem.type === "image") { - return ( -
- {mediaItem.name} -
- ); - } - - if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { - return ( -
-
- {mediaItem.name} -
- - {clip.name} - -
- ); - } - - if (mediaItem.type === "audio") { - return ( -
-
- -
-
- ); - } - - // Fallback for videos without thumbnails - return ( - {clip.name} - ); - }; - - const handleSplitClip = (clip: TimelineClip) => { - // 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 (
{ - handleResizeEnd(); if (isDragging) { endDrag(track.id); } }} - onMouseLeave={handleResizeEnd} >
{track.clips.map((clip) => { - 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 dragPosition = getDraggedClipPosition(clip.id); - const clipStartTime = - dragPosition !== null ? dragPosition : clip.startTime; - const clipLeft = clipStartTime * 50 * zoomLevel; - const isSelected = selectedClips.some( (c) => c.trackId === track.id && c.clipId === clip.id ); - const isBeingDragged = draggedClipId === clip.id; return ( -
handleClipMouseDown(e, clip)} - onClick={(e) => { - e.stopPropagation(); - - // Don't handle click if we just finished dragging - if (justFinishedDrag) { - return; - } - - // Close context menu if it's open - if (contextMenu) { - setContextMenu(null); - return; // Don't handle selection when closing context menu - } - - // Only handle deselection here (selection is handled in mouseDown) - const isSelected = selectedClips.some( - (c) => c.trackId === track.id && c.clipId === clip.id - ); - - if (isSelected && !e.metaKey && !e.ctrlKey && !e.shiftKey) { - // If clip is already selected and no modifier keys, deselect it - deselectClip(track.id, clip.id); - } - }} - 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 */} -
{ - e.stopPropagation(); // Prevent triggering clip drag - handleResizeStart(e, clip.id, "left"); - }} - /> - {/* Clip content */} -
- {renderClipContent(clip)} - {/* Clip options menu */} -
- - {clipMenuOpen === clip.id && ( -
e.stopPropagation()} - > - - -
- )} -
-
- {/* Right trim handle */} -
{ - e.stopPropagation(); // Prevent triggering clip drag - handleResizeStart(e, clip.id, "right"); - }} - /> -
+ clip={clip} + track={track} + zoomLevel={zoomLevel} + isSelected={isSelected} + onContextMenu={handleClipContextMenu} + onClipMouseDown={handleClipMouseDown} + onClipClick={handleClipClick} + /> ); })} diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts new file mode 100644 index 0000000..21be520 --- /dev/null +++ b/apps/web/src/types/timeline.ts @@ -0,0 +1,27 @@ +import { TimelineTrack, TimelineClip } from "@/stores/timeline-store"; + +export interface TimelineClipProps { + clip: TimelineClip; + track: TimelineTrack; + zoomLevel: number; + isSelected: boolean; + onContextMenu: (e: React.MouseEvent, clipId: string) => void; + onClipMouseDown: (e: React.MouseEvent, clip: TimelineClip) => void; + onClipClick: (e: React.MouseEvent, clip: TimelineClip) => void; +} + +export interface ResizeState { + clipId: string; + side: "left" | "right"; + startX: number; + initialTrimStart: number; + initialTrimEnd: number; +} + +export interface ContextMenuState { + type: "track" | "clip"; + trackId: string; + clipId?: string; + x: number; + y: number; +}