feat: unify rundown features

This commit is contained in:
Carlos Valente
2026-01-13 21:47:55 +01:00
committed by Carlos Valente
parent c5045ad82e
commit d7af73d9ed
8 changed files with 829 additions and 508 deletions
@@ -0,0 +1,172 @@
import { useCallback } from 'react';
import { type EntryId, type OntimeEntry, type Rundown, isOntimeGroup, SupportedEntry } from 'ontime-types';
import {
getFirstNormal,
getLastNormal,
getNextGroupNormal,
getNextNormal,
getPreviousGroupNormal,
getPreviousNormal,
} from 'ontime-utils';
import type { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
type SelectionMode = 'shift' | 'click' | 'ctrl';
interface UseRundownCommandsOptions {
entries: Rundown['entries'];
order: Rundown['order'];
entryActions: ReturnType<typeof useEntryActions>;
setSelectedEvents: (selection: { id: EntryId; selectMode: SelectionMode; index: number }) => void;
handleCollapseGroup: (collapsed: boolean, groupId: EntryId) => void;
}
export function useRundownCommands({
entries,
order,
entryActions,
setSelectedEvents,
handleCollapseGroup,
}: UseRundownCommandsOptions) {
const { addEntry, clone, deleteEntry, move } = entryActions;
const deleteAtCursor = useCallback(
(cursor: string | null) => {
if (!cursor) return;
const { entry, index } = getPreviousNormal(entries, order, cursor);
deleteEntry([cursor]);
if (entry && index !== null) {
setSelectedEvents({ id: entry.id, selectMode: 'click', index });
}
},
[entries, order, deleteEntry, setSelectedEvents],
);
const insertCopyAtId = useCallback(
(atId: EntryId | null, above = false) => {
// lazily get the value from the store
const { entryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection
return;
}
let normalisedAtId = atId;
const elementToCopy = entries[entryCopyId];
const refElement = atId ? entries[atId] : undefined;
if (refElement && 'parent' in refElement && refElement.parent && elementToCopy.type === SupportedEntry.Group) {
normalisedAtId = refElement.parent;
}
clone(entryCopyId, {
after: above ? undefined : normalisedAtId ?? undefined,
// if we don't have a cursor add the new event on top
before: above ? normalisedAtId ?? undefined : undefined,
});
},
[entries, clone],
);
/**
* Add a new item referring to an existing one
*/
const insertAtId = useCallback(
(patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: EntryId | null, above = false) => {
addEntry(patch, {
after: id && !above ? id : undefined,
before: id && above ? id : undefined,
lastEventId: !above && id ? id : undefined,
});
},
[addEntry],
);
const selectGroup = useCallback(
(cursor: EntryId | 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(entries, order) : getFirstNormal(entries, order);
if (isOntimeGroup(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'
? getPreviousGroupNormal(entries, order, newCursor)
: getNextGroupNormal(entries, order, newCursor);
if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
}
},
[order, entries, setSelectedEvents],
);
/**
* TODO: getPreviousNormal and getNextNormal do not work across group boundaries
*/
const selectEntry = useCallback(
(cursor: EntryId | 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(entries, order) : getFirstNormal(entries, 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(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, entries, setSelectedEvents],
);
const moveEntry = useCallback(
async (cursor: EntryId | null, direction: 'up' | 'down') => {
if (cursor == null) {
return;
}
const movedIntoGroupId = await move(cursor, direction);
// if we are moving into a group, we need to make sure it is expanded
if (movedIntoGroupId) {
handleCollapseGroup(false, movedIntoGroupId);
}
},
[handleCollapseGroup, move],
);
return {
deleteAtCursor,
insertCopyAtId,
insertAtId,
selectGroup,
selectEntry,
moveEntry,
};
}
@@ -0,0 +1,154 @@
import { Dispatch, SetStateAction, useCallback, useMemo, useRef } from 'react';
import { DragEndEvent, DragOverEvent, DragStartEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { type EntryId, type Rundown, isOntimeGroup, SupportedEntry } from 'ontime-types';
import { reorderArray } from 'ontime-utils';
import type { useEntryActions } from '../../../common/hooks/useEntryAction';
import { canDrop } from '../rundown.utils';
interface UseRundownDndOptions {
entries: Rundown['entries'];
sortableData: EntryId[];
setSortableData: Dispatch<SetStateAction<EntryId[]>>;
getIsCollapsed: (groupId: EntryId) => boolean;
handleCollapseGroup: (collapsed: boolean, groupId: EntryId) => void;
entryActions: ReturnType<typeof useEntryActions>;
}
export function useRundownDnd({
entries,
sortableData,
setSortableData,
getIsCollapsed,
handleCollapseGroup,
entryActions,
}: UseRundownDndOptions) {
const { reorderEntry } = entryActions;
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
const isDraggingRef = useRef(false);
/**
* On drag end, we reorder the events
*/
const handleOnDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
isDraggingRef.current = false;
if (!over?.id || active.id === over.id) {
return;
}
if (!active.data.current || !over.data.current) {
return;
}
const fromIndex: number = active.data.current.sortable.index;
const toIndex: number = over.data.current.sortable.index;
let placement: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
let destinationId = over.id as EntryId;
const isDraggingGroup = active.data.current?.type === SupportedEntry.Group;
// prevent dropping a group inside another
if (
isDraggingGroup &&
!canDrop(over.data.current.type, over.data.current.parent, placement, getIsCollapsed(destinationId))
) {
return;
}
/**
* We need to specially handle the end-group
* Dragging before a end-group will add the entry to the end of the group
* Dragging after a end-group will add the event after the group itself
* Dragging to the top of a group either place before first entry or if no entries do insert
*/
if (destinationId.startsWith('end-')) {
destinationId = destinationId.replace('end-', '');
// if we are moving before the end, we use the insert operation
if (placement === 'before') {
placement = 'insert';
}
} else {
const group = entries[destinationId];
// if dragging into a group
if (isOntimeGroup(group) && placement === 'after') {
if (isDraggingGroup) {
// ... and the dragged entry is a group, we know that the group is collapsed, because of the safe check canDrop from before
// so we can safely push the dragged event after the group
destinationId = group.id;
} else if (group.entries.length === 0) {
// ... and the group is entry, we insert
destinationId = group.id;
placement = 'insert';
} else {
// otherwise we add it to before the first group child
destinationId = group.entries[0];
placement = 'before';
}
}
}
// Optimistic update pattern to keep DND responsive
// 1. Keep copy of current state in case we need to revert
const currentEntries = [...sortableData];
// 2. Immediately update local state for responsive UI
setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex);
});
// 3. Trigger async mutation, revert on error
reorderEntry(active.id as EntryId, destinationId, placement).catch((_) => {
setSortableData(currentEntries);
});
},
[entries, sortableData, setSortableData, getIsCollapsed, reorderEntry],
);
/**
* When we drag a group, we force collapse it
* This avoids strange scenarios like dropping a group inside itself
*/
const collapseDraggedGroups = useCallback(
(event: DragStartEvent) => {
isDraggingRef.current = true;
const isGroup = event.active.data.current?.type === SupportedEntry.Group;
if (isGroup) {
handleCollapseGroup(true, event.active.id as EntryId);
}
},
[handleCollapseGroup],
);
/**
* When we drag over a group, we expand it if it is collapsed
*/
const expandOverGroup = useCallback(
(event: DragOverEvent) => {
// if we are dragging a group, the drop operation is invalid so we dont expand
if (event.active.data.current?.type === SupportedEntry.Group) {
return;
}
if (event.over?.data.current?.type !== SupportedEntry.Group) {
return;
}
const groupId = event.over?.id as EntryId;
const isCollapsed = getIsCollapsed(groupId);
if (isCollapsed) {
handleCollapseGroup(false, groupId);
}
},
[getIsCollapsed, handleCollapseGroup],
);
return useMemo(
() => ({
sensors,
isDraggingRef,
handleOnDragEnd,
collapseDraggedGroups,
expandOverGroup,
}),
[sensors, handleOnDragEnd, collapseDraggedGroups, expandOverGroup],
);
}
@@ -0,0 +1,100 @@
import { useHotkeys } from '@mantine/hooks';
import { type OntimeEntry, EntryId, SupportedEntry } from 'ontime-types';
interface UseRundownKeyboardOptions {
cursor: EntryId | null;
commands: {
selectEntry: (cursor: EntryId | null, direction: 'up' | 'down') => void;
selectGroup: (cursor: EntryId | null, direction: 'up' | 'down') => void;
moveEntry: (cursor: EntryId | null, direction: 'up' | 'down') => void;
deleteAtCursor: (cursor: EntryId | null) => void;
insertAtId: (patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: EntryId | null, above?: boolean) => void;
insertCopyAtId: (atId: EntryId | null, above?: boolean) => void;
};
clearSelectedEvents: () => void;
setEntryCopyId: (id: EntryId | null) => void;
}
export function useRundownKeyboard({
cursor,
commands,
clearSelectedEvents,
setEntryCopyId,
}: UseRundownKeyboardOptions) {
useHotkeys([
['alt + ArrowDown', () => commands.selectEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
['alt + ArrowUp', () => commands.selectEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
[
'alt + shift + ArrowDown',
() => commands.selectGroup(cursor, 'down'),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + ArrowUp',
() => commands.selectGroup(cursor, 'up'),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + mod + ArrowDown',
() => commands.moveEntry(cursor, 'down'),
{ preventDefault: true, usePhysicalKeys: true },
],
['alt + mod + ArrowUp', () => commands.moveEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
['Escape', () => clearSelectedEvents(), { preventDefault: true, usePhysicalKeys: true }],
['mod + Backspace', () => commands.deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
[
'alt + E',
() => commands.insertAtId({ type: SupportedEntry.Event }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + E',
() => commands.insertAtId({ type: SupportedEntry.Event }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + G',
() => commands.insertAtId({ type: SupportedEntry.Group }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + G',
() => commands.insertAtId({ type: SupportedEntry.Group }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + D',
() => commands.insertAtId({ type: SupportedEntry.Delay }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + D',
() => commands.insertAtId({ type: SupportedEntry.Delay }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + M',
() => commands.insertAtId({ type: SupportedEntry.Milestone }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + M',
() => commands.insertAtId({ type: SupportedEntry.Milestone }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
['mod + C', () => setEntryCopyId(cursor)],
['mod + V', () => commands.insertCopyAtId(cursor)],
['mod + shift + V', () => commands.insertCopyAtId(cursor, true), { preventDefault: true, usePhysicalKeys: true }],
['alt + backspace', () => commands.deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
]);
}