summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-01-31 00:20:04 +0100
committerGitHub <noreply@github.com>2026-01-31 00:20:04 +0100
commit7bf0984698e43b9aa2dd879f65951318ed410390 (patch)
treeccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/components/editor/timeline/timeline-toolbar.tsx
parent0173db99448d1c4c6415dcf15272e17551b48b04 (diff)
downloadopencut-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.tsx696
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>
+ );
}