diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-01-31 00:20:04 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-31 00:20:04 +0100 |
| commit | 7bf0984698e43b9aa2dd879f65951318ed410390 (patch) | |
| tree | ccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/components/editor/timeline/timeline-toolbar.tsx | |
| parent | 0173db99448d1c4c6415dcf15272e17551b48b04 (diff) | |
| download | opencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz | |
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/components/editor/timeline/timeline-toolbar.tsx')
| -rw-r--r-- | apps/web/src/components/editor/timeline/timeline-toolbar.tsx | 696 |
1 files changed, 327 insertions, 369 deletions
diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx index 80c6ce08..822bb438 100644 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -1,397 +1,355 @@ -import { usePlaybackStore } from "@/stores/playback-store"; -import { useProjectStore } from "@/stores/project-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { useSceneStore } from "@/stores/scene-store"; -import { toast } from "sonner"; +import { useEditor } from "@/hooks/use-editor"; import { - TooltipProvider, - Tooltip, - TooltipTrigger, - TooltipContent, + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipContent, } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; +import { SkipBack, SplitSquareHorizontal } from "lucide-react"; import { - Pause, - Play, - SkipBack, - Bookmark, - Magnet, - Link, - ZoomOut, - ZoomIn, - Copy, - Trash2, - Snowflake, - ArrowLeftToLine, - ArrowRightToLine, - SplitSquareHorizontal, - Scissors, - LayersIcon, -} from "lucide-react"; -import { - SplitButton, - SplitButtonLeft, - SplitButtonRight, - SplitButtonSeparator, + SplitButton, + SplitButtonLeft, + SplitButtonRight, + SplitButtonSeparator, } from "@/components/ui/split-button"; import { Slider } from "@/components/ui/slider"; -import { DEFAULT_FPS } from "@/stores/project-store"; import { formatTimeCode } from "@/lib/time"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { EditableTimecode } from "@/components/ui/editable-timecode"; +import { EditableTimecode } from "@/components/editable-timecode"; import { ScenesView } from "../scenes-view"; +import { type TAction, invokeAction } from "@/lib/actions"; +import { cn } from "@/utils/ui"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Bookmark02Icon, + Delete02Icon, + SnowIcon, + ScissorIcon, + MagnetIcon, + Link04Icon, + SearchAddIcon, + SearchMinusIcon, + PauseIcon, + PlayIcon, + Copy01Icon, + AlignLeftIcon, + AlignRightIcon, + Layers01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; export function TimelineToolbar({ - zoomLevel, - setZoomLevel, + zoomLevel, + minZoom, + setZoomLevel, }: { - zoomLevel: number; - setZoomLevel: (zoom: number) => void; + zoomLevel: number; + minZoom: number; + setZoomLevel: ({ zoom }: { zoom: number }) => void; }) { - const { - tracks, - addTrack, - addElementToTrack, - selectedElements, - clearSelectedElements, - deleteSelected, - splitSelected, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, - snappingEnabled, - toggleSnapping, - rippleEditingEnabled, - toggleRippleEditing, - } = useTimelineStore(); - const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); - const { toggleBookmark, isBookmarked, activeProject } = useProjectStore(); - const { scenes, currentScene } = useSceneStore(); + const handleZoom = ({ direction }: { direction: "in" | "out" }) => { + const newZoomLevel = + direction === "in" + ? Math.min( + TIMELINE_CONSTANTS.ZOOM_MAX, + zoomLevel + TIMELINE_CONSTANTS.ZOOM_STEP, + ) + : Math.max(minZoom, zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP); + setZoomLevel({ zoom: newZoomLevel }); + }; + + return ( + <ScrollArea className="scrollbar-hidden"> + <div className="flex h-10 items-center justify-between border-b px-2 py-1"> + <ToolbarLeftSection /> + + <SceneSelector /> + + <ToolbarRightSection + zoomLevel={zoomLevel} + minZoom={minZoom} + onZoomChange={(zoom) => setZoomLevel({ zoom })} + onZoom={handleZoom} + /> + </div> + </ScrollArea> + ); +} + +function ToolbarLeftSection() { + const editor = useEditor(); + const currentTime = editor.playback.getCurrentTime(); + const isPlaying = editor.playback.getIsPlaying(); + const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime }); + + const handleAction = ({ + action, + event, + }: { + action: TAction; + event: React.MouseEvent; + }) => { + event.stopPropagation(); + invokeAction(action); + }; + + return ( + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <ToolbarButton + icon={ + isPlaying ? ( + <HugeiconsIcon icon={PauseIcon} /> + ) : ( + <HugeiconsIcon icon={PlayIcon} /> + ) + } + tooltip={isPlaying ? "Pause" : "Play"} + onClick={({ event }) => + handleAction({ action: "toggle-play", event }) + } + /> + + <ToolbarButton + icon={<SkipBack />} + tooltip="Go to start" + onClick={({ event }) => handleAction({ action: "goto-start", event })} + /> + + <div className="bg-border mx-1 h-6 w-px" /> + + <TimeDisplay /> - const handleSplitSelected = () => { - splitSelected(currentTime); - }; + <div className="bg-border mx-1 h-6 w-px" /> - const handleDuplicateSelected = () => { - if (selectedElements.length === 0) return; - const canDuplicate = selectedElements.length === 1; - if (!canDuplicate) return; + <ToolbarButton + icon={<HugeiconsIcon icon={ScissorIcon} />} + tooltip="Split element" + onClick={({ event }) => + handleAction({ action: "split", event }) + } + /> - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((el) => el.id === elementId); - if (element) { - const newStartTime = - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1; - const { id, ...elementWithoutId } = element; - addElementToTrack(trackId, { - ...elementWithoutId, - startTime: newStartTime, - }); - } - }); - clearSelectedElements(); - }; + <ToolbarButton + icon={<HugeiconsIcon icon={AlignLeftIcon} />} + tooltip="Split left" + onClick={({ event }) => + handleAction({ action: "split-left", event }) + } + /> - const handleFreezeSelected = () => { - toast.info("Freeze frame functionality coming soon!"); - }; + <ToolbarButton + icon={<HugeiconsIcon icon={AlignRightIcon} />} + tooltip="Split right" + onClick={({ event }) => + handleAction({ action: "split-right", event }) + } + /> - const handleSplitAndKeepLeft = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepLeft(trackId, elementId, currentTime); - }; + <ToolbarButton + icon={<SplitSquareHorizontal />} + tooltip="Coming soon" /* separate audio */ + disabled={true} + onClick={({ event: _event }) => {}} + /> - const handleSplitAndKeepRight = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepRight(trackId, elementId, currentTime); - }; + <ToolbarButton + icon={<HugeiconsIcon icon={Copy01Icon} />} + tooltip="Duplicate element" + onClick={({ event }) => + handleAction({ action: "duplicate-selected", event }) + } + /> - const handleSeparateAudio = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one media element to separate audio"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - if (!track || track.type !== "media") { - toast.error("Select a media element to separate audio"); - return; - } - separateAudio(trackId, elementId); - }; + <ToolbarButton + icon={<HugeiconsIcon icon={SnowIcon} />} + tooltip="Coming soon" /* freeze frame */ + disabled={true} + onClick={({ event: _event }) => {}} + /> - const handleDeleteSelected = () => { - deleteSelected(); - }; + <ToolbarButton + icon={<HugeiconsIcon icon={Delete02Icon} />} + tooltip="Delete element" + onClick={({ event }) => + handleAction({ action: "delete-selected", event }) + } + /> - const handleZoomIn = () => { - setZoomLevel(Math.min(4, zoomLevel + 0.25)); - }; + <div className="bg-border mx-1 h-6 w-px" /> - const handleZoomOut = () => { - setZoomLevel(Math.max(0.25, zoomLevel - 0.25)); - }; + <Tooltip> + <ToolbarButton + icon={ + <HugeiconsIcon + icon={Bookmark02Icon} + className={currentBookmarked ? "fill-primary text-primary" : ""} + /> + } + tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"} + onClick={({ event }) => + handleAction({ action: "toggle-bookmark", event }) + } + /> + </Tooltip> + </TooltipProvider> + </div> + ); +} + +function TimeDisplay() { + const editor = useEditor(); + const currentTime = editor.playback.getCurrentTime(); + const totalDuration = editor.timeline.getTotalDuration(); + const fps = editor.project.getActive().settings.fps; + + return ( + <div className="flex flex-row items-center justify-center px-2"> + <EditableTimecode + time={currentTime} + duration={totalDuration} + format="HH:MM:SS:FF" + fps={fps} + onTimeChange={({ time }) => editor.playback.seek({ time })} + className="text-center" + /> + <div className="text-muted-foreground px-2 font-mono text-xs">/</div> + <div className="text-muted-foreground text-center font-mono text-xs"> + {formatTimeCode({ + timeInSeconds: totalDuration, + format: "HH:MM:SS:FF", + fps, + })} + </div> + </div> + ); +} + +function SceneSelector() { + const editor = useEditor(); + const currentScene = editor.scenes.getActiveScene(); + + return ( + <div> + <SplitButton className="border-foreground/10 border"> + <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft> + <SplitButtonSeparator /> + <ScenesView> + <SplitButtonRight onClick={() => {}} type="button"> + <HugeiconsIcon icon={Layers01Icon} className="size-4" /> + </SplitButtonRight> + </ScenesView> + </SplitButton> + </div> + ); +} - const handleZoomSliderChange = (values: number[]) => { - setZoomLevel(values[0]); - }; +function ToolbarRightSection({ + zoomLevel, + minZoom, + onZoomChange, + onZoom, +}: { + zoomLevel: number; + minZoom: number; + onZoomChange: (zoom: number) => void; + onZoom: (options: { direction: "in" | "out" }) => void; +}) { + const { + snappingEnabled, + rippleEditingEnabled, + toggleSnapping, + toggleRippleEditing, + } = useTimelineStore(); - const handleToggleBookmark = async () => { - await toggleBookmark(currentTime); - }; + return ( + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <ToolbarButton + icon={ + <HugeiconsIcon + icon={MagnetIcon} + className={cn(snappingEnabled ? "text-primary" : "")} + /> + } + tooltip="Auto snapping" + onClick={() => toggleSnapping()} + /> - const currentBookmarked = isBookmarked(currentTime); - return ( - <div className="flex items-center justify-between px-2 py-1 border-b h-10"> - <div className="flex items-center gap-1"> - <TooltipProvider delayDuration={500}> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={toggle}> - {isPlaying ? ( - <Pause className="h-4 w-4" /> - ) : ( - <Play className="h-4 w-4" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent> - {isPlaying ? "Pause (Space)" : "Play (Space)"} - </TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={() => seek(0)}> - <SkipBack className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Return to Start (Home / Enter)</TooltipContent> - </Tooltip> - <div className="w-px h-6 bg-border mx-1" /> - {/* Time Display */} - <div className="flex flex-row items-center justify-center px-2"> - <EditableTimecode - time={currentTime} - duration={duration} - format="HH:MM:SS:FF" - fps={activeProject?.fps ?? DEFAULT_FPS} - onTimeChange={seek} - className="text-center" - /> - <div className="text-xs text-muted-foreground font-mono px-2"> - / - </div> - <div className="text-xs text-muted-foreground font-mono text-center"> - {formatTimeCode(duration, "HH:MM:SS:FF")} - </div> - </div> - {tracks.length === 0 && ( - <> - <div className="w-px h-6 bg-border mx-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="outline" - size="sm" - onClick={() => { - const trackId = addTrack("media"); - addElementToTrack(trackId, { - type: "media", - mediaId: "test", - name: "Test Clip", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - }); - }} - className="text-xs" - > - Add Test Clip - </Button> - </TooltipTrigger> - <TooltipContent>Add a test clip to try playback</TooltipContent> - </Tooltip> - </> - )} - <div className="w-px h-6 bg-border mx-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleSplitSelected}> - <Scissors className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split element (Ctrl+S)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitAndKeepLeft} - > - <ArrowLeftToLine className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitAndKeepRight} - > - <ArrowRightToLine className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleSeparateAudio}> - <SplitSquareHorizontal className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Separate audio (Ctrl+D)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleDuplicateSelected} - > - <Copy className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleFreezeSelected}> - <Snowflake className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Freeze frame (F)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleDeleteSelected}> - <Trash2 className="h-4 w-4" /> - </Button> - </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> - <SplitButton className="border border-foreground/10"> - <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft> - <SplitButtonSeparator /> - <ScenesView> - <SplitButtonRight disabled={scenes.length === 1} onClick={() => {}}> - <LayersIcon /> - </SplitButtonRight> - </ScenesView> - </SplitButton> - </div> - <div className="flex items-center gap-1"> - <TooltipProvider delayDuration={500}> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={toggleSnapping}> - {snappingEnabled ? ( - <Magnet className="h-4 w-4 text-primary" /> - ) : ( - <Magnet className="h-4 w-4" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent>Auto snapping</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={toggleRippleEditing}> - <Link - className={`h-4 w-4 ${ - rippleEditingEnabled ? "text-primary" : "" - }`} - /> - </Button> - </TooltipTrigger> - <TooltipContent> - {rippleEditingEnabled - ? "Disable Ripple Editing" - : "Enable Ripple Editing"} - </TooltipContent> - </Tooltip> - </TooltipProvider> + <ToolbarButton + icon={ + <HugeiconsIcon + icon={Link04Icon} + className={cn( + rippleEditingEnabled ? "text-primary" : "", + "scale-110", + )} + /> + } + tooltip="Ripple editing" + onClick={() => toggleRippleEditing()} + /> + </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" /> - </Button> - <Slider - className="w-24" - value={[zoomLevel]} - onValueChange={handleZoomSliderChange} - min={0.25} - max={4} - step={0.25} - /> - <Button variant="text" size="icon" onClick={handleZoomIn}> - <ZoomIn className="h-4 w-4" /> - </Button> - </div> - </div> - </div> - ); + <div className="bg-border mx-1 h-6 w-px" /> + + <div className="flex items-center gap-1"> + <Button + variant="text" + size="icon" + type="button" + onClick={() => onZoom({ direction: "out" })} + > + <HugeiconsIcon icon={SearchAddIcon} /> + </Button> + <Slider + className="w-24" + value={[zoomLevel]} + onValueChange={(values) => onZoomChange(values[0])} + min={minZoom} + max={TIMELINE_CONSTANTS.ZOOM_MAX} + step={TIMELINE_CONSTANTS.ZOOM_STEP} + /> + <Button + variant="text" + size="icon" + type="button" + onClick={() => onZoom({ direction: "in" })} + > + <HugeiconsIcon icon={SearchMinusIcon} /> + </Button> + </div> + </div> + ); +} + +function ToolbarButton({ + icon, + tooltip, + onClick, + disabled, +}: { + icon: React.ReactNode; + tooltip: string; + onClick: ({ event }: { event: React.MouseEvent }) => void; + disabled?: boolean; +}) { + return ( + <Tooltip delayDuration={200}> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + type="button" + onClick={(event) => onClick({ event })} + className={disabled ? "cursor-not-allowed opacity-50" : ""} + > + {icon} + </Button> + </TooltipTrigger> + <TooltipContent>{tooltip}</TooltipContent> + </Tooltip> + ); } |
