mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-14 20:03:52 +00:00
1849b4d39f
* chore: migrate eslint to oxlint * chore: migrate prettier to oxfmt * chore: migrate typescript * chore: toThrow should have a expected value * chore: cast test value as Day * chore: small title fix * chore: mocks should be hoisted * chore: incorrect async useage * chore: test should be inside description * chore: test sohuld include an expeced * chore: oxfmt --------- Co-authored-by: alex-Arc <omnivox@LAPTOP-RC5SNBVV.localdomain>
338 lines
12 KiB
TypeScript
338 lines
12 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 { 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<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 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<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,
|
|
});
|
|
|
|
// 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 (
|
|
<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}
|
|
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],
|
|
);
|
|
|
|
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} 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';
|