From d6724e2552d14140017b194e7020252bda73751a Mon Sep 17 00:00:00 2001 From: leimingsheng Date: Mon, 21 Jul 2025 19:15:52 +0800 Subject: [PATCH 1/3] feat: init timeline zoom --- .../src/components/editor/timeline-clean.tsx | 665 +++++++++++ .../src/components/editor/timeline-fixed.tsx | 1008 +++++++++++++++++ .../editor/timeline-zoom-control.tsx | 126 +++ apps/web/src/components/editor/timeline.tsx | 78 +- apps/web/src/constants/actions.ts | 4 + .../src/hooks/use-keyboard-shortcuts-help.ts | 4 + .../src/hooks/use-timeline-zoom-actions.ts | 37 + apps/web/src/hooks/use-timeline-zoom.ts | 64 +- apps/web/src/stores/keybindings-store.ts | 4 + apps/web/src/stores/timeline-store.ts | 20 + apps/web/src/types/keybinding.ts | 2 +- apps/web/tsconfig.tsbuildinfo | 1 + 12 files changed, 1988 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/components/editor/timeline-clean.tsx create mode 100644 apps/web/src/components/editor/timeline-fixed.tsx create mode 100644 apps/web/src/components/editor/timeline-zoom-control.tsx create mode 100644 apps/web/src/hooks/use-timeline-zoom-actions.ts create mode 100644 apps/web/tsconfig.tsbuildinfo 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(null); + const rulerRef = useRef(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(null); + const tracksScrollRef = useRef(null); + const trackLabelsRef = useRef(null); + const playheadRef = useRef(null); + const trackLabelsScrollRef = useRef(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(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( + 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 ( +
setIsInTimeline(true)} + onMouseLeave={() => setIsInTimeline(false)} + > + {/* Toolbar */} +
+
+ + {/* Play/Pause Button */} + + + + + + {isPlaying ? "Pause (Space)" : "Play (Space)"} + + + +
+ + {/* Time Display */} +
+ {(() => { + 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)}`; + })()} +
+ +
+ + {/* Timeline Settings */} + + + + + + {snappingEnabled ? "Disable Snapping" : "Enable Snapping"} + + + + + + + + + {rippleEditingEnabled ? "Disable Ripple Editing" : "Enable Ripple Editing"} + + + + + {/* Timeline Zoom Control */} +
+ +
+
+ + {/* Timeline Container */} +
+ + + + + {/* Timeline Header with Ruler */} +
+ {/* Track Labels Header */} +
+ . +
+ + {/* Timeline Ruler */} +
+
+ {/* 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 ( +
+ + {(() => { + 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); + })()} + +
+ ); + }).filter(Boolean); + })()} +
+
+
+ + {/* Tracks Area */} +
+ {/* Track Labels */} + {tracks.length > 0 && ( +
+
+
+ {tracks.map((track) => ( +
+
+ +
+ {track.muted && ( + + Muted + + )} +
+ ))} +
+
+
+ )} + + {/* Timeline Tracks Content */} +
{ + handleTimelineMouseDown(e); + handleSelectionMouseDown(e); + }} + onClick={handleTimelineContentClick} + ref={tracksContainerRef} + > +
+ +
+ {tracks.length === 0 ? ( +
+ ) : ( + <> + {tracks.map((track, index) => ( + + +
{ + if ( + !(e.target as HTMLElement).closest(".timeline-element") + ) { + clearSelectedElements(); + } + }} + > + +
+
+ + toggleTrackMute(track.id)} + > + {track.muted ? "Unmute Track" : "Mute Track"} + + + Track settings (soon) + + +
+ ))} + + )} +
+
+
+
+
+
+ ); +} + +function TrackIcon({ track }: { track: TimelineTrack }) { + return ( + <> + {track.type === "media" && ( +