mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 22:24:11 +00:00
feat: unify rundown features
This commit is contained in:
committed by
Carlos Valente
parent
c5045ad82e
commit
d7af73d9ed
@@ -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 }],
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user