refactor: restructure model to contain an object of rundowns

This commit is contained in:
Carlos Valente
2025-03-15 09:04:33 +01:00
committed by arc-alex
parent 0a80d6db31
commit abd9b127db
111 changed files with 4357 additions and 4515 deletions
+35 -28
View File
@@ -1,15 +1,16 @@
import { Fragment, lazy, 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 { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useHotkeys } from '@mantine/hooks';
import {
type EntryId,
type MaybeString,
type PlayableEvent,
type Rundown,
isOntimeBlock,
isOntimeEvent,
isPlayableEvent,
MaybeString,
PlayableEvent,
Playback,
RundownCached,
SupportedEvent,
} from 'ontime-types';
import {
@@ -21,6 +22,7 @@ import {
getPreviousBlockNormal,
getPreviousNormal,
isNewLatest,
reorderArray,
} from 'ontime-utils';
import { type EventOptions, useEventAction } from '../../common/hooks/useEventAction';
@@ -39,12 +41,12 @@ import style from './Rundown.module.scss';
const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps {
data: RundownCached;
data: Rundown;
}
export default function Rundown({ data }: RundownProps) {
const { order, rundown } = data;
const [statefulEntries, setStatefulEntries] = useState(order);
const { order, entries } = data;
const [statefulEntries, setStatefulEntries] = useState<EntryId[]>(order);
const featureData = useRundownEditor();
const { addEvent, reorderEvent, deleteEvent } = useEventAction();
@@ -65,30 +67,30 @@ export default function Rundown({ data }: RundownProps) {
const deleteAtCursor = useCallback(
(cursor: string | null) => {
if (!cursor) return;
const { entry, index } = getPreviousNormal(rundown, order, cursor);
const { entry, index } = getPreviousNormal(entries, order, cursor);
deleteEvent([cursor]);
if (entry && index !== null) {
setSelectedEvents({ id: entry.id, selectMode: 'click', index });
}
},
[rundown, order, deleteEvent, setSelectedEvents],
[entries, order, deleteEvent, setSelectedEvents],
);
const insertCopyAtId = useCallback(
(atId: string | null, copyId: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(rundown, order, atId ?? '').entry?.id ?? null : atId;
const adjustedCursor = above ? getPreviousNormal(entries, order, atId ?? '').entry?.id ?? null : atId;
if (copyId === null) {
// we cant clone without selection
return;
}
const cloneEntry = rundown[copyId];
const cloneEntry = entries[copyId];
if (cloneEntry?.type === SupportedEvent.Event) {
//if we don't have a cursor add the new event on top
const newEvent = cloneEvent(cloneEntry);
addEvent(newEvent, { after: adjustedCursor ?? undefined });
}
},
[addEvent, order, rundown],
[addEvent, order, entries],
);
const insertAtId = useCallback(
@@ -124,7 +126,7 @@ export default function Rundown({ data }: RundownProps) {
let newCursor = cursor;
if (cursor === null) {
// there is no cursor, we select the first or last depending on direction
const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order);
const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
if (isOntimeBlock(selected)) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
@@ -140,14 +142,14 @@ export default function Rundown({ data }: RundownProps) {
// otherwise we select the next or previous
const selected =
direction === 'up'
? getPreviousBlockNormal(rundown, order, newCursor)
: getNextBlockNormal(rundown, order, newCursor);
? getPreviousBlockNormal(entries, order, newCursor)
: getNextBlockNormal(entries, order, newCursor);
if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
}
},
[order, rundown, setSelectedEvents],
[order, entries, setSelectedEvents],
);
const selectEntry = useCallback(
@@ -158,7 +160,7 @@ export default function Rundown({ data }: RundownProps) {
if (cursor === null) {
// there is no cursor, we select the first or last depending on direction if it exists
const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order);
const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
if (selected !== null) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
}
@@ -167,13 +169,13 @@ export default function Rundown({ data }: RundownProps) {
// otherwise we select the next or previous
const selected =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor);
if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
}
},
[order, rundown, setSelectedEvents],
[order, entries, setSelectedEvents],
);
const moveEntry = useCallback(
@@ -182,14 +184,14 @@ export default function Rundown({ data }: RundownProps) {
return;
}
const { index } =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor);
if (index !== null) {
const offsetIndex = direction === 'up' ? index + 1 : index - 1;
reorderEvent(cursor, offsetIndex, index);
}
},
[order, reorderEvent, rundown],
[order, reorderEvent, entries],
);
// shortcuts
@@ -238,6 +240,9 @@ export default function Rundown({ data }: RundownProps) {
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
}, [appMode, featureData.selectedEventId, order, setSelectedEvents]);
/**
* On drag end, we reorder the events
*/
const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
@@ -245,9 +250,10 @@ export default function Rundown({ data }: RundownProps) {
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
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
setStatefulEntries((currentEntries) => {
return arrayMove(currentEntries, fromIndex, toIndex);
return reorderArray(currentEntries, fromIndex, toIndex);
});
reorderEvent(String(active.id), fromIndex, toIndex);
}
@@ -259,11 +265,11 @@ export default function Rundown({ data }: RundownProps) {
}
// last event is used to calculate relative timings
let lastEvent: PlayableEvent | undefined; // used by indicators
let thisEvent: PlayableEvent | undefined;
let lastEvent: PlayableEvent | null = null; // used by indicators
let thisEvent: PlayableEvent | null = null;
// previous entry is used to infer position in the rundown for new events
let previousEntryId: string | undefined;
let thisId = previousEntryId;
let previousEntryId: MaybeString = null;
let thisId: MaybeString = null;
let eventIndex = 0;
// all events before the current selected are in the past
@@ -272,6 +278,7 @@ export default function Rundown({ data }: RundownProps) {
let totalGap = 0;
const isEditMode = appMode === AppMode.Edit;
let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event
return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
@@ -281,7 +288,7 @@ export default function Rundown({ data }: RundownProps) {
// we iterate through a stateful copy of order to make the operations smoother
// this means that this can be out of sync with order until the useEffect runs
// instead of writing all the logic guards, we simply short circuit rendering here
const entry = rundown[entryId];
const entry = entries[entryId];
if (!entry) {
return null;
}
@@ -1,5 +1,14 @@
import { useCallback } from 'react';
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import {
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
MaybeString,
OntimeEntry,
OntimeEvent,
Playback,
SupportedEvent,
} from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
@@ -28,13 +37,13 @@ export type EventItemActions =
interface RundownEntryProps {
type: SupportedEvent;
isPast: boolean;
data: OntimeRundownEntry;
data: OntimeEntry;
loaded: boolean;
eventIndex: number;
hasCursor: boolean;
isNext: boolean;
isNextDay: boolean;
previousEntryId?: string;
previousEntryId: MaybeString;
previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing
isRolling: boolean; // we need to know even if not related to this event
@@ -150,7 +159,7 @@ export default function RundownEntry(props: RundownEntryProps) {
}
});
if (data.type === SupportedEvent.Event) {
if (isOntimeEvent(data)) {
return (
<EventBlock
eventId={data.id}
@@ -167,7 +176,7 @@ export default function RundownEntry(props: RundownEntryProps) {
timerType={data.timerType}
title={data.title}
note={data.note}
delay={data.delay ?? 0}
delay={data.delay}
colour={data.colour}
isPast={isPast}
isNext={isNext}
@@ -184,9 +193,15 @@ export default function RundownEntry(props: RundownEntryProps) {
actionHandler={actionHandler}
/>
);
} else if (data.type === SupportedEvent.Block) {
return <BlockBlock data={data} hasCursor={hasCursor} onDelete={() => actionHandler('delete')} />;
} else if (data.type === SupportedEvent.Delay) {
} else if (isOntimeBlock(data)) {
return (
<BlockBlock data={data} hasCursor={hasCursor}>
{data.events.map((eventId) => {
return <div key={eventId}>{eventId}</div>;
})}
</BlockBlock>
);
} else if (isOntimeDelay(data)) {
return <DelayBlock data={data} hasCursor={hasCursor} />;
}
return null;
@@ -22,7 +22,3 @@
.drag {
@include drag-style;
}
.actionMenu {
justify-self: flex-end;
}
@@ -1,4 +1,4 @@
import { useRef } from 'react';
import { PropsWithChildren, useRef } from 'react';
import { IoReorderTwo } from 'react-icons/io5';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
@@ -7,18 +7,15 @@ import { OntimeBlock } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
import EditableBlockTitle from '../common/EditableBlockTitle';
import BlockDelete from './BlockDelete';
import style from './BlockBlock.module.scss';
interface BlockBlockProps {
data: OntimeBlock;
hasCursor: boolean;
onDelete: () => void;
}
export default function BlockBlock(props: BlockBlockProps) {
const { data, hasCursor, onDelete } = props;
export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
const { data, hasCursor, children } = props;
const handleRef = useRef<null | HTMLSpanElement>(null);
@@ -46,7 +43,8 @@ export default function BlockBlock(props: BlockBlockProps) {
<IoReorderTwo />
</span>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<BlockDelete onDelete={onDelete} />
<button>+++</button>
<div>{children}</div>
</div>
);
}
@@ -1,27 +0,0 @@
import { IoTrash } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react';
import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
interface BlockDeleteProps {
onDelete: () => void;
}
export default function BlockDelete(props: BlockDeleteProps) {
const { onDelete } = props;
const mode = useAppMode((state) => state.mode);
const isRunMode = mode === AppMode.Run;
return (
<IconButton
aria-label='Delete'
size='sm'
icon={<IoTrash />}
variant='ontime-subtle'
color='#FA5656'
onClick={onDelete}
isDisabled={isRunMode}
/>
);
}
@@ -15,23 +15,22 @@ interface CuesheetEventEditorProps {
export default function CuesheetEventEditor(props: CuesheetEventEditorProps) {
const { eventId } = props;
const { data } = useRundown();
const { order, rundown } = data;
const [event, setEvent] = useState<OntimeEvent | null>(null);
useEffect(() => {
if (order.length === 0) {
if (data.order.length === 0) {
setEvent(null);
return;
}
const event = rundown[eventId];
const event = data.entries[eventId];
if (event && isOntimeEvent(event)) {
setEvent(event);
} else {
setEvent(null);
}
}, [data, eventId, order, rundown]);
}, [eventId, data.order, data.entries]);
if (!event) {
return null;
@@ -13,29 +13,28 @@ import style from './EventEditor.module.scss';
export default function RundownEventEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown();
const { order, rundown } = data;
const [event, setEvent] = useState<OntimeEvent | null>(null);
useEffect(() => {
if (order.length === 0) {
if (data.order.length === 0) {
setEvent(null);
return;
}
const selectedEventId = order.find((eventId) => selectedEvents.has(eventId));
const selectedEventId = data.order.find((entryId) => selectedEvents.has(entryId));
if (!selectedEventId) {
setEvent(null);
return;
}
const event = rundown[selectedEventId];
const event = data.entries[selectedEventId];
if (event && isOntimeEvent(event)) {
setEvent(event);
} else {
setEvent(null);
}
}, [order, rundown, selectedEvents]);
}, [data.order, data.entries, selectedEvents]);
if (!event) {
return <EventEditorEmpty />;
@@ -1,7 +1,7 @@
import { memo, useCallback, useRef } from 'react';
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { SupportedEvent } from 'ontime-types';
import { MaybeString, SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { useEmitLog } from '../../../common/stores/logger';
@@ -9,7 +9,7 @@ import { useEmitLog } from '../../../common/stores/logger';
import style from './QuickAddBlock.module.scss';
interface QuickAddBlockProps {
previousEventId?: string;
previousEventId: MaybeString;
}
export default memo(QuickAddBlock);
@@ -1,5 +1,5 @@
import { MouseEvent } from 'react';
import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, RundownCached } from 'ontime-types';
import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, Rundown } from 'ontime-types';
import { create } from 'zustand';
import { RUNDOWN } from '../../common/api/constants';
@@ -33,7 +33,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
const rundownData = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN);
const rundownData = ontimeQueryClient.getQueryData<Rundown>(RUNDOWN);
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
@@ -50,7 +50,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents.delete(id);
const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.rundown[eventId]) && selectedEvents.has(eventId),
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && selectedEvents.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
@@ -62,13 +62,13 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
const rundownData = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN);
const rundownData = ontimeQueryClient.getQueryData<Rundown>(RUNDOWN);
if (!rundownData) return;
// get list of rundown with only ontime events
const events: OntimeEvent[] = [];
rundownData.order.forEach((eventId) => {
const event = rundownData.rundown[eventId];
const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) {
events.push(event);
}