summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/components/index.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/timeline/components/index.tsx')
-rw-r--r--apps/web/src/timeline/components/index.tsx946
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>
+ );
+}