mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-14 10:39:37 +00:00
48c76947c5
Editable panels now share a rundown scope for data, selection, and mutations; runtime-only views resolve the loaded rundown explicitly. This also prevents stale data while switching scopes and provides the Operator editor with its mutation scope.
376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
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 useAutomationSettings from '../../common/hooks-query/useAutomationSettings';
|
|
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 { data: automationSettings, status: automationStatus } = useAutomationSettings();
|
|
const automationsEnabled = automationStatus === 'pending' ? undefined : automationSettings.enabledAutomations;
|
|
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
|
|
const [metadata, setMetadata] = useState<RundownMetadataObject>(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 setEntryCopy = useEntryCopy((state) => state.setEntryCopyId);
|
|
const setEntryCopyId = useCallback(
|
|
(entryId: EntryId | null, mode?: 'copy' | 'cut') => setEntryCopy(entryId, id, mode),
|
|
[setEntryCopy, id],
|
|
);
|
|
|
|
// 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<HTMLDivElement | null>(null);
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const virtuosoRef = useRef<VirtuosoHandle | null>(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,
|
|
});
|
|
|
|
// Jump to the current element on demand: the running event in Run mode, the edit cursor otherwise.
|
|
// Scrolls only (no selection change) so jumping to the running event does not hijack the edit cursor.
|
|
const jumpToCurrent = useCallback(() => {
|
|
const targetId = editorMode === AppMode.Run ? featureData?.selectedEventId : cursor;
|
|
if (!targetId) {
|
|
return;
|
|
}
|
|
|
|
// Open parent group if the target is inside a collapsed group
|
|
const entry = entries[targetId];
|
|
if (entry && 'parent' in entry) {
|
|
expandGroup(entry.parent);
|
|
}
|
|
scrollToEntry(targetId);
|
|
}, [editorMode, featureData?.selectedEventId, cursor, entries, expandGroup, scrollToEntry]);
|
|
|
|
// Keyboard shortcuts
|
|
useRundownKeyboard({
|
|
cursor,
|
|
commands,
|
|
clearSelectedEvents,
|
|
setEntryCopyId,
|
|
jumpToCurrent,
|
|
});
|
|
|
|
// 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 (
|
|
<Fragment key={entryId}>
|
|
{isEditMode && parentMetadata?.groupEntries === 0 && (
|
|
<QuickAddButtons
|
|
previousEventId={null}
|
|
parentGroup={parentId}
|
|
backgroundColor={parentMetadata?.groupColour}
|
|
/>
|
|
)}
|
|
<RundownGroupEnd key={entryId} id={entryId} colour={parentMetadata?.groupColour} />
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Fragment key={entry.id}>
|
|
{/* QuickAddInline before the entry - edit mode only, if there is a cursor, if it is not the first entry */}
|
|
{isEditMode && hasCursor && !isFirst && (
|
|
<QuickAddInline placement='before' referenceEntryId={entry.id} parentGroup={parentIdForBefore} />
|
|
)}
|
|
{isGroup ? (
|
|
<RundownGroup data={entry} hasCursor={hasCursor} collapsed={collapsed} onCollapse={handleCollapseGroup} />
|
|
) : (
|
|
<div
|
|
className={style.entryWrapper}
|
|
data-testid={`entry-${entryMetadata.eventIndex}`}
|
|
style={groupColour ? { '--user-bg': groupColour } : {}}
|
|
>
|
|
{isOntimeEvent(entry) && (
|
|
<div className={style.entryIndex}>
|
|
{entry.flag && <TbFlagFilled className={style.flag} />}
|
|
<div className={style.index}>{entryMetadata.eventIndex}</div>
|
|
</div>
|
|
)}
|
|
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
|
<RundownEntry
|
|
type={entry.type}
|
|
isPast={entryMetadata.isPast}
|
|
eventIndex={entryMetadata.eventIndex}
|
|
data={entry}
|
|
automationsEnabled={automationsEnabled}
|
|
loaded={entryMetadata.isLoaded}
|
|
hasCursor={hasCursor}
|
|
isNext={isNext}
|
|
isNextDay={entryMetadata.isNextDay}
|
|
playback={entryMetadata.isLoaded ? playback : undefined}
|
|
isRolling={playback === Playback.Roll}
|
|
totalGap={entryMetadata.totalGap}
|
|
isLinkedToLoaded={entryMetadata.isLinkedToLoaded}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* QuickAddInline after the entry - edit mode only, if there is a cursor, if it is not the last entry */}
|
|
{isEditMode && hasCursor && !isLast && (
|
|
<QuickAddInline placement='after' referenceEntryId={entry.id} parentGroup={parentIdForAfter} />
|
|
)}
|
|
</Fragment>
|
|
);
|
|
},
|
|
[
|
|
entries,
|
|
metadata,
|
|
getIsCollapsed,
|
|
isEditMode,
|
|
cursor,
|
|
nextEventId,
|
|
playback,
|
|
lastEntryId,
|
|
handleCollapseGroup,
|
|
automationsEnabled,
|
|
],
|
|
);
|
|
|
|
if (sortableData.length < 1) {
|
|
return <RundownEmpty handleAddNew={(type: SupportedEntry) => entryActions.addEntry({ type })} />;
|
|
}
|
|
|
|
return (
|
|
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
|
<DndContext
|
|
onDragEnd={dnd.handleOnDragEnd}
|
|
onDragStart={dnd.collapseDraggedGroups}
|
|
onDragOver={dnd.expandOverGroup}
|
|
sensors={dnd.sensors}
|
|
collisionDetection={closestCenter}
|
|
>
|
|
<SortableContext items={sortableData} strategy={verticalListSortingStrategy}>
|
|
<Virtuoso
|
|
ref={virtuosoRef}
|
|
data={visibleData}
|
|
computeItemKey={(_index, entryId) => entryId}
|
|
itemContent={itemContent}
|
|
increaseViewportBy={{ top: 200, bottom: 400 }}
|
|
style={{ height: '100%' }}
|
|
components={{
|
|
Header: isEditMode
|
|
? () => <QuickAddButtons previousEventId={null} nextEventId={order[0]} parentGroup={null} />
|
|
: undefined,
|
|
Footer: () => (
|
|
<>
|
|
{isEditMode && (
|
|
<QuickAddButtons
|
|
previousEventId={metadata[lastMetadataKey]?.groupId ?? metadata[lastMetadataKey]?.thisId}
|
|
parentGroup={null}
|
|
/>
|
|
)}
|
|
<div className={style.spacer} />
|
|
</>
|
|
),
|
|
List: VirtuosoListComponent,
|
|
}}
|
|
/>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Virtuoso components - extracted to prevent recreation on every render
|
|
const VirtuosoListComponent = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(({ children, ...props }, ref) => (
|
|
<div ref={ref} {...props} className={style.list}>
|
|
{children}
|
|
</div>
|
|
));
|
|
VirtuosoListComponent.displayName = 'VirtuosoListComponent';
|