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
+228 -492
View File
@@ -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 */
+165
View File
@@ -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: () => { /* ... */ },
}
```