import { DndContext, closestCenter } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { type EntryId, Playback, type Rundown as RundownType, SupportedEntry, isOntimeEvent, isOntimeGroup, } from 'ontime-types'; import { Fragment, type HTMLProps, forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { TbFlagFilled } from 'react-icons/tb'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { useEntryActionsContext } from '../../common/context/EntryActionsContext'; import { useEntryCopy } from '../../common/stores/entryCopyStore'; import { RundownMetadataObject, lastMetadataKey } from '../../common/utils/rundownMetadata'; import { AppMode } from '../../ontimeConfig'; import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons'; import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline'; import { useRundownCommands } from './hooks/useRundownCommands'; import { useRundownDnd } from './hooks/useRundownDnd'; import { useRundownKeyboard } from './hooks/useRundownKeyboard'; import RundownGroup from './rundown-group/RundownGroup'; import RundownGroupEnd from './rundown-group/RundownGroupEnd'; import { filterVisibleEntries, makeSortableList } from './rundown.utils'; import RundownEmpty from './RundownEmpty'; import RundownEntry from './RundownEntry'; import { useCollapsedGroups } from './useCollapsedGroups'; import { useEditorFollowMode } from './useEditorFollowMode'; import { useEventSelection } from './useEventSelection'; import style from './Rundown.module.scss'; interface RundownProps { entries: RundownType['entries']; id: RundownType['id']; order: RundownType['order']; flatOrder: RundownType['flatOrder']; rundownMetadata: RundownMetadataObject; featureData: { playback: Playback; selectedEventId: EntryId | null; nextEventId: EntryId | null; }; } export default function Rundown({ order, flatOrder, entries, id, rundownMetadata, featureData }: RundownProps) { // invoke the compiler for the component 'use memo'; const [sortableData, setSortableData] = useState(() => makeSortableList(order, entries)); const [metadata, setMetadata] = useState(rundownMetadata); /** * We need to keep a copy of the events we receive to * workaround issues with dnd-kit optimistic updates */ useEffect(() => { setSortableData(makeSortableList(order, entries)); setMetadata(rundownMetadata); }, [order, entries, rundownMetadata]); const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id); const entryActions = useEntryActionsContext(); const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId); // cursor const { editorMode } = useEditorFollowMode(); const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents); const cursor = useEventSelection((state) => state.cursor); const scrollToEntry = useEventSelection((state) => state.scrollToEntry); const setScrollHandler = useEventSelection((state) => state.setScrollHandler); const selectEntry = useEventSelection((state) => state.setSelectedEvents); const cursorRef = useRef(null); const scrollRef = useRef(null); const virtuosoRef = useRef(null); /** * Handles logic for collapsing groups */ const handleCollapseGroup = useCallback( (collapsed: boolean, groupId: EntryId | undefined) => { if (collapsed) { collapseGroup(groupId); } else { expandGroup(groupId); } }, [collapseGroup, expandGroup], ); // Commands layer - business logic const commands = useRundownCommands({ entries, flatOrder, entryActions, selectEntry, handleCollapseGroup, }); // Keyboard shortcuts useRundownKeyboard({ cursor, commands, clearSelectedEvents, setEntryCopyId, }); // DND handlers const dnd = useRundownDnd({ entries, sortableData, setSortableData, getIsCollapsed, handleCollapseGroup, entryActions, }); // Filter visible data to exclude collapsed items // DND-kit (SortableContext) needs the full sortableData for drag calculations // Virtuoso only renders visibleData to avoid null items that reserve space const visibleData = useMemo(() => { return filterVisibleEntries(sortableData, entries, getIsCollapsed); }, [sortableData, entries, getIsCollapsed]); // Provide an imperative scroll handler for explicit jumps (finder/keyboard) useEffect(() => { setScrollHandler((entryId) => { if (!virtuosoRef.current || dnd.isDraggingRef.current) { return; } const index = visibleData.indexOf(entryId); if (index === -1) { return; } virtuosoRef.current.scrollToIndex({ index, align: 'start', behavior: 'smooth', offset: -100, // show the previous entry for context }); }); return () => { setScrollHandler(null); }; }, [visibleData, dnd.isDraggingRef, setScrollHandler]); // Keep selected cursor visible by expanding its parent group in any mode. // Finder can select entries while layout is in Run mode, where we do not follow cursor scroll. useEffect(() => { if (!cursor) { return; } const entry = entries[cursor]; if (entry && 'parent' in entry) { expandGroup(entry.parent); } }, [cursor, entries, expandGroup]); // Auto-follow behavior in Edit mode: follow the user's cursor useEffect(() => { if (dnd.isDraggingRef.current || editorMode !== AppMode.Edit || !cursor) { return; } scrollToEntry(cursor); }, [editorMode, cursor, scrollToEntry, dnd.isDraggingRef]); // Auto-follow behavior in Run mode: follow the currently playing event useEffect(() => { if (dnd.isDraggingRef.current || editorMode !== AppMode.Run || !featureData?.selectedEventId) { return; } // Open parent group if the target is inside a collapsed group const entry = entries[featureData.selectedEventId]; if (entry && 'parent' in entry) { expandGroup(entry.parent); } scrollToEntry(featureData.selectedEventId); }, [editorMode, featureData?.selectedEventId, entries, expandGroup, scrollToEntry, dnd.isDraggingRef]); // gather presentation options const isEditMode = editorMode === AppMode.Edit; // gather rundown wide data const lastEntryId = order.at(-1); // Extract primitive values from featureData to avoid unnecessary callback recreations const nextEventId = featureData?.nextEventId; const playback = featureData?.playback; // Virtuoso item renderer const itemContent = useCallback( (index: number, entryId: EntryId) => { // Handle end-group pseudo entries const isEndGroup = entryId.startsWith('end-'); if (isEndGroup) { const parentId = entryId.split('end-')[1]; const parentMetadata = metadata[parentId]; return ( {isEditMode && parentMetadata?.groupEntries === 0 && ( )} ); } const entry = entries[entryId]; const entryMetadata = metadata[entryId]; if (!entry || !entryMetadata) { return null; } const isNext = nextEventId === entry.id; const hasCursor = entry.id === cursor; const isGroup = isOntimeGroup(entry); const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour; const isFirst = index === 0; const isLast = entryId === lastEntryId; const parentIdForBefore = entryMetadata.thisId !== entryMetadata.groupId ? entryMetadata.groupId : null; const parentIdForAfter = entryMetadata.groupId; const collapsed = isGroup ? getIsCollapsed(entry.id) : false; return ( {/* QuickAddInline before the entry - edit mode only, if there is a cursor, if it is not the first entry */} {isEditMode && hasCursor && !isFirst && ( )} {isGroup ? ( ) : (
{isOntimeEvent(entry) && (
{entry.flag && }
{entryMetadata.eventIndex}
)}
)} {/* QuickAddInline after the entry - edit mode only, if there is a cursor, if it is not the last entry */} {isEditMode && hasCursor && !isLast && ( )}
); }, [entries, metadata, getIsCollapsed, isEditMode, cursor, nextEventId, playback, lastEntryId, handleCollapseGroup], ); if (sortableData.length < 1) { return entryActions.addEntry({ type })} />; } return (
entryId} itemContent={itemContent} increaseViewportBy={{ top: 200, bottom: 400 }} style={{ height: '100%' }} components={{ Header: isEditMode ? () => : undefined, Footer: () => ( <> {isEditMode && ( )}
), List: VirtuosoListComponent, }} />
); } // Virtuoso components - extracted to prevent recreation on every render const VirtuosoListComponent = forwardRef>(({ children, ...props }, ref) => (
{children}
)); VirtuosoListComponent.displayName = 'VirtuosoListComponent';