import { useCallback, type ReactNode } from "react"; import { Timeline } from "../../player"; import type { TimelineElement } from "../../player"; import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; import { TimelineResizeDivider } from "./TimelineResizeDivider"; import { useTimelineEditContext } from "../../contexts/TimelineEditContext"; import { trackStudioExpandedClipEdit } from "../../telemetry/events"; import { useNLEContext } from "./NLEContext"; import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter"; type TimelineMoveEdit = { element: TimelineElement; updates: Pick; }; export function forwardRebasedTimelineMoveElements( edits: TimelineMoveEdit[], coalesceKey: string | undefined, operation: TimelineMoveOperation | undefined, onMoveElements: ( edits: TimelineMoveEdit[], coalesceKey?: string, operation?: TimelineMoveOperation, coalesceMs?: number, ) => Promise | void, coalesceMs?: number, ) { return onMoveElements( edits.map(({ element, updates }) => { const basis = element.expandedParentStart; if (basis === undefined) return { element, updates }; return { element: { ...element, id: element.domId ?? element.id, start: element.start - basis }, updates: { ...updates, start: Math.max(0, updates.start - basis) }, }; }), coalesceKey, operation, coalesceMs, ); } type TimelineResizeChange = { element: TimelineElement; start: number; duration: number; playbackStart?: number; }; export function forwardRebasedTimelineResizeElements( changes: TimelineResizeChange[], options: { coalesceKey?: string } | undefined, onResizeElements: ( changes: TimelineResizeChange[], options?: { coalesceKey?: string }, ) => Promise | void, ) { return onResizeElements( changes.map((change) => { const basis = change.element.expandedParentStart; if (basis === undefined) return change; return { ...change, element: { ...change.element, id: change.element.domId ?? change.element.id, start: change.element.start - basis, }, start: Math.max(0, change.start - basis), }; }), options, ); } export interface TimelinePaneProps { /** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */ timelineToolbar?: ReactNode; /** Slot rendered below the timeline tracks */ timelineFooter?: ReactNode; /** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */ renderClipContent?: ( element: TimelineElement, style: { clip: string; label: string }, ) => ReactNode; onFileDrop?: ( files: File[], placement?: Pick, ) => Promise | void; onDeleteElement?: (element: TimelineElement) => Promise | void; onAssetDrop?: ( assetPath: string, placement: Pick, ) => Promise | void; onBlockDrop?: ( blockName: string, placement: Pick, ) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSelectTimelineElement?: (element: TimelineElement | null) => void; } // fallow-ignore-next-line complexity export function TimelinePane({ timelineToolbar, timelineFooter, renderClipContent, onFileDrop, onDeleteElement, onAssetDrop, onBlockDrop, onBlockedEditAttempt, onSelectTimelineElement, }: TimelinePaneProps) { const { seek, handleDrillDown, compositionStack, updateCompositionStack, timelineH, setTimelineH, persistTimelineH, containerRef, timelineDisabled, } = useNLEContext(); // Move/resize/split come from the timeline edit context, not props — the // wrappers below intercept expanded clips and must call the *real* handlers. // (Delete is a direct prop; it stays that way.) const { onMoveElement, onMoveElements, onResizeElement, onResizeElements, onSplitElement } = useTimelineEditContext(); // An expanded sub-comp child reaches the normal edit handlers in its own // local coordinates: addressed by its real DOM id, with timeline time rebased // onto the sub-comp it lives in. The handlers then save + reloadPreview exactly // as they do for top-level clips — no separate live-DOM path. const toLocalElement = useCallback( (element: TimelineElement, basis: number): TimelineElement => ({ ...element, id: element.domId ?? element.id, start: element.start - basis, }), [], ); const handleMoveElement = useCallback( (element: TimelineElement, updates: Pick) => { const basis = element.expandedParentStart; if (basis === undefined) return onMoveElement?.(element, updates); trackStudioExpandedClipEdit({ action: "move" }); onMoveElement?.(toLocalElement(element, basis), { ...updates, start: Math.max(0, updates.start - basis), }); }, [onMoveElement, toLocalElement], ); // Batched move (ripple / insert): rebase each expanded sub-comp child to its // local coords, exactly as handleMoveElement does for a single clip. const handleMoveElements = useCallback( ( edits: Array<{ element: TimelineElement; updates: Pick }>, coalesceKey?: string, operation?: TimelineMoveOperation, coalesceMs?: number, ) => { // Match the sibling handlers: report the telemetry when the batch touches at // least one expanded sub-comp child (the clips being rebased to local coords). if (edits.some(({ element }) => element.expandedParentStart !== undefined)) { trackStudioExpandedClipEdit({ action: "move" }); } if (!onMoveElements) return; return forwardRebasedTimelineMoveElements( edits, coalesceKey, operation, onMoveElements, coalesceMs, ); }, [onMoveElements], ); const handleResizeElement = useCallback( ( element: TimelineElement, updates: Pick, ) => { const basis = element.expandedParentStart; if (basis === undefined) return onResizeElement?.(element, updates); trackStudioExpandedClipEdit({ action: "resize" }); onResizeElement?.(toLocalElement(element, basis), { ...updates, start: Math.max(0, updates.start - basis), }); }, [onResizeElement, toLocalElement], ); const handleResizeElements = useCallback( ( changes: Array<{ element: TimelineElement; start: number; duration: number; playbackStart?: number; }>, options?: { coalesceKey?: string }, ) => { if (!onResizeElements) return; if (changes.some(({ element }) => element.expandedParentStart !== undefined)) { trackStudioExpandedClipEdit({ action: "resize" }); } return forwardRebasedTimelineResizeElements(changes, options, onResizeElements); }, [onResizeElements], ); const handleDeleteElement = useCallback( (element: TimelineElement) => { const basis = element.expandedParentStart; if (basis === undefined) return onDeleteElement?.(element); trackStudioExpandedClipEdit({ action: "delete" }); return onDeleteElement?.(toLocalElement(element, basis)); }, [onDeleteElement, toLocalElement], ); const handleSplitElement = useCallback( (element: TimelineElement, splitTime: number) => { const basis = element.expandedParentStart; if (basis === undefined) return onSplitElement?.(element, splitTime); trackStudioExpandedClipEdit({ action: "split" }); return onSplitElement?.(toLocalElement(element, basis), Math.max(0, splitTime - basis)); }, [onSplitElement, toLocalElement], ); return ( <> {/* Timeline section — inner padding (not margin) keeps the divider's height math exact while giving the panel a gap from the shell edges. */}
{ if ((e.target as HTMLElement).closest("[data-clip]")) return; if (timelineDisabled) return; if (compositionStack.length > 1) { updateCompositionStack((prev) => prev.slice(0, -1)); } }} >
{timelineToolbar}
{timelineFooter &&
{timelineFooter}
} {timelineDisabled && (
event.preventDefault()} onDragOver={(event) => event.preventDefault()} onDrop={(event) => event.preventDefault()} > Loading composition…
)}
); }