diff options
| author | DevloperAmanSingh <amansinghneh211205@gmail.com> | 2025-06-26 23:41:07 +0530 |
|---|---|---|
| committer | DevloperAmanSingh <amansinghneh211205@gmail.com> | 2025-06-26 23:41:07 +0530 |
| commit | efdd2aa6edd092ff81d6d7cf38e57cc5b58df285 (patch) | |
| tree | fb5a27d7328d1c65c2dcf3f53bde8adc29ffebc8 /apps/web/src | |
| parent | 12a2ec59fd4eef9792dcfcf15bc77ea398420c3f (diff) | |
| download | opencut-efdd2aa6edd092ff81d6d7cf38e57cc5b58df285.tar.gz | |
feat: add dropdown menu for clip options including split and audio separation in timeline component
Diffstat (limited to 'apps/web/src')
| -rw-r--r-- | apps/web/src/components/editor/timeline-clip.tsx | 263 |
1 files changed, 182 insertions, 81 deletions
diff --git a/apps/web/src/components/editor/timeline-clip.tsx b/apps/web/src/components/editor/timeline-clip.tsx index 4c0d3b77..b5b8d20a 100644 --- a/apps/web/src/components/editor/timeline-clip.tsx +++ b/apps/web/src/components/editor/timeline-clip.tsx @@ -2,7 +2,15 @@ import { useState } from "react"; import { Button } from "../ui/button"; -import { MoreVertical, Scissors, Trash2 } from "lucide-react"; +import { + MoreVertical, + Scissors, + Trash2, + SplitSquareHorizontal, + Music, + ChevronRight, + ChevronLeft, +} from "lucide-react"; import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; import { usePlaybackStore } from "@/stores/playback-store"; @@ -10,6 +18,17 @@ import { useDragClip } from "@/hooks/use-drag-clip"; import AudioWaveform from "./audio-waveform"; import { toast } from "sonner"; import { TimelineClipProps, ResizeState } from "@/types/timeline"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "../ui/dropdown-menu"; +import { isDragging } from "motion/react"; export function TimelineClip({ clip, @@ -21,8 +40,16 @@ export function TimelineClip({ onClipClick, }: TimelineClipProps) { const { mediaItems } = useMediaStore(); - const { updateClipTrim, addClipToTrack, removeClipFromTrack, dragState } = - useTimelineStore(); + const { + updateClipTrim, + addClipToTrack, + removeClipFromTrack, + dragState, + splitClip, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + } = useTimelineStore(); const { currentTime } = usePlaybackStore(); const [resizing, setResizing] = useState<ResizeState | null>(null); @@ -31,7 +58,6 @@ export function TimelineClip({ 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; const clipStartTime = isBeingDragged && dragState.isDragging @@ -107,44 +133,85 @@ export function TimelineClip({ const handleDeleteClip = () => { removeClipFromTrack(track.id, clip.id); setClipMenuOpen(false); + toast.success("Clip deleted"); }; 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) { + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { toast.error("Playhead must be within clip to split"); return; } - const firstDuration = splitTime - effectiveStart; - const secondDuration = effectiveEnd - splitTime; + const secondClipId = splitClip(track.id, clip.id, currentTime); + if (secondClipId) { + toast.success("Clip split successfully"); + } else { + toast.error("Failed to split clip"); + } + setClipMenuOpen(false); + }; - // First part: adjust original clip - updateClipTrim( - track.id, - clip.id, - clip.trimStart, - clip.trimEnd + secondDuration - ); + const handleSplitAndKeepLeft = () => { + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); - // 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, - }); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within clip"); + return; + } + + splitAndKeepLeft(track.id, clip.id, currentTime); + toast.success("Split and kept left portion"); + setClipMenuOpen(false); + }; + + const handleSplitAndKeepRight = () => { + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within clip"); + return; + } + + splitAndKeepRight(track.id, clip.id, currentTime); + toast.success("Split and kept right portion"); + setClipMenuOpen(false); + }; + const handleSeparateAudio = () => { + const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + + if (!mediaItem || mediaItem.type !== "video") { + toast.error("Audio separation only available for video clips"); + return; + } + + const audioClipId = separateAudio(track.id, clip.id); + if (audioClipId) { + toast.success("Audio separated to audio track"); + } else { + toast.error("Failed to separate audio"); + } setClipMenuOpen(false); - toast.success("Clip split successfully"); + }; + + const canSplitAtPlayhead = () => { + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + return currentTime > effectiveStart && currentTime < effectiveEnd; + }; + + const canSeparateAudio = () => { + const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + return mediaItem?.type === "video" && track.type === "video"; }; const renderClipContent = () => { @@ -201,76 +268,110 @@ export function TimelineClip({ ); } - // Fallback for videos without thumbnails return ( <span className="text-xs text-foreground/80 truncate">{clip.name}</span> ); }; + const handleClipMouseDown = (e: React.MouseEvent) => { + if (onClipMouseDown) { + onClipMouseDown(e, clip); + } + }; + return ( <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" : ""}`} - style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }} - onMouseDown={(e) => onClipMouseDown(e, clip)} - onClick={(e) => onClipClick(e, clip)} - onMouseMove={handleResizeMove} - onMouseUp={handleResizeEnd} - onMouseLeave={handleResizeEnd} - tabIndex={0} - onContextMenu={(e) => onContextMenu(e, clip.id)} + className={`absolute top-0 h-full select-none transition-all duration-75 ${ + isBeingDragged ? "z-50" : "z-10" + } ${isSelected ? "ring-2 ring-primary" : ""}`} + style={{ + left: `${clipLeft}px`, + width: `${clipWidth}px`, + }} + onMouseMove={resizing ? handleResizeMove : undefined} + onMouseUp={resizing ? handleResizeEnd : undefined} + onMouseLeave={resizing ? handleResizeEnd : undefined} > - {/* Left trim handle */} <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"}`} - onMouseDown={(e) => handleResizeStart(e, clip.id, "left")} - /> - - {/* Clip content */} - <div className="flex-1 relative"> - {renderClipContent()} + className={`relative h-full rounded border cursor-pointer overflow-hidden ${getTrackColor( + track.type + )} ${isSelected ? "ring-2 ring-primary ring-offset-1" : ""}`} + onClick={(e) => onClipClick && onClipClick(e, clip)} + onMouseDown={handleClipMouseDown} + onContextMenu={(e) => onContextMenu && onContextMenu(e, clip.id)} + > + <div className="absolute inset-1 flex items-center p-1"> + {renderClipContent()} + </div> - {/* 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={(e) => { - e.stopPropagation(); - setClipMenuOpen(!clipMenuOpen); - }} - onMouseDown={(e) => e.stopPropagation()} - > - <MoreVertical className="h-4 w-4" /> - </Button> + <div + className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize hover:bg-primary/50 transition-colors" + onMouseDown={(e) => handleResizeStart(e, clip.id, "left")} + /> + <div + className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize hover:bg-primary/50 transition-colors" + onMouseDown={(e) => handleResizeStart(e, clip.id, "right")} + /> - {clipMenuOpen && ( - <div - className="absolute right-0 mt-2 w-32 bg-white border rounded shadow z-50" - onMouseDown={(e) => e.stopPropagation()} - > - <button - className="flex items-center w-full px-3 py-2 text-sm hover:bg-muted/30" - onClick={handleSplitClip} + <div className="absolute top-1 right-1"> + <DropdownMenu open={clipMenuOpen} onOpenChange={setClipMenuOpen}> + <DropdownMenuTrigger asChild> + <Button + variant="outline" + size="sm" + className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80 hover:bg-background" + onClick={(e) => { + e.stopPropagation(); + setClipMenuOpen(true); + }} > - <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" + <MoreVertical className="h-3 w-3" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + <DropdownMenuSub> + <DropdownMenuSubTrigger disabled={!canSplitAtPlayhead()}> + <Scissors className="mr-2 h-4 w-4" /> + Split + </DropdownMenuSubTrigger> + <DropdownMenuSubContent> + <DropdownMenuItem onClick={handleSplitClip}> + <SplitSquareHorizontal className="mr-2 h-4 w-4" /> + Split at Playhead + </DropdownMenuItem> + <DropdownMenuItem onClick={handleSplitAndKeepLeft}> + <ChevronLeft className="mr-2 h-4 w-4" /> + Split and Keep Left + </DropdownMenuItem> + <DropdownMenuItem onClick={handleSplitAndKeepRight}> + <ChevronRight className="mr-2 h-4 w-4" /> + Split and Keep Right + </DropdownMenuItem> + </DropdownMenuSubContent> + </DropdownMenuSub> + + {canSeparateAudio() && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={handleSeparateAudio}> + <Music className="mr-2 h-4 w-4" /> + Separate Audio + </DropdownMenuItem> + </> + )} + + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={handleDeleteClip} + className="text-destructive" > - <Trash2 className="h-4 w-4 mr-2" /> Delete - </button> - </div> - )} + <Trash2 className="mr-2 h-4 w-4" /> + Delete Clip + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> </div> </div> - - {/* Right trim handle */} - <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"}`} - onMouseDown={(e) => handleResizeStart(e, clip.id, "right")} - /> </div> ); } |
