diff options
| author | ryu <ryumacodes@gmail.com> | 2025-08-07 19:37:06 +0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-08-07 19:37:06 +0500 |
| commit | e71788014d137a74f10420601451ddfbb84701bb (patch) | |
| tree | 2c08f7fbbc7537b12935e842981830aed976ce87 /apps/web/src/components/editor/timeline | |
| parent | 6412158003f295a350fc66800796f30c8f3c534b (diff) | |
| parent | d022a78ae2330665e7d763fdc6e831a71f3b9ea1 (diff) | |
| download | opencut-e71788014d137a74f10420601451ddfbb84701bb.tar.gz | |
Merge branch 'main' into feat/timeline-return-to-start
Diffstat (limited to 'apps/web/src/components/editor/timeline')
4 files changed, 209 insertions, 179 deletions
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 0d9660bb..bff0a90a 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { ScrollArea } from "../../ui/scroll-area"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Button } from "../../ui/button"; import { Scissors, @@ -21,6 +21,10 @@ import { Link, ZoomIn, ZoomOut, + Bookmark, + Eye, + MicOff, + Mic, } from "lucide-react"; import { Tooltip, @@ -545,7 +549,7 @@ export function Timeline() { {/* Timeline Header with Ruler */} <div className="flex bg-panel sticky top-0 z-10"> {/* Track Labels Header */} - <div className="w-48 flex-shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2"> + <div className="w-28 shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2"> {/* Empty space */} <span className="text-sm font-medium text-muted-foreground opacity-0"> . @@ -653,6 +657,30 @@ export function Timeline() { ); }).filter(Boolean); })()} + + {/* Bookmark markers */} + {(() => { + const { activeProject } = useProjectStore.getState(); + if (!activeProject?.bookmarks?.length) return null; + + return activeProject.bookmarks.map((bookmarkTime, i) => ( + <div + key={`bookmark-${i}`} + className="absolute top-0 h-10 w-0.5 !bg-primary cursor-pointer" + style={{ + left: `${bookmarkTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, + }} + onClick={(e) => { + e.stopPropagation(); + usePlaybackStore.getState().seek(bookmarkTime); + }} + > + <div className="absolute top-[-1px] left-[-5px] text-primary"> + <Bookmark className="h-3 w-3 fill-primary" /> + </div> + </div> + )); + })()} </div> </ScrollArea> </div> @@ -664,7 +692,7 @@ export function Timeline() { {tracks.length > 0 && ( <div ref={trackLabelsRef} - className="w-48 flex-shrink-0 border-r border-black overflow-y-auto z-[200] bg-panel" + className="w-28 shrink-0 border-r overflow-y-auto z-100 bg-panel" data-track-labels > <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}> @@ -672,17 +700,24 @@ export function Timeline() { {tracks.map((track) => ( <div key={track.id} - className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5" + className="flex items-center px-3 group" style={{ height: `${getTrackHeight(track.type)}px` }} > - <div className="flex items-center flex-1 min-w-0"> + <div className="flex items-center justify-end flex-1 min-w-0 gap-2"> + {track.muted ? ( + <MicOff + className="h-4 w-4 text-destructive cursor-pointer" + onClick={() => toggleTrackMute(track.id)} + /> + ) : ( + <Mic + className="h-4 w-4 text-muted-foreground cursor-pointer" + onClick={() => toggleTrackMute(track.id)} + /> + )} + <Eye className="h-4 w-4 text-muted-foreground" /> <TrackIcon track={track} /> </div> - {track.muted && ( - <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0"> - Muted - </span> - )} </div> ))} </div> @@ -713,12 +748,7 @@ export function Timeline() { containerRef={tracksContainerRef} isActive={selectionBox?.isActive || false} /> - <ScrollArea - className="w-full h-full" - ref={tracksScrollRef} - type="scroll" - showHorizontalScrollbar - > + <ScrollArea className="w-full h-full" ref={tracksScrollRef}> <div className="relative flex-1" style={{ @@ -737,7 +767,7 @@ export function Timeline() { <ContextMenu key={track.id}> <ContextMenuTrigger asChild> <div - className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]" + className="absolute left-0 right-0" style={{ top: `${getCumulativeHeightBefore( tracks, @@ -763,7 +793,7 @@ export function Timeline() { /> </div> </ContextMenuTrigger> - <ContextMenuContent className="z-[200]"> + <ContextMenuContent className="z-200"> <ContextMenuItem onClick={(e) => { e.stopPropagation(); @@ -793,13 +823,13 @@ function TrackIcon({ track }: { track: TimelineTrack }) { return ( <> {track.type === "media" && ( - <Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + <Video className="w-4 h-4 shrink-0 text-muted-foreground" /> )} {track.type === "text" && ( - <TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + <TypeIcon className="w-4 h-4 shrink-0 text-muted-foreground" /> )} {track.type === "audio" && ( - <Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + <Music className="w-4 h-4 shrink-0 text-muted-foreground" /> )} </> ); @@ -832,6 +862,7 @@ function TimelineToolbar({ toggleRippleEditing, } = useTimelineStore(); const { currentTime, duration, isPlaying, toggle } = usePlaybackStore(); + const { toggleBookmark, isBookmarked } = useProjectStore(); // Action handlers const handleSplitSelected = () => { @@ -961,6 +992,13 @@ function TimelineToolbar({ const handleZoomSliderChange = (values: number[]) => { setZoomLevel(values[0]); }; + + const handleToggleBookmark = async () => { + await toggleBookmark(currentTime); + }; + + // Check if the current time is bookmarked + const currentBookmarked = isBookmarked(currentTime); return ( <div className="border-b flex items-center justify-between px-2 py-1"> <div className="flex items-center gap-1 w-full"> @@ -1108,6 +1146,19 @@ function TimelineToolbar({ </TooltipTrigger> <TooltipContent>Delete element (Delete)</TooltipContent> </Tooltip> + <div className="w-px h-6 bg-border mx-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={handleToggleBookmark}> + <Bookmark + className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`} + /> + </Button> + </TooltipTrigger> + <TooltipContent> + {currentBookmarked ? "Remove bookmark" : "Add bookmark"} + </TooltipContent> + </Tooltip> </TooltipProvider> </div> <div className="flex items-center gap-1"> @@ -1141,6 +1192,8 @@ function TimelineToolbar({ </TooltipContent> </Tooltip> </TooltipProvider> + + <div className="h-6 w-px bg-border mx-1" /> <div className="flex items-center gap-1"> <Button variant="text" size="icon" onClick={handleZoomOut}> <ZoomOut className="h-4 w-4" /> diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index f581bbf0..b26a14bb 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -1,25 +1,21 @@ "use client"; -import { useState } from "react"; -import { Button } from "../../ui/button"; import { - MoreVertical, Scissors, Trash2, - SplitSquareHorizontal, - Music, - ChevronRight, - ChevronLeft, - Type, Copy, RefreshCw, + EyeOff, + Eye, + Volume2, + VolumeX, } from "lucide-react"; import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; import { usePlaybackStore } from "@/stores/playback-store"; import AudioWaveform from "../audio-waveform"; import { toast } from "sonner"; -import { TimelineElementProps, TrackType } from "@/types/timeline"; +import { TimelineElementProps } from "@/types/timeline"; import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; import { getTrackElementClasses, @@ -27,16 +23,6 @@ import { getTrackHeight, } from "@/constants/timeline-constants"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, -} from "../../ui/dropdown-menu"; -import { ContextMenu, ContextMenuContent, ContextMenuItem, @@ -60,30 +46,27 @@ export function TimelineElement({ removeElementFromTrackWithRipple, dragState, splitElement, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, addElementToTrack, replaceElementMedia, rippleEditingEnabled, + toggleElementHidden, } = useTimelineStore(); const { currentTime } = usePlaybackStore(); - const [elementMenuOpen, setElementMenuOpen] = useState(false); + const mediaItem = + element.type === "media" + ? mediaItems.find((item) => item.id === element.mediaId) + : null; + const isAudio = mediaItem?.type === "audio"; - const { - resizing, - isResizing, - handleResizeStart, - handleResizeMove, - handleResizeEnd, - } = useTimelineElementResize({ - element, - track, - zoomLevel, - onUpdateTrim: updateElementTrim, - onUpdateDuration: updateElementDuration, - }); + const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } = + useTimelineElementResize({ + element, + track, + zoomLevel, + onUpdateTrim: updateElementTrim, + onUpdateDuration: updateElementDuration, + }); const effectiveDuration = element.duration - element.trimStart - element.trimEnd; @@ -141,6 +124,11 @@ export function TimelineElement({ } }; + const handleToggleElementHidden = (e: React.MouseEvent) => { + e.stopPropagation(); + toggleElementHidden(track.id, element.id); + }; + const handleReplaceClip = (e: React.MouseEvent) => { e.stopPropagation(); if (element.type !== "media") { @@ -177,9 +165,7 @@ export function TimelineElement({ if (element.type === "text") { return ( <div className="w-full h-full flex items-center justify-start pl-2"> - <span className="text-xs text-foreground/80 truncate"> - {element.content} - </span> + <span className="text-xs text-white truncate">{element.content}</span> </div> ); } @@ -196,86 +182,36 @@ export function TimelineElement({ const TILE_ASPECT_RATIO = 16 / 9; - if (mediaItem.type === "image") { + if ( + mediaItem.type === "image" || + (mediaItem.type === "video" && mediaItem.thumbnailUrl) + ) { // Calculate tile size based on 16:9 aspect ratio const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - 8; // Account for padding + const tileHeight = trackHeight; const tileWidth = tileHeight * TILE_ASPECT_RATIO; - return ( - <div className="w-full h-full flex items-center justify-center"> - <div className="bg-[#004D52] py-3 w-full h-full relative"> - {/* Background with tiled images */} - <div - className="absolute top-3 bottom-3 left-0 right-0" - style={{ - backgroundImage: mediaItem.url - ? `url(${mediaItem.url})` - : "none", - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${tileHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - aria-label={`Tiled background of ${mediaItem.name}`} - /> - {/* Overlay with vertical borders */} - <div - className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none" - style={{ - backgroundImage: `repeating-linear-gradient( - to right, - transparent 0px, - transparent ${tileWidth - 1}px, - rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, - rgba(255, 255, 255, 0.6) ${tileWidth}px - )`, - backgroundPosition: "left center", - }} - /> - </div> - </div> - ); - } - - const VIDEO_TILE_PADDING = 16; - const OVERLAY_SPACE_MULTIPLIER = 1.5; - - if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { - const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - 8; // Match image padding - const tileWidth = tileHeight * TILE_ASPECT_RATIO; + const imageUrl = + mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl; + const isImage = mediaItem.type === "image"; return ( <div className="w-full h-full flex items-center justify-center"> - <div className="bg-[#004D52] py-3 w-full h-full relative"> - {/* Background with tiled thumbnails */} + <div + className={`w-full h-full relative ${ + isSelected ? "bg-primary" : "bg-transparent" + }`} + > <div - className="absolute top-3 bottom-3 left-0 right-0" + className={`absolute top-[0.15rem] bottom-[0.15rem] left-0 right-0`} style={{ - backgroundImage: mediaItem.thumbnailUrl - ? `url(${mediaItem.thumbnailUrl})` - : "none", + backgroundImage: imageUrl ? `url(${imageUrl})` : "none", backgroundRepeat: "repeat-x", backgroundSize: `${tileWidth}px ${tileHeight}px`, backgroundPosition: "left center", pointerEvents: "none", }} - aria-label={`Tiled thumbnail of ${mediaItem.name}`} - /> - {/* Overlay with vertical borders */} - <div - className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none" - style={{ - backgroundImage: `repeating-linear-gradient( - to right, - transparent 0px, - transparent ${tileWidth - 1}px, - rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, - rgba(255, 255, 255, 0.6) ${tileWidth}px - )`, - backgroundPosition: "left center", - }} + aria-label={`Tiled ${isImage ? "background" : "thumbnail"} of ${mediaItem.name}`} /> </div> </div> @@ -330,9 +266,9 @@ export function TimelineElement({ <div className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses( track.type - )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${ + )} ${isSelected ? "" : ""} ${ isBeingDragged ? "z-50" : "z-10" - }`} + } ${element.hidden ? "opacity-50" : ""}`} onClick={(e) => onElementClick && onElementClick(e, element)} onMouseDown={handleElementMouseDown} onContextMenu={(e) => @@ -343,14 +279,24 @@ export function TimelineElement({ {renderElementContent()} </div> + {element.hidden && ( + <div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none"> + {isAudio ? ( + <VolumeX className="h-6 w-6 text-white" /> + ) : ( + <EyeOff className="h-6 w-6 text-white" /> + )} + </div> + )} + {isSelected && ( <> <div - className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50" + className="absolute left-0 top-0 bottom-0 w-[0.2rem] cursor-w-resize bg-primary z-50" onMouseDown={(e) => handleResizeStart(e, element.id, "left")} /> <div - className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50" + className="absolute right-0 top-0 bottom-0 w-[0.2rem] cursor-e-resize bg-primary z-50" onMouseDown={(e) => handleResizeStart(e, element.id, "right")} /> </> @@ -358,11 +304,34 @@ export function TimelineElement({ </div> </div> </ContextMenuTrigger> - <ContextMenuContent className="z-[200]"> + <ContextMenuContent className="z-200"> <ContextMenuItem onClick={handleElementSplitContext}> <Scissors className="h-4 w-4 mr-2" /> Split at playhead </ContextMenuItem> + <ContextMenuItem onClick={handleToggleElementHidden}> + {isAudio ? ( + element.hidden ? ( + <Volume2 className="h-4 w-4 mr-2" /> + ) : ( + <VolumeX className="h-4 w-4 mr-2" /> + ) + ) : element.hidden ? ( + <Eye className="h-4 w-4 mr-2" /> + ) : ( + <EyeOff className="h-4 w-4 mr-2" /> + )} + <span> + {isAudio + ? element.hidden + ? "Unmute" + : "Mute" + : element.hidden + ? "Show" + : "Hide"}{" "} + {element.type === "text" ? "text" : "clip"} + </span> + </ContextMenuItem> <ContextMenuItem onClick={handleElementDuplicateContext}> <Copy className="h-4 w-4 mr-2" /> Duplicate {element.type === "text" ? "text" : "clip"} diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index dbbd18df..248c6176 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -70,7 +70,7 @@ export function TimelinePlayhead({ // Use timeline container height minus a few pixels for breathing room const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; - const totalHeight = timelineContainerHeight - 8; // 8px padding from edges + const totalHeight = timelineContainerHeight - 4; // Get dynamic track labels width, fallback to 0 if no tracks or no ref const trackLabelsWidth = @@ -126,7 +126,7 @@ export function TimelinePlayhead({ return ( <div ref={playheadRef} - className="absolute pointer-events-auto z-[150]" + className="absolute pointer-events-auto z-150" style={{ left: `${leftPosition}px`, top: 0, @@ -137,12 +137,12 @@ export function TimelinePlayhead({ > {/* The playhead line spanning full height */} <div - className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`} + className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`} /> {/* Playhead dot indicator at the top (in ruler area) */} <div - className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`} + className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`} /> </div> ); diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 097fc051..1982d03e 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -68,45 +68,51 @@ export function TimelineTrackContent({ elementDuration: number, excludeElementId?: string ) => { - if (!snappingEnabled) { - // Use frame snapping if project has FPS, otherwise use decimal snapping - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - return snapTimeToFrame(dropTime, projectFps); - } + // Always apply frame snapping first + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + let finalTime = snapTimeToFrame(dropTime, projectFps); - // Try snapping both start and end edges for drops - const startSnapResult = snapElementEdge( - dropTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - excludeElementId, - true // snap to start edge - ); + // Additionally apply element snapping if enabled + if (snappingEnabled) { + // Try snapping both start and end edges for drops + const startSnapResult = snapElementEdge( + dropTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + excludeElementId, + true // snap to start edge + ); - const endSnapResult = snapElementEdge( - dropTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - excludeElementId, - false // snap to end edge - ); + const endSnapResult = snapElementEdge( + dropTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + excludeElementId, + false // snap to end edge + ); - // Choose the snap result with the smaller distance (closer snap) - let bestSnapResult = startSnapResult; - if ( - endSnapResult.snapPoint && - (!startSnapResult.snapPoint || - endSnapResult.snapDistance < startSnapResult.snapDistance) - ) { - bestSnapResult = endSnapResult; + // Choose the snap result with the smaller distance (closer snap) + let bestSnapResult = startSnapResult; + if ( + endSnapResult.snapPoint && + (!startSnapResult.snapPoint || + endSnapResult.snapDistance < startSnapResult.snapDistance) + ) { + bestSnapResult = endSnapResult; + } + + // Only use element snapping if it found a snap point, otherwise keep frame-snapped time + if (bestSnapResult.snapPoint) { + finalTime = bestSnapResult.snappedTime; + } } - return bestSnapResult.snappedTime; + return finalTime; }; const timelineRef = useRef<HTMLDivElement>(null); @@ -148,9 +154,13 @@ export function TimelineTrackContent({ ); const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - // Apply snapping if enabled - let finalTime = adjustedTime; + // Always apply frame snapping first + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + let finalTime = snapTimeToFrame(adjustedTime, projectFps); let snapPoint = null; + + // Additionally apply element snapping if enabled if (snappingEnabled) { // Find the element being dragged to get its duration let elementDuration = 5; // fallback duration @@ -196,18 +206,16 @@ export function TimelineTrackContent({ bestSnapResult = endSnapResult; } - finalTime = bestSnapResult.snappedTime; - snapPoint = bestSnapResult.snapPoint; + // Only use element snapping if it found a snap point, otherwise keep frame-snapped time + if (bestSnapResult.snapPoint) { + finalTime = bestSnapResult.snappedTime; + snapPoint = bestSnapResult.snapPoint; + } // Notify parent component about snap point change onSnapPointChange?.(snapPoint); } else { - // Use frame snapping if project has FPS, otherwise use decimal snapping - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - finalTime = snapTimeToFrame(adjustedTime, projectFps); - - // Clear snap point when not snapping + // Clear snap point when element snapping is disabled onSnapPointChange?.(null); } |
