mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
feat: unify rundown features
This commit is contained in:
committed by
Carlos Valente
parent
c5045ad82e
commit
d7af73d9ed
@@ -1,49 +1,24 @@
|
||||
import { Fragment, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { type HTMLProps, forwardRef, Fragment, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { TbFlagFilled } from 'react-icons/tb';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { closestCenter, DndContext } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useHotkeys, useSessionStorage } from '@mantine/hooks';
|
||||
import {
|
||||
type EntryId,
|
||||
type MaybeString,
|
||||
type Rundown,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
OntimeEntry,
|
||||
Playback,
|
||||
SupportedEntry,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
getFirstNormal,
|
||||
getLastNormal,
|
||||
getNextGroupNormal,
|
||||
getNextNormal,
|
||||
getPreviousGroupNormal,
|
||||
getPreviousNormal,
|
||||
reorderArray,
|
||||
} from 'ontime-utils';
|
||||
import { useSessionStorage } from '@mantine/hooks';
|
||||
import { type EntryId, type Rundown, isOntimeEvent, isOntimeGroup, Playback, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||
import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
|
||||
import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
||||
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
|
||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||
|
||||
import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
|
||||
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
|
||||
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
|
||||
import { useRundownCommands } from './hooks/useRundownCommands';
|
||||
import { useRundownDnd } from './hooks/useRundownDnd';
|
||||
import { useRundownKeyboard } from './hooks/useRundownKeyboard';
|
||||
import RundownGroup from './rundown-group/RundownGroup';
|
||||
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
|
||||
import { canDrop, makeSortableList } from './rundown.utils';
|
||||
import { filterVisibleEntries, makeSortableList } from './rundown.utils';
|
||||
import RundownEmpty from './RundownEmpty';
|
||||
import { useEventSelection } from './useEventSelection';
|
||||
|
||||
@@ -52,19 +27,33 @@ import style from './Rundown.module.scss';
|
||||
const RundownEntry = lazy(() => import('./RundownEntry'));
|
||||
|
||||
interface RundownProps {
|
||||
data: Rundown;
|
||||
entries: Rundown['entries'];
|
||||
id: Rundown['id'];
|
||||
order: Rundown['order'];
|
||||
rundownMetadata: RundownMetadataObject;
|
||||
featureData: {
|
||||
playback: Playback;
|
||||
selectedEventId: EntryId | null;
|
||||
nextEventId: EntryId | null;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
||||
export default function Rundown({ order, entries, id, rundownMetadata, featureData }: RundownProps) {
|
||||
// invoke the compiler for the component
|
||||
'use memo';
|
||||
|
||||
const { order, entries, id } = data;
|
||||
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs
|
||||
const featureData = useRundownEditor();
|
||||
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
|
||||
const [metadata, setMetadata] = useState(rundownMetadata);
|
||||
const [metadata, setMetadata] = useState<RundownMetadataObject>(rundownMetadata);
|
||||
|
||||
/**
|
||||
* We need to keep a copy of the events we receive to
|
||||
* workaround issues with dnd-kit optimistic updates
|
||||
*/
|
||||
useEffect(() => {
|
||||
setSortableData(makeSortableList(order, entries));
|
||||
setMetadata(rundownMetadata);
|
||||
}, [order, entries, rundownMetadata]);
|
||||
|
||||
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
|
||||
// we ensure that this is unique to the rundown
|
||||
key: `rundown.${id}-editor-collapsed-groups`,
|
||||
@@ -72,7 +61,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
||||
});
|
||||
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
|
||||
|
||||
const { addEntry, clone, deleteEntry, move, reorderEntry } = useEntryActionsContext();
|
||||
const entryActions = useEntryActionsContext();
|
||||
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
|
||||
|
||||
// cursor
|
||||
@@ -87,128 +76,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
||||
|
||||
const cursorRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
useFollowComponent({
|
||||
followRef: cursorRef,
|
||||
scrollRef,
|
||||
doFollow: true,
|
||||
followTrigger: editorMode === AppMode.Edit ? cursor : featureData?.selectedEventId,
|
||||
});
|
||||
|
||||
// DND KIT
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
|
||||
|
||||
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: string | 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: MaybeString, above = false) => {
|
||||
addEntry(patch, {
|
||||
after: id && !above ? id : undefined,
|
||||
before: id && above ? id : undefined,
|
||||
lastEventId: !above && id ? id : undefined,
|
||||
});
|
||||
},
|
||||
[addEntry],
|
||||
);
|
||||
|
||||
const selectGroup = 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(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],
|
||||
);
|
||||
|
||||
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(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 virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||
|
||||
/**
|
||||
* Checks whether a group is collapsed
|
||||
@@ -240,93 +108,75 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
||||
[getIsCollapsed, setCollapsedGroups],
|
||||
);
|
||||
|
||||
const moveEntry = useCallback(
|
||||
async (cursor: EntryId | null, direction: 'up' | 'down') => {
|
||||
if (cursor == null) {
|
||||
return;
|
||||
}
|
||||
// Commands layer - business logic
|
||||
const commands = useRundownCommands({
|
||||
entries,
|
||||
order,
|
||||
entryActions,
|
||||
setSelectedEvents,
|
||||
handleCollapseGroup,
|
||||
});
|
||||
|
||||
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],
|
||||
);
|
||||
// Keyboard shortcuts
|
||||
useRundownKeyboard({
|
||||
cursor,
|
||||
commands,
|
||||
clearSelectedEvents,
|
||||
setEntryCopyId,
|
||||
});
|
||||
|
||||
// shortcuts
|
||||
useHotkeys([
|
||||
['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
// DND handlers
|
||||
const dnd = useRundownDnd({
|
||||
entries,
|
||||
sortableData,
|
||||
setSortableData,
|
||||
getIsCollapsed,
|
||||
handleCollapseGroup,
|
||||
entryActions,
|
||||
});
|
||||
|
||||
['alt + shift + ArrowDown', () => selectGroup(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
['alt + shift + ArrowUp', () => selectGroup(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
// Filter visible data to exclude collapsed items
|
||||
// DND-kit (SortableContext) needs the full sortableData for drag calculations
|
||||
// Virtuoso only renders visibleData to avoid null items that reserve space
|
||||
const visibleData = useMemo(() => {
|
||||
return filterVisibleEntries(sortableData, entries, getIsCollapsed);
|
||||
}, [sortableData, entries, getIsCollapsed]);
|
||||
|
||||
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
|
||||
['Escape', () => clearSelectedEvents(), { preventDefault: true, usePhysicalKeys: true }],
|
||||
|
||||
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
|
||||
|
||||
[
|
||||
'alt + E',
|
||||
() => insertAtId({ type: SupportedEntry.Event }, cursor),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
[
|
||||
'alt + shift + E',
|
||||
() => insertAtId({ type: SupportedEntry.Event }, cursor, true),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
|
||||
[
|
||||
'alt + G',
|
||||
() => insertAtId({ type: SupportedEntry.Group }, cursor),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
[
|
||||
'alt + shift + G',
|
||||
() => insertAtId({ type: SupportedEntry.Group }, cursor, true),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
|
||||
[
|
||||
'alt + D',
|
||||
() => insertAtId({ type: SupportedEntry.Delay }, cursor),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
[
|
||||
'alt + shift + D',
|
||||
() => insertAtId({ type: SupportedEntry.Delay }, cursor, true),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
|
||||
[
|
||||
'alt + M',
|
||||
() => insertAtId({ type: SupportedEntry.Milestone }, cursor),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
[
|
||||
'alt + shift + M',
|
||||
() => insertAtId({ type: SupportedEntry.Milestone }, cursor, true),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
|
||||
['mod + C', () => setEntryCopyId(cursor)],
|
||||
['mod + V', () => insertCopyAtId(cursor)],
|
||||
['mod + shift + V', () => insertCopyAtId(cursor, true), { preventDefault: true, usePhysicalKeys: true }],
|
||||
|
||||
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
|
||||
]);
|
||||
|
||||
// we copy the state from the store here
|
||||
// to workaround async updates on the drag mutations
|
||||
// Follow-scroll with Virtuoso in run mode
|
||||
// Always scrolls when playback selection changes, not during drag operations
|
||||
useEffect(() => {
|
||||
setSortableData(makeSortableList(order, entries));
|
||||
setMetadata(rundownMetadata);
|
||||
}, [order, entries, rundownMetadata]);
|
||||
if (editorMode !== AppMode.Run || !virtuosoRef.current || dnd.isDraggingRef.current) return;
|
||||
|
||||
const targetId = featureData?.selectedEventId;
|
||||
if (!targetId) return;
|
||||
|
||||
const index = visibleData.indexOf(targetId);
|
||||
if (index === -1) return;
|
||||
|
||||
virtuosoRef.current.scrollToIndex({
|
||||
index,
|
||||
align: 'start',
|
||||
behavior: 'smooth',
|
||||
offset: -100,
|
||||
});
|
||||
}, [editorMode, featureData?.selectedEventId, visibleData, dnd.isDraggingRef]);
|
||||
|
||||
// Scroll to the active cursor when editing (e.g. finder results)
|
||||
useEffect(() => {
|
||||
if (editorMode !== AppMode.Edit || !virtuosoRef.current || dnd.isDraggingRef.current) return;
|
||||
|
||||
if (!cursor) return;
|
||||
|
||||
const index = visibleData.indexOf(cursor);
|
||||
if (index === -1) return;
|
||||
|
||||
virtuosoRef.current.scrollToIndex({
|
||||
index,
|
||||
align: 'start',
|
||||
behavior: 'smooth',
|
||||
offset: -100, // show the previous entry for context
|
||||
});
|
||||
}, [editorMode, cursor, visibleData, dnd.isDraggingRef]);
|
||||
|
||||
// in run mode, we follow the playback selection and open groups as needed
|
||||
useEffect(() => {
|
||||
@@ -344,268 +194,154 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
||||
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
|
||||
}, [editorMode, entries, featureData.selectedEventId, order, setCollapsedGroups, setSelectedEvents]);
|
||||
|
||||
/**
|
||||
* On drag end, we reorder the events
|
||||
*/
|
||||
const handleOnDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
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 = data.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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keep copy of the current state in case we need to revert
|
||||
const currentEntries = [...sortableData];
|
||||
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
|
||||
setSortableData((currentEntries) => {
|
||||
return reorderArray(currentEntries, fromIndex, toIndex);
|
||||
});
|
||||
reorderEntry(active.id as EntryId, destinationId, placement).catch((_) => {
|
||||
setSortableData(currentEntries);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* When we drag a group, we force collapse it
|
||||
* This avoids strange scenarios like dropping a group inside itself
|
||||
*/
|
||||
const collapseDraggedGroups = (event: DragStartEvent) => {
|
||||
const isGroup = event.active.data.current?.type === SupportedEntry.Group;
|
||||
if (isGroup) {
|
||||
handleCollapseGroup(true, event.active.id as EntryId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When we drag over a group, we expand it if it is collapsed
|
||||
*/
|
||||
const expandOverGroup = (event: DragOverEvent) => {
|
||||
// if we are dragging a group, the drop operation is invalid so we dont expand
|
||||
if (event.active.data.current?.type === 'group') {
|
||||
return;
|
||||
}
|
||||
if (event.over?.data.current?.type !== 'group') {
|
||||
return;
|
||||
}
|
||||
const groupId = event.over?.id as EntryId;
|
||||
const isCollapsed = getIsCollapsed(groupId);
|
||||
if (isCollapsed) {
|
||||
handleCollapseGroup(false, groupId);
|
||||
}
|
||||
};
|
||||
|
||||
if (sortableData.length < 1) {
|
||||
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />;
|
||||
}
|
||||
|
||||
// gather presentation options
|
||||
const isEditMode = editorMode === AppMode.Edit;
|
||||
|
||||
// gather rundown wide data
|
||||
const lastEntryId = order.at(-1);
|
||||
|
||||
// Extract primitive values from featureData to avoid unnecessary callback recreations
|
||||
const nextEventId = featureData?.nextEventId;
|
||||
const playback = featureData?.playback;
|
||||
|
||||
// Virtuoso item renderer
|
||||
const itemContent = useCallback(
|
||||
(index: number, entryId: EntryId) => {
|
||||
// Handle end-group pseudo entries
|
||||
const isEndGroup = entryId.startsWith('end-');
|
||||
|
||||
if (isEndGroup) {
|
||||
const parentId = entryId.split('end-')[1];
|
||||
const parentMetadata = metadata[parentId];
|
||||
|
||||
return (
|
||||
<Fragment key={entryId}>
|
||||
{isEditMode && parentMetadata?.groupEntries === 0 && (
|
||||
<QuickAddButtons
|
||||
previousEventId={null}
|
||||
parentGroup={parentId}
|
||||
backgroundColor={parentMetadata?.groupColour}
|
||||
/>
|
||||
)}
|
||||
<RundownGroupEnd key={entryId} id={entryId} colour={parentMetadata?.groupColour} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular entry handling - compute all values upfront
|
||||
const entry = entries[entryId];
|
||||
const entryMetadata = metadata[entryId];
|
||||
|
||||
// Null check after computing - return null if missing
|
||||
if (!entry || !entryMetadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isNext = nextEventId === entry.id;
|
||||
const hasCursor = entry.id === cursor;
|
||||
const isGroup = isOntimeGroup(entry);
|
||||
const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
|
||||
const isFirst = index === 0;
|
||||
const isLast = entryId === lastEntryId;
|
||||
const parentIdForBefore = entryMetadata.thisId !== entryMetadata.groupId ? entryMetadata.groupId : null;
|
||||
const parentIdForAfter = entryMetadata.groupId;
|
||||
const collapsed = isGroup ? getIsCollapsed(entry.id) : false;
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
{/* QuickAddInline before the entry - edit mode only, if there is a cursor, if it is not the first entry */}
|
||||
{isEditMode && hasCursor && !isFirst && (
|
||||
<QuickAddInline placement='before' referenceEntryId={entry.id} parentGroup={parentIdForBefore} />
|
||||
)}
|
||||
{isGroup ? (
|
||||
<RundownGroup data={entry} hasCursor={hasCursor} collapsed={collapsed} onCollapse={handleCollapseGroup} />
|
||||
) : (
|
||||
<div
|
||||
className={style.entryWrapper}
|
||||
data-testid={`entry-${entryMetadata.eventIndex}`}
|
||||
style={groupColour ? { '--user-bg': groupColour } : {}}
|
||||
>
|
||||
{isOntimeEvent(entry) && (
|
||||
<div className={style.entryIndex}>
|
||||
{entry.flag && <TbFlagFilled className={style.flag} />}
|
||||
<div className={style.index}>{entryMetadata.eventIndex}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
||||
<RundownEntry
|
||||
type={entry.type}
|
||||
isPast={entryMetadata.isPast}
|
||||
eventIndex={entryMetadata.eventIndex}
|
||||
data={entry}
|
||||
loaded={entryMetadata.isLoaded}
|
||||
hasCursor={hasCursor}
|
||||
isNext={isNext}
|
||||
isNextDay={entryMetadata.isNextDay}
|
||||
playback={entryMetadata.isLoaded ? playback : undefined}
|
||||
isRolling={playback === Playback.Roll}
|
||||
totalGap={entryMetadata.totalGap}
|
||||
isLinkedToLoaded={entryMetadata.isLinkedToLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* QuickAddInline after the entry - edit mode only, if there is a cursor, if it is not the last entry */}
|
||||
{isEditMode && hasCursor && !isLast && (
|
||||
<QuickAddInline placement='after' referenceEntryId={entry.id} parentGroup={parentIdForAfter} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
[entries, metadata, getIsCollapsed, isEditMode, cursor, nextEventId, playback, lastEntryId, handleCollapseGroup],
|
||||
);
|
||||
|
||||
if (sortableData.length < 1) {
|
||||
return <RundownEmpty handleAddNew={(type: SupportedEntry) => entryActions.addEntry({ type })} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
||||
<DndContext
|
||||
onDragEnd={handleOnDragEnd}
|
||||
onDragStart={collapseDraggedGroups}
|
||||
onDragOver={expandOverGroup}
|
||||
sensors={sensors}
|
||||
onDragEnd={dnd.handleOnDragEnd}
|
||||
onDragStart={dnd.collapseDraggedGroups}
|
||||
onDragOver={dnd.expandOverGroup}
|
||||
sensors={dnd.sensors}
|
||||
collisionDetection={closestCenter}
|
||||
>
|
||||
<SortableContext items={sortableData} strategy={verticalListSortingStrategy}>
|
||||
<div className={style.list}>
|
||||
{isEditMode && <QuickAddButtons previousEventId={null} parentGroup={null} />}
|
||||
{sortableData.map((entryId, index) => {
|
||||
// the entry might be a pseudo end-group which does not generate metadata and should not be processed
|
||||
if (entryId.startsWith('end-')) {
|
||||
const parentId = entryId.split('end-')[1];
|
||||
const isGroupCollapsed = getIsCollapsed(parentId);
|
||||
const parentMetadata = metadata[parentId];
|
||||
|
||||
if (isGroupCollapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if the previous element is selected, it will have its own QuickAddInline
|
||||
// we use thisId instead of previousEntryId because the end-group does not process
|
||||
// and it does not cause the reassignment of the iteration id to the previous entry
|
||||
return (
|
||||
<Fragment key={entryId}>
|
||||
{isEditMode && parentMetadata?.groupEntries === 0 && (
|
||||
<QuickAddButtons
|
||||
previousEventId={null}
|
||||
parentGroup={parentId}
|
||||
backgroundColor={parentMetadata?.groupColour}
|
||||
/>
|
||||
)}
|
||||
<RundownGroupEnd key={entryId} id={entryId} colour={parentMetadata?.groupColour} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
// we iterate through a stateful copy of order to make the dnd 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 = entries[entryId];
|
||||
const entryMetadata = metadata[entryId];
|
||||
if (!entry || !entryMetadata) return null;
|
||||
|
||||
// if the entry has a parent, and it is collapsed, render nothing
|
||||
if (
|
||||
entry.type !== SupportedEntry.Group &&
|
||||
entryMetadata.groupId !== null &&
|
||||
getIsCollapsed(entryMetadata.groupId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isNext = featureData?.nextEventId === entry.id;
|
||||
const hasCursor = entry.id === cursor;
|
||||
|
||||
/**
|
||||
* Outside a group, the value will be undefined
|
||||
* If the colour is empty string ''
|
||||
* ie: we are inside a group, but there is no defined colour
|
||||
* we default to $gray-500 #9d9d9d
|
||||
*/
|
||||
const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
|
||||
|
||||
const isFirst = index === 0;
|
||||
const isLast = entryId === lastEntryId;
|
||||
|
||||
/**
|
||||
* We need to provide the parent ID for the QuickAdd components
|
||||
* This should be different depending on whether we are adding before or after an element
|
||||
* - when adding before, we need to avoid a group referencing itself as the parent
|
||||
* - when adding after, we can use the group ID directly to insert at the top of the group
|
||||
*/
|
||||
|
||||
const parentIdForBefore = entryMetadata.thisId !== entryMetadata.groupId ? entryMetadata.groupId : null;
|
||||
const parentIdForAfter = entryMetadata.groupId;
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
{/**
|
||||
* Before the entry
|
||||
* - edit mode only
|
||||
* - if there is a cursor
|
||||
* - if it is not the first entry (the buttons would be there)
|
||||
*/}
|
||||
{isEditMode && hasCursor && !isFirst && (
|
||||
<QuickAddInline placement='before' referenceEntryId={entry.id} parentGroup={parentIdForBefore} />
|
||||
)}
|
||||
{isOntimeGroup(entry) ? (
|
||||
<RundownGroup
|
||||
data={entry}
|
||||
hasCursor={hasCursor}
|
||||
collapsed={getIsCollapsed(entry.id)}
|
||||
onCollapse={handleCollapseGroup}
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={visibleData}
|
||||
computeItemKey={(_index, entryId) => entryId}
|
||||
itemContent={itemContent}
|
||||
increaseViewportBy={{ top: 200, bottom: 400 }}
|
||||
style={{ height: '100%' }}
|
||||
components={{
|
||||
Header: isEditMode ? () => <QuickAddButtons previousEventId={null} parentGroup={null} /> : undefined,
|
||||
Footer: () => (
|
||||
<>
|
||||
{isEditMode && (
|
||||
<QuickAddButtons
|
||||
previousEventId={metadata[lastMetadataKey]?.groupId ?? metadata[lastMetadataKey]?.thisId}
|
||||
parentGroup={null}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={style.entryWrapper}
|
||||
data-testid={`entry-${entryMetadata.eventIndex}`}
|
||||
style={groupColour ? { '--user-bg': groupColour } : {}}
|
||||
>
|
||||
{isOntimeEvent(entry) && (
|
||||
<div className={style.entryIndex}>
|
||||
{entry.flag && <TbFlagFilled className={style.flag} />}
|
||||
<div className={style.index}>{entryMetadata.eventIndex}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
||||
<RundownEntry
|
||||
type={entry.type}
|
||||
isPast={entryMetadata.isPast}
|
||||
eventIndex={entryMetadata.eventIndex}
|
||||
data={entry}
|
||||
loaded={entryMetadata.isLoaded}
|
||||
hasCursor={hasCursor}
|
||||
isNext={isNext}
|
||||
isNextDay={entryMetadata.isNextDay}
|
||||
playback={entryMetadata.isLoaded ? featureData.playback : undefined}
|
||||
isRolling={featureData.playback === Playback.Roll}
|
||||
totalGap={entryMetadata.totalGap}
|
||||
isLinkedToLoaded={entryMetadata.isLinkedToLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/**
|
||||
* After the entry
|
||||
* - edit mode only
|
||||
* - if there is a cursor
|
||||
* - if it is not the last entry (the buttons would be there)
|
||||
* - if the entry is not the group header
|
||||
*/}
|
||||
{isEditMode && hasCursor && !isLast && (
|
||||
<QuickAddInline placement='after' referenceEntryId={entry.id} parentGroup={parentIdForAfter} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{isEditMode && (
|
||||
<QuickAddButtons
|
||||
previousEventId={metadata[lastMetadataKey]?.groupId ?? metadata[lastMetadataKey].thisId}
|
||||
parentGroup={null}
|
||||
/>
|
||||
)}
|
||||
<div className={style.spacer} />
|
||||
</div>
|
||||
<div className={style.spacer} />
|
||||
</>
|
||||
),
|
||||
List: VirtuosoListComponent,
|
||||
}}
|
||||
/>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Virtuoso components - extracted to prevent recreation on every render
|
||||
const VirtuosoListComponent = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(({ children, ...props }, ref) => (
|
||||
<div ref={ref} {...props} className={style.list}>
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
VirtuosoListComponent.displayName = 'VirtuosoListComponent';
|
||||
|
||||
@@ -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 }],
|
||||
]);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import { EntryId, isOntimeEvent, MaybeNumber, MaybeString, Rundown } from 'ontime-types';
|
||||
import { EntryId, isOntimeEvent, MaybeNumber, Rundown } from 'ontime-types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { RUNDOWN } from '../../common/api/constants';
|
||||
@@ -11,13 +11,10 @@ type SelectionMode = 'shift' | 'click' | 'ctrl';
|
||||
interface EventSelectionStore {
|
||||
selectedEvents: Set<EntryId>;
|
||||
anchoredIndex: MaybeNumber;
|
||||
cursor: MaybeString;
|
||||
scrollTargetId: MaybeString;
|
||||
cursor: EntryId | null;
|
||||
entryMode: 'event' | 'single' | null;
|
||||
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
|
||||
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
|
||||
setScrollTargetId: (id: EntryId | null) => void;
|
||||
clearScrollTargetId: () => void;
|
||||
clearSelectedEvents: () => void;
|
||||
clearMultiSelect: () => void;
|
||||
unselect: (id: EntryId) => void;
|
||||
@@ -27,7 +24,6 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
||||
selectedEvents: new Set(),
|
||||
anchoredIndex: null,
|
||||
cursor: null,
|
||||
scrollTargetId: null,
|
||||
entryMode: null,
|
||||
setSingleEntrySelection: ({ id }) => {
|
||||
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
|
||||
@@ -103,10 +99,8 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
||||
});
|
||||
}
|
||||
},
|
||||
setScrollTargetId: (id) => set({ scrollTargetId: id }),
|
||||
clearScrollTargetId: () => set({ scrollTargetId: null }),
|
||||
clearSelectedEvents: () =>
|
||||
set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null, scrollTargetId: null }),
|
||||
set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
|
||||
clearMultiSelect: () => {
|
||||
const { selectedEvents } = get();
|
||||
const [firstSelected] = selectedEvents;
|
||||
|
||||
@@ -114,21 +114,23 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
||||
setColumnSizing({});
|
||||
}, [setColumnSizing]);
|
||||
|
||||
// in edit mode, we follow the cursor (e.g. from finder)
|
||||
// Follow selection changes depending on mode
|
||||
useEffect(() => {
|
||||
if (virtuosoRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = cuesheetMode === AppMode.Edit ? cursor : selectedEventId;
|
||||
const targetId = cuesheetMode === AppMode.Run ? selectedEventId : cursor ?? selectedEventId;
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventIndex = data.findIndex((event) => event.id === targetId);
|
||||
if (eventIndex !== -1) {
|
||||
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'smooth', align: 'center' });
|
||||
if (eventIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'smooth', align: 'start', offset: -50 });
|
||||
}, [cuesheetMode, data, selectedEventId, cursor]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -45,7 +45,6 @@ export default function useFinder() {
|
||||
const lastSearchString = useRef('');
|
||||
|
||||
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
|
||||
const setScrollTargetId = useEventSelection((state) => state.setScrollTargetId);
|
||||
|
||||
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
|
||||
// we ensure that this is unique to the rundown
|
||||
@@ -235,9 +234,8 @@ export default function useFinder() {
|
||||
|
||||
// Then select the event
|
||||
setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' });
|
||||
setScrollTargetId(selectedEvent.id);
|
||||
},
|
||||
[collapsedGroups, setCollapsedGroups, setSelectedEvents, setScrollTargetId],
|
||||
[collapsedGroups, setCollapsedGroups, setSelectedEvents],
|
||||
);
|
||||
|
||||
/** clear results when source data changes */
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# Implementation Plan: Enhanced Rundown Keyboard Shortcuts
|
||||
|
||||
This document outlines the plan to extend the keyboard shortcuts for the Rundown feature, including functionality for Duplicate, Delete, Cut, and improved navigation (Home/End, PageUp/PageDown).
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `apps/client/src/features/rundown/hooks/useRundownCommands.ts`
|
||||
2. `apps/client/src/features/rundown/hooks/useRundownKeyboard.ts`
|
||||
|
||||
## Step 1: Logic Implementation (`useRundownCommands.ts`)
|
||||
|
||||
We need to add the underlying logic for the new actions.
|
||||
|
||||
### 1. `cloneEntry`
|
||||
Create a function to clone the currently selected entry.
|
||||
* **Action**: Use `entryActions.clone`.
|
||||
* **Logic**:
|
||||
* If no cursor, return.
|
||||
* Call `clone(cursor, { after: cursor })`.
|
||||
|
||||
### 2. `selectEdge`
|
||||
Create a function to jump to the top or bottom of the list.
|
||||
* **Arguments**: `direction: 'top' | 'bottom'`.
|
||||
* **Logic**:
|
||||
* Use `getFirstNormal(entries, order)` for `'top'`.
|
||||
* Use `getLastNormal(entries, order)` for `'bottom'`.
|
||||
* Call `setSelectedEvents` with the result.
|
||||
|
||||
### 3. `selectPage`
|
||||
Create a function to move selection by a "page" (e.g., 10 items).
|
||||
* **Arguments**: `cursor: string | null`, `direction: 'up' | 'down'`.
|
||||
* **Constant**: `PAGE_SIZE = 10`.
|
||||
* **Logic**:
|
||||
* Use `getNextNormal` / `getPreviousNormal` in a loop (up to `PAGE_SIZE` times) to find the target entry.
|
||||
* Call `setSelectedEvents` with the result.
|
||||
|
||||
### 4. `cutEntry`
|
||||
While "Cut" is often a compound action in the keyboard hook (Copy + Delete), implementing a helper here allows for cleaner handling if additional logic is needed later.
|
||||
* **Logic**:
|
||||
* Set copy ID (requires access to `entryCopyStore` or passed via props, but current pattern passes `setEntryCopyId` to `useRundownKeyboard` separately).
|
||||
* *Note*: Since `setEntryCopyId` is separate, we can handle "Cut" composition in `useRundownKeyboard.ts` or add a `cut` command here that combines them if we bring `setEntryCopyId` into commands. *Recommendation: Handle composition in `useRundownKeyboard.ts` to matching existing patterns, or add a specific `cutEntry` if complex.*
|
||||
|
||||
**Update Return Interface**: Ensure `selectEdge`, `selectPage`, and `cloneEntry` are returned from the hook.
|
||||
|
||||
## Step 2: Keyboard Mapping (`useRundownKeyboard.ts`)
|
||||
|
||||
Update the `UseRundownKeyboardOptions` interface and the `useHotkeys` hook configuration.
|
||||
|
||||
### 1. Update Interface
|
||||
Update `UseRundownKeyboardOptions['commands']` to include:
|
||||
```typescript
|
||||
interface UseRundownKeyboardOptions {
|
||||
// ... existing
|
||||
commands: {
|
||||
// ... existing
|
||||
cloneEntry: (cursor: EntryId | null) => void;
|
||||
selectEdge: (direction: 'top' | 'bottom') => void;
|
||||
selectPage: (cursor: EntryId | null, direction: 'up' | 'down') => void;
|
||||
};
|
||||
// ... existing
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Add Hotkeys implementation
|
||||
|
||||
Add the following mappings to the `useHotkeys` array:
|
||||
* **Note**: `mod + D` (Clone) does not collide with `alt + D` (Add Delay).
|
||||
|
||||
| Action | Shortcut | Handler Logic |
|
||||
| :--- | :--- | :--- |
|
||||
| **Clone** | `mod + D` | `commands.cloneEntry(cursor)` |
|
||||
| **Delete** | `mod + Delete` | `commands.deleteAtCursor(cursor)` |
|
||||
| **Cut** | `mod + X` | `() => { setEntryCopyId(cursor); commands.deleteAtCursor(cursor); }` |
|
||||
| **Home** | `Home` | `commands.selectEdge('top')` |
|
||||
| **End** | `End` | `commands.selectEdge('bottom')` |
|
||||
| **Page Up** | `PageUp` | `commands.selectPage(cursor, 'up')` |
|
||||
| **Page Down** | `PageDown` | `commands.selectPage(cursor, 'down')` |
|
||||
|
||||
*Note*: Ensure `{ preventDefault: true, usePhysicalKeys: true }` is used for navigation keys to prevent browser scrolling.
|
||||
## Step 3: UI Updates for Discoverability
|
||||
|
||||
To ensure users can discover these new features, we must update the UI to reflect new shortcuts.
|
||||
|
||||
### 1. Update Context Menus
|
||||
Add "Clone" and "Delete" options (or ensure they use the new keyboard shortcuts in their labels) to the context menus of rundown items.
|
||||
|
||||
**Files to Modify**:
|
||||
* `apps/client/src/features/rundown/rundown-event/RundownEvent.tsx`
|
||||
* `apps/client/src/features/rundown/rundown-group/RundownGroup.tsx`
|
||||
* `apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx`
|
||||
|
||||
**Changes**:
|
||||
* Locate `useContextMenu` implementation.
|
||||
* Add/Update `Clone` option with shortcut label `Mod+D`.
|
||||
* Ensure `Delete` option shows correct `Mod/Del` shortcut.
|
||||
|
||||
### 2. Update Empty State Shortcuts
|
||||
Update the shortcut list displayed when no event is selected.
|
||||
|
||||
**File to Modify**:
|
||||
* `apps/client/src/features/rundown/entry-editor/EventEditorEmpty.tsx`
|
||||
|
||||
**Changes**:
|
||||
* Add rows to the shortcut table for:
|
||||
* **Clone Entry**: `Mod + D`
|
||||
* **Delete Entry**: `Mod + Delete` / `Delete`
|
||||
* **Navigation**: `Home`, `End`, `PgUp`, `PgDn` (Group under "Navigation")
|
||||
|
||||
## Step 4: UX Review & Interface Improvements
|
||||
|
||||
### 1. `EventEditorEmpty` Redesign
|
||||
The current table-based layout is rigid. We will refactor `apps/client/src/features/rundown/entry-editor/EventEditorEmpty.tsx` to use a sleek CSS Grid layout.
|
||||
* **Action**: Replace `<table>` with `div` based grid.
|
||||
* **Visuals**: Use subtle headers for groups (Navigation, Editing, System).
|
||||
* **Refinement**: Ensure `<Kbd>` components use a flat, minimal design.
|
||||
|
||||
### 2. Global Shortcuts Dialog
|
||||
* **Decision**: Implement a Global Shortcuts Dialog triggered by `?` (Shift + /).
|
||||
* **Rationale**: Users lose the `EventEditorEmpty` reference once they add content. A persistent dialog ensures "recognition over recall".
|
||||
|
||||
### 3. Context Menu Implementation Guide
|
||||
We will enhance the context menu to display shortcuts inline.
|
||||
|
||||
**A. Update Type Definition**
|
||||
Modify `apps/client/src/common/components/dropdown-menu/DropdownMenu.tsx`:
|
||||
```typescript
|
||||
type DropdownMenuItem = {
|
||||
// ... existing fields
|
||||
shortcut?: string; // New field
|
||||
};
|
||||
```
|
||||
|
||||
**B. Update Component Rendering**
|
||||
In `apps/client/src/common/components/dropdown-menu/DropdownMenu.tsx`, update the render loop to display the shortcut:
|
||||
```tsx
|
||||
<BaseMenu.Item className={style.item}>
|
||||
<span className={style.labelContainer}>
|
||||
{item.icon && <item.icon />}
|
||||
{item.label}
|
||||
</span>
|
||||
{item.type === 'item' && item.shortcut && (
|
||||
<span className={style.shortcut}>{item.shortcut}</span>
|
||||
)}
|
||||
</BaseMenu.Item>
|
||||
```
|
||||
*Note*: Update `DropdownMenu.module.scss` to use `justify-content: space-between` on the item.
|
||||
|
||||
**C. Update Usage in `RundownEvent.tsx`**
|
||||
Add shortcuts to the context menu options:
|
||||
```tsx
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Clone',
|
||||
icon: IoDuplicateOutline,
|
||||
shortcut: `${deviceMod}+D`,
|
||||
onClick: () => clone(eventId, { after: eventId }),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Delete',
|
||||
icon: IoTrash,
|
||||
shortcut: 'Del',
|
||||
onClick: () => { /* ... */ },
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user