diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-25 16:47:22 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:48:22 +0200 |
| commit | 8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch) | |
| tree | e9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/components/editor/panels/timeline | |
| parent | 39ea298a9ccba3104c549c0ca079c231981811f5 (diff) | |
| download | opencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz | |
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline')
11 files changed, 1066 insertions, 748 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx index bbc5cb83..cbb19bd0 100644 --- a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx +++ b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx @@ -1,176 +1,167 @@ -import { useEffect, useRef, useState } from "react"; -import WaveSurfer from "wavesurfer.js"; +"use client"; +import { useCallback, useEffect, useRef } from "react"; +import { useResizeObserver } from "@/hooks/use-resize-observer"; +import { computeGlobalMaxRms, extractRmsRange } from "@/lib/media/audio"; +import { findScrollParent } from "@/utils/browser"; +import { cn } from "@/utils/ui"; + +const BAR_WIDTH = 2; +const BAR_GAP = 1; +const BAR_STEP = BAR_WIDTH + BAR_GAP; interface AudioWaveformProps { audioUrl?: string; audioBuffer?: AudioBuffer; - height?: number; + color?: string; className?: string; } -function extractPeaks({ - buffer, - length = 512, -}: { - buffer: AudioBuffer; - length?: number; -}): number[][] { - const channels = buffer.numberOfChannels; - const peaks: number[][] = []; - - for (let c = 0; c < channels; c++) { - const data = buffer.getChannelData(c); - const step = Math.floor(data.length / length); - const channelPeaks: number[] = []; - - for (let i = 0; i < length; i++) { - const start = i * step; - const end = Math.min(start + step, data.length); - let max = 0; - for (let j = start; j < end; j++) { - const abs = Math.abs(data[j]); - if (abs > max) max = abs; - } - channelPeaks.push(max); - } - peaks.push(channelPeaks); - } - - return peaks; -} - export function AudioWaveform({ audioUrl, audioBuffer, - height = 32, + color = "rgba(255, 255, 255, 0.7)", className = "", }: AudioWaveformProps) { - const waveformRef = useRef<HTMLDivElement>(null); - const wavesurfer = useRef<WaveSurfer | null>(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(false); + const canvasRef = useRef<HTMLCanvasElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const bufferRef = useRef<AudioBuffer | null>(null); + const globalMaxRef = useRef<number>(1); + const scrollParentRef = useRef<HTMLElement | null>(null); + const heightRef = useRef<number>(0); - useEffect(() => { - let mounted = true; - const ws = wavesurfer.current; + const drawVisible = useCallback(() => { + const container = containerRef.current; + const canvas = canvasRef.current; + const buffer = bufferRef.current; + const height = heightRef.current; - const initWaveSurfer = async () => { - if (!waveformRef.current || (!audioUrl && !audioBuffer)) return; + if (!container || !canvas || !buffer || height <= 0) return; - try { - if (ws) { - wavesurfer.current = null; - } + const elementWidth = container.offsetWidth; + if (elementWidth <= 0) return; - const newWaveSurfer = WaveSurfer.create({ - container: waveformRef.current, - waveColor: "rgba(255, 255, 255, 0.6)", - progressColor: "rgba(255, 255, 255, 0.9)", - cursorColor: "transparent", - barWidth: 2, - barGap: 1, - height, - normalize: true, - interact: false, - }); + const containerRect = container.getBoundingClientRect(); + const scrollParent = scrollParentRef.current; - if (mounted) { - wavesurfer.current = newWaveSurfer; - } else { - try { - newWaveSurfer.destroy(); - } catch {} - return; - } + let clipLeft: number; + let clipRight: number; - newWaveSurfer.on("ready", () => { - if (mounted) { - setIsLoading(false); - setError(false); - } - }); + if (scrollParent) { + const parentRect = scrollParent.getBoundingClientRect(); + clipLeft = Math.max(0, parentRect.left - containerRect.left); + clipRight = Math.min(elementWidth, parentRect.right - containerRect.left); + } else { + clipLeft = Math.max(0, -containerRect.left); + clipRight = Math.min( + elementWidth, + window.innerWidth - containerRect.left, + ); + } - newWaveSurfer.on("error", (err) => { - if (mounted) { - console.error("WaveSurfer error:", err); - setError(true); - setIsLoading(false); - } - }); + const visibleWidth = clipRight - clipLeft; + if (visibleWidth <= 0) return; - if (audioBuffer) { - const peaks = extractPeaks({ buffer: audioBuffer }); - newWaveSurfer.load("", peaks, audioBuffer.duration); - } else if (audioUrl) { - await newWaveSurfer.load(audioUrl); - } - } catch (err) { - if (mounted) { - console.error("Failed to initialize WaveSurfer:", err); - setError(true); - setIsLoading(false); - } - } - }; + const dpr = window.devicePixelRatio || 1; + const canvasW = Math.round(visibleWidth * dpr); + const canvasH = Math.round(height * dpr); + + if (canvasW <= 0 || canvasH <= 0) return; + + canvas.width = canvasW; + canvas.height = canvasH; + canvas.style.width = `${visibleWidth}px`; + canvas.style.height = `${height}px`; + canvas.style.left = `${clipLeft}px`; + + const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP)); + const startFraction = clipLeft / elementWidth; + const endFraction = clipRight / elementWidth; + const startSample = Math.floor(startFraction * buffer.length); + const endSample = Math.min( + buffer.length, + Math.ceil(endFraction * buffer.length), + ); - if (ws) { - const wsToDestroy = ws; - wavesurfer.current = null; + const peaks = extractRmsRange({ + buffer, + count: barCount, + startSample, + endSample, + globalMax: globalMaxRef.current, + }); - requestAnimationFrame(() => { + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.clearRect(0, 0, canvasW, canvasH); + ctx.scale(dpr, dpr); + ctx.fillStyle = color; + + const maxBarHeight = height * 0.7; + + for (let i = 0; i < barCount; i++) { + const scaled = Math.log1p(peaks[i]) / Math.log1p(1); + const barH = Math.max(1, scaled * maxBarHeight); + ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH); + } + }, [color]); + + useEffect(() => { + let isCancelled = false; + + async function load() { + let buffer = audioBuffer ?? null; + + if (!buffer && audioUrl) { try { - wsToDestroy.destroy(); - } catch {} - if (mounted) { - initWaveSurfer(); + const resp = await fetch(audioUrl); + const arrayBuffer = await resp.arrayBuffer(); + const actx = new AudioContext(); + buffer = await actx.decodeAudioData(arrayBuffer); + actx.close(); + } catch { + return; } - }); - } else { - initWaveSurfer(); + } + + if (!buffer || isCancelled) return; + + bufferRef.current = buffer; + globalMaxRef.current = computeGlobalMaxRms({ buffer }); + drawVisible(); } + load(); return () => { - mounted = false; + isCancelled = true; + }; + }, [audioUrl, audioBuffer, drawVisible]); - const wsToDestroy = wavesurfer.current; + useEffect(() => { + const container = containerRef.current; + if (!container) return; - wavesurfer.current = null; + scrollParentRef.current = findScrollParent({ element: container }); + const scrollParent = scrollParentRef.current; + if (!scrollParent) return; - if (wsToDestroy) { - requestAnimationFrame(() => { - try { - wsToDestroy.destroy(); - } catch {} - }); - } - }; - }, [audioUrl, audioBuffer, height]); + scrollParent.addEventListener("scroll", drawVisible, { passive: true }); + return () => scrollParent.removeEventListener("scroll", drawVisible); + }, [drawVisible]); - if (error) { - return ( - <div - className={`flex items-center justify-center ${className}`} - style={{ height }} - > - <span className="text-foreground/60 text-xs">Audio unavailable</span> - </div> - ); - } + const onResize = useCallback( + (entry: ResizeObserverEntry) => { + heightRef.current = entry.contentRect.height; + drawVisible(); + }, + [drawVisible], + ); + + useResizeObserver({ ref: containerRef, onResize }); return ( - <div className={`relative ${className}`}> - {isLoading && ( - <div className="absolute inset-0 flex items-center justify-center"> - <span className="text-foreground/60 text-xs">Loading...</span> - </div> - )} - <div - ref={waveformRef} - className={`w-full ${isLoading ? "opacity-0" : "opacity-100"}`} - style={{ height }} - /> + <div ref={containerRef} className={cn("relative size-full", className)}> + <canvas ref={canvasRef} className="absolute bottom-0" /> </div> ); } - -export default AudioWaveform; diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx index 864bb557..7000f98b 100644 --- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx @@ -5,7 +5,10 @@ import type { EditorCore } from "@/core"; import { useEditor } from "@/hooks/use-editor"; import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag"; import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; -import { DEFAULT_BOOKMARK_COLOR } from "@/constants/timeline-constants"; +import { + DEFAULT_BOOKMARK_COLOR, + TIMELINE_BOOKMARK_ROW_HEIGHT, +} from "@/constants/timeline-constants"; import { DEFAULT_FPS } from "@/constants/project-constants"; import { getSnappedSeekTime } from "@/lib/time"; import { @@ -13,7 +16,7 @@ import { Delete02Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import type { Bookmark } from "@/types/timeline"; +import type { Bookmark } from "@/lib/timeline"; import { Popover, PopoverAnchor, @@ -25,7 +28,10 @@ import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { uppercase } from "@/utils/string"; import { clamp } from "@/utils/math"; -import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/lib/timeline"; +import { + timelineTimeToPixels, + timelineTimeToSnappedPixels, +} from "@/lib/timeline"; const MIN_BOOKMARK_WIDTH_PX = 2; const BOOKMARK_MARKER_WIDTH_PX = 12; @@ -72,14 +78,17 @@ export function TimelineBookmarksRow({ handleRulerTrackingMouseDown, handleRulerMouseDown, }: TimelineBookmarksRowProps) { - const editor = useEditor(); - const activeScene = editor.scenes.getActiveScene(); + const bookmarks = useEditor((e) => e.scenes.getActiveScene().bookmarks); return ( - <div className="relative h-4 flex-1 overflow-hidden"> + <div + className="relative flex-1 overflow-hidden" + style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT }} + > <button - className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0" + className="relative w-full cursor-default select-none border-0 bg-transparent p-0" style={{ + height: TIMELINE_BOOKMARK_ROW_HEIGHT, width: `${dynamicTimelineWidth}px`, }} aria-label="Timeline ruler" @@ -95,7 +104,7 @@ export function TimelineBookmarksRow({ handleRulerTrackingMouseDown(event); }} > - {activeScene.bookmarks.map((bookmark) => ( + {bookmarks.map((bookmark) => ( <TimelineBookmark key={`bookmark-${bookmark.time}`} bookmark={bookmark} @@ -174,7 +183,7 @@ function TimelineBookmark({ <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}> <PopoverAnchor asChild> <button - className={`absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0 ${isDragging ? "cursor-grabbing" : "cursor-grab"}`} + className="absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0" style={{ left: `${bookmarkLeft}px`, width: `${bookmarkWidth}px`, diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx index 291a4633..7597a620 100644 --- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx +++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx @@ -1,5 +1,6 @@ import { getDropLineY } from "@/lib/timeline/drop-utils"; -import type { TimelineTrack, DropTarget } from "@/types/timeline"; +import type { TimelineTrack, DropTarget } from "@/lib/timeline"; +import { TIMELINE_LAYERS } from "@/constants/timeline-constants"; interface DragLineProps { dropTarget: DropTarget | null; @@ -22,7 +23,7 @@ export function DragLine({ return ( <div className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5" - style={{ top: `${lineTop}px` }} + style={{ top: `${lineTop}px`, zIndex: TIMELINE_LAYERS.dragLine }} /> ); } diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 593976a8..f1ecd9f4 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -3,31 +3,44 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Delete02Icon, + MagicWand05Icon, + MusicNote03Icon, TaskAdd02Icon, + TextIcon, ViewIcon, ViewOffSlashIcon, VolumeHighIcon, VolumeOffIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; +import { OcShapesIcon, OcVideoIcon } from "@opencut/ui/icons"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, -} from "../../../ui/context-menu"; +} from "@/components/ui/context-menu"; import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom"; -import { useState, useRef, useCallback } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import type { ElementDragState, DropTarget } from "@/lib/timeline"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead } from "./timeline-playhead"; -import { SelectionBox } from "../../selection-box"; -import { useSelectionBox } from "@/hooks/timeline/use-selection-box"; +import { SelectionBox } from "@/lib/selection/selection-box"; +import { useBoxSelect } from "@/lib/selection/hooks/use-box-select"; import { SnapIndicator } from "./snap-indicator"; import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { TIMELINE_CONSTANTS, - TRACK_CONFIG, + TRACK_GAP, + TIMELINE_SCROLLBAR_SIZE_PX, } from "@/constants/timeline-constants"; import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction"; import { @@ -36,12 +49,11 @@ import { getTotalTracksHeight, canTracktHaveAudio, canTrackBeHidden, + isMainTrack, getTimelineZoomMin, getTimelinePaddingPx, - isMainTrack, } from "@/lib/timeline"; import { TimelineToolbar } from "./timeline-toolbar"; -import { useScrollSync } from "@/hooks/timeline/use-scroll-sync"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek"; import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop"; @@ -49,27 +61,58 @@ import { TimelineRuler } from "./timeline-ruler"; import { TimelineBookmarksRow } from "./bookmarks"; import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; +import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom"; import { useTimelineStore } from "@/stores/timeline-store"; import { useEditor } from "@/hooks/use-editor"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { DragLine } from "./drag-line"; import { invokeAction } from "@/lib/actions"; +import { resolveTimelineElementIntersections } from "@/lib/timeline/selection-hit-testing"; +import { cn } from "@/utils/ui"; const TRACKS_CONTAINER_MAX_HEIGHT = 800; const FALLBACK_CONTAINER_WIDTH = 1000; +const TRACKS_CONTAINER_HEIGHT = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT }; +const TRACK_ICONS: Record<TimelineTrack["type"], ReactNode> = { + video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />, + text: ( + <HugeiconsIcon + icon={TextIcon} + className="text-muted-foreground size-4 shrink-0" + /> + ), + audio: ( + <HugeiconsIcon + icon={MusicNote03Icon} + className="text-muted-foreground size-4 shrink-0" + /> + ), + graphic: <OcShapesIcon className="text-muted-foreground size-4 shrink-0" />, + effect: ( + <HugeiconsIcon + icon={MagicWand05Icon} + className="text-muted-foreground size-4 shrink-0" + /> + ), +}; export function Timeline() { - const tracksContainerHeight = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT }; const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); - const { clearElementSelection, setElementSelection } = useElementSelection(); + const { + selectedElements, + clearElementSelection, + setElementSelection, + mergeElementsIntoSelection, + } = useElementSelection(); const editor = useEditor(); const timeline = editor.timeline; - const tracks = timeline.getTracks(); + const tracks = useEditor((editor) => editor.timeline.getTracks()); const seek = (time: number) => editor.playback.seek({ time }); const timelineRef = useRef<HTMLDivElement>(null); const timelineHeaderRef = useRef<HTMLDivElement>(null); const rulerRef = useRef<HTMLDivElement>(null); + const rulerScrollRef = useRef<HTMLDivElement>(null); const tracksContainerRef = useRef<HTMLDivElement>(null); const tracksScrollRef = useRef<HTMLDivElement>(null); const trackLabelsRef = useRef<HTMLDivElement>(null); @@ -110,8 +153,109 @@ export function Timeline() { initialScrollLeft: savedViewState?.scrollLeft, initialPlayheadTime: savedViewState?.playheadTime, tracksScrollRef, - rulerScrollRef: tracksScrollRef, + rulerScrollRef, + }); + + // Stable refs so the wheel listener never goes stale + const setZoomLevelRef = useRef(setZoomLevel); + useEffect(() => { + setZoomLevelRef.current = setZoomLevel; + }, [setZoomLevel]); + + const saveScrollPositionRef = useRef(saveScrollPosition); + useEffect(() => { + saveScrollPositionRef.current = saveScrollPosition; + }, [saveScrollPosition]); + + const minZoomLevelRef = useRef(minZoomLevel); + useEffect(() => { + minZoomLevelRef.current = minZoomLevel; + }, [minZoomLevel]); + + // Pushes tracks scroll position to the two overflow:hidden followers + // (ruler and track labels). Called from the wheel handler (before paint, + // zero lag) and from onScroll on the tracks area (covers scrollbar drag). + const syncFollowers = useCallback(() => { + const tracks = tracksScrollRef.current; + if (!tracks) return; + if (rulerScrollRef.current) { + rulerScrollRef.current.scrollLeft = tracks.scrollLeft; + } + if (trackLabelsScrollRef.current) { + trackLabelsScrollRef.current.scrollTop = tracks.scrollTop; + } + }, []); + + // Single non-passive capture listener owns all wheel input. Prevents any + // native scroll or browser zoom from firing inside the timeline. + useEffect(() => { + const container = timelineRef.current; + if (!container) return; + + let pendingZoomDelta = 0; + let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null; + + const onWheel = (e: WheelEvent) => { + const isZoom = e.ctrlKey || e.metaKey; + + if (isZoom) { + e.preventDefault(); + const normalizedDelta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; + pendingZoomDelta += normalizedDelta; + + if (zoomRafId === null) { + zoomRafId = requestAnimationFrame(() => { + const frameRawDelta = pendingZoomDelta; + const cappedDelta = + Math.sign(frameRawDelta) * Math.min(Math.abs(frameRawDelta), 30); + const zoomFactor = Math.exp(-cappedDelta / 300); + setZoomLevelRef.current((prev) => prev * zoomFactor); + pendingZoomDelta = 0; + zoomRafId = null; + }); + } + return; + } + + const tracks = tracksScrollRef.current; + if (!tracks) return; + + const isHorizontal = + e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY); + + e.preventDefault(); + + if (isHorizontal) { + const raw = + Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + const clamped = + Math.sign(raw) * + Math.min(Math.abs(raw), TIMELINE_CONSTANTS.HORIZONTAL_WHEEL_STEP_PX); + tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped); + } else { + tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY); + } + + syncFollowers(); + saveScrollPositionRef.current(); + }; + + container.addEventListener("wheel", onWheel, { + passive: false, + capture: true, }); + return () => { + container.removeEventListener("wheel", onWheel, { capture: true }); + if (zoomRafId !== null) cancelAnimationFrame(zoomRafId); + }; + }, [syncFollowers]); + + useInitialScrollBottom({ + tracksScrollRef, + trackLabelsScrollRef, + onAfterScroll: () => saveScrollPositionRef.current(), + isReady: tracks.length > 0, + }); const { dragState, @@ -124,7 +268,6 @@ export function Timeline() { timelineRef, tracksContainerRef, tracksScrollRef, - headerRef: timelineHeaderRef, snappingEnabled, onSnapPointChange: handleSnapPointChange, }); @@ -144,14 +287,13 @@ export function Timeline() { useTimelinePlayhead({ zoomLevel, rulerRef, - rulerScrollRef: tracksScrollRef, + rulerScrollRef, tracksScrollRef, playheadRef, }); const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ containerRef: tracksContainerRef, - headerRef: timelineHeaderRef, tracksScrollRef, zoomLevel, }); @@ -161,17 +303,37 @@ export function Timeline() { handleMouseDown: handleSelectionMouseDown, isSelecting, shouldIgnoreClick, - } = useSelectionBox({ + } = useBoxSelect({ containerRef: tracksContainerRef, - headerRef: timelineHeaderRef, - onSelectionComplete: (elements) => { - setElementSelection({ elements }); + selectedIds: selectedElements, + anchorId: null, + getIsAdditiveSelection: (event) => + event.shiftKey || event.ctrlKey || event.metaKey, + resolveIntersections: ({ startPos, currentPos }) => { + if (!tracksContainerRef.current) { + return []; + } + + return resolveTimelineElementIntersections({ + container: tracksContainerRef.current, + scrollContainer: tracksScrollRef.current, + tracks, + zoomLevel, + startPos, + currentPos, + }); + }, + onSelectionChange: ({ intersectedIds, isAdditive }) => { + if (isAdditive) { + mergeElementsIntoSelection({ elements: intersectedIds }); + } else { + setElementSelection({ elements: intersectedIds }); + } }, - tracksScrollRef, - zoomLevel, }); - const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; + const containerWidth = + tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; const contentWidth = timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; const paddingPx = getTimelinePaddingPx({ @@ -183,11 +345,16 @@ export function Timeline() { contentWidth + paddingPx, containerWidth, ); + const tracksViewportWidth = + tracksScrollRef.current?.clientWidth ?? + tracksContainerRef.current?.clientWidth ?? + containerWidth; + const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth; useEdgeAutoScroll({ isActive: bookmarkDragState.isDragging, getMouseClientX: () => bookmarkLastMouseXRef.current, - rulerScrollRef: tracksScrollRef, + rulerScrollRef, tracksScrollRef, contentWidth: dynamicTimelineWidth, }); @@ -205,7 +372,7 @@ export function Timeline() { } = useTimelineSeek({ playheadRef, trackLabelsRef, - rulerScrollRef: tracksScrollRef, + rulerScrollRef, tracksScrollRef, zoomLevel, duration: timeline.getTotalDuration(), @@ -214,13 +381,9 @@ export function Timeline() { seek, }); - useScrollSync({ - tracksScrollRef, - trackLabelsScrollRef, - }); - const timelineHeaderHeight = - timelineHeaderRef.current?.getBoundingClientRect().height ?? 0; + (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) + + TIMELINE_CONSTANTS.PADDING_TOP_PX || 0; return ( <section @@ -236,315 +399,409 @@ export function Timeline() { setZoomLevel={({ zoom }) => setZoomLevel(zoom)} /> - <div - className="relative flex flex-1 flex-col overflow-hidden" - ref={timelineRef} - > - <SnapIndicator - snapPoint={currentSnapPoint} - zoomLevel={zoomLevel} - tracks={tracks} - timelineRef={timelineRef} + <div className="relative flex flex-1 overflow-hidden" ref={timelineRef}> + <TrackLabelsPanel trackLabelsRef={trackLabelsRef} - tracksScrollRef={tracksScrollRef} - isVisible={showSnapIndicator} + trackLabelsScrollRef={trackLabelsScrollRef} + timelineHeaderHeight={timelineHeaderHeight} + hasHorizontalScrollbar={hasHorizontalScrollbar} /> - <div className="flex flex-1 overflow-hidden"> - <div className="bg-background flex w-28 shrink-0 flex-col border-r"> - <div className="bg-background flex h-4 items-center justify-between px-3"> - <span className="opacity-0">.</span> - </div> - <div className="bg-background flex h-4 items-center justify-between px-3"> - <span className="opacity-0">.</span> + + <div + className="relative isolate flex flex-1 flex-col overflow-hidden" + ref={tracksContainerRef} + > + <SelectionBox + startPos={selectionBox?.startPos || null} + currentPos={selectionBox?.currentPos || null} + containerRef={tracksContainerRef} + isActive={selectionBox?.isActive || false} + /> + <DragLine + dropTarget={dropTarget} + tracks={timeline.getTracks()} + isVisible={isDragOver && !dropTarget?.targetElement} + headerHeight={timelineHeaderHeight} + /> + <DragLine + dropTarget={dragDropTarget} + tracks={timeline.getTracks()} + isVisible={dragState.isDragging} + headerHeight={timelineHeaderHeight} + /> + + <div ref={rulerScrollRef} className="shrink-0 overflow-hidden"> + <div + ref={timelineHeaderRef} + className="flex flex-col" + style={{ width: `${dynamicTimelineWidth}px` }} + > + <TimelineRuler + zoomLevel={zoomLevel} + dynamicTimelineWidth={dynamicTimelineWidth} + rulerRef={rulerRef} + tracksScrollRef={rulerScrollRef} + handleWheel={handleWheel} + handleTimelineContentClick={handleRulerClick} + handleRulerTrackingMouseDown={handleRulerMouseDown} + handleRulerMouseDown={handlePlayheadRulerMouseDown} + /> + <TimelineBookmarksRow + zoomLevel={zoomLevel} + dynamicTimelineWidth={dynamicTimelineWidth} + dragState={bookmarkDragState} + onBookmarkMouseDown={handleBookmarkMouseDown} + handleWheel={handleWheel} + handleTimelineContentClick={handleRulerClick} + handleRulerTrackingMouseDown={handleRulerMouseDown} + handleRulerMouseDown={handlePlayheadRulerMouseDown} + /> </div> - {tracks.length > 0 && ( - <div - ref={trackLabelsRef} - className="bg-background flex-1 overflow-y-auto" - style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }} - > - <ScrollArea className="size-full" ref={trackLabelsScrollRef}> - <div className="flex flex-col gap-1"> - {tracks.map((track) => ( - <div - key={track.id} - className="group flex items-center px-3" - style={{ - height: `${getTrackHeight({ type: track.type })}px`, - }} - > - <div className="flex min-w-0 flex-1 items-center justify-end gap-2"> - {process.env.NODE_ENV === "development" && - isMainTrack(track) && ( - <div className="bg-red-500 size-1.5 rounded-full" /> - )} - {canTracktHaveAudio(track) && ( - <TrackToggleIcon - isOff={track.muted} - icons={{ - on: VolumeHighIcon, - off: VolumeOffIcon, - }} - onClick={() => - editor.timeline.toggleTrackMute({ - trackId: track.id, - }) - } - /> - )} - {canTrackBeHidden(track) && ( - <TrackToggleIcon - isOff={track.hidden} - icons={{ - on: ViewIcon, - off: ViewOffSlashIcon, - }} - onClick={() => - editor.timeline.toggleTrackVisibility({ - trackId: track.id, - }) - } - /> - )} - <TrackIcon track={track} /> - </div> - </div> - ))} - </div> - </ScrollArea> - </div> - )} </div> - <div - className="relative flex flex-1 flex-col overflow-hidden" - ref={tracksContainerRef} + <ScrollArea + className="flex-1" + ref={tracksScrollRef} + onScroll={() => { + syncFollowers(); + saveScrollPosition(); + }} > - <SelectionBox - startPos={selectionBox?.startPos || null} - currentPos={selectionBox?.currentPos || null} - containerRef={tracksContainerRef} - isActive={selectionBox?.isActive || false} - /> - <DragLine - dropTarget={dropTarget} - tracks={timeline.getTracks()} - isVisible={isDragOver && !dropTarget?.targetElement} - headerHeight={timelineHeaderHeight} - /> - <DragLine - dropTarget={dragDropTarget} - tracks={timeline.getTracks()} - isVisible={dragState.isDragging} - headerHeight={timelineHeaderHeight} - /> - <ScrollArea - className="size-full overflow-y-hidden" - ref={tracksScrollRef} - onMouseDown={(event) => { - const isDirectTarget = event.target === event.currentTarget; - if (!isDirectTarget) return; - event.stopPropagation(); - handleTracksMouseDown(event); - handleSelectionMouseDown(event); - }} - onClick={(event) => { - const isDirectTarget = event.target === event.currentTarget; - if (!isDirectTarget) return; - event.stopPropagation(); - handleTracksClick(event); - }} - onWheel={(event) => { - if ( - event.shiftKey || - Math.abs(event.deltaX) > Math.abs(event.deltaY) - ) { - return; - } - handleWheel(event); - }} - onScroll={() => { - saveScrollPosition(); - }} + <div + className="flex min-h-full flex-col" + style={{ width: `${dynamicTimelineWidth}px` }} > + {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */} <div - className="relative" + className="relative shrink-0" style={{ - width: `${dynamicTimelineWidth}px`, + height: `${ + Math.max( + TRACKS_CONTAINER_HEIGHT.min, + Math.min( + TRACKS_CONTAINER_HEIGHT.max, + getTotalTracksHeight({ tracks }), + ), + ) + TIMELINE_CONSTANTS.PADDING_TOP_PX + }px`, + }} + onMouseDown={(event) => { + const isDirectTarget = event.target === event.currentTarget; + if (!isDirectTarget) return; + event.stopPropagation(); + handleTracksMouseDown(event); + handleSelectionMouseDown(event); + }} + onClick={(event) => { + const isDirectTarget = event.target === event.currentTarget; + if (!isDirectTarget) return; + event.stopPropagation(); + handleTracksClick(event); }} > - <div - ref={timelineHeaderRef} - className="bg-background sticky top-0 flex flex-col" - > - <TimelineRuler + {tracks.length > 0 && ( + <TimelineTrackRows + dragElementId={dragState.elementId} zoomLevel={zoomLevel} - dynamicTimelineWidth={dynamicTimelineWidth} - rulerRef={rulerRef} + dragState={dragState} tracksScrollRef={tracksScrollRef} - handleWheel={handleWheel} - handleTimelineContentClick={handleRulerClick} - handleRulerTrackingMouseDown={handleRulerMouseDown} - handleRulerMouseDown={handlePlayheadRulerMouseDown} - /> - <TimelineBookmarksRow - zoomLevel={zoomLevel} - dynamicTimelineWidth={dynamicTimelineWidth} - dragState={bookmarkDragState} - onBookmarkMouseDown={handleBookmarkMouseDown} - handleWheel={handleWheel} - handleTimelineContentClick={handleRulerClick} - handleRulerTrackingMouseDown={handleRulerMouseDown} - handleRulerMouseDown={handlePlayheadRulerMouseDown} + lastMouseXRef={lastMouseXRef} + onSnapPointChange={handleSnapPointChange} + onResizeStateChange={handleResizeStateChange} + onElementMouseDown={handleElementMouseDown} + onElementClick={handleElementClick} + onTrackMouseDown={(event) => { + handleSelectionMouseDown(event); + handleTracksMouseDown(event); + }} + onTrackMouseUp={handleTracksClick} + shouldIgnoreClick={shouldIgnoreClick} + isDragOver={isDragOver} + dropTarget={dropTarget} /> - </div> - <TimelinePlayhead - zoomLevel={zoomLevel} - rulerRef={rulerRef} - rulerScrollRef={tracksScrollRef} - tracksScrollRef={tracksScrollRef} - timelineRef={timelineRef} - playheadRef={playheadRef} - isSnappingToPlayhead={ - showSnapIndicator && currentSnapPoint?.type === "playhead" - } - /> + )} + </div> + <TimelineGutter + onMouseDown={(event) => { + handleTracksMouseDown(event); + handleSelectionMouseDown(event); + }} + onClick={handleTracksClick} + /> + </div> + </ScrollArea> + + <TimelinePlayhead + zoomLevel={zoomLevel} + hasHorizontalScrollbar={hasHorizontalScrollbar} + rulerRef={rulerRef} + rulerScrollRef={rulerScrollRef} + tracksScrollRef={tracksScrollRef} + timelineRef={timelineRef} + playheadRef={playheadRef} + isSnappingToPlayhead={ + showSnapIndicator && currentSnapPoint?.type === "playhead" + } + /> + </div> + <SnapIndicator + snapPoint={currentSnapPoint} + zoomLevel={zoomLevel} + timelineRef={timelineRef} + tracksScrollRef={tracksScrollRef} + isVisible={showSnapIndicator} + tracks={tracks} + /> + </div> + </section> + ); +} + +function TrackLabelsPanel({ + trackLabelsRef, + trackLabelsScrollRef, + timelineHeaderHeight, + hasHorizontalScrollbar, +}: { + trackLabelsRef: React.RefObject<HTMLDivElement | null>; + trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>; + timelineHeaderHeight: number; + hasHorizontalScrollbar: boolean; +}) { + const editor = useEditor(); + const tracks = useEditor((e) => e.timeline.getTracks()); + const { selectedElements } = useElementSelection(); + const tracksWithSelection = useMemo( + () => new Set(selectedElements.map((el) => el.trackId)), + [selectedElements], + ); + + return ( + <div className="flex w-28 shrink-0 flex-col border-r"> + <div + className="shrink-0" + style={{ height: timelineHeaderHeight || 48 }} + /> + <div ref={trackLabelsRef} className="flex-1 overflow-hidden"> + <div ref={trackLabelsScrollRef} className="size-full overflow-hidden"> + {tracks.length > 0 && ( + <div className="flex flex-col" style={{ gap: `${TRACK_GAP}px` }}> + {tracks.map((track) => ( <div - className="relative" + key={track.id} + className={cn( + "group flex items-center px-3", + tracksWithSelection.has(track.id) && + TIMELINE_CONSTANTS.TRACK_SELECTED_BG, + )} style={{ - height: `${Math.max( - tracksContainerHeight.min, - Math.min( - tracksContainerHeight.max, - getTotalTracksHeight({ tracks }), - ), - )}px`, + height: `${getTrackHeight({ type: track.type })}px`, }} > - {tracks.length === 0 ? ( - <div /> - ) : ( - [...tracks] - .map((track, index) => ({ track, index })) - .sort((a, b) => { - const aHasDragged = a.track.elements.some( - (element) => element.id === dragState.elementId, - ); - const bHasDragged = b.track.elements.some( - (element) => element.id === dragState.elementId, - ); - if (aHasDragged) return 1; - if (bHasDragged) return -1; - return 0; - }) - .map(({ track, index }) => ( - <ContextMenu key={track.id}> - <ContextMenuTrigger asChild> - <div - className="absolute right-0 left-0" - style={{ - top: `${getCumulativeHeightBefore({ - tracks, - trackIndex: index, - })}px`, - height: `${getTrackHeight({ - type: track.type, - })}px`, - }} - > - <TimelineTrackContent - track={track} - zoomLevel={zoomLevel} - dragState={dragState} - rulerScrollRef={tracksScrollRef} - tracksScrollRef={tracksScrollRef} - lastMouseXRef={lastMouseXRef} - onSnapPointChange={handleSnapPointChange} - onResizeStateChange={handleResizeStateChange} - onElementMouseDown={handleElementMouseDown} - onElementClick={handleElementClick} - onTrackMouseDown={(event) => { - handleSelectionMouseDown(event); - handleTracksMouseDown(event); - }} - onTrackClick={handleTracksClick} - shouldIgnoreClick={shouldIgnoreClick} - targetElementId={ - isDragOver - ? dropTarget?.targetElement?.elementId ?? null - : null - } - /> - </div> - </ContextMenuTrigger> - <ContextMenuContent className="w-40"> - <ContextMenuItem - icon={<HugeiconsIcon icon={TaskAdd02Icon} />} - onClick={(event) => { - event.stopPropagation(); - invokeAction("paste-copied"); - }} - > - Paste elements - </ContextMenuItem> - <ContextMenuItem - onClick={(event) => { - event.stopPropagation(); - timeline.toggleTrackMute({ - trackId: track.id, - }); - }} - > - <HugeiconsIcon icon={VolumeHighIcon} /> - <span> - {canTracktHaveAudio(track) && track.muted - ? "Unmute track" - : "Mute track"} - </span> - </ContextMenuItem> - <ContextMenuItem - onClick={(event) => { - event.stopPropagation(); - timeline.toggleTrackVisibility({ - trackId: track.id, - }); - }} - > - <HugeiconsIcon icon={ViewIcon} /> - <span> - {canTrackBeHidden(track) && track.hidden - ? "Show track" - : "Hide track"} - </span> - </ContextMenuItem> - <ContextMenuItem - onClick={(event) => { - event.stopPropagation(); - timeline.removeTrack({ - trackId: track.id, - }); - }} - variant="destructive" - > - <HugeiconsIcon icon={Delete02Icon} /> - Delete track - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - )) - )} + <div className="flex min-w-0 flex-1 items-center justify-end gap-2"> + {canTracktHaveAudio(track) && ( + <TrackToggleIcon + isOff={track.muted} + icons={{ on: VolumeHighIcon, off: VolumeOffIcon }} + onClick={() => + editor.timeline.toggleTrackMute({ trackId: track.id }) + } + /> + )} + {canTrackBeHidden(track) && ( + <TrackToggleIcon + isOff={track.hidden} + icons={{ on: ViewIcon, off: ViewOffSlashIcon }} + onClick={() => + editor.timeline.toggleTrackVisibility({ + trackId: track.id, + }) + } + /> + )} + <TrackIcon track={track} /> + </div> </div> - </div> - </ScrollArea> - </div> + ))} + </div> + )} </div> </div> - </section> + <div + className="bg-background shrink-0" + style={{ + height: hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX : 0, + }} + /> + </div> + ); +} + +function TimelineTrackRows({ + dragElementId, + zoomLevel, + dragState, + tracksScrollRef, + lastMouseXRef, + onSnapPointChange, + onResizeStateChange, + onElementMouseDown, + onElementClick, + onTrackMouseDown, + onTrackMouseUp, + shouldIgnoreClick, + isDragOver, + dropTarget, +}: { + dragElementId: string | null; + zoomLevel: number; + dragState: ElementDragState; + tracksScrollRef: React.RefObject<HTMLDivElement | null>; + lastMouseXRef: React.RefObject<number>; + onSnapPointChange: (snapPoint: SnapPoint | null) => void; + onResizeStateChange: (params: { isResizing: boolean }) => void; + onElementMouseDown: React.ComponentProps< + typeof TimelineTrackContent + >["onElementMouseDown"]; + onElementClick: React.ComponentProps< + typeof TimelineTrackContent + >["onElementClick"]; + onTrackMouseDown: (event: React.MouseEvent) => void; + onTrackMouseUp: (event: React.MouseEvent) => void; + shouldIgnoreClick: () => boolean; + isDragOver: boolean; + dropTarget: DropTarget | null; +}) { + const timeline = useEditor((e) => e.timeline); + const tracks = useEditor((e) => e.timeline.getTracks()); + const { selectedElements } = useElementSelection(); + const tracksWithSelection = useMemo( + () => new Set(selectedElements.map((el) => el.trackId)), + [selectedElements], + ); + + const sortedTracks = useMemo( + () => + [...tracks] + .map((track, index) => ({ track, index })) + .sort((a, b) => { + const aHasDragged = a.track.elements.some( + (el) => el.id === dragElementId, + ); + const bHasDragged = b.track.elements.some( + (el) => el.id === dragElementId, + ); + if (aHasDragged) return 1; + if (bHasDragged) return -1; + return 0; + }), + [tracks, dragElementId], ); + + return ( + <> + {sortedTracks.map(({ track, index }) => ( + <ContextMenu key={track.id}> + <ContextMenuTrigger asChild> + <div + className={cn( + "absolute right-0 left-0 transition-colors", + tracksWithSelection.has(track.id) && + TIMELINE_CONSTANTS.TRACK_SELECTED_BG, + )} + style={{ + top: `${TIMELINE_CONSTANTS.PADDING_TOP_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`, + height: `${getTrackHeight({ type: track.type })}px`, + }} + > + <TimelineTrackContent + track={track} + zoomLevel={zoomLevel} + dragState={dragState} + rulerScrollRef={tracksScrollRef} + tracksScrollRef={tracksScrollRef} + lastMouseXRef={lastMouseXRef} + onSnapPointChange={onSnapPointChange} + onResizeStateChange={onResizeStateChange} + onElementMouseDown={onElementMouseDown} + onElementClick={onElementClick} + onTrackMouseDown={onTrackMouseDown} + onTrackMouseUp={onTrackMouseUp} + shouldIgnoreClick={shouldIgnoreClick} + targetElementId={ + isDragOver + ? (dropTarget?.targetElement?.elementId ?? null) + : null + } + /> + </div> + </ContextMenuTrigger> + <ContextMenuContent className="w-40"> + <ContextMenuItem + icon={<HugeiconsIcon icon={TaskAdd02Icon} />} + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + invokeAction("paste-copied"); + }} + > + Paste elements + </ContextMenuItem> + <ContextMenuItem + icon={<HugeiconsIcon icon={VolumeHighIcon} />} + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + timeline.toggleTrackMute({ trackId: track.id }); + }} + > + {canTracktHaveAudio(track) && track.muted + ? "Unmute track" + : "Mute track"} + </ContextMenuItem> + <ContextMenuItem + icon={<HugeiconsIcon icon={ViewIcon} />} + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + timeline.toggleTrackVisibility({ trackId: track.id }); + }} + > + {canTrackBeHidden(track) && track.hidden + ? "Show track" + : "Hide track"} + </ContextMenuItem> + {!isMainTrack(track) && ( + <ContextMenuItem + icon={<HugeiconsIcon icon={Delete02Icon} />} + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + timeline.removeTrack({ trackId: track.id }); + }} + variant="destructive" + > + Delete track + </ContextMenuItem> + )} + </ContextMenuContent> + </ContextMenu> + ))} + </> + ); +} + +function TimelineGutter({ + onMouseDown, + onClick, +}: { + onMouseDown: (event: React.MouseEvent) => void; + onClick: (event: React.MouseEvent) => void; +}) { + // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system + // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system + return <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} />; } function TrackIcon({ track }: { track: TimelineTrack }) { - return <>{TRACK_CONFIG[track.type].icon}</>; + return <>{TRACK_ICONS[track.type]}</>; } function TrackToggleIcon({ diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx index 927d336a..acb57203 100644 --- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx @@ -6,15 +6,12 @@ import { getCenteredLineLeft, TIMELINE_INDICATOR_LINE_WIDTH_PX, } from "@/lib/timeline"; -import type { TimelineTrack } from "@/types/timeline"; - +import { TIMELINE_LAYERS } from "@/constants/timeline-constants"; interface SnapIndicatorProps { snapPoint: SnapPoint | null; zoomLevel: number; isVisible: boolean; - tracks: TimelineTrack[]; timelineRef: React.RefObject<HTMLDivElement | null>; - trackLabelsRef?: React.RefObject<HTMLDivElement | null>; tracksScrollRef: React.RefObject<HTMLDivElement | null>; } @@ -22,17 +19,13 @@ export function SnapIndicator({ snapPoint, zoomLevel, isVisible, - tracks, timelineRef, - trackLabelsRef, tracksScrollRef, }: SnapIndicatorProps) { const { leftPosition, topPosition, height } = useSnapIndicatorPosition({ snapPoint, zoomLevel, - tracks, timelineRef, - trackLabelsRef, tracksScrollRef, }); @@ -48,6 +41,7 @@ export function SnapIndicator({ top: topPosition, height: `${height}px`, width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, + zIndex: TIMELINE_LAYERS.snapIndicator, }} > <div className={"bg-primary/40 h-full w-0.5 opacity-80"} /> diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 9121f6c2..13a31375 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -2,7 +2,7 @@ import { useEditor } from "@/hooks/use-editor"; import { useAssetsPanelStore } from "@/stores/assets-panel-store"; -import AudioWaveform from "./audio-waveform"; +import { AudioWaveform } from "./audio-waveform"; import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; import { useKeyframeDrag, @@ -12,14 +12,16 @@ import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-sele import type { SnapPoint } from "@/lib/timeline/snap-utils"; import { getElementKeyframes } from "@/lib/animation"; import { - getTrackClasses, + getElementClasses, getTrackHeight, canElementHaveAudio, canElementBeHidden, + hasElementEffects, hasMediaId, timelineTimeToPixels, timelineTimeToSnappedPixels, } from "@/lib/timeline"; +import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants"; import { ContextMenu, ContextMenuContent, @@ -30,14 +32,22 @@ import { import type { TimelineElement as TimelineElementType, TimelineTrack, - VisualElement, ElementDragState, -} from "@/types/timeline"; -import type { MediaAsset } from "@/types/assets"; + VideoElement, + ImageElement, + AudioElement, +} from "@/lib/timeline"; +import type { MediaAsset } from "@/lib/media/types"; import { mediaSupportsAudio } from "@/lib/media/media-utils"; -import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions"; +import { + getActionDefinition, + type TAction, + type TActionWithOptionalArgs, + invokeAction, +} from "@/lib/actions"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { resolveStickerId } from "@/lib/stickers"; +import { buildGraphicPreviewUrl } from "@/lib/graphics"; import Image from "next/image"; import { ScissorIcon, @@ -56,13 +66,16 @@ import { import { HugeiconsIcon } from "@hugeicons/react"; import { uppercase } from "@/utils/string"; import type { ComponentProps, ReactNode } from "react"; -import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation"; +import type { + SelectedKeyframeRef, + ElementKeyframe, +} from "@/lib/animation/types"; import { cn } from "@/utils/ui"; -import { Button } from "@/components/ui/button"; -import { usePropertiesStore } from "@/stores/properties-store"; +import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store"; const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; const ELEMENT_RING_WIDTH_PX = 1.5; +const THUMBNAIL_ASPECT_RATIO = 16 / 9; interface KeyframeIndicator { time: number; @@ -195,16 +208,15 @@ export function TimelineElement({ dragState, isDropTarget = false, }: TimelineElementProps) { - const editor = useEditor(); + const mediaAssets = useEditor((e) => e.media.getAssets()); const { selectedElements } = useElementSelection(); - const { requestRevealMedia } = useAssetsPanelStore(); + const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia); let mediaAsset: MediaAsset | null = null; if (hasMediaId(element)) { mediaAsset = - editor.media.getAssets().find((asset) => asset.id === element.mediaId) ?? - null; + mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; } const hasAudio = mediaSupportsAudio({ media: mediaAsset }); @@ -394,60 +406,46 @@ function ElementInner({ }) => void; isDropTarget?: boolean; }) { - const opacityClass = - (canElementBeHidden(element) && element.hidden) || isDropTarget - ? "opacity-50" - : ""; - const closeClipEffects = usePropertiesStore( - (state) => state.closeClipEffects, - ); - + const isReducedOpacity = + (canElementBeHidden(element) && element.hidden) || isDropTarget; return ( <div - className="relative h-full cursor-pointer" - style={{ marginInline: ELEMENT_RING_WIDTH_PX }} + className="absolute top-0 bottom-0" + style={{ + left: `${ELEMENT_RING_WIDTH_PX}px`, + right: `${ELEMENT_RING_WIDTH_PX}px`, + }} > <div - className={cn( - "absolute inset-0 overflow-hidden rounded-sm", - getTrackClasses({ type: track.type }), - opacityClass, - )} + className="absolute inset-0 rounded-sm" style={ isSelected ? { - boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--foreground)`, + boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`, } : undefined } > - <button - type="button" - className="absolute inset-0 size-full cursor-pointer flex flex-col" - onClick={(event) => { - closeClipEffects(); - onElementClick(event, element); - }} - onMouseDown={(event) => onElementMouseDown(event, element)} + <div + className={cn( + "absolute inset-0 overflow-hidden rounded-sm", + getElementClasses({ type: track.type }), + isReducedOpacity && "opacity-50", + )} > - <div className="flex flex-1 min-h-0 items-center overflow-hidden"> - <ElementContent - element={element} - track={track} - isSelected={isSelected} - /> - </div> - </button> - </div> - - {element.type !== "audio" && element.type !== "effect" && ( - <div className="sticky left-1 mt-1 ml-1 w-fit"> - <EffectsButton - element={element as VisualElement} - trackId={track.id} - /> + <button + type="button" + tabIndex={-1} + className="absolute inset-0 size-full flex flex-col" + onClick={(event) => onElementClick(event, element)} + onMouseDown={(event) => onElementMouseDown(event, element)} + > + <div className="flex flex-1 min-h-0 items-center overflow-hidden"> + <ElementContent element={element} track={track} /> + </div> + </button> </div> - )} + </div> {isSelected && ( <> @@ -535,8 +533,8 @@ function KeyframeIndicators({ const isIndicatorSelected = indicator.keyframes.some((keyframe) => isKeyframeSelected({ keyframe }), ); - const isBeingDragged = indicator.keyframes.some((kf) => - dragState.draggingKeyframeIds.has(kf.keyframeId), + const isBeingDragged = indicator.keyframes.some((keyframe) => + dragState.draggingKeyframeIds.has(keyframe.keyframeId), ); const visualOffsetPx = getVisualOffsetPx({ indicatorTime: indicator.time, @@ -581,212 +579,244 @@ function KeyframeIndicators({ interface ElementContentProps { element: TimelineElementType; track: TimelineTrack; - isSelected: boolean; } -interface ElementContentRendererProps extends ElementContentProps { - mediaAssets: MediaAsset[]; +function TextElementContent({ + element, +}: { + element: Extract<TimelineElementType, { type: "text" }>; +}) { + return ( + <div className="flex size-full items-center justify-start pl-2"> + <span className="truncate text-xs text-white">{element.content}</span> + </div> + ); +} + +function EffectElementContent({ + element, +}: { + element: Extract<TimelineElementType, { type: "effect" }>; +}) { + return ( + <div className="flex size-full items-center justify-start gap-1 pl-2"> + <HugeiconsIcon + icon={MagicWand05Icon} + className="size-4 shrink-0 text-white" + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); } -type ElementContentRenderer = (props: ElementContentRendererProps) => ReactNode; +function StickerElementContent({ + element, +}: { + element: Extract<TimelineElementType, { type: "sticker" }>; +}) { + return ( + <div className="flex size-full items-center gap-2 pl-2"> + <Image + src={resolveStickerId({ + stickerId: element.stickerId, + options: { width: 20, height: 20 }, + })} + alt={element.name} + className="size-4 shrink-0" + width={20} + height={20} + unoptimized + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); +} -export function renderTiledMedia({ +function GraphicElementContent({ element, - imageUrl, - track, }: { - element: VisualElement; - imageUrl: string | undefined; - track: ElementContentProps["track"]; -}): ReactNode { - if (!imageUrl) { + element: Extract<TimelineElementType, { type: "graphic" }>; +}) { + return ( + <div className="flex size-full items-center gap-2 pl-2"> + <Image + src={buildGraphicPreviewUrl({ + definitionId: element.definitionId, + params: element.params, + size: 20, + })} + alt={element.name} + className="size-4 shrink-0" + width={20} + height={20} + unoptimized + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); +} + +function AudioElementContent({ element }: { element: AudioElement }) { + const mediaAssets = useEditor((e) => e.media.getAssets()); + const mediaAsset = + element.sourceType === "upload" + ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null) + : null; + + const audioBuffer = + element.sourceType === "library" ? element.buffer : undefined; + const audioUrl = + element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url; + const mediaLabel = mediaAsset?.name ?? element.name; + + if (audioBuffer || audioUrl) { return ( - <span className="text-foreground/80 truncate text-xs"> - {element.name} - </span> + <div className="relative size-full"> + <AudioWaveform + audioBuffer={audioBuffer} + audioUrl={audioUrl} + color={ELEMENT_TYPE_CONFIG.audio.waveformColor} + /> + <MediaElementHeader name={mediaLabel} hasFade={false} /> + </div> ); } - const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * (16 / 9); - return ( - <div - className="absolute inset-0" - style={{ - backgroundImage: `url(${imageUrl})`, - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${trackHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - /> + <span className="text-foreground/80 truncate text-xs">{element.name}</span> ); } function EffectsButton({ element, - trackId, - className, + track, }: { - element: VisualElement; - trackId: string; - className?: string; + element: VideoElement | ImageElement; + track: TimelineTrack; }) { - const openClipEffects = usePropertiesStore((state) => state.openClipEffects); - const { selectElement } = useElementSelection(); - - if (!element.effects?.length) { - return null; - } + const editor = useEditor(); + const setActiveTab = usePropertiesStore((s) => s.setActiveTab); const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); - selectElement({ elementId: element.id, trackId }); - openClipEffects({ elementId: element.id, trackId }); + editor.selection.setSelectedElements({ + elements: [{ trackId: track.id, elementId: element.id }], + }); + setActiveTab(element.type, "effects"); }; return ( - <Button - variant="text" - size="icon" - className={cn("rounded-sm !size-5 bg-black/50 text-white", className)} - onClick={handleClick} + <button + type="button" + className="flex shrink-0 justify-center text-white cursor-pointer" onMouseDown={(event) => event.stopPropagation()} + onClick={handleClick} > - <HugeiconsIcon icon={MagicWand05Icon} /> - </Button> + <HugeiconsIcon icon={MagicWand05Icon} size={12} /> + </button> ); } -const ELEMENT_CONTENT_RENDERERS: Record< - TimelineElementType["type"], - ElementContentRenderer -> = { - text: ({ element }) => { - const textElement = element as Extract< - TimelineElementType, - { type: "text" } - >; - return ( - <div className="flex size-full items-center justify-start pl-2"> - <span className="truncate text-xs text-white"> - {textElement.content} - </span> - </div> - ); - }, - effect: ({ element }) => ( - <div className="flex size-full items-center justify-start gap-1 pl-2"> - <HugeiconsIcon - icon={MagicWand05Icon} - className="size-4 shrink-0 text-white" - /> - <span className="truncate text-xs text-white ml-1">{element.name}</span> - </div> - ), - sticker: ({ element }) => { - const stickerElement = element as Extract< - TimelineElementType, - { type: "sticker" } - >; - return ( - <div className="flex size-full items-center gap-2 pl-2"> - <Image - src={resolveStickerId({ - stickerId: stickerElement.stickerId, - options: { width: 20, height: 20 }, - })} - alt={stickerElement.name} - className="size-5 shrink-0" - width={20} - height={20} - unoptimized - /> - <span className="truncate text-xs text-white"> - {stickerElement.name} - </span> - </div> - ); - }, - audio: ({ element, mediaAssets }) => { - const audioElement = element as Extract< - TimelineElementType, - { type: "audio" } - >; - const audioBuffer = - audioElement.sourceType === "library" ? audioElement.buffer : undefined; - const audioUrl = - audioElement.sourceType === "library" - ? audioElement.sourceUrl - : mediaAssets.find((asset) => asset.id === audioElement.mediaId)?.url; +function TiledMediaContent({ + element, + track, +}: { + element: VideoElement | ImageElement; + track: TimelineTrack; +}) { + const mediaAssets = useEditor((e) => e.media.getAssets()); - if (audioBuffer || audioUrl) { - return ( - <div className="flex size-full items-center gap-2"> - <div className="min-w-0 flex-1"> - <AudioWaveform - audioBuffer={audioBuffer} - audioUrl={audioUrl} - height={24} - className="w-full" - /> - </div> - </div> - ); - } + const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); + const imageUrl = + element.type === "video" + ? mediaAsset?.thumbnailUrl + : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url); + if (!imageUrl) { return ( <span className="text-foreground/80 truncate text-xs"> - {audioElement.name} + {element.name} </span> ); - }, - video: ({ element, track, mediaAssets }) => { - const videoElement = element as Extract< - TimelineElementType, - { type: "video" } - >; - const mediaAsset = mediaAssets.find( - (asset) => asset.id === videoElement.mediaId, - ); - return renderTiledMedia({ - element: videoElement, - imageUrl: mediaAsset?.thumbnailUrl, - track, - }); - }, - image: ({ element, track, mediaAssets }) => { - const imageElement = element as Extract< - TimelineElementType, - { type: "image" } - >; - const mediaAsset = mediaAssets.find( - (asset) => asset.id === imageElement.mediaId, - ); - return renderTiledMedia({ - element: imageElement, - imageUrl: mediaAsset?.url, - track, - }); - }, -}; + } + + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO; -function ElementContent({ element, track, isSelected }: ElementContentProps) { - const editor = useEditor(); - const renderer = ELEMENT_CONTENT_RENDERERS[element.type]; return ( <> - {renderer({ - element, - track, - isSelected, - mediaAssets: editor.media.getAssets(), - })} + <div + className="absolute inset-0" + style={{ + backgroundColor: "var(--muted)", + backgroundImage: `url(${imageUrl})`, + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${trackHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + /> + <MediaElementHeader + name={mediaAsset?.name} + leading={ + hasElementEffects({ element }) ? ( + <EffectsButton element={element} track={track} /> + ) : null + } + hasFade={true} + /> </> ); } +function MediaElementHeader({ + name, + leading, + hasFade, +}: { + name?: string | null; + leading?: ReactNode; + hasFade?: boolean; +}) { + if (!name && !leading) { + return null; + } + + return ( + <div + className={cn( + "absolute top-0 left-0 flex h-7 w-full bg-linear-to-b pt-1", + hasFade && "from-black/30 to-transparent", + )} + > + {leading && <div className="pl-1">{leading}</div>} + {name && ( + <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75"> + {name} + </span> + )} + </div> + ); +} + +function ElementContent({ element, track }: ElementContentProps) { + switch (element.type) { + case "text": + return <TextElementContent element={element} />; + case "effect": + return <EffectElementContent element={element} />; + case "sticker": + return <StickerElementContent element={element} />; + case "graphic": + return <GraphicElementContent element={element} />; + case "audio": + return <AudioElementContent element={element} />; + case "video": + case "image": + return <TiledMediaContent element={element} track={track} />; + } +} + function CopyMenuItem() { return ( <ActionMenuItem @@ -886,7 +916,7 @@ function ActionMenuItem({ children, ...props }: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & { - action: TAction; + action: TActionWithOptionalArgs; children: ReactNode; }) { return ( diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx index e290259e..1abe65bd 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx @@ -8,9 +8,14 @@ import { } from "@/lib/timeline"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { useEditor } from "@/hooks/use-editor"; +import { + TIMELINE_LAYERS, + TIMELINE_SCROLLBAR_SIZE_PX, +} from "@/constants/timeline-constants"; interface TimelinePlayheadProps { zoomLevel: number; + hasHorizontalScrollbar: boolean; rulerRef: React.RefObject<HTMLDivElement | null>; rulerScrollRef: React.RefObject<HTMLDivElement | null>; tracksScrollRef: React.RefObject<HTMLDivElement | null>; @@ -21,6 +26,7 @@ interface TimelinePlayheadProps { export function TimelinePlayhead({ zoomLevel, + hasHorizontalScrollbar, rulerRef, rulerScrollRef, tracksScrollRef, @@ -33,7 +39,7 @@ export function TimelinePlayhead({ const internalPlayheadRef = useRef<HTMLDivElement>(null); const playheadRef = externalPlayheadRef || internalPlayheadRef; - const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ + const { handlePlayheadMouseDown } = useTimelinePlayhead({ zoomLevel, rulerRef, rulerScrollRef, @@ -42,16 +48,23 @@ export function TimelinePlayhead({ }); const timelineContainerHeight = - tracksScrollRef.current?.clientHeight ?? timelineRef.current?.clientHeight ?? + tracksScrollRef.current?.clientHeight ?? 400; - const totalHeight = Math.max(0, timelineContainerHeight - 4); + const totalHeight = Math.max( + 0, + timelineContainerHeight - + (hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX - 5 : 0), + ); + const currentTime = editor.playback.getCurrentTime(); const centerPosition = timelineTimeToSnappedPixels({ - time: playheadPosition, + time: currentTime, zoomLevel, }); - const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }); + const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0; + const leftPosition = + getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft; const handlePlayheadKeyDown = ( event: React.KeyboardEvent<HTMLDivElement>, @@ -61,10 +74,8 @@ export function TimelinePlayhead({ event.preventDefault(); const step = 1 / Math.max(1, editor.project.getActive().settings.fps); const direction = event.key === "ArrowRight" ? 1 : -1; - const nextTime = Math.max( - 0, - Math.min(duration, playheadPosition + direction * step), - ); + const now = editor.playback.getCurrentTime(); + const nextTime = Math.max(0, Math.min(duration, now + direction * step)); editor.playback.seek({ time: nextTime }); }; @@ -76,23 +87,24 @@ export function TimelinePlayhead({ aria-label="Timeline playhead" aria-valuemin={0} aria-valuemax={duration} - aria-valuenow={playheadPosition} + aria-valuenow={currentTime} tabIndex={0} - className="pointer-events-none absolute z-5" + className="pointer-events-none absolute" style={{ left: `${leftPosition}px`, top: 0, height: `${totalHeight}px`, width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, + zIndex: TIMELINE_LAYERS.playhead, }} onKeyDown={handlePlayheadKeyDown} > - <div className="bg-foreground pointer-events-none absolute left-0 h-full w-0.5" /> + <div className="bg-primary pointer-events-none absolute left-0 h-full w-0.5" /> <button type="button" aria-label="Drag playhead" - className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`} + className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-primary border-primary/50"}`} onMouseDown={handlePlayheadMouseDown} /> </div> diff --git a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx index b0261aa2..09562f23 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx @@ -1,5 +1,8 @@ import { type JSX, useLayoutEffect, useRef } from "react"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { + TIMELINE_CONSTANTS, + TIMELINE_RULER_HEIGHT, +} from "@/constants/timeline-constants"; import { DEFAULT_FPS } from "@/constants/project-constants"; import { useEditor } from "@/hooks/use-editor"; import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils"; @@ -27,13 +30,12 @@ export function TimelineRuler({ handleRulerTrackingMouseDown, handleRulerMouseDown, }: TimelineRulerProps) { - const editor = useEditor(); - const duration = editor.timeline.getTotalDuration(); + const duration = useEditor((e) => e.timeline.getTotalDuration()); const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; const visibleDuration = dynamicTimelineWidth / pixelsPerSecond; const effectiveDuration = Math.max(duration, visibleDuration); - const project = editor.project.getActive(); - const fps = project?.settings.fps ?? DEFAULT_FPS; + const fps = + useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS; const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({ zoomLevel, fps, @@ -104,17 +106,25 @@ export function TimelineRuler({ aria-valuemin={0} aria-valuemax={effectiveDuration} aria-valuenow={0} - className="relative h-4 flex-1 overflow-x-visible" + className="relative flex-1 overflow-x-visible" + style={{ height: TIMELINE_RULER_HEIGHT }} onWheel={handleWheel} - onClick={handleTimelineContentClick} + onClick={(event) => { + // Ruler seek already happens on mousedown via playhead scrubbing. + // Forwarding the follow-up click re-enters the selection-clearing path. + if (event.target === event.currentTarget) { + handleTimelineContentClick(event); + } + }} onMouseDown={handleRulerTrackingMouseDown} onKeyDown={() => {}} > <div role="none" ref={rulerRef} - className="relative h-4 cursor-default select-none" + className="relative cursor-default select-none" style={{ + height: TIMELINE_RULER_HEIGHT, width: `${dynamicTimelineWidth}px`, }} onMouseDown={handleRulerMouseDown} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx index 22cd52cf..29fd513b 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx @@ -22,7 +22,7 @@ export function TimelineTick({ const label = formatRulerLabel({ timeInSeconds: time, fps }); return ( <span - className="text-muted-foreground/85 absolute bottom-0 select-none text-[10px] leading-none" + className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none" style={{ left: `${leftPosition}px` }} > {label} @@ -32,7 +32,7 @@ export function TimelineTick({ return ( <div - className="border-muted-foreground/25 absolute bottom-0.5 h-1.5 border-l" + className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l" style={{ left: `${leftPosition}px` }} /> ); diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx index 533ea63d..e785d147 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -16,8 +16,8 @@ import { import { Slider } from "@/components/ui/slider"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils"; -import { ScenesView } from "../../scenes-view"; -import { type TAction, invokeAction } from "@/lib/actions"; +import { ScenesView } from "@/components/editor/scenes-view"; +import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions"; import { cn } from "@/utils/ui"; import { useTimelineStore } from "@/stores/timeline-store"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -76,15 +76,15 @@ export function TimelineToolbar({ } function ToolbarLeftSection() { - const editor = useEditor(); - const currentTime = editor.playback.getCurrentTime(); - const isCurrentlyBookmarked = editor.scenes.isBookmarked({ time: currentTime }); + const isCurrentlyBookmarked = useEditor((e) => + e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }), + ); const handleAction = ({ action, event, }: { - action: TAction; + action: TActionWithOptionalArgs; event: React.MouseEvent; }) => { event.stopPropagation(); @@ -149,8 +149,8 @@ function ToolbarLeftSection() { <Tooltip> <ToolbarButton icon={<HugeiconsIcon icon={Bookmark02Icon} />} - isActive={isCurrentlyBookmarked} - tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"} + isActive={isCurrentlyBookmarked} + tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"} onClick={({ event }) => handleAction({ action: "toggle-bookmark", event }) } diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index 81cda7a6..6a9e4dfe 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -2,12 +2,12 @@ import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { TimelineElement } from "./timeline-element"; -import type { TimelineTrack } from "@/types/timeline"; -import type { TimelineElement as TimelineElementType } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; +import type { TimelineElement as TimelineElementType } from "@/lib/timeline"; import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { TIMELINE_CONSTANTS, TIMELINE_LAYERS } from "@/constants/timeline-constants"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; -import type { ElementDragState } from "@/types/timeline"; +import type { ElementDragState } from "@/lib/timeline"; import { useEditor } from "@/hooks/use-editor"; interface TimelineTrackContentProps { @@ -30,7 +30,7 @@ interface TimelineTrackContentProps { track: TimelineTrack; }) => void; onTrackMouseDown?: (event: React.MouseEvent) => void; - onTrackClick?: (event: React.MouseEvent) => void; + onTrackMouseUp?: (event: React.MouseEvent) => void; shouldIgnoreClick?: () => boolean; targetElementId?: string | null; } @@ -47,14 +47,12 @@ export function TimelineTrackContent({ onElementMouseDown, onElementClick, onTrackMouseDown, - onTrackClick, + onTrackMouseUp, shouldIgnoreClick, targetElementId = null, }: TimelineTrackContentProps) { - const editor = useEditor(); const { isElementSelected } = useElementSelection(); - - const duration = editor.timeline.getTotalDuration(); + const duration = useEditor((e) => e.timeline.getTotalDuration()); useEdgeAutoScroll({ isActive: dragState.isDragging, @@ -65,21 +63,37 @@ export function TimelineTrackContent({ }); return ( - <button - className="size-full" - onClick={(event) => { - if (shouldIgnoreClick?.()) return; - onTrackClick?.(event); - }} - onMouseDown={(event) => { - event.preventDefault(); - onTrackMouseDown?.(event); - }} - type="button" - > - <div className="relative h-full min-w-full"> + <div className="relative size-full"> + <button + type="button" + className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0" + aria-label={`Select ${track.name} track`} + onMouseUp={(event) => { + if (shouldIgnoreClick?.()) return; + onTrackMouseUp?.(event); + }} + onMouseDown={(event) => { + event.preventDefault(); + onTrackMouseDown?.(event); + }} + /> + {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */} + <div + className="relative h-full min-w-full" + style={{ zIndex: TIMELINE_LAYERS.trackContent }} + onMouseUp={(event) => { + if (event.target !== event.currentTarget) return; + if (shouldIgnoreClick?.()) return; + onTrackMouseUp?.(event); + }} + onMouseDown={(event) => { + if (event.target !== event.currentTarget) return; + event.preventDefault(); + onTrackMouseDown?.(event); + }} + > {track.elements.length === 0 ? ( - <div className="text-muted-foreground border-muted/30 flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" /> + <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" /> ) : ( track.elements.map((element) => { const isSelected = isElementSelected({ @@ -109,6 +123,6 @@ export function TimelineTrackContent({ }) )} </div> - </button> + </div> ); } |
