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/index.tsx | |
| 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/index.tsx')
| -rw-r--r-- | apps/web/src/components/editor/panels/timeline/index.tsx | 885 |
1 files changed, 571 insertions, 314 deletions
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({ |
