OpenCut/apps/web/src/timeline/components/index.tsx

951 lines
27 KiB
TypeScript

"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 { MediaTime } from "@/wasm";
import type { ElementDragView, 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: MediaTime) => 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 { dragView, handleElementMouseDown, handleElementClick } =
useElementInteraction({
zoomLevel,
tracksContainerRef,
tracksScrollRef,
snappingEnabled,
onSnapPointChange: handleSnapPointChange,
});
const isElementDragging = dragView.kind === "dragging";
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,
});
useEdgeAutoScroll({
isActive: isElementDragging,
getMouseClientX: () =>
dragView.kind === "dragging" ? dragView.currentMouseX : 0,
rulerScrollRef,
tracksScrollRef,
contentWidth: dynamicTimelineWidth,
});
const showSnapIndicator =
snappingEnabled &&
currentSnapPoint !== null &&
(isElementDragging || 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={isElementDragging ? dragView.dropTarget : null}
tracks={tracks}
isVisible={isElementDragging}
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}
dragView={dragView}
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,
dragView,
onResizeStart,
onElementMouseDown,
onElementClick,
onTrackMouseDown,
onTrackMouseUp,
shouldIgnoreClick,
isDragOver,
dropTarget,
}: {
mainTrackId: string | null;
zoomLevel: number;
dragView: ElementDragView;
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 draggingElementIds = useMemo(
() =>
dragView.kind === "dragging"
? dragView.memberTimeOffsets
: (null as ReadonlyMap<string, MediaTime> | null),
[dragView],
);
const sortedTracks = useMemo(() => {
if (!draggingElementIds)
return tracks.map((track, index) => ({ track, index }));
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, draggingElementIds]);
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}
dragView={dragView}
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>
);
}