diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index fd08c4958..9d1158d1d 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -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(() => makeSortableList(order, entries)); - const [metadata, setMetadata] = useState(rundownMetadata); + const [metadata, setMetadata] = useState(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({ // 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(null); const scrollRef = useRef(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 & { 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(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 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 ( + + {isEditMode && parentMetadata?.groupEntries === 0 && ( + + )} + + + ); + } + + // 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 ( + + {/* QuickAddInline before the entry - edit mode only, if there is a cursor, if it is not the first entry */} + {isEditMode && hasCursor && !isFirst && ( + + )} + {isGroup ? ( + + ) : ( +
+ {isOntimeEvent(entry) && ( +
+ {entry.flag && } +
{entryMetadata.eventIndex}
+
+ )} +
+ +
+
+ )} + {/* QuickAddInline after the entry - edit mode only, if there is a cursor, if it is not the last entry */} + {isEditMode && hasCursor && !isLast && ( + + )} +
+ ); + }, + [entries, metadata, getIsCollapsed, isEditMode, cursor, nextEventId, playback, lastEntryId, handleCollapseGroup], + ); + + if (sortableData.length < 1) { + return entryActions.addEntry({ type })} />; + } + return (
-
- {isEditMode && } - {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 ( - - {isEditMode && parentMetadata?.groupEntries === 0 && ( - - )} - - - ); - } - - // 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 ( - - {/** - * 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 && ( - - )} - {isOntimeGroup(entry) ? ( - entryId} + itemContent={itemContent} + increaseViewportBy={{ top: 200, bottom: 400 }} + style={{ height: '100%' }} + components={{ + Header: isEditMode ? () => : undefined, + Footer: () => ( + <> + {isEditMode && ( + - ) : ( -
- {isOntimeEvent(entry) && ( -
- {entry.flag && } -
{entryMetadata.eventIndex}
-
- )} -
- -
-
)} - {/** - * 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 && ( - - )} -
- ); - })} - {isEditMode && ( - - )} -
-
+
+ + ), + List: VirtuosoListComponent, + }} + />
); } + +// Virtuoso components - extracted to prevent recreation on every render +const VirtuosoListComponent = forwardRef>(({ children, ...props }, ref) => ( +
+ {children} +
+)); +VirtuosoListComponent.displayName = 'VirtuosoListComponent'; diff --git a/apps/client/src/features/rundown/hooks/useRundownCommands.ts b/apps/client/src/features/rundown/hooks/useRundownCommands.ts new file mode 100644 index 000000000..07859c5f3 --- /dev/null +++ b/apps/client/src/features/rundown/hooks/useRundownCommands.ts @@ -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; + 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 & { 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, + }; +} diff --git a/apps/client/src/features/rundown/hooks/useRundownDnd.ts b/apps/client/src/features/rundown/hooks/useRundownDnd.ts new file mode 100644 index 000000000..531a3455e --- /dev/null +++ b/apps/client/src/features/rundown/hooks/useRundownDnd.ts @@ -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>; + getIsCollapsed: (groupId: EntryId) => boolean; + handleCollapseGroup: (collapsed: boolean, groupId: EntryId) => void; + entryActions: ReturnType; +} + +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], + ); +} diff --git a/apps/client/src/features/rundown/hooks/useRundownKeyboard.ts b/apps/client/src/features/rundown/hooks/useRundownKeyboard.ts new file mode 100644 index 000000000..efca596fc --- /dev/null +++ b/apps/client/src/features/rundown/hooks/useRundownKeyboard.ts @@ -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 & { 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 }], + ]); +} diff --git a/apps/client/src/features/rundown/useEventSelection.ts b/apps/client/src/features/rundown/useEventSelection.ts index d1c06ad90..9586d4f2f 100644 --- a/apps/client/src/features/rundown/useEventSelection.ts +++ b/apps/client/src/features/rundown/useEventSelection.ts @@ -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; 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()((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()((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; diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx index 2bdb3b9a0..e96e2a2f5 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx @@ -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]); /** diff --git a/apps/client/src/views/editor/finder/useFinder.tsx b/apps/client/src/views/editor/finder/useFinder.tsx index e6ad26037..764e4b656 100644 --- a/apps/client/src/views/editor/finder/useFinder.tsx +++ b/apps/client/src/views/editor/finder/useFinder.tsx @@ -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({ // 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 */ diff --git a/docs/keyboard-shortcuts-plan.md b/docs/keyboard-shortcuts-plan.md new file mode 100644 index 000000000..5d1464dad --- /dev/null +++ b/docs/keyboard-shortcuts-plan.md @@ -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 `` with `div` based grid. +* **Visuals**: Use subtle headers for groups (Navigation, Editing, System). +* **Refinement**: Ensure `` 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 + + + {item.icon && } + {item.label} + + {item.type === 'item' && item.shortcut && ( + {item.shortcut} + )} + +``` +*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: () => { /* ... */ }, +} +```