diff options
Diffstat (limited to 'apps/web/src/timeline/components/index.tsx')
| -rw-r--r-- | apps/web/src/timeline/components/index.tsx | 946 |
1 files changed, 946 insertions, 0 deletions
diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx new file mode 100644 index 00000000..e6a9df49 --- /dev/null +++ b/apps/web/src/timeline/components/index.tsx @@ -0,0 +1,946 @@ +"use client"; + +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 "@/components/icons"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { useTimelineZoom } from "@/timeline/hooks/use-timeline-zoom"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import type { ElementDragState, DropTarget } from "@/timeline"; +import { TimelineTrackContent } from "./timeline-track"; +import { TimelinePlayhead } from "./timeline-playhead"; +import { SelectionBox } from "@/selection/selection-box"; +import { useBoxSelect } from "@/selection/hooks/use-box-select"; +import { SnapIndicator } from "./snap-indicator"; +import type { SnapPoint } from "@/timeline/snapping"; +import type { TimelineTrack } from "@/timeline"; +import { + TIMELINE_SCROLLBAR_SIZE_PX, + TIMELINE_CONTENT_TOP_PADDING_PX, + TIMELINE_TRACK_GAP_PX, + TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX, + KEYFRAME_LANE_HEIGHT_PX, +} from "./layout"; +import { useElementInteraction } from "@/timeline/hooks/element/use-element-interaction"; +import { + canTrackHaveAudio, + canTrackBeHidden, + getTimelineZoomMin, + getTimelinePaddingPx, +} from "@/timeline"; +import { timelineTimeToPixels } from "@/timeline/pixel-utils"; +import { + getTrackHeight, + getCumulativeHeightBefore, + getTotalTracksHeight, +} from "./track-layout"; +import { SELECTED_TRACK_ROW_CLASS } from "./theme"; +import { + computeTrackExpansionHeight, + getTrackExpandedRows, + getPropertyLabel, + type ExpandedRow, +} from "./expanded-layout"; +import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction"; +import { TimelineToolbar } from "./timeline-toolbar"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { useTimelineSeek } from "@/timeline/hooks/use-timeline-seek"; +import { useTimelineDragDrop } from "@/timeline/hooks/use-timeline-drag-drop"; +import { TimelineRuler } from "./timeline-ruler"; +import { + TimelineBookmarksRow, + useBookmarkDrag, +} from "@/timeline/bookmarks/index"; +import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import { useInitialScrollBottom } from "@/timeline/hooks/use-initial-scroll-bottom"; +import { useTimelineResize } from "@/timeline/hooks/use-timeline-resize"; +import { useTimelineStore } from "@/timeline/timeline-store"; +import { useEditor } from "@/editor/use-editor"; +import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; +import { DragLine } from "./drag-line"; +import { invokeAction } from "@/actions"; +import { resolveTimelineElementIntersections } from "./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 snappingEnabled = useTimelineStore((s) => s.snappingEnabled); + const { + selectedElements, + clearElementSelection, + setElementSelection, + mergeElementsIntoSelection, + } = useElementSelection(); + const editor = useEditor(); + const timeline = editor.timeline; + const scene = useEditor((currentEditor) => + currentEditor.scenes.getActiveSceneOrNull(), + ); + const tracks = useMemo<TimelineTrack[]>( + () => + scene + ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] + : [], + [scene], + ); + const mainTrackId = scene?.tracks.main.id ?? null; + 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); + const playheadRef = useRef<HTMLDivElement>(null); + const trackLabelsScrollRef = useRef<HTMLDivElement>(null); + + const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( + null, + ); + + const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { + setCurrentSnapPoint(snapPoint); + }, []); + + const timelineDuration = timeline.getTotalDuration() || 0; + const minZoomLevel = getTimelineZoomMin({ + duration: timelineDuration, + containerWidth: tracksContainerRef.current?.clientWidth, + }); + + const savedViewState = editor.project.getTimelineViewState(); + + const { zoomLevel, setZoomLevel, handleWheel, saveScrollPosition } = + useTimelineZoom({ + containerRef: timelineRef, + minZoom: minZoomLevel, + initialZoom: savedViewState?.zoomLevel, + initialScrollLeft: savedViewState?.scrollLeft, + initialPlayheadTime: savedViewState?.playheadTime, + tracksScrollRef, + rulerScrollRef, + }); + const { isResizing, handleResizeStart } = useTimelineResize({ + zoomLevel, + onSnapPointChange: handleSnapPointChange, + }); + + const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); + + const getTrackExpansionHeight = useCallback( + (trackIndex: number) => { + const track = tracks[trackIndex]; + if (!track) return 0; + return computeTrackExpansionHeight({ track, expandedElementIds }); + }, + [tracks, expandedElementIds], + ); + + // 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_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, + dragDropTarget, + handleElementMouseDown, + handleElementClick, + lastMouseXRef, + } = useElementInteraction({ + zoomLevel, + timelineRef, + tracksContainerRef, + tracksScrollRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); + + const { + dragState: bookmarkDragState, + handleBookmarkMouseDown, + lastMouseXRef: bookmarkLastMouseXRef, + } = useBookmarkDrag({ + zoomLevel, + scrollRef: tracksScrollRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); + + const { handleRulerMouseDown: handlePlayheadRulerMouseDown } = + useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ + containerRef: tracksContainerRef, + tracksScrollRef, + zoomLevel, + }); + + const { + selectionBox, + handleMouseDown: handleSelectionMouseDown, + isSelecting, + shouldIgnoreClick, + } = useBoxSelect({ + containerRef: tracksContainerRef, + 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 }); + } + }, + }); + + const containerWidth = + tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; + const contentWidth = timelineTimeToPixels({ + time: timelineDuration, + zoomLevel, + }); + const paddingPx = getTimelinePaddingPx({ + containerWidth, + zoomLevel, + minZoom: minZoomLevel, + }); + const dynamicTimelineWidth = Math.max( + 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, + contentWidth: dynamicTimelineWidth, + }); + + const showSnapIndicator = + snappingEnabled && + currentSnapPoint !== null && + (dragState.isDragging || bookmarkDragState.isDragging || isResizing); + + const { + handleTracksMouseDown, + handleTracksClick, + handleRulerMouseDown, + handleRulerClick, + } = useTimelineSeek({ + playheadRef, + trackLabelsRef, + rulerScrollRef, + tracksScrollRef, + zoomLevel, + duration: timeline.getTotalDuration(), + isSelecting, + clearSelectedElements: clearElementSelection, + seek, + }); + + const timelineHeaderHeight = + (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) + + TIMELINE_CONTENT_TOP_PADDING_PX || 0; + + return ( + <section + className={ + "panel bg-background relative flex h-full flex-col overflow-hidden rounded-sm border" + } + {...dragProps} + aria-label="Timeline" + > + <TimelineToolbar + zoomLevel={zoomLevel} + minZoom={minZoomLevel} + setZoomLevel={({ zoom }) => setZoomLevel(zoom)} + /> + + <div className="relative flex flex-1 overflow-hidden" ref={timelineRef}> + <TrackLabelsPanel + trackLabelsRef={trackLabelsRef} + trackLabelsScrollRef={trackLabelsScrollRef} + timelineHeaderHeight={timelineHeaderHeight} + hasHorizontalScrollbar={hasHorizontalScrollbar} + getTrackExpansionHeight={getTrackExpansionHeight} + /> + + <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={tracks} + isVisible={isDragOver && !dropTarget?.targetElement} + headerHeight={timelineHeaderHeight} + /> + <DragLine + dropTarget={dragDropTarget} + tracks={tracks} + 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> + </div> + + <ScrollArea + className="flex-1" + ref={tracksScrollRef} + onScroll={() => { + syncFollowers(); + 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 shrink-0" + style={{ + height: `${ + Math.max( + TRACKS_CONTAINER_HEIGHT.min, + Math.min( + TRACKS_CONTAINER_HEIGHT.max, + getTotalTracksHeight({ + tracks, + getExtraHeight: getTrackExpansionHeight, + }), + ), + ) + TIMELINE_CONTENT_TOP_PADDING_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); + }} + > + {tracks.length > 0 && ( + <TimelineTrackRows + mainTrackId={mainTrackId} + zoomLevel={zoomLevel} + dragState={dragState} + tracksScrollRef={tracksScrollRef} + lastMouseXRef={lastMouseXRef} + onResizeStart={handleResizeStart} + onElementMouseDown={handleElementMouseDown} + onElementClick={handleElementClick} + onTrackMouseDown={(event) => { + handleSelectionMouseDown(event); + handleTracksMouseDown(event); + }} + onTrackMouseUp={handleTracksClick} + shouldIgnoreClick={shouldIgnoreClick} + isDragOver={isDragOver} + dropTarget={dropTarget} + /> + )} + </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} + /> + </div> + </section> + ); +} + +function TrackLabelsPanel({ + trackLabelsRef, + trackLabelsScrollRef, + timelineHeaderHeight, + hasHorizontalScrollbar, + getTrackExpansionHeight, +}: { + trackLabelsRef: React.RefObject<HTMLDivElement | null>; + trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>; + timelineHeaderHeight: number; + hasHorizontalScrollbar: boolean; + getTrackExpansionHeight: (trackIndex: number) => number; +}) { + const editor = useEditor(); + const scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); + const tracks = useMemo<TimelineTrack[]>( + () => + scene + ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] + : [], + [scene], + ); + const { selectedElements } = useElementSelection(); + const tracksWithSelection = useMemo( + () => new Set(selectedElements.map((el) => el.trackId)), + [selectedElements], + ); + + const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); + const trackExpandedRowsMap = useMemo( + () => + tracks.map((track) => + getTrackExpandedRows({ track, expandedElementIds }), + ), + [tracks, expandedElementIds], + ); + + return ( + <div + className="flex shrink-0 flex-col border-r" + style={{ width: `${TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX}px` }} + > + <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: `${TIMELINE_TRACK_GAP_PX}px` }} + > + {tracks.map((track, index) => { + const expandedRows = trackExpandedRowsMap[index]; + const baseHeight = getTrackHeight({ type: track.type }); + + return ( + <div + key={track.id} + className={cn( + "group flex flex-col", + tracksWithSelection.has(track.id) && + SELECTED_TRACK_ROW_CLASS, + )} + style={{ + height: `${baseHeight + getTrackExpansionHeight(index)}px`, + }} + > + <div + className="flex shrink-0 items-center justify-end gap-2 px-3" + style={{ height: `${baseHeight}px` }} + > + {canTrackHaveAudio(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> + {expandedRows.length > 0 && ( + <PropertyTree rows={expandedRows} /> + )} + </div> + ); + })} + </div> + )} + </div> + </div> + <div + className="bg-background shrink-0" + style={{ + height: hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX : 0, + }} + /> + </div> + ); +} + +function TimelineTrackRows({ + mainTrackId, + zoomLevel, + dragState, + tracksScrollRef, + lastMouseXRef, + onResizeStart, + onElementMouseDown, + onElementClick, + onTrackMouseDown, + onTrackMouseUp, + shouldIgnoreClick, + isDragOver, + dropTarget, +}: { + mainTrackId: string | null; + zoomLevel: number; + dragState: ElementDragState; + tracksScrollRef: React.RefObject<HTMLDivElement | null>; + lastMouseXRef: React.RefObject<number>; + onResizeStart: React.ComponentProps< + typeof TimelineTrackContent + >["onResizeStart"]; + 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 scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); + const tracks = useMemo<TimelineTrack[]>( + () => + scene + ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] + : [], + [scene], + ); + const { selectedElements } = useElementSelection(); + const tracksWithSelection = useMemo( + () => new Set(selectedElements.map((el) => el.trackId)), + [selectedElements], + ); + + const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); + + const getTrackExpansionHeight = useCallback( + (trackIndex: number) => { + const track = tracks[trackIndex]; + if (!track) return 0; + return computeTrackExpansionHeight({ track, expandedElementIds }); + }, + [tracks, expandedElementIds], + ); + + const sortedTracks = useMemo(() => { + const draggingElementIds = new Set(dragState.dragElementIds); + return [...tracks] + .map((track, index) => ({ track, index })) + .sort((a, b) => { + const aHasDragged = a.track.elements.some((element) => + draggingElementIds.has(element.id), + ); + const bHasDragged = b.track.elements.some((element) => + draggingElementIds.has(element.id), + ); + if (aHasDragged) return 1; + if (bHasDragged) return -1; + return 0; + }); + }, [tracks, dragState.dragElementIds]); + + 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) && SELECTED_TRACK_ROW_CLASS, + )} + style={{ + top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`, + height: `${getTrackHeight({ type: track.type }) + getTrackExpansionHeight(index)}px`, + }} + > + <TimelineTrackContent + track={track} + zoomLevel={zoomLevel} + dragState={dragState} + rulerScrollRef={tracksScrollRef} + tracksScrollRef={tracksScrollRef} + lastMouseXRef={lastMouseXRef} + onResizeStart={onResizeStart} + 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 }); + }} + > + {canTrackHaveAudio(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> + {track.id !== mainTrackId && ( + <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_ICONS[track.type]}</>; +} + +function TrackToggleIcon({ + isOff, + icons, + onClick, +}: { + isOff: boolean; + icons: { + on: IconSvgElement; + off: IconSvgElement; + }; + onClick: () => void; +}) { + return ( + <> + {isOff ? ( + <HugeiconsIcon + icon={icons.off} + className="text-destructive size-4 cursor-pointer" + onClick={onClick} + /> + ) : ( + <HugeiconsIcon + icon={icons.on} + className="text-muted-foreground size-4 cursor-pointer" + onClick={onClick} + /> + )} + </> + ); +} + +function PropertyTree({ rows }: { rows: ExpandedRow[] }) { + return ( + <div className="flex flex-col overflow-hidden"> + {rows.map((row) => ( + <div + key={row.propertyPath} + className={cn("flex shrink-0 items-center px-3 bg-muted/50")} + style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }} + > + <span className="text-muted-foreground truncate text-xs leading-none"> + {getPropertyLabel(row.propertyPath)} + </span> + </div> + ))} + </div> + ); +} |
