summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
authorleimingsheng <leimingsheng@58.com>2025-07-21 19:15:52 +0800
committerleimingsheng <leimingsheng@58.com>2025-07-21 19:15:52 +0800
commitd6724e2552d14140017b194e7020252bda73751a (patch)
tree0b0b400b7933e21ad911cb2366b1806ff2e35a64 /apps/web/src
parent263f14ef2a83b0cc78189f764b0c1859fa392539 (diff)
downloadopencut-d6724e2552d14140017b194e7020252bda73751a.tar.gz
feat: init timeline zoom
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/components/editor/timeline-clean.tsx665
-rw-r--r--apps/web/src/components/editor/timeline-fixed.tsx1008
-rw-r--r--apps/web/src/components/editor/timeline-zoom-control.tsx126
-rw-r--r--apps/web/src/components/editor/timeline.tsx78
-rw-r--r--apps/web/src/constants/actions.ts4
-rw-r--r--apps/web/src/hooks/use-keyboard-shortcuts-help.ts4
-rw-r--r--apps/web/src/hooks/use-timeline-zoom-actions.ts37
-rw-r--r--apps/web/src/hooks/use-timeline-zoom.ts64
-rw-r--r--apps/web/src/stores/keybindings-store.ts4
-rw-r--r--apps/web/src/stores/timeline-store.ts20
-rw-r--r--apps/web/src/types/keybinding.ts2
11 files changed, 1987 insertions, 25 deletions
diff --git a/apps/web/src/components/editor/timeline-clean.tsx b/apps/web/src/components/editor/timeline-clean.tsx
new file mode 100644
index 00000000..cfb031a8
--- /dev/null
+++ b/apps/web/src/components/editor/timeline-clean.tsx
@@ -0,0 +1,665 @@
+"use client";
+
+import { Button } from "../ui/button";
+import {
+ Scissors,
+ ArrowLeftToLine,
+ ArrowRightToLine,
+ Trash2,
+ Snowflake,
+ Copy,
+ SplitSquareHorizontal,
+ Pause,
+ Play,
+ Video,
+ Music,
+ TypeIcon,
+ Lock,
+ LockOpen,
+ Link,
+} from "lucide-react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+ TooltipProvider,
+} from "../ui/tooltip";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "../ui/context-menu";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useMediaStore } from "@/stores/media-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+import { useProjectStore } from "@/stores/project-store";
+import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
+import { processMediaFiles } from "@/lib/media-processing";
+import { toast } from "sonner";
+import { useState, useRef, useEffect, useCallback } from "react";
+import { TimelineTrackContent } from "./timeline-track";
+import {
+ TimelinePlayhead,
+ useTimelinePlayheadRuler,
+} from "./timeline-playhead";
+import { SelectionBox } from "./selection-box";
+import { useSelectionBox } from "@/hooks/use-selection-box";
+import { SnapIndicator } from "./snap-indicator";
+import { SnapPoint } from "@/hooks/use-timeline-snapping";
+import type { DragData, TimelineTrack } from "@/types/timeline";
+import {
+ getTrackHeight,
+ getCumulativeHeightBefore,
+ getTotalTracksHeight,
+ TIMELINE_CONSTANTS,
+ snapTimeToFrame,
+} from "@/constants/timeline-constants";
+import { TimelineZoomControl } from "./timeline-zoom-control";
+import { useTimelineZoomActions } from "@/hooks/use-timeline-zoom-actions";
+
+export function Timeline() {
+ const {
+ tracks,
+ addTrack,
+ addElementToTrack,
+ removeElementFromTrack,
+ removeElementFromTrackWithRipple,
+ getTotalDuration,
+ calculateFitToWindowZoom,
+ selectedElements,
+ clearSelectedElements,
+ setSelectedElements,
+ splitElement,
+ splitAndKeepLeft,
+ splitAndKeepRight,
+ toggleTrackMute,
+ separateAudio,
+ snappingEnabled,
+ toggleSnapping,
+ rippleEditingEnabled,
+ toggleRippleEditing,
+ dragState,
+ } = useTimelineStore();
+ const { mediaItems, addMediaItem } = useMediaStore();
+ const { activeProject } = useProjectStore();
+ const { currentTime, duration, seek, setDuration, isPlaying, toggle } =
+ usePlaybackStore();
+ const [isDragOver, setIsDragOver] = useState(false);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const dragCounterRef = useRef(0);
+ const timelineRef = useRef<HTMLDivElement>(null);
+ const rulerRef = useRef<HTMLDivElement>(null);
+ const [isInTimeline, setIsInTimeline] = useState(false);
+
+ // Track mouse down/up for distinguishing clicks from drag/resize ends
+ const mouseTrackingRef = useRef({
+ isMouseDown: false,
+ downX: 0,
+ downY: 0,
+ downTime: 0,
+ });
+
+ // Fit to window function
+ const handleFitToWindow = useCallback(() => {
+ if (timelineRef.current) {
+ const containerWidth = timelineRef.current.clientWidth;
+ const optimalZoom = calculateFitToWindowZoom(containerWidth);
+ setZoomLevel(optimalZoom);
+ }
+ }, [calculateFitToWindowZoom]);
+
+ // Timeline zoom functionality
+ const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({
+ containerRef: timelineRef,
+ isInTimeline,
+ onFitToWindow: handleFitToWindow,
+ });
+
+ // Dynamic timeline width calculation based on playhead position and duration
+ const dynamicTimelineWidth = Math.max(
+ (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
+ (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
+ timelineRef.current?.clientWidth || 1000
+ );
+
+ // Scroll synchronization and auto-scroll to playhead
+ const rulerScrollRef = useRef<HTMLDivElement>(null);
+ const tracksScrollRef = useRef<HTMLDivElement>(null);
+ const trackLabelsRef = useRef<HTMLDivElement>(null);
+ const playheadRef = useRef<HTMLDivElement>(null);
+ const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
+ const isUpdatingRef = useRef(false);
+ const lastRulerSync = useRef(0);
+ const lastTracksSync = useRef(0);
+ const lastVerticalSync = useRef(0);
+
+ // Timeline playhead ruler handlers
+ const { handleRulerMouseDown } = useTimelinePlayheadRuler({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ // Zoom actions
+ useTimelineZoomActions({
+ zoomLevel,
+ setZoomLevel,
+ onFitToWindow: handleFitToWindow,
+ });
+
+ // Selection box functionality
+ const tracksContainerRef = useRef<HTMLDivElement>(null);
+ const {
+ selectionBox,
+ handleMouseDown: handleSelectionMouseDown,
+ isSelecting,
+ justFinishedSelecting,
+ } = useSelectionBox({
+ containerRef: tracksContainerRef,
+ playheadRef,
+ onSelectionComplete: (elements) => {
+ console.log(JSON.stringify({ onSelectionComplete: elements.length }));
+ setSelectedElements(elements);
+ },
+ });
+
+ // Calculate snap indicator state
+ const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
+ null
+ );
+ const showSnapIndicator =
+ dragState.isDragging && snappingEnabled && currentSnapPoint !== null;
+
+ // Callback to handle snap point changes from TimelineTrackContent
+ const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
+ setCurrentSnapPoint(snapPoint);
+ }, []);
+
+ // Track mouse down to distinguish real clicks from drag/resize ends
+ const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => {
+ // Only track mouse down on timeline background areas (not elements)
+ const target = e.target as HTMLElement;
+ const isTimelineBackground =
+ !target.closest(".timeline-element") &&
+ !playheadRef.current?.contains(target) &&
+ !target.closest("[data-track-labels]");
+
+ if (isTimelineBackground) {
+ const now = Date.now();
+ mouseTrackingRef.current = {
+ isMouseDown: true,
+ downX: e.clientX,
+ downY: e.clientY,
+ downTime: now,
+ };
+ }
+ }, []);
+
+ // Track mouse up globally to reset mouse tracking
+ useEffect(() => {
+ const handleGlobalMouseUp = () => {
+ mouseTrackingRef.current.isMouseDown = false;
+ };
+
+ window.addEventListener("mouseup", handleGlobalMouseUp);
+ return () => window.removeEventListener("mouseup", handleGlobalMouseUp);
+ }, []);
+
+ // Handle clicks on timeline content (not playhead/ruler)
+ const handleTimelineContentClick = useCallback((e: React.MouseEvent) => {
+ // Only handle clicks if we didn't move much (distinguishing from drag end)
+ const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
+ const now = Date.now();
+ const distance = Math.sqrt(
+ Math.pow(e.clientX - downX, 2) + Math.pow(e.clientY - downY, 2)
+ );
+
+ // Not a real click if:
+ // - Mouse wasn't pressed down in timeline background
+ // - Too much movement (likely drag end)
+ // - Too much time passed (likely hold or slow drag)
+ if (!isMouseDown || distance > 5 || now - downTime > 300) {
+ return;
+ }
+
+ // Only proceed if we're not currently selecting and didn't just finish selecting
+ if (isSelecting || justFinishedSelecting) {
+ return;
+ }
+
+ // Get timeline content element and click position
+ const timelineContent = e.currentTarget as HTMLElement;
+ const rect = timelineContent.getBoundingClientRect();
+ const clickX = e.clientX - rect.left;
+
+ // Convert click position to time
+ const timeFromClick =
+ clickX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
+
+ // Snap to frame boundary for accurate seeking
+ const snappedTime = snapTimeToFrame(timeFromClick, 30);
+
+ // Seek to the clicked time
+ seek(Math.max(0, snappedTime));
+ }, [zoomLevel, seek, isSelecting, justFinishedSelecting]);
+
+ // Scroll synchronization effect
+ useEffect(() => {
+ const rulerViewport = rulerScrollRef.current;
+ const tracksViewport = tracksScrollRef.current?.parentElement;
+ const trackLabelsViewport = trackLabelsScrollRef.current?.parentElement;
+
+ if (!rulerViewport || !tracksViewport) return;
+
+ // Horizontal scroll synchronization between ruler and tracks
+ const handleRulerScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
+ lastRulerSync.current = now;
+ isUpdatingRef.current = true;
+ tracksViewport.scrollLeft = rulerViewport.scrollLeft;
+ isUpdatingRef.current = false;
+ };
+ const handleTracksScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
+ lastTracksSync.current = now;
+ isUpdatingRef.current = true;
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft;
+ isUpdatingRef.current = false;
+ };
+
+ rulerViewport.addEventListener("scroll", handleRulerScroll);
+ tracksViewport.addEventListener("scroll", handleTracksScroll);
+
+ // Vertical scroll synchronization between track labels and tracks content
+ if (trackLabelsViewport) {
+ const handleTrackLabelsScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
+ return;
+ lastVerticalSync.current = now;
+ isUpdatingRef.current = true;
+ tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
+ isUpdatingRef.current = false;
+ };
+ const handleTracksVerticalScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
+ return;
+ lastVerticalSync.current = now;
+ isUpdatingRef.current = true;
+ trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
+ isUpdatingRef.current = false;
+ };
+
+ trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
+ tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
+
+ return () => {
+ rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
+ trackLabelsViewport.removeEventListener("scroll", handleTrackLabelsScroll);
+ tracksViewport.removeEventListener("scroll", handleTracksVerticalScroll);
+ };
+ }
+
+ return () => {
+ rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
+ };
+ }, []);
+
+ return (
+ <div
+ className="h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden"
+ onMouseEnter={() => setIsInTimeline(true)}
+ onMouseLeave={() => setIsInTimeline(false)}
+ >
+ {/* Toolbar */}
+ <div className="border-b flex items-center justify-between px-2 py-1">
+ <div className="flex items-center gap-1 w-full">
+ <TooltipProvider delayDuration={500}>
+ {/* Play/Pause Button */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={toggle}
+ className="mr-2"
+ >
+ {isPlaying ? (
+ <Pause className="h-4 w-4" />
+ ) : (
+ <Play className="h-4 w-4" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {isPlaying ? "Pause (Space)" : "Play (Space)"}
+ </TooltipContent>
+ </Tooltip>
+
+ <div className="w-px h-6 bg-border mx-1" />
+
+ {/* Time Display */}
+ <div className="text-sm text-muted-foreground mr-2 font-mono">
+ {(() => {
+ const formatTime = (seconds: number) => {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ const s = Math.floor(seconds % 60);
+ const ms = Math.floor((seconds % 1) * 100);
+ return h > 0
+ ? `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms.toString().padStart(2, "0")}`
+ : `${m}:${s.toString().padStart(2, "0")}.${ms.toString().padStart(2, "0")}`;
+ };
+ return `${formatTime(currentTime)} / ${formatTime(duration)}`;
+ })()}
+ </div>
+
+ <div className="w-px h-6 bg-border mx-1" />
+
+ {/* Timeline Settings */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant={snappingEnabled ? "default" : "text"}
+ size="icon"
+ onClick={toggleSnapping}
+ >
+ <Snowflake className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {snappingEnabled ? "Disable Snapping" : "Enable Snapping"}
+ </TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant={rippleEditingEnabled ? "default" : "text"}
+ size="icon"
+ onClick={toggleRippleEditing}
+ >
+ <Link className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {rippleEditingEnabled ? "Disable Ripple Editing" : "Enable Ripple Editing"}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+
+ {/* Timeline Zoom Control */}
+ <div className="w-px h-6 bg-border mx-2" />
+ <TimelineZoomControl
+ zoomLevel={zoomLevel}
+ onZoomChange={setZoomLevel}
+ onFitToWindow={handleFitToWindow}
+ className="mr-2"
+ />
+ </div>
+ </div>
+
+ {/* Timeline Container */}
+ <div
+ className="flex-1 flex flex-col overflow-hidden relative"
+ ref={timelineRef}
+ >
+ <TimelinePlayhead
+ currentTime={currentTime}
+ duration={duration}
+ zoomLevel={zoomLevel}
+ tracks={tracks}
+ seek={seek}
+ rulerRef={rulerRef}
+ rulerScrollRef={rulerScrollRef}
+ tracksScrollRef={tracksScrollRef}
+ trackLabelsRef={trackLabelsRef}
+ timelineRef={timelineRef}
+ playheadRef={playheadRef}
+ isSnappingToPlayhead={
+ showSnapIndicator && currentSnapPoint?.type === "playhead"
+ }
+ />
+
+ <SnapIndicator
+ snapPoint={currentSnapPoint}
+ zoomLevel={zoomLevel}
+ tracks={tracks}
+ timelineRef={timelineRef}
+ trackLabelsRef={trackLabelsRef}
+ isVisible={showSnapIndicator}
+ />
+
+ {/* 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-muted/30 border-r flex items-center justify-between px-3 py-2">
+ <span className="text-sm font-medium text-muted-foreground opacity-0">.</span>
+ </div>
+
+ {/* Timeline Ruler */}
+ <div
+ className="flex-1 relative overflow-x-auto overflow-y-hidden h-4"
+ onWheel={handleWheel}
+ onMouseDown={handleSelectionMouseDown}
+ onClick={handleTimelineContentClick}
+ data-ruler-area
+ ref={rulerScrollRef}
+ >
+ <div
+ ref={rulerRef}
+ className="relative h-4 select-none cursor-default"
+ style={{
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ onMouseDown={handleRulerMouseDown}
+ >
+ {/* Time markers */}
+ {(() => {
+ const getTimeInterval = (zoom: number) => {
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom;
+ if (pixelsPerSecond >= 200) return 0.1;
+ if (pixelsPerSecond >= 100) return 0.5;
+ if (pixelsPerSecond >= 50) return 1;
+ if (pixelsPerSecond >= 25) return 2;
+ if (pixelsPerSecond >= 12) return 5;
+ if (pixelsPerSecond >= 6) return 10;
+ return 30;
+ };
+
+ const interval = getTimeInterval(zoomLevel);
+ const markerCount = Math.ceil(duration / interval) + 1;
+
+ return Array.from({ length: markerCount }, (_, i) => {
+ const time = i * interval;
+ if (time > duration) return null;
+
+ const isMainMarker = time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
+
+ return (
+ <div
+ key={i}
+ className={`absolute top-0 bottom-0 ${
+ isMainMarker
+ ? "border-l border-muted-foreground/40"
+ : "border-l border-muted-foreground/20"
+ }`}
+ style={{
+ left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
+ }}
+ >
+ <span
+ className={`absolute top-1 left-1 text-[0.6rem] ${
+ isMainMarker
+ ? "text-muted-foreground font-medium"
+ : "text-muted-foreground/70"
+ }`}
+ >
+ {(() => {
+ const formatTime = (seconds: number) => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`;
+ } else if (minutes > 0) {
+ return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`;
+ } else if (interval >= 1) {
+ return `${Math.floor(secs)}s`;
+ } else {
+ return `${secs.toFixed(1)}s`;
+ }
+ };
+ return formatTime(time);
+ })()}
+ </span>
+ </div>
+ );
+ }).filter(Boolean);
+ })()}
+ </div>
+ </div>
+ </div>
+
+ {/* Tracks Area */}
+ <div className="flex-1 flex overflow-hidden">
+ {/* Track Labels */}
+ {tracks.length > 0 && (
+ <div
+ ref={trackLabelsRef}
+ className="w-48 flex-shrink-0 border-r border-black overflow-y-auto"
+ data-track-labels
+ >
+ <div
+ ref={trackLabelsScrollRef}
+ className="w-full h-full"
+ >
+ <div className="flex flex-col gap-1">
+ {tracks.map((track) => (
+ <div
+ key={track.id}
+ className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5"
+ style={{ height: `${getTrackHeight(track.type)}px` }}
+ >
+ <div className="flex items-center flex-1 min-w-0">
+ <TrackIcon track={track} />
+ </div>
+ {track.muted && (
+ <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0">
+ Muted
+ </span>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Timeline Tracks Content */}
+ <div
+ className="flex-1 relative overflow-auto"
+ onWheel={handleWheel}
+ onMouseDown={(e) => {
+ handleTimelineMouseDown(e);
+ handleSelectionMouseDown(e);
+ }}
+ onClick={handleTimelineContentClick}
+ ref={tracksContainerRef}
+ >
+ <div
+ ref={tracksScrollRef}
+ className="w-full h-full"
+ >
+ <SelectionBox
+ startPos={selectionBox?.startPos || null}
+ currentPos={selectionBox?.currentPos || null}
+ containerRef={tracksContainerRef}
+ isActive={selectionBox?.isActive || false}
+ />
+ <div
+ className="relative flex-1"
+ style={{
+ height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`,
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ >
+ {tracks.length === 0 ? (
+ <div></div>
+ ) : (
+ <>
+ {tracks.map((track, index) => (
+ <ContextMenu key={track.id}>
+ <ContextMenuTrigger asChild>
+ <div
+ className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]"
+ style={{
+ top: `${getCumulativeHeightBefore(tracks, index)}px`,
+ height: `${getTrackHeight(track.type)}px`,
+ }}
+ onClick={(e) => {
+ if (
+ !(e.target as HTMLElement).closest(".timeline-element")
+ ) {
+ clearSelectedElements();
+ }
+ }}
+ >
+ <TimelineTrackContent
+ track={track}
+ zoomLevel={zoomLevel}
+ onSnapPointChange={handleSnapPointChange}
+ />
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem
+ onClick={() => toggleTrackMute(track.id)}
+ >
+ {track.muted ? "Unmute Track" : "Mute Track"}
+ </ContextMenuItem>
+ <ContextMenuItem>
+ Track settings (soon)
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ ))}
+ </>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function TrackIcon({ track }: { track: TimelineTrack }) {
+ return (
+ <>
+ {track.type === "media" && (
+ <Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
+ )}
+ {track.type === "text" && (
+ <TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
+ )}
+ {track.type === "audio" && (
+ <Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
+ )}
+ </>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline-fixed.tsx b/apps/web/src/components/editor/timeline-fixed.tsx
new file mode 100644
index 00000000..12b7012c
--- /dev/null
+++ b/apps/web/src/components/editor/timeline-fixed.tsx
@@ -0,0 +1,1008 @@
+"use client";
+
+import { Button } from "../ui/button";
+import {
+ Scissors,
+ ArrowLeftToLine,
+ ArrowRightToLine,
+ Trash2,
+ Snowflake,
+ Copy,
+ SplitSquareHorizontal,
+ Pause,
+ Play,
+ Video,
+ Music,
+ TypeIcon,
+ Lock,
+ LockOpen,
+ Link,
+} from "lucide-react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+ TooltipProvider,
+} from "../ui/tooltip";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "../ui/context-menu";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useMediaStore } from "@/stores/media-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+import { useProjectStore } from "@/stores/project-store";
+import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
+import { processMediaFiles } from "@/lib/media-processing";
+import { toast } from "sonner";
+import { useState, useRef, useEffect, useCallback } from "react";
+import { TimelineTrackContent } from "./timeline-track";
+import {
+ TimelinePlayhead,
+ useTimelinePlayheadRuler,
+} from "./timeline-playhead";
+import { SelectionBox } from "./selection-box";
+import { useSelectionBox } from "@/hooks/use-selection-box";
+import { SnapIndicator } from "./snap-indicator";
+import { SnapPoint } from "@/hooks/use-timeline-snapping";
+import type { DragData, TimelineTrack } from "@/types/timeline";
+import {
+ getTrackHeight,
+ getCumulativeHeightBefore,
+ getTotalTracksHeight,
+ TIMELINE_CONSTANTS,
+ snapTimeToFrame,
+} from "@/constants/timeline-constants";
+import { TimelineZoomControl } from "./timeline-zoom-control";
+import { useTimelineZoomActions } from "@/hooks/use-timeline-zoom-actions";
+
+export function Timeline() {
+ // Timeline shows all tracks (video, audio, effects) and their elements.
+ // You can drag media here to add it to your project.
+ // elements can be trimmed, deleted, and moved.
+
+ const {
+ tracks,
+ addTrack,
+ addElementToTrack,
+ removeElementFromTrack,
+ removeElementFromTrackWithRipple,
+ getTotalDuration,
+ calculateFitToWindowZoom,
+ selectedElements,
+ clearSelectedElements,
+ setSelectedElements,
+ splitElement,
+ splitAndKeepLeft,
+ splitAndKeepRight,
+ toggleTrackMute,
+ separateAudio,
+ snappingEnabled,
+ toggleSnapping,
+ rippleEditingEnabled,
+ toggleRippleEditing,
+ dragState,
+ } = useTimelineStore();
+ const { mediaItems, addMediaItem } = useMediaStore();
+ const { activeProject } = useProjectStore();
+ const { currentTime, duration, seek, setDuration, isPlaying, toggle } =
+ usePlaybackStore();
+ const [isDragOver, setIsDragOver] = useState(false);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const dragCounterRef = useRef(0);
+ const timelineRef = useRef<HTMLDivElement>(null);
+ const rulerRef = useRef<HTMLDivElement>(null);
+ const [isInTimeline, setIsInTimeline] = useState(false);
+
+ // Track mouse down/up for distinguishing clicks from drag/resize ends
+ const mouseTrackingRef = useRef({
+ isMouseDown: false,
+ downX: 0,
+ downY: 0,
+ downTime: 0,
+ });
+
+ // Fit to window function
+ const handleFitToWindow = useCallback(() => {
+ if (timelineRef.current) {
+ const containerWidth = timelineRef.current.clientWidth;
+ const optimalZoom = calculateFitToWindowZoom(containerWidth);
+ setZoomLevel(optimalZoom);
+ }
+ }, [calculateFitToWindowZoom]);
+
+ // Timeline zoom functionality
+ const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({
+ containerRef: timelineRef,
+ isInTimeline,
+ onFitToWindow: handleFitToWindow,
+ });
+
+ // Old marquee selection removed - using new SelectionBox component instead
+
+ // Dynamic timeline width calculation based on playhead position and duration
+ const dynamicTimelineWidth = Math.max(
+ (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration
+ (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer
+ timelineRef.current?.clientWidth || 1000 // Minimum width
+ );
+
+ // Scroll synchronization and auto-scroll to playhead
+ const rulerScrollRef = useRef<HTMLDivElement>(null);
+ const tracksScrollRef = useRef<HTMLDivElement>(null);
+ const trackLabelsRef = useRef<HTMLDivElement>(null);
+ const playheadRef = useRef<HTMLDivElement>(null);
+ const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
+ const isUpdatingRef = useRef(false);
+ const lastRulerSync = useRef(0);
+ const lastTracksSync = useRef(0);
+ const lastVerticalSync = useRef(0);
+
+ // Timeline playhead ruler handlers
+ const { handleRulerMouseDown } = useTimelinePlayheadRuler({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ // Zoom actions
+ useTimelineZoomActions({
+ zoomLevel,
+ setZoomLevel,
+ onFitToWindow: handleFitToWindow,
+ });
+
+ // Selection box functionality
+ const tracksContainerRef = useRef<HTMLDivElement>(null);
+ const {
+ selectionBox,
+ handleMouseDown: handleSelectionMouseDown,
+ isSelecting,
+ justFinishedSelecting,
+ } = useSelectionBox({
+ containerRef: tracksContainerRef,
+ playheadRef,
+ onSelectionComplete: (elements) => {
+ console.log(JSON.stringify({ onSelectionComplete: elements.length }));
+ setSelectedElements(elements);
+ },
+ });
+
+ // Calculate snap indicator state
+ const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
+ null
+ );
+ const showSnapIndicator =
+ dragState.isDragging && snappingEnabled && currentSnapPoint !== null;
+
+ // Callback to handle snap point changes from TimelineTrackContent
+ const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
+ setCurrentSnapPoint(snapPoint);
+ }, []);
+
+ // Track mouse down to distinguish real clicks from drag/resize ends
+ const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => {
+ // Only track mouse down on timeline background areas (not elements)
+ const target = e.target as HTMLElement;
+ const isTimelineBackground =
+ !target.closest(".timeline-element") &&
+ !playheadRef.current?.contains(target) &&
+ !target.closest("[data-track-labels]");
+
+ if (isTimelineBackground) {
+ const now = Date.now();
+ mouseTrackingRef.current = {
+ isMouseDown: true,
+ downX: e.clientX,
+ downY: e.clientY,
+ downTime: now,
+ };
+ }
+ }, []);
+
+ // Track mouse up globally to reset mouse tracking
+ useEffect(() => {
+ const handleGlobalMouseUp = () => {
+ mouseTrackingRef.current.isMouseDown = false;
+ };
+
+ window.addEventListener("mouseup", handleGlobalMouseUp);
+ return () => window.removeEventListener("mouseup", handleGlobalMouseUp);
+ }, []);
+
+ // Handle clicks on timeline content (not playhead/ruler)
+ const handleTimelineContentClick = useCallback((e: React.MouseEvent) => {
+ // Only handle clicks if we didn't move much (distinguishing from drag end)
+ const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
+ const now = Date.now();
+ const distance = Math.sqrt(
+ Math.pow(e.clientX - downX, 2) + Math.pow(e.clientY - downY, 2)
+ );
+
+ // Not a real click if:
+ // - Mouse wasn't pressed down in timeline background
+ // - Too much movement (likely drag end)
+ // - Too much time passed (likely hold or slow drag)
+ if (!isMouseDown || distance > 5 || now - downTime > 300) {
+ return;
+ }
+
+ // Only proceed if we're not currently selecting and didn't just finish selecting
+ if (isSelecting || justFinishedSelecting) {
+ return;
+ }
+
+ // Get timeline content element and click position
+ const timelineContent = e.currentTarget as HTMLElement;
+ const rect = timelineContent.getBoundingClientRect();
+ const clickX = e.clientX - rect.left;
+
+ // Convert click position to time
+ const timeFromClick =
+ clickX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
+
+ // Snap to frame boundary for accurate seeking
+ const snappedTime = snapTimeToFrame(timeFromClick);
+
+ // Seek to the clicked time
+ seek(Math.max(0, snappedTime));
+ }, [zoomLevel, seek, isSelecting, justFinishedSelecting]);
+
+ // Drag and drop handlers for adding media to timeline
+ const dragProps = {
+ onDragEnter: handleDragEnter,
+ onDragOver: handleDragOver,
+ onDragLeave: handleDragLeave,
+ onDrop: handleDrop,
+ };
+
+ function handleDragEnter(e: React.DragEvent) {
+ e.preventDefault();
+
+ // Don't update state for timeline elements - they're handled by tracks
+ if (e.dataTransfer.types.includes("application/x-timeline-element")) {
+ return;
+ }
+
+ dragCounterRef.current += 1;
+ if (dragCounterRef.current === 1) {
+ setIsDragOver(true);
+ }
+ }
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // Don't update state for timeline elements - they're handled by tracks
+ if (e.dataTransfer.types.includes("application/x-timeline-element")) {
+ return;
+ }
+
+ dragCounterRef.current -= 1;
+ if (dragCounterRef.current === 0) {
+ setIsDragOver(false);
+ }
+ };
+
+ const handleDrop = async (e: React.DragEvent) => {
+ // When media is dropped, add it as a new track/element
+ e.preventDefault();
+ setIsDragOver(false);
+ dragCounterRef.current = 0;
+
+ // Ignore timeline element drags - they're handled by track-specific handlers
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ if (hasTimelineElement) {
+ return;
+ }
+
+ const itemData = e.dataTransfer.getData("application/x-media-item");
+ if (itemData) {
+ try {
+ const dragData: DragData = JSON.parse(itemData);
+
+ if (dragData.type === "text") {
+ // Always create new text track to avoid overlaps
+ useTimelineStore.getState().addTextToNewTrack(dragData);
+ } else {
+ // Handle media items
+ const mediaItem = mediaItems.find(
+ (item: any) => item.id === dragData.id
+ );
+ if (!mediaItem) {
+ toast.error("Media item not found");
+ return;
+ }
+
+ // Add to first available track or create new one
+ const targetTrack = tracks.find(
+ (track) => track.type === "media" && track.elements.length === 0
+ );
+
+ if (targetTrack) {
+ await addElementToTrack(targetTrack.id, {
+ id: crypto.randomUUID(),
+ type: "media",
+ mediaId: mediaItem.id,
+ startTime: 0,
+ duration: mediaItem.duration,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ } else {
+ const trackId = crypto.randomUUID();
+ addTrack({
+ id: trackId,
+ type: "media",
+ name: `Track ${tracks.length + 1}`,
+ elements: [],
+ muted: false,
+ volume: 1,
+ });
+ await addElementToTrack(trackId, {
+ id: crypto.randomUUID(),
+ type: "media",
+ mediaId: mediaItem.id,
+ startTime: 0,
+ duration: mediaItem.duration,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }
+ }
+ } catch (error) {
+ console.error("Error parsing drag data:", error);
+ toast.error("Failed to add media to timeline");
+ }
+ return;
+ }
+
+ // Handle file drops (e.g., from desktop)
+ const files = Array.from(e.dataTransfer.files);
+ if (files.length > 0) {
+ setIsProcessing(true);
+ setProgress(0);
+ try {
+ const processedMedia = await processMediaFiles(files, (progress) => {
+ setProgress(progress);
+ });
+
+ for (const media of processedMedia) {
+ addMediaItem(media);
+
+ // Add to timeline automatically
+ const trackId = crypto.randomUUID();
+ addTrack({
+ id: trackId,
+ type: "media",
+ name: `Track ${tracks.length + 1}`,
+ elements: [],
+ muted: false,
+ volume: 1,
+ });
+ await addElementToTrack(trackId, {
+ id: crypto.randomUUID(),
+ type: "media",
+ mediaId: media.id,
+ startTime: 0,
+ duration: media.duration,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }
+
+ toast.success(`Added ${processedMedia.length} media files to timeline`);
+ } catch (error) {
+ console.error("Error processing dropped files:", error);
+ toast.error("Failed to process some files");
+ } finally {
+ setIsProcessing(false);
+ setProgress(0);
+ }
+ }
+ };
+
+ // Keyboard shortcuts for timeline actions
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Only handle shortcuts when timeline is focused or no input is focused
+ const isInputFocused = document.activeElement?.tagName.match(/INPUT|TEXTAREA|SELECT/);
+ if (isInputFocused) return;
+
+ if (e.key === 'Delete' || e.key === 'Backspace') {
+ e.preventDefault();
+ handleDeleteSelected();
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [selectedElements, rippleEditingEnabled]);
+
+ // Context menu handlers
+ const handleSplitElement = () => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element to split");
+ return;
+ }
+ const { trackId, elementId } = selectedElements[0];
+ splitElement(trackId, elementId, currentTime);
+ };
+
+ const handleSplitAndKeepLeft = () => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element to split");
+ return;
+ }
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
+ if (!track || !element) return;
+
+ const effectiveStart = element.startTime + element.trimStart;
+ 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);
+ };
+
+ const handleSplitAndKeepRight = () => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element to split");
+ return;
+ }
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
+ if (!track || !element) return;
+
+ const effectiveStart = element.startTime + element.trimStart;
+ 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);
+ };
+
+ 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);
+ };
+
+ const handleDeleteSelected = () => {
+ if (selectedElements.length === 0) {
+ toast.error("No elements selected");
+ return;
+ }
+ selectedElements.forEach(({ trackId, elementId }) => {
+ if (rippleEditingEnabled) {
+ removeElementFromTrackWithRipple(trackId, elementId);
+ } else {
+ removeElementFromTrack(trackId, elementId);
+ }
+ });
+ clearSelectedElements();
+ };
+
+ // --- Scroll synchronization effect ---
+ useEffect(() => {
+ const rulerViewport = rulerScrollRef.current;
+ const tracksViewport = tracksScrollRef.current?.parentElement; // The container with overflow-auto
+ const trackLabelsViewport = trackLabelsScrollRef.current?.parentElement; // The container with overflow-y-auto
+
+ if (!rulerViewport || !tracksViewport) return;
+
+ // Horizontal scroll synchronization between ruler and tracks
+ const handleRulerScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
+ lastRulerSync.current = now;
+ isUpdatingRef.current = true;
+ tracksViewport.scrollLeft = rulerViewport.scrollLeft;
+ isUpdatingRef.current = false;
+ };
+ const handleTracksScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
+ lastTracksSync.current = now;
+ isUpdatingRef.current = true;
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft;
+ isUpdatingRef.current = false;
+ };
+
+ rulerViewport.addEventListener("scroll", handleRulerScroll);
+ tracksViewport.addEventListener("scroll", handleTracksScroll);
+
+ // Vertical scroll synchronization between track labels and tracks content
+ if (trackLabelsViewport) {
+ const handleTrackLabelsScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
+ return;
+ lastVerticalSync.current = now;
+ isUpdatingRef.current = true;
+ tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
+ isUpdatingRef.current = false;
+ };
+ const handleTracksVerticalScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
+ return;
+ lastVerticalSync.current = now;
+ isUpdatingRef.current = true;
+ trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
+ isUpdatingRef.current = false;
+ };
+
+ trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
+ tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
+
+ return () => {
+ rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
+ trackLabelsViewport.removeEventListener("scroll", handleTrackLabelsScroll);
+ tracksViewport.removeEventListener("scroll", handleTracksVerticalScroll);
+ };
+ }
+
+ return () => {
+ rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
+ };
+ }, []);
+
+ return (
+ <div
+ className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`}
+ {...dragProps}
+ onMouseEnter={() => setIsInTimeline(true)}
+ onMouseLeave={() => setIsInTimeline(false)}
+ >
+ {/* Toolbar */}
+ <div className="border-b flex items-center justify-between px-2 py-1">
+ <div className="flex items-center gap-1 w-full">
+ <TooltipProvider delayDuration={500}>
+ {/* Play/Pause Button */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={toggle}
+ className="mr-2"
+ >
+ {isPlaying ? (
+ <Pause className="h-4 w-4" />
+ ) : (
+ <Play className="h-4 w-4" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {isPlaying ? "Pause (Space)" : "Play (Space)"}
+ </TooltipContent>
+ </Tooltip>
+ <div className="w-px h-6 bg-border mx-1" />
+ {/* Time Display */}
+ <div className="text-sm text-muted-foreground mr-2 font-mono">
+ {(() => {
+ const formatTime = (seconds: number) => {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ const s = Math.floor(seconds % 60);
+ const ms = Math.floor((seconds % 1) * 100);
+ return h > 0
+ ? `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms.toString().padStart(2, "0")}`
+ : `${m}:${s.toString().padStart(2, "0")}.${ms.toString().padStart(2, "0")}`;
+ };
+ return `${formatTime(currentTime)} / ${formatTime(duration)}`;
+ })()}
+ </div>
+ <div className="w-px h-6 bg-border mx-1" />
+ {/* Element Actions */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitElement}
+ disabled={selectedElements.length !== 1}
+ >
+ <Scissors className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split element at playhead (S)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitAndKeepLeft}
+ disabled={selectedElements.length !== 1}
+ >
+ <ArrowLeftToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep left part</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitAndKeepRight}
+ disabled={selectedElements.length !== 1}
+ >
+ <ArrowRightToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep right part</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleDeleteSelected}
+ disabled={selectedElements.length === 0}
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Delete selected elements (Del)</TooltipContent>
+ </Tooltip>
+ <div className="w-px h-6 bg-border mx-1" />
+ {/* Media Actions */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSeparateAudio}
+ disabled={
+ selectedElements.length !== 1 ||
+ !tracks
+ .find((t) => t.id === selectedElements[0]?.trackId)
+ ?.elements.find((e) => e.id === selectedElements[0]?.elementId)
+ }
+ >
+ <SplitSquareHorizontal className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Separate audio from video</TooltipContent>
+ </Tooltip>
+ <div className="w-px h-6 bg-border mx-1" />
+ {/* Timeline Settings */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant={snappingEnabled ? "accent" : "text"}
+ size="icon"
+ onClick={toggleSnapping}
+ >
+ <Snowflake className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {snappingEnabled ? "Disable Snapping" : "Enable Snapping"}
+ </TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant={rippleEditingEnabled ? "accent" : "text"}
+ size="icon"
+ onClick={toggleRippleEditing}
+ >
+ <Link className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {rippleEditingEnabled
+ ? "Disable Ripple Editing"
+ : "Enable Ripple Editing"}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+
+ {/* Timeline Zoom Control */}
+ <div className="w-px h-6 bg-border mx-2" />
+ <TimelineZoomControl
+ zoomLevel={zoomLevel}
+ onZoomChange={setZoomLevel}
+ onFitToWindow={handleFitToWindow}
+ className="mr-2"
+ />
+ </div>
+ </div>
+
+ {/* Timeline Container */}
+ <div
+ className="flex-1 flex flex-col overflow-hidden relative"
+ ref={timelineRef}
+ >
+ <TimelinePlayhead
+ currentTime={currentTime}
+ duration={duration}
+ zoomLevel={zoomLevel}
+ tracks={tracks}
+ seek={seek}
+ rulerRef={rulerRef}
+ rulerScrollRef={rulerScrollRef}
+ tracksScrollRef={tracksScrollRef}
+ trackLabelsRef={trackLabelsRef}
+ timelineRef={timelineRef}
+ playheadRef={playheadRef}
+ isSnappingToPlayhead={
+ showSnapIndicator && currentSnapPoint?.type === "playhead"
+ }
+ />
+ <SnapIndicator
+ snapPoint={currentSnapPoint}
+ zoomLevel={zoomLevel}
+ tracks={tracks}
+ timelineRef={timelineRef}
+ trackLabelsRef={trackLabelsRef}
+ isVisible={showSnapIndicator}
+ />
+ {/* 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-muted/30 border-r flex items-center justify-between px-3 py-2">
+ {/* Empty space */}
+ <span className="text-sm font-medium text-muted-foreground opacity-0">
+ .
+ </span>
+ </div>
+
+ {/* Timeline Ruler */}
+ <div
+ className="flex-1 relative overflow-x-auto overflow-y-hidden h-4"
+ onWheel={handleWheel}
+ onMouseDown={handleSelectionMouseDown}
+ onClick={handleTimelineContentClick}
+ data-ruler-area
+ ref={rulerScrollRef}
+ >
+ <div
+ ref={rulerRef}
+ className="relative h-4 select-none cursor-default"
+ style={{
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ onMouseDown={handleRulerMouseDown}
+ >
+ {/* Time markers */}
+ {(() => {
+ // Calculate appropriate time interval based on zoom level
+ const getTimeInterval = (zoom: number) => {
+ const pixelsPerSecond =
+ TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom;
+ if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in
+ if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in
+ if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom
+ if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out
+ if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out
+ if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out
+ return 30; // Every 30s when extremely zoomed out
+ };
+
+ const interval = getTimeInterval(zoomLevel);
+ const markerCount = Math.ceil(duration / interval) + 1;
+
+ return Array.from({ length: markerCount }, (_, i) => {
+ const time = i * interval;
+ if (time > duration) return null;
+
+ const isMainMarker =
+ time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
+
+ return (
+ <div
+ key={i}
+ className={`absolute top-0 bottom-0 ${
+ isMainMarker
+ ? "border-l border-muted-foreground/40"
+ : "border-l border-muted-foreground/20"
+ }`}
+ style={{
+ left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
+ }}
+ >
+ <span
+ className={`absolute top-1 left-1 text-[0.6rem] ${
+ isMainMarker
+ ? "text-muted-foreground font-medium"
+ : "text-muted-foreground/70"
+ }`}
+ >
+ {(() => {
+ const formatTime = (seconds: number) => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`;
+ } else if (minutes > 0) {
+ return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`;
+ } else if (interval >= 1) {
+ return `${Math.floor(secs)}s`;
+ } else {
+ return `${secs.toFixed(1)}s`;
+ }
+ };
+ return formatTime(time);
+ })()}
+ </span>
+ </div>
+ );
+ }).filter(Boolean);
+ })()}
+ </div>
+ </div>
+ </div>
+
+ {/* Tracks Area */}
+ <div className="flex-1 flex overflow-hidden">
+ {/* Track Labels */}
+ {tracks.length > 0 && (
+ <div
+ ref={trackLabelsRef}
+ className="w-48 flex-shrink-0 border-r border-black overflow-y-auto"
+ data-track-labels
+ >
+ <div
+ ref={trackLabelsScrollRef}
+ className="w-full h-full"
+ >
+ <div className="flex flex-col gap-1">
+ {tracks.map((track) => (
+ <div
+ key={track.id}
+ className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5"
+ style={{ height: `${getTrackHeight(track.type)}px` }}
+ >
+ <div className="flex items-center flex-1 min-w-0">
+ <TrackIcon track={track} />
+ </div>
+ {track.muted && (
+ <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0">
+ Muted
+ </span>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Timeline Tracks Content */}
+ <div
+ className="flex-1 relative overflow-auto"
+ onWheel={handleWheel}
+ onMouseDown={(e) => {
+ handleTimelineMouseDown(e);
+ handleSelectionMouseDown(e);
+ }}
+ onClick={handleTimelineContentClick}
+ ref={tracksContainerRef}
+ >
+ <div
+ ref={tracksScrollRef}
+ className="w-full h-full"
+ >
+ <SelectionBox
+ startPos={selectionBox?.startPos || null}
+ currentPos={selectionBox?.currentPos || null}
+ containerRef={tracksContainerRef}
+ isActive={selectionBox?.isActive || false}
+ />
+ <div
+ className="relative flex-1"
+ style={{
+ height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`,
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ >
+ {tracks.length === 0 ? (
+ <div></div>
+ ) : (
+ <>
+ {tracks.map((track, index) => (
+ <ContextMenu key={track.id}>
+ <ContextMenuTrigger asChild>
+ <div
+ className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]"
+ style={{
+ top: `${getCumulativeHeightBefore(tracks, index)}px`,
+ height: `${getTrackHeight(track.type)}px`,
+ }}
+ onClick={(e) => {
+ // If clicking empty area (not on a element), deselect all elements
+ if (
+ !(e.target as HTMLElement).closest(
+ ".timeline-element"
+ )
+ ) {
+ clearSelectedElements();
+ }
+ }}
+ >
+ <TimelineTrackContent
+ track={track}
+ zoomLevel={zoomLevel}
+ onSnapPointChange={handleSnapPointChange}
+ />
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem
+ onClick={() => toggleTrackMute(track.id)}
+ >
+ {track.muted ? "Unmute Track" : "Mute Track"}
+ </ContextMenuItem>
+ <ContextMenuItem>
+ Track settings (soon)
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ ))}
+ </>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function TrackIcon({ track }: { track: TimelineTrack }) {
+ return (
+ <>
+ {track.type === "media" && (
+ <Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
+ )}
+ {track.type === "text" && (
+ <TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
+ )}
+ {track.type === "audio" && (
+ <Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
+ )}
+ </>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline-zoom-control.tsx b/apps/web/src/components/editor/timeline-zoom-control.tsx
new file mode 100644
index 00000000..ab9c57a5
--- /dev/null
+++ b/apps/web/src/components/editor/timeline-zoom-control.tsx
@@ -0,0 +1,126 @@
+import { useState } from "react";
+import { Button } from "../ui/button";
+import { Slider } from "../ui/slider";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "../ui/tooltip";
+import { ZoomIn, ZoomOut, Maximize2 } from "lucide-react";
+
+interface TimelineZoomControlProps {
+ zoomLevel: number;
+ onZoomChange: (zoom: number) => void;
+ onFitToWindow?: () => void;
+ className?: string;
+}
+
+export function TimelineZoomControl({
+ zoomLevel,
+ onZoomChange,
+ onFitToWindow,
+ className,
+}: TimelineZoomControlProps) {
+ // Convert zoom level to percentage for display
+ const zoomPercentage = Math.round(zoomLevel * 100);
+
+ const handleZoomIn = () => {
+ onZoomChange(Math.min(10, zoomLevel + 0.2));
+ };
+
+ const handleZoomOut = () => {
+ onZoomChange(Math.max(0.1, zoomLevel - 0.2));
+ };
+
+ const handleReset = () => {
+ if (onFitToWindow) {
+ onFitToWindow();
+ } else {
+ onZoomChange(1);
+ }
+ };
+
+ const handleSliderChange = (value: number[]) => {
+ onZoomChange(value[0]);
+ };
+
+ return (
+ <div className={`flex items-center gap-1 ${className || ""}`}>
+ {/* Zoom Out Button */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleZoomOut}
+ disabled={zoomLevel <= 0.1}
+ className="h-7 w-7 p-0"
+ >
+ <ZoomOut className="h-3 w-3" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>Zoom out</p>
+ <p className="text-xs text-muted-foreground">Ctrl + - / Ctrl + Wheel</p>
+ </TooltipContent>
+ </Tooltip>
+
+ {/* Zoom Slider */}
+ <div className="flex items-center gap-2 w-20">
+ <Slider
+ value={[zoomLevel]}
+ onValueChange={handleSliderChange}
+ min={0.1}
+ max={5}
+ step={0.1}
+ className="flex-1"
+ />
+ </div>
+
+ {/* Zoom In Button */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleZoomIn}
+ disabled={zoomLevel >= 5}
+ className="h-7 w-7 p-0"
+ >
+ <ZoomIn className="h-3 w-3" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>Zoom in</p>
+ <p className="text-xs text-muted-foreground">Ctrl + + / Ctrl + Wheel</p>
+ </TooltipContent>
+ </Tooltip>
+
+ {/* Fit to Window / Reset Button */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleReset}
+ className="h-7 px-2 min-w-[60px] flex items-center gap-1"
+ >
+ {onFitToWindow ? (
+ <>
+ <Maximize2 className="h-3 w-3" />
+ </>
+ ) : (
+ <span className="text-xs font-mono">{zoomPercentage}%</span>
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{onFitToWindow ? "Fit to Window" : "Reset Zoom"}</p>
+ <p className="text-xs text-muted-foreground">
+ {onFitToWindow ? "Show all track content" : "Ctrl + 0"}
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index 9e68fd4d..84c228f0 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -56,6 +56,8 @@ import {
TIMELINE_CONSTANTS,
snapTimeToFrame,
} from "@/constants/timeline-constants";
+import { TimelineZoomControl } from "./timeline-zoom-control";
+import { useTimelineZoomActions } from "@/hooks/use-timeline-zoom-actions";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their elements.
@@ -69,6 +71,7 @@ export function Timeline() {
removeElementFromTrack,
removeElementFromTrackWithRipple,
getTotalDuration,
+ calculateFitToWindowZoom,
selectedElements,
clearSelectedElements,
setSelectedElements,
@@ -103,10 +106,20 @@ export function Timeline() {
downTime: 0,
});
+ // Fit to window function
+ const handleFitToWindow = useCallback(() => {
+ if (timelineRef.current) {
+ const containerWidth = timelineRef.current.clientWidth;
+ const optimalZoom = calculateFitToWindowZoom(containerWidth);
+ setZoomLevel(optimalZoom);
+ }
+ }, [calculateFitToWindowZoom]);
+
// Timeline zoom functionality
const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({
containerRef: timelineRef,
isInTimeline,
+ onFitToWindow: handleFitToWindow,
});
// Old marquee selection removed - using new SelectionBox component instead
@@ -141,6 +154,13 @@ export function Timeline() {
playheadRef,
});
+ // Zoom actions
+ useTimelineZoomActions({
+ zoomLevel,
+ setZoomLevel,
+ onFitToWindow: handleFitToWindow,
+ });
+
// Selection box functionality
const tracksContainerRef = useRef<HTMLDivElement>(null);
const {
@@ -836,6 +856,15 @@ export function Timeline() {
</TooltipContent>
</Tooltip>
</TooltipProvider>
+
+ {/* Timeline Zoom Control */}
+ <div className="w-px h-6 bg-border mx-2" />
+ <TimelineZoomControl
+ zoomLevel={zoomLevel}
+ onZoomChange={setZoomLevel}
+ onFitToWindow={handleFitToWindow}
+ className="mr-2"
+ />
</div>
</div>
@@ -880,21 +909,21 @@ export function Timeline() {
{/* Timeline Ruler */}
<div
- className="flex-1 relative overflow-hidden h-4"
+ className="flex-1 relative overflow-x-auto overflow-y-hidden h-4"
onWheel={handleWheel}
onMouseDown={handleSelectionMouseDown}
onClick={handleTimelineContentClick}
data-ruler-area
+ ref={rulerScrollRef}
>
- <ScrollArea className="w-full" ref={rulerScrollRef}>
- <div
- ref={rulerRef}
- className="relative h-4 select-none cursor-default"
- style={{
- width: `${dynamicTimelineWidth}px`,
- }}
- onMouseDown={handleRulerMouseDown}
- >
+ <div
+ ref={rulerRef}
+ className="relative h-4 select-none cursor-default"
+ style={{
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ onMouseDown={handleRulerMouseDown}
+ >
{/* Time markers */}
{(() => {
// Calculate appropriate time interval based on zoom level
@@ -962,8 +991,7 @@ export function Timeline() {
);
}).filter(Boolean);
})()}
- </div>
- </ScrollArea>
+ </div>
</div>
</div>
@@ -976,7 +1004,10 @@ export function Timeline() {
className="w-48 flex-shrink-0 border-r border-black overflow-y-auto"
data-track-labels
>
- <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}>
+ <ScrollArea
+ ref={trackLabelsScrollRef}
+ className="w-full h-full"
+ >
<div className="flex flex-col gap-1">
{tracks.map((track) => (
<div
@@ -1001,7 +1032,7 @@ export function Timeline() {
{/* Timeline Tracks Content */}
<div
- className="flex-1 relative overflow-hidden"
+ className="flex-1 relative overflow-auto"
onWheel={handleWheel}
onMouseDown={(e) => {
handleTimelineMouseDown(e);
@@ -1010,13 +1041,16 @@ export function Timeline() {
onClick={handleTimelineContentClick}
ref={tracksContainerRef}
>
- <SelectionBox
- startPos={selectionBox?.startPos || null}
- currentPos={selectionBox?.currentPos || null}
- containerRef={tracksContainerRef}
- isActive={selectionBox?.isActive || false}
- />
- <ScrollArea className="w-full h-full" ref={tracksScrollRef}>
+ <div
+ ref={tracksScrollRef}
+ className="w-full h-full"
+ >
+ <SelectionBox
+ startPos={selectionBox?.startPos || null}
+ currentPos={selectionBox?.currentPos || null}
+ containerRef={tracksContainerRef}
+ isActive={selectionBox?.isActive || false}
+ />
<div
className="relative flex-1"
style={{
@@ -1070,7 +1104,7 @@ export function Timeline() {
</>
)}
</div>
- </ScrollArea>
+ </div>
</div>
</div>
</div>
diff --git a/apps/web/src/constants/actions.ts b/apps/web/src/constants/actions.ts
index 5f1883f1..6945d148 100644
--- a/apps/web/src/constants/actions.ts
+++ b/apps/web/src/constants/actions.ts
@@ -44,6 +44,10 @@ export type Action =
| "select-all" // Select all elements
| "duplicate-selected" // Duplicate selected element
| "toggle-snapping" // Toggle snapping
+ | "zoom-in" // Zoom in timeline
+ | "zoom-out" // Zoom out timeline
+ | "zoom-reset" // Reset timeline zoom
+ | "zoom-fit" // Fit timeline to window
| "undo" // Undo last action
| "redo"; // Redo last undone action
diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
index c7de8466..d0f177f6 100644
--- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
+++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
@@ -60,6 +60,10 @@ const actionDescriptions: Record<
category: "Selection",
},
"toggle-snapping": { description: "Toggle snapping", category: "Editing" },
+ "zoom-in": { description: "Zoom in timeline", category: "View" },
+ "zoom-out": { description: "Zoom out timeline", category: "View" },
+ "zoom-reset": { description: "Reset timeline zoom", category: "View" },
+ "zoom-fit": { description: "Fit timeline to window", category: "View" },
undo: { description: "Undo", category: "History" },
redo: { description: "Redo", category: "History" },
};
diff --git a/apps/web/src/hooks/use-timeline-zoom-actions.ts b/apps/web/src/hooks/use-timeline-zoom-actions.ts
new file mode 100644
index 00000000..85003684
--- /dev/null
+++ b/apps/web/src/hooks/use-timeline-zoom-actions.ts
@@ -0,0 +1,37 @@
+"use client";
+
+import { useActionHandler } from "@/constants/actions";
+
+interface UseTimelineZoomActionsProps {
+ zoomLevel: number;
+ setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
+ onFitToWindow?: () => void;
+}
+
+export function useTimelineZoomActions({
+ zoomLevel,
+ setZoomLevel,
+ onFitToWindow,
+}: UseTimelineZoomActionsProps) {
+ // Zoom in action
+ useActionHandler("zoom-in", () => {
+ setZoomLevel((prev) => Math.min(5, prev + 0.2));
+ });
+
+ // Zoom out action
+ useActionHandler("zoom-out", () => {
+ setZoomLevel((prev) => Math.max(0.1, prev - 0.2));
+ });
+
+ // Reset zoom action
+ useActionHandler("zoom-reset", () => {
+ setZoomLevel(1);
+ });
+
+ // Fit to window action
+ useActionHandler("zoom-fit", () => {
+ if (onFitToWindow) {
+ onFitToWindow();
+ }
+ });
+}
diff --git a/apps/web/src/hooks/use-timeline-zoom.ts b/apps/web/src/hooks/use-timeline-zoom.ts
index ce32c9f0..8d41d917 100644
--- a/apps/web/src/hooks/use-timeline-zoom.ts
+++ b/apps/web/src/hooks/use-timeline-zoom.ts
@@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, RefObject } from "react";
interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement>;
isInTimeline?: boolean;
+ onFitToWindow?: () => void;
}
interface UseTimelineZoomReturn {
@@ -11,22 +12,81 @@ interface UseTimelineZoomReturn {
handleWheel: (e: React.WheelEvent) => void;
}
+const ZOOM_STORAGE_KEY = 'opencut-timeline-zoom';
+
export function useTimelineZoom({
containerRef,
isInTimeline = false,
+ onFitToWindow,
}: UseTimelineZoomProps): UseTimelineZoomReturn {
- const [zoomLevel, setZoomLevel] = useState(1);
+ // Load initial zoom level from localStorage
+ const [zoomLevel, setZoomLevel] = useState(() => {
+ if (typeof window !== 'undefined') {
+ const stored = localStorage.getItem(ZOOM_STORAGE_KEY);
+ if (stored) {
+ const parsed = parseFloat(stored);
+ if (!isNaN(parsed) && parsed >= 0.1 && parsed <= 5) {
+ return parsed;
+ }
+ }
+ }
+ return 1;
+ });
+
+ // Save zoom level to localStorage whenever it changes
+ useEffect(() => {
+ if (typeof window !== 'undefined') {
+ localStorage.setItem(ZOOM_STORAGE_KEY, zoomLevel.toString());
+ }
+ }, [zoomLevel]);
const handleWheel = useCallback((e: React.WheelEvent) => {
// Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.15 : 0.15;
- setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
+ setZoomLevel((prev) => Math.max(0.1, Math.min(5, prev + delta)));
}
// Otherwise, allow normal scrolling
}, []);
+ // Keyboard shortcuts for zoom
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Only handle zoom shortcuts when in timeline or when timeline is focused
+ if (!isInTimeline) return;
+
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
+ switch (e.key) {
+ case '=':
+ case '+':
+ e.preventDefault();
+ setZoomLevel((prev) => Math.min(5, prev + 0.2));
+ break;
+ case '-':
+ e.preventDefault();
+ setZoomLevel((prev) => Math.max(0.1, prev - 0.2));
+ break;
+ case '0':
+ e.preventDefault();
+ setZoomLevel(1);
+ break;
+ case '9':
+ e.preventDefault();
+ if (onFitToWindow) {
+ onFitToWindow();
+ }
+ break;
+ }
+ }
+ };
+
+ if (isInTimeline) {
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }
+ }, [isInTimeline, setZoomLevel]);
+
// Prevent browser zooming in/out when in timeline
useEffect(() => {
const preventZoom = (e: WheelEvent) => {
diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts
index 60c71afd..f36f2632 100644
--- a/apps/web/src/stores/keybindings-store.ts
+++ b/apps/web/src/stores/keybindings-store.ts
@@ -25,6 +25,10 @@ export const defaultKeybindings: KeybindingConfig = {
"ctrl+z": "undo",
"ctrl+shift+z": "redo",
"ctrl+y": "redo",
+ "ctrl+=": "zoom-in",
+ "ctrl+-": "zoom-out",
+ "ctrl+0": "zoom-reset",
+ "ctrl+9": "zoom-fit",
delete: "delete-selected",
backspace: "delete-selected",
};
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 1fcfc60e..00bbc743 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -158,6 +158,8 @@ interface TimelineStore {
// Computed values
getTotalDuration: () => number;
+ getContentWidth: (zoomLevel: number) => number;
+ calculateFitToWindowZoom: (containerWidth: number) => number;
// History actions
undo: () => void;
@@ -1157,6 +1159,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
return Math.max(...trackEndTimes, 0);
},
+ getContentWidth: (zoomLevel: number) => {
+ const totalDuration = get().getTotalDuration();
+ return totalDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ },
+
+ calculateFitToWindowZoom: (containerWidth: number) => {
+ const totalDuration = get().getTotalDuration();
+ if (totalDuration === 0) return 1;
+
+ // Leave some padding (10% on each side)
+ const availableWidth = containerWidth * 0.8;
+ const requiredWidth = totalDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND;
+ const calculatedZoom = availableWidth / requiredWidth;
+
+ // Clamp between min and max zoom levels
+ return Math.max(0.1, Math.min(5, calculatedZoom));
+ },
+
redo: () => {
const { redoStack } = get();
if (redoStack.length === 0) return;
diff --git a/apps/web/src/types/keybinding.ts b/apps/web/src/types/keybinding.ts
index d97c88da..8ec12cda 100644
--- a/apps/web/src/types/keybinding.ts
+++ b/apps/web/src/types/keybinding.ts
@@ -20,7 +20,7 @@ export type Key =
| "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t"
| "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3"
| "4" | "5" | "6" | "7" | "8" | "9" | "up" | "down" | "left"
- | "right" | "/" | "?" | "." | "enter" | "tab" | "space"
+ | "right" | "/" | "?" | "." | "+" | "-" | "=" | "enter" | "tab" | "space"
| "escape" | "esc" | "backspace" | "delete" | "home" | "end"
/* eslint-enable */