import { memo } from "react"; import { TimelineRuler } from "./TimelineRuler"; import { PlayheadIndicator } from "./PlayheadIndicator"; import { getTimelineEditCapabilities, type TimelineRangeSelection } from "./timelineEditing"; import { getRenderedTimelineElement } from "./timelineTheme"; import { GUTTER, TRACK_H, RULER_H, CLIP_Y, TRACKS_TOP_PAD, TRACKS_BOTTOM_PAD, TRACKS_LEFT_PAD, PLAYHEAD_HEAD_W, getTimelinePlayheadLeft, getTimelineRowTop, } from "./timelineLayout"; import { usePlayerStore } from "../store/playerStore"; import type { ResizingClipState } from "./useTimelineClipDrag"; import { type MultiDragPreviewInput } from "./timelineMultiDragPreview"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import type { Rect } from "../../utils/marqueeGeometry"; import { TimelineClip } from "./TimelineClip"; import { TimelineLanes, type TimelineLaneBaseProps } from "./TimelineLanes"; import { renderClipChildren } from "./timelineClipChildren"; import { useTimelineRevealClip } from "./useTimelineRevealClip"; import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights"; interface TimelineCanvasProps extends TimelineLaneBaseProps { major: number[]; minor: number[]; totalH: number; effectiveDuration: number; majorTickInterval: number; rangeSelection: TimelineRangeSelection | null; /** Live rubber-band multi-select rectangle (canvas coordinates), or null. */ marqueeRect: Rect | null; resizingClip: ResizingClipState | null; /** Playhead is being actively scrubbed — fills the grab-handle head. */ isScrubbing: boolean; playheadRef: React.RefObject; /** Gap strips: loud on gap-menu-row hover, quiet on the selected clip's lane. */ laneGapStrips: TimelineLaneGapStrips[]; } export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) { const { draggedClip, scrollRef, selectedElementIds, displayTrackOrder } = props; const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } = useTimelineEditContextOptional(); const beatDragging = usePlayerStore((s) => s.beatDragging); // Scroll a clip into view when the sidebar (asset card) requests a reveal. useTimelineRevealClip(scrollRef); const draggedElement = draggedClip?.element ?? null; const activeDraggedElement = draggedClip?.started === true && draggedElement ? getRenderedTimelineElement({ element: draggedElement, draggedElementId: draggedElement.key ?? draggedElement.id, previewStart: draggedClip.previewStart, previewTrack: draggedClip.previewTrack, }) : null; // The drag ghost follows the cursor freely (both axes) — CapCut-style. The // "magnetic" affordance is a highlight on the destination lane (draggedRowIndex), // which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it. const draggedRowIndex = draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1; // Live multi-selection drag: while a selected clip is dragged, ALL selected // clips move together as one rigid formation. The GRABBED clip is the free // ghost below; its co-selected "passengers" slide by the SAME group-clamped // delta (cheap translateX, no re-layout) — the delta is derived from the // grabbed clip's ALREADY-clamped previewStart, so the whole formation stops at // the wall together and never deforms. Matches what the commit will do — see // timelineMultiDragPreview + commit. const multiDragPreview: MultiDragPreviewInput | null = draggedClip?.started === true && draggedElement ? { dragStarted: true, draggedKey: draggedElement.key ?? draggedElement.id, draggedOriginStart: draggedElement.start, draggedPreviewStart: draggedClip.previewStart, selectedKeys: selectedElementIds, } : null; const activeDraggedPosition = draggedClip?.started === true && activeDraggedElement && scrollRef.current ? { left: draggedClip.pointerClientX - scrollRef.current.getBoundingClientRect().left + scrollRef.current.scrollLeft - draggedClip.pointerOffsetX, top: draggedClip.pointerClientY - scrollRef.current.getBoundingClientRect().top + scrollRef.current.scrollTop - draggedClip.pointerOffsetY, } : null; return (
{/* Breathing room between the sticky ruler and the first track lane — the top half of the CapCut-style padding (see TRACKS_TOP_PAD). */}