summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/timeline/index.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline/index.tsx')
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx944
1 files changed, 0 insertions, 944 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
deleted file mode 100644
index 20e9faf1..00000000
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ /dev/null
@@ -1,944 +0,0 @@
-"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 "@/hooks/timeline/use-timeline-zoom";
-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 "@/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 "@/lib/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 "@/hooks/timeline/element/use-element-interaction";
-import {
- canTrackHaveAudio,
- canTrackBeHidden,
- getTimelineZoomMin,
- getTimelinePaddingPx,
-} from "@/lib/timeline";
-import { timelineTimeToPixels } from "@/lib/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 "@/hooks/timeline/element/use-element-selection";
-import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
-import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
-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 { useTimelineResize } from "@/hooks/timeline/use-timeline-resize";
-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 "./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>
- );
-}