Files
ontime/apps/client/src/features/rundown/rundown.utils.ts
T
2026-02-08 11:20:09 +01:00

252 lines
8.5 KiB
TypeScript

import { EntryId, isOntimeEvent, isOntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
/**
* Creates a sortable list of entries
* ------------------------------------
* Due to limitations in dnd-kit we need to flatten the list of entries
* This list should also be aware of any elements that are sortable (ie: group ends)
*
* Note: This creates the FULL structure including all entries and pseudo end-group entries.
* For rendering, use filterVisibleEntries() to exclude collapsed items.
*/
export function makeSortableList(order: EntryId[], entries: RundownEntries): EntryId[] {
const flatIds: EntryId[] = [];
for (let i = 0; i < order.length; i++) {
const entry = entries[order[i]];
if (!entry) {
continue;
}
if (isOntimeGroup(entry)) {
// inside a group there are delays and events
// there is no need for special handling
flatIds.push(entry.id);
flatIds.push(...entry.entries);
// close the group
flatIds.push(`end-${entry.id}`);
} else {
flatIds.push(entry.id);
}
}
return flatIds;
}
/**
* Filters sortable list to only include visible entries based on collapsed state
* ------------------------------------
* Excludes:
* - Children of collapsed groups
* - End-group markers of collapsed groups
*
* This is used by Virtuoso for rendering, while DND-kit uses the full sortableData.
*/
export function filterVisibleEntries(
sortableData: EntryId[],
entries: RundownEntries,
getIsCollapsed: (groupId: EntryId) => boolean,
): EntryId[] {
return sortableData.filter((entryId) => {
// group end pseudo entries are only shown if the group is expanded
if (entryId.startsWith('end-')) {
const parentId = entryId.split('end-')[1];
return !getIsCollapsed(parentId);
}
// retrieve the entry as usual
const entry = entries[entryId];
if (!entry) {
return false;
}
// if entry has a parent and parent is collapsed, filter it out
if (entry.type !== SupportedEntry.Group && 'parent' in entry && entry.parent) {
return !getIsCollapsed(entry.parent);
}
return true;
});
}
/**
* Checks whether a drop operation is valid
* Currently only used for validating dropping groups
*/
export function canDrop(
targetType: SupportedEntry | 'end-group',
targetParent: EntryId | null,
order?: 'after' | 'before',
isTargetCollapsed?: boolean,
): boolean {
// inserting before would mean adding a group inside another
if (targetType === 'end-group') {
return order === 'after';
}
// this means swapping places with another group
// !!! if the user is dragging down, they could be inserting into a group depending on whether the group is collapsed
if (targetType === 'group') {
if (order !== undefined && order === 'after' && !isTargetCollapsed) {
return false;
}
return true;
}
// for all other cases, we just need to check if we are inside a group
return targetParent === null;
}
/**
* calculates destinations for an entry moving one position up in the rundown
* @returns An object describing how to move the entry:
* - destinationId: The target entry ID (null if no movement possible)
* - order: How to position relative to the destination:
* - 'before': Place before the destination
* - 'after': Place after the destination
* - 'insert': Insert into the destination (for groups)
*/
export function moveUp(
entryId: EntryId,
flatOrder: EntryId[],
entries: RundownEntries,
): { destinationId: EntryId | null; order: 'before' | 'after' | 'insert' } {
const currentEntry = entries[entryId];
const currentIndex = flatOrder.indexOf(entryId);
const previousEntryId = flatOrder[currentIndex - 1];
// 1. moving at the top of the list
if (!previousEntryId) {
// 1a. we are in a group and need to move outside of it
if ('parent' in currentEntry && currentEntry.parent !== null) {
return { destinationId: currentEntry.parent, order: 'before' };
}
// 1b. we are at the start of the rundown, no movement possible
return { destinationId: null, order: 'before' };
}
// 2. moving a group (always moves at top level)
if (isOntimeGroup(currentEntry)) {
// 21. if previous entry is inside a group, swap with parent
const previousEntry = entries[previousEntryId];
if ('parent' in previousEntry && previousEntry.parent !== null) {
return { destinationId: previousEntry.parent, order: 'before' };
}
// 2b. previous entry is at top level, we just swap places
return { destinationId: previousEntryId, order: 'before' };
}
const previousEntry = entries[previousEntryId];
const currentEntryParent = currentEntry.parent;
// 3. moving in and out of a group
if (isOntimeGroup(previousEntry)) {
// 3a. if we're not already in the group, move into it
if (currentEntryParent === null) {
return { destinationId: previousEntryId, order: 'insert' };
}
// 3b. otherwise, move before the group
return { destinationId: previousEntryId, order: 'before' };
}
// 4. moving into the same group as previous entry
if ('parent' in previousEntry && previousEntry.parent !== null && currentEntryParent === null) {
return { destinationId: previousEntryId, order: 'after' };
}
// default - swap positions with previous entry
return { destinationId: previousEntryId, order: 'before' };
}
/**
* calculates destinations for an entry moving one position down in the rundown
* @returns An object describing how to move the entry:
* - destinationId: The target entry ID (null if no movement possible)
* - order: How to position relative to the destination:
* - 'before': Place before the destination
* - 'after': Place after the destination
* - 'insert': Insert into the destination (for groups)
*/
export function moveDown(
entryId: EntryId,
flatOrder: EntryId[],
entries: RundownEntries,
): { destinationId: EntryId | null; order: 'before' | 'after' | 'insert' } {
const currentEntry = entries[entryId];
const currentIndex = flatOrder.indexOf(entryId);
const nextEntryId = flatOrder[currentIndex + 1];
// 1. check if we're the last entry in a group
if ('parent' in currentEntry && currentEntry.parent !== null) {
const parentGroup = entries[currentEntry.parent];
if (isOntimeGroup(parentGroup) && parentGroup.entries[parentGroup.entries.length - 1] === entryId) {
return { destinationId: currentEntry.parent, order: 'after' };
}
}
// 2. moving at the end of the list
if (!nextEntryId) {
return { destinationId: null, order: 'after' };
}
// 3. moving a group (always moves at top level)
if (isOntimeGroup(currentEntry)) {
// if next entry is inside this group, skip past all children
if (currentEntry.entries.includes(nextEntryId)) {
const afterGroupIndex = currentIndex + currentEntry.entries.length + 1;
const afterGroupId = flatOrder[afterGroupIndex];
// 2a. group is the last top level entry
if (!afterGroupId) {
return { destinationId: null, order: 'after' };
}
// 2b. move after the next top level event
return { destinationId: afterGroupId, order: 'after' };
}
// 2c. empty group move after the next entry
return { destinationId: nextEntryId, order: 'after' };
}
const nextEntry = entries[nextEntryId];
const currentEntryParent = currentEntry.parent;
// 4. handle moving relative to groups
if (isOntimeGroup(nextEntry)) {
if (currentEntryParent === null) {
// we are entering a group
if (nextEntry.entries.length === 0) {
// 3a. if the group is empty, insert into it
return { destinationId: nextEntryId, order: 'insert' };
}
// 3b. otherwise, add before the first entry in the group
const firstGroupEntryId = nextEntry.entries[0];
return { destinationId: firstGroupEntryId, order: 'before' };
}
}
// 5. handle moving between group and top level
const nextEntryParent = 'parent' in nextEntry ? nextEntry.parent : null;
if (nextEntryParent !== null && currentEntryParent === null) {
return { destinationId: nextEntryId, order: 'after' };
}
// default - swap positions with next entry
return { destinationId: nextEntryId, order: 'after' };
}
/**
* Reorders unorderedArray to match the flatOrder entries
* Useful for operations that convert selections (out of order) to rundown
*/
export function orderEntries(unorderedArray: EntryId[], flatOrder: EntryId[]): EntryId[] {
const orderedArray: EntryId[] = [];
for (const id of flatOrder) {
if (unorderedArray.includes(id)) {
orderedArray.push(id);
}
}
return orderedArray;
}