import { Fragment, lazy, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react'; import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types'; import { getFirst, getNext, getPrevious } from 'ontime-utils'; import { useEventAction } from '../../common/hooks/useEventAction'; import { useRundownEditor } from '../../common/hooks/useSocket'; import { AppMode, useAppMode } from '../../common/stores/appModeStore'; import { useEditorSettings } from '../../common/stores/editorSettings'; import { cloneEvent } from '../../common/utils/eventsManager'; import QuickAddBlock from './quick-add-block/QuickAddBlock'; import RundownEmpty from './RundownEmpty'; import style from './Rundown.module.scss'; const RundownEntry = lazy(() => import('./RundownEntry')); interface RundownProps { entries: OntimeRundown; } export default function Rundown(props: RundownProps) { const { entries } = props; const [statefulEntries, setStatefulEntries] = useState(entries); const featureData = useRundownEditor(); const { addEvent, reorderEvent } = useEventAction(); const eventSettings = useEditorSettings((state) => state.eventSettings); const defaultPublic = eventSettings.defaultPublic; const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd; const showQuickEntry = eventSettings.showQuickEntry; const isExtracted = window.location.pathname.includes('/rundown'); // cursor const cursor = useAppMode((state) => state.cursor); const appMode = useAppMode((state) => state.mode); const viewFollowsCursor = appMode === AppMode.Run; const moveCursorTo = useAppMode((state) => state.setCursor); const cursorRef = useRef(null); const scrollRef = useRef(null); // DND KIT const sensors = useSensors(useSensor(PointerSensor)); const insertAtCursor = useCallback( (type: SupportedEvent | 'clone', cursor: string | null) => { if (cursor === null) { // we cant clone without selection if (type === 'clone') { return; } // the only thing to do is adding an event at top addEvent({ type }); return; } if (type === 'clone') { const cursorEvent = entries.find((event) => event.id === cursor); if (cursorEvent?.type === SupportedEvent.Event) { const newEvent = cloneEvent(cursorEvent, cursorEvent.id); addEvent(newEvent); } } else if (type === SupportedEvent.Event) { const newEvent = { type: SupportedEvent.Event, }; const options = { defaultPublic: defaultPublic, startTimeIsLastEnd: startTimeIsLastEnd, lastEventId: cursor, after: cursor, }; addEvent(newEvent, options); } else { addEvent({ type }, { after: cursor }); } }, [addEvent, defaultPublic, entries, startTimeIsLastEnd], ); // Handle keyboard shortcuts const handleKeyPress = useCallback( (event: KeyboardEvent) => { // handle held key if (event.repeat) return; // Check if the alt key is pressed if (event.altKey && (!event.ctrlKey || !event.shiftKey)) { switch (event.code) { case 'ArrowDown': { if (entries.length < 1) { return; } const nextEvent = cursor == null ? getFirst(entries) : getNext(entries, cursor); if (nextEvent) { moveCursorTo(nextEvent.id, nextEvent.type === SupportedEvent.Event); } break; } case 'ArrowUp': { if (entries.length < 1) { return; } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we check for this before const previousEvent = cursor == null ? getFirst(entries) : getPrevious(entries, cursor); if (previousEvent) { moveCursorTo(previousEvent.id, previousEvent.type === SupportedEvent.Event); } break; } case 'KeyE': { event.preventDefault(); insertAtCursor(SupportedEvent.Event, cursor); break; } case 'KeyD': { event.preventDefault(); insertAtCursor(SupportedEvent.Delay, cursor); break; } case 'KeyB': { event.preventDefault(); insertAtCursor(SupportedEvent.Block, cursor); break; } case 'KeyC': { event.preventDefault(); insertAtCursor('clone', cursor); break; } } } }, [cursor, entries, insertAtCursor, moveCursorTo], ); // we copy the state from the store here // to workaround async updates on the drag mutations useEffect(() => { if (entries) { setStatefulEntries(entries); } }, [entries]); // listen to keys useEffect(() => { document.addEventListener('keydown', handleKeyPress); return () => { document.removeEventListener('keydown', handleKeyPress); }; }, [handleKeyPress]); // when cursor moves, view should follow useEffect(() => { function scrollToComponent( componentRef: MutableRefObject, scrollRef: MutableRefObject, ) { const componentRect = componentRef.current.getBoundingClientRect(); const scrollRect = scrollRef.current.getBoundingClientRect(); const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100; scrollRef.current.scrollTo({ top, behavior: 'smooth' }); } if (cursorRef.current && scrollRef.current) { // Use requestAnimationFrame to ensure the component is fully loaded window.requestAnimationFrame(() => { scrollToComponent(cursorRef as MutableRefObject, scrollRef as MutableRefObject); }); } // eslint-disable-next-line -- the prompt seems incorrect }, [cursorRef?.current, scrollRef]); useEffect(() => { // in run mode, we follow selection if (!viewFollowsCursor || !featureData?.selectedEventId) { return; } moveCursorTo(featureData.selectedEventId); }, [featureData?.selectedEventId, viewFollowsCursor, moveCursorTo]); const handleOnDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (over?.id) { if (active.id !== over?.id) { const fromIndex = active.data.current?.sortable.index; const toIndex = over.data.current?.sortable.index; // ugly hack to handle inconsistencies between dnd-kit and async store updates setStatefulEntries((currentEntries) => { return arrayMove(currentEntries, fromIndex, toIndex); }); reorderEvent(String(active.id), fromIndex, toIndex); } } }; if (statefulEntries?.length < 1) { return insertAtCursor(SupportedEvent.Event, null)} />; } let previousEnd = 0; let thisEnd = 0; let previousEventId: string | undefined; let eventIndex = 0; let isPast = Boolean(featureData?.selectedEventId); return (
{statefulEntries.map((entry, index) => { if (index === 0) { eventIndex = 0; } if (entry.type === SupportedEvent.Event) { eventIndex++; previousEnd = thisEnd; thisEnd = entry.timeEnd; previousEventId = entry.id; } const isLast = index === entries.length - 1; const isSelected = featureData?.selectedEventId === entry.id; const isNext = featureData?.nextEventId === entry.id; const hasCursor = entry.id === cursor; if (isSelected) { isPast = false; } return (
{entry.type === SupportedEvent.Event &&
{eventIndex}
}
{((showQuickEntry && hasCursor) || isLast) && ( )}
); })}
); }