mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-14 20:03:52 +00:00
722e045b20
* Revert "refactor: add version to session endpoint" This reverts commitcd40a6e55e. * Revert "Remove public event feature (#1645)" This reverts commit08d9e24871. * Revert "Fix: rearrange playing event (#1640)" This reverts commit0649678dca. * Revert "refactor: style tweaks to edit css modal" This reverts commita00ec2d02a. * Revert "refactor: remove usages of framer-motion" This reverts commit54a74ccc2a. * Revert "Upgrade expressjs (#1633)" This reverts commit6f3ab274bd. * Revert "Refactor: require trigger in all events objects (#1636)" This reverts commit90870ecfb6. * Revert "Fix: Correct boundary condition in applyDelay" This reverts commitb640e0e181. * Revert "let vite be the proxy to the dev server (#1630)" This reverts commitc41fe824cf. * Revert "refactor: migrate custom fields to transactions" This reverts commit62c8319d70. * Revert "refactor: simplify validations" This reverts commitb1d23467a2. * Revert "refactor: create transaction system and apply to adding entry (#1620)" This reverts commit9a62daf047. * Revert "Refactor: better rounding (#1594)" This reverts commitb9ab1c6fd7. * Revert "refactor: improve reorder logic" This reverts commitc1054711b0. * Revert "chore: simplify URLs" This reverts commita4d4f29a37. * Revert "refactor: order is single source of truth" This reverts commit2793aadea0. * Revert "refactor: refetch targets is enum" This reverts commite7cfb7d9d9. * Revert "refactor: small ux improvements" This reverts commit256a851c9b. * Revert "refactor: remove trivially inferred numEvents" This reverts commit4ab9c81cb8. * Revert "feat: duplicate groups" This reverts commit2f13d6c89e. * Revert "feat: create group from entry selection" This reverts commitf1f7bad25e. * Revert "feat: create block from rundown empty" This reverts commita8b52a48f7. * Revert "fix: collapsed blocks dont render children" This reverts commitcd0999b2ab. * Revert "refactor: type cleanup and test improvements" This reverts commitb4c60f3f04. * Revert "feat: allow dissolving a block" This reverts commit5cefad3666. * Revert "fix: uncontrolled prop on controlled component" This reverts commit7a6ecd8c34. * Revert "refactor: improve return of reorder" This reverts commitba96ecfd91. * Revert "refactor: mutations on batch elements must have IDs" This reverts commit7bed3757f2. * Revert "chore: upgrade dependencies" This reverts commite2e755b1d2. * Revert "refactor: extract utility to merge two arrays" This reverts commita77d23109d. * Revert "refactor: change network mode defaults" This reverts commit0eb3b8d382. * Revert "fix: delete nested events" This reverts commit0021185288. * Revert "fix: add event at end of block" This reverts commit94c72ff4f6. * Revert "refactor: make finder available in exported rundown" This reverts commite4c08dc9b2. * Revert "assert non null and update test (#1604)" This reverts commitec74af0d62. * Revert "Fix project renumber (#1597)" This reverts commitb6d72dd082. * Revert "Refactor: WebSocket from flush queue to one patch (#1595)" This reverts commit31c311daf0. * Revert "Refactor: ms for api calls (#1593)" This reverts commite9b3cc6090. * Revert "fix test (#1601)" This reverts commit543b04a097. * Revert "fix: rebase master" This reverts commitd39b85b6e6. * Revert "chore: correct test path" This reverts commitbbe107bb2b. * Revert "refactor: extract rundown parsing" This reverts commitc616240db1. * Revert "chore: improve convention entry <> event" This reverts commit166be66ce3. * Revert "refactor: maintain flat orders" This reverts commit4180d0a337. * Revert "refactor: implement operations on nested events" This reverts commit78108e316c. * Revert "refactor: process events in rundown" This reverts commit3bb8b70915. * Revert "chore: improve convention entry <> event" This reverts commit1c4f13a0ed. * Revert "chore: rename currentBlock > parent" This reverts commit3ca0abad53. * Revert "refactor: fix delay positioning in gaps" This reverts commit030c8f897f. * Revert "refactor(e2e): skip flaky test" This reverts commit68175cfa3b. * Revert "refactor: improve project loading" This reverts commitfd8f757851. * Revert "refactor: gather group metadata" This reverts commit876d111c61. * Revert "refactor: swap maintains schedule" This reverts commit730cb95c04. * Revert "chore: rename files" This reverts commit3c388d4fb5. * Revert "refactor: restructure model to contain an object of rundowns" This reverts commit2e23718d73. * Revert "refactor: clearer relationship on rundown elements" This reverts commit89ea8c470b. * Revert "refactor: use strict typing" This reverts commit178640bfc4. * Revert "refactor: remove stop as a possible end action" This reverts commit4ed38340e0. * Revert "refactor: restructure model to contain an object of rundowns" This reverts commit69eb9a5eff. * Revert "chore: remove IDE files" This reverts commit351425127a. * Revert "refactor: remove unused and legacy code" This reverts commit95f2ba37cc.
356 lines
13 KiB
TypeScript
356 lines
13 KiB
TypeScript
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 { useHotkeys } from '@mantine/hooks';
|
|
import {
|
|
isOntimeBlock,
|
|
isOntimeEvent,
|
|
isPlayableEvent,
|
|
MaybeString,
|
|
PlayableEvent,
|
|
Playback,
|
|
RundownCached,
|
|
SupportedEvent,
|
|
} from 'ontime-types';
|
|
import {
|
|
checkIsNextDay,
|
|
getFirstNormal,
|
|
getLastNormal,
|
|
getNextBlockNormal,
|
|
getNextNormal,
|
|
getPreviousBlockNormal,
|
|
getPreviousNormal,
|
|
isNewLatest,
|
|
} from 'ontime-utils';
|
|
|
|
import { type EventOptions, useEventAction } from '../../common/hooks/useEventAction';
|
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
|
import { useRundownEditor } from '../../common/hooks/useSocket';
|
|
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
|
import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
|
import { cloneEvent } from '../../common/utils/eventsManager';
|
|
|
|
import QuickAddBlock from './quick-add-block/QuickAddBlock';
|
|
import RundownEmpty from './RundownEmpty';
|
|
import { useEventSelection } from './useEventSelection';
|
|
|
|
import style from './Rundown.module.scss';
|
|
|
|
const RundownEntry = lazy(() => import('./RundownEntry'));
|
|
|
|
interface RundownProps {
|
|
data: RundownCached;
|
|
}
|
|
|
|
export default function Rundown({ data }: RundownProps) {
|
|
const { order, rundown } = data;
|
|
const [statefulEntries, setStatefulEntries] = useState(order);
|
|
|
|
const featureData = useRundownEditor();
|
|
const { addEvent, reorderEvent, deleteEvent } = useEventAction();
|
|
|
|
const { entryCopyId, setEntryCopyId } = useEntryCopy();
|
|
|
|
// cursor
|
|
const { mode: appMode } = useAppMode();
|
|
const { clearSelectedEvents, setSelectedEvents, cursor } = useEventSelection();
|
|
|
|
const cursorRef = useRef<HTMLDivElement | null>(null);
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run });
|
|
|
|
// DND KIT
|
|
const sensors = useSensors(useSensor(PointerSensor));
|
|
|
|
const deleteAtCursor = useCallback(
|
|
(cursor: string | null) => {
|
|
if (!cursor) return;
|
|
const { entry, index } = getPreviousNormal(rundown, order, cursor);
|
|
deleteEvent([cursor]);
|
|
if (entry && index !== null) {
|
|
setSelectedEvents({ id: entry.id, selectMode: 'click', index });
|
|
}
|
|
},
|
|
[rundown, 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;
|
|
if (copyId === null) {
|
|
// we cant clone without selection
|
|
return;
|
|
}
|
|
const cloneEntry = rundown[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],
|
|
);
|
|
|
|
const insertAtId = useCallback(
|
|
(type: SupportedEvent, id: MaybeString, above = false) => {
|
|
const options: EventOptions =
|
|
id === null
|
|
? {}
|
|
: {
|
|
after: above ? undefined : id,
|
|
before: above ? id : undefined,
|
|
};
|
|
|
|
if (type === SupportedEvent.Event) {
|
|
const newEvent = {
|
|
type: SupportedEvent.Event,
|
|
};
|
|
if (!above && id) {
|
|
options.lastEventId = id;
|
|
}
|
|
addEvent(newEvent, options);
|
|
} else {
|
|
addEvent({ type }, options);
|
|
}
|
|
},
|
|
[addEvent],
|
|
);
|
|
|
|
const selectBlock = useCallback(
|
|
(cursor: string | null, direction: 'up' | 'down') => {
|
|
if (order.length < 1) {
|
|
return;
|
|
}
|
|
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);
|
|
|
|
if (isOntimeBlock(selected)) {
|
|
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
|
|
return;
|
|
}
|
|
newCursor = selected?.id ?? null;
|
|
}
|
|
|
|
if (newCursor === null) {
|
|
return;
|
|
}
|
|
|
|
// otherwise we select the next or previous
|
|
const selected =
|
|
direction === 'up'
|
|
? getPreviousBlockNormal(rundown, order, newCursor)
|
|
: getNextBlockNormal(rundown, order, newCursor);
|
|
|
|
if (selected.entry !== null && selected.index !== null) {
|
|
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
|
|
}
|
|
},
|
|
[order, rundown, setSelectedEvents],
|
|
);
|
|
|
|
const selectEntry = useCallback(
|
|
(cursor: string | null, direction: 'up' | 'down') => {
|
|
if (order.length < 1) {
|
|
return;
|
|
}
|
|
|
|
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);
|
|
if (selected !== null) {
|
|
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// otherwise we select the next or previous
|
|
const selected =
|
|
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
|
|
|
|
if (selected.entry !== null && selected.index !== null) {
|
|
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
|
|
}
|
|
},
|
|
[order, rundown, setSelectedEvents],
|
|
);
|
|
|
|
const moveEntry = useCallback(
|
|
(cursor: string | null, direction: 'up' | 'down') => {
|
|
if (order.length < 2 || cursor == null) {
|
|
return;
|
|
}
|
|
const { index } =
|
|
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
|
|
|
|
if (index !== null) {
|
|
const offsetIndex = direction === 'up' ? index + 1 : index - 1;
|
|
reorderEvent(cursor, offsetIndex, index);
|
|
}
|
|
},
|
|
[order, reorderEvent, rundown],
|
|
);
|
|
|
|
// shortcuts
|
|
useHotkeys([
|
|
['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true }],
|
|
['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true }],
|
|
|
|
['alt + shift + ArrowDown', () => selectBlock(cursor, 'down'), { preventDefault: true }],
|
|
['alt + shift + ArrowUp', () => selectBlock(cursor, 'up'), { preventDefault: true }],
|
|
|
|
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }],
|
|
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }],
|
|
|
|
['Escape', () => clearSelectedEvents(), { preventDefault: true }],
|
|
|
|
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
|
|
|
|
['alt + E', () => insertAtId(SupportedEvent.Event, cursor), { preventDefault: true }],
|
|
['alt + shift + E', () => insertAtId(SupportedEvent.Event, cursor, true), { preventDefault: true }],
|
|
|
|
['alt + B', () => insertAtId(SupportedEvent.Block, cursor), { preventDefault: true }],
|
|
['alt + shift + B', () => insertAtId(SupportedEvent.Block, cursor, true), { preventDefault: true }],
|
|
|
|
['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }],
|
|
['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
|
|
|
|
['mod + C', () => setEntryCopyId(cursor)],
|
|
['mod + V', () => insertCopyAtId(cursor, entryCopyId)],
|
|
['mod + shift + V', () => insertCopyAtId(cursor, entryCopyId, true), { preventDefault: true }],
|
|
|
|
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
|
|
]);
|
|
|
|
// we copy the state from the store here
|
|
// to workaround async updates on the drag mutations
|
|
useEffect(() => {
|
|
setStatefulEntries(order);
|
|
}, [order]);
|
|
|
|
useEffect(() => {
|
|
// in run mode, we follow selection
|
|
if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
|
|
return;
|
|
}
|
|
const index = order.findIndex((id) => id === featureData.selectedEventId);
|
|
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
|
|
}, [appMode, featureData.selectedEventId, order, setSelectedEvents]);
|
|
|
|
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 <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
|
|
}
|
|
|
|
// last event is used to calculate relative timings
|
|
let lastEvent: PlayableEvent | undefined; // used by indicators
|
|
let thisEvent: PlayableEvent | undefined;
|
|
// previous entry is used to infer position in the rundown for new events
|
|
let previousEntryId: string | undefined;
|
|
let thisId = previousEntryId;
|
|
|
|
let eventIndex = 0;
|
|
// all events before the current selected are in the past
|
|
let isPast = Boolean(featureData?.selectedEventId);
|
|
let isNextDay = false;
|
|
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}>
|
|
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}>
|
|
<div className={style.list}>
|
|
{statefulEntries.map((entryId, index) => {
|
|
// 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];
|
|
if (!entry) {
|
|
return null;
|
|
}
|
|
if (index === 0) {
|
|
eventIndex = 0;
|
|
}
|
|
isNextDay = false;
|
|
previousEntryId = thisId;
|
|
thisId = entryId;
|
|
if (isOntimeEvent(entry)) {
|
|
// event indexes are 1 based in frontend
|
|
eventIndex++;
|
|
lastEvent = thisEvent;
|
|
|
|
if (isPlayableEvent(entry)) {
|
|
isNextDay = checkIsNextDay(entry, lastEvent);
|
|
if (!isPast) {
|
|
totalGap += entry.gap;
|
|
// We also include countToEnd in this test as the behavior of a linked event coming after a countToEnd is simelar to an unlinked event
|
|
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null && !lastEvent?.countToEnd;
|
|
}
|
|
if (isNewLatest(entry, lastEvent)) {
|
|
// populate previous entry
|
|
thisEvent = entry;
|
|
}
|
|
}
|
|
}
|
|
const isFirst = index === 0;
|
|
const isLast = index === order.length - 1;
|
|
const isLoaded = featureData?.selectedEventId === entry.id;
|
|
const isNext = featureData?.nextEventId === entry.id;
|
|
const hasCursor = entry.id === cursor;
|
|
if (isLoaded) {
|
|
isPast = false;
|
|
}
|
|
|
|
return (
|
|
<Fragment key={entry.id}>
|
|
{isEditMode && (hasCursor || isFirst) && <QuickAddBlock previousEventId={previousEntryId} />}
|
|
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}>
|
|
{isOntimeEvent(entry) && <div className={style.entryIndex}>{eventIndex}</div>}
|
|
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
|
<RundownEntry
|
|
type={entry.type}
|
|
isPast={isPast}
|
|
eventIndex={eventIndex}
|
|
data={entry}
|
|
loaded={isLoaded}
|
|
hasCursor={hasCursor}
|
|
isNext={isNext}
|
|
previousEntryId={previousEntryId}
|
|
previousEventId={lastEvent?.id}
|
|
playback={isLoaded ? featureData.playback : undefined}
|
|
isRolling={featureData.playback === Playback.Roll}
|
|
isNextDay={isNextDay}
|
|
totalGap={totalGap}
|
|
isLinkedToLoaded={isLinkedToLoaded}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{isEditMode && (hasCursor || isLast) && <QuickAddBlock previousEventId={entry.id} />}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
<div className={style.spacer} />
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
);
|
|
}
|