diff --git a/apps/client/src/common/components/dropdown-menu/DropdownMenu.module.scss b/apps/client/src/common/components/dropdown-menu/DropdownMenu.module.scss index ce57bb3da..6e6bf6872 100644 --- a/apps/client/src/common/components/dropdown-menu/DropdownMenu.module.scss +++ b/apps/client/src/common/components/dropdown-menu/DropdownMenu.module.scss @@ -31,10 +31,11 @@ outline: 0; cursor: default; padding-block: 0.5rem; - padding-inline: 1rem 2rem; + padding-inline: 1rem; display: flex; - gap: 0.5rem; + justify-content: space-between; + gap: 1rem; line-height: 1em; svg { @@ -69,6 +70,18 @@ } } +.label { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.shortcut { + color: $gray-500; + font-size: calc(1rem - 4px); + letter-spacing: 0.02em; +} + .separator { margin: 0.25rem 0.75rem; height: 1px; diff --git a/apps/client/src/common/components/dropdown-menu/DropdownMenu.tsx b/apps/client/src/common/components/dropdown-menu/DropdownMenu.tsx index 192702b81..557aa10b9 100644 --- a/apps/client/src/common/components/dropdown-menu/DropdownMenu.tsx +++ b/apps/client/src/common/components/dropdown-menu/DropdownMenu.tsx @@ -11,6 +11,7 @@ type DropdownMenuItem = { icon?: IconType; disabled?: boolean; onClick: () => void; + shortcut?: string; }; export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem; @@ -38,8 +39,11 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil disabled={item.disabled} data-type={item.type} > - {item.icon && } - {item.label} + + {item.icon && } + {item.label} + + {item.shortcut && {item.shortcut}} ); })} @@ -75,8 +79,11 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos } return ( - {item.icon && } - {item.label} + + {item.icon && } + {item.label} + + {item.shortcut && {item.shortcut}} ); })} diff --git a/apps/client/src/common/stores/entryCopyStore.ts b/apps/client/src/common/stores/entryCopyStore.ts index 0a005313e..4ccc2de7b 100644 --- a/apps/client/src/common/stores/entryCopyStore.ts +++ b/apps/client/src/common/stores/entryCopyStore.ts @@ -2,10 +2,13 @@ import { create } from 'zustand'; type EntryCopyStore = { entryCopyId: string | null; - setEntryCopyId: (eventId: string | null) => void; + entryCopyMode: 'copy' | 'cut'; + setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void; }; export const useEntryCopy = create()((set) => ({ entryCopyId: null, - setEntryCopyId: (entryCopyId: string | null) => set({ entryCopyId }), + entryCopyMode: 'copy', + setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') => + set({ entryCopyId, entryCopyMode: mode }), })); diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 9d1158d1d..3af8f6433 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -30,6 +30,7 @@ interface RundownProps { entries: Rundown['entries']; id: Rundown['id']; order: Rundown['order']; + flatOrder: Rundown['flatOrder']; rundownMetadata: RundownMetadataObject; featureData: { playback: Playback; @@ -38,7 +39,7 @@ interface RundownProps { }; } -export default function Rundown({ order, entries, id, rundownMetadata, featureData }: RundownProps) { +export default function Rundown({ order, flatOrder, entries, id, rundownMetadata, featureData }: RundownProps) { // invoke the compiler for the component 'use memo'; @@ -73,6 +74,8 @@ export default function Rundown({ order, entries, id, rundownMetadata, featureDa const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents); const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents); const cursor = useEventSelection((state) => state.cursor); + const scrollToEntry = useEventSelection((state) => state.scrollToEntry); + const setScrollHandler = useEventSelection((state) => state.setScrollHandler); const cursorRef = useRef(null); const scrollRef = useRef(null); @@ -111,7 +114,7 @@ export default function Rundown({ order, entries, id, rundownMetadata, featureDa // Commands layer - business logic const commands = useRundownCommands({ entries, - order, + flatOrder, entryActions, setSelectedEvents, handleCollapseGroup, @@ -142,41 +145,42 @@ export default function Rundown({ order, entries, id, rundownMetadata, featureDa return filterVisibleEntries(sortableData, entries, getIsCollapsed); }, [sortableData, entries, getIsCollapsed]); - // Follow-scroll with Virtuoso in run mode - // Always scrolls when playback selection changes, not during drag operations + // Scroll to a specific entry when requested by keyboard/finder useEffect(() => { - if (editorMode !== AppMode.Run || !virtuosoRef.current || dnd.isDraggingRef.current) return; + setScrollHandler('rundown-list', (entryId) => { + if (!virtuosoRef.current || dnd.isDraggingRef.current) { + return; + } + const index = visibleData.indexOf(entryId); + if (index === -1) { + return; + } + virtuosoRef.current.scrollToIndex({ + index, + align: 'start', + behavior: 'smooth', + offset: -100, // show the previous entry for context + }); + }); + + return () => { + setScrollHandler('rundown-list', null); + }; + }, [visibleData, dnd.isDraggingRef, setScrollHandler]); + + // Follow-scroll in run mode via the shared scroll handler + useEffect(() => { + if (editorMode !== AppMode.Run || dnd.isDraggingRef.current) { + return; + } const targetId = featureData?.selectedEventId; - if (!targetId) return; + 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]); + scrollToEntry(targetId); + }, [editorMode, featureData?.selectedEventId, visibleData, dnd.isDraggingRef, scrollToEntry]); // in run mode, we follow the playback selection and open groups as needed useEffect(() => { diff --git a/apps/client/src/features/rundown/RundownList.tsx b/apps/client/src/features/rundown/RundownList.tsx index 46a0dfc3f..94673400a 100644 --- a/apps/client/src/features/rundown/RundownList.tsx +++ b/apps/client/src/features/rundown/RundownList.tsx @@ -20,6 +20,7 @@ function RundownList() { return ( ↓ + + Jump to top / bottom + + Home + / + End + + + + Page up / down + + PgUp + / + PgDn + + Deselect entry @@ -80,6 +96,14 @@ function EventEditorEmpty() { C + + Cut selected entry + + {deviceMod} + + + X + + Paste above @@ -99,10 +123,18 @@ function EventEditorEmpty() { - Delete selected entry + Clone selected entry {deviceMod} + + D + + + + Delete selected entry + + {deviceAlt} + + Backspace @@ -140,7 +172,7 @@ function EventEditorEmpty() { + Shift + - M + G @@ -148,7 +180,7 @@ function EventEditorEmpty() { {deviceAlt} + - G + M diff --git a/apps/client/src/features/rundown/hooks/useRundownCommands.ts b/apps/client/src/features/rundown/hooks/useRundownCommands.ts index 07859c5f3..96da8ae32 100644 --- a/apps/client/src/features/rundown/hooks/useRundownCommands.ts +++ b/apps/client/src/features/rundown/hooks/useRundownCommands.ts @@ -13,6 +13,7 @@ import type { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryCopy } from '../../../common/stores/entryCopyStore'; type SelectionMode = 'shift' | 'click' | 'ctrl'; +const PAGE_SIZE = 5; interface UseRundownCommandsOptions { entries: Rundown['entries']; @@ -22,6 +23,9 @@ interface UseRundownCommandsOptions { handleCollapseGroup: (collapsed: boolean, groupId: EntryId) => void; } +/** + * Common operations for the rundown lists + */ export function useRundownCommands({ entries, order, @@ -29,7 +33,8 @@ export function useRundownCommands({ setSelectedEvents, handleCollapseGroup, }: UseRundownCommandsOptions) { - const { addEntry, clone, deleteEntry, move } = entryActions; + const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions; + const deleteAtCursor = useCallback( (cursor: string | null) => { if (!cursor) return; @@ -45,7 +50,7 @@ export function useRundownCommands({ const insertCopyAtId = useCallback( (atId: EntryId | null, above = false) => { // lazily get the value from the store - const { entryCopyId } = useEntryCopy.getState(); + const { entryCopyId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState(); if (entryCopyId === null || !entries[entryCopyId]) { // we cant clone without selection return; @@ -60,13 +65,34 @@ export function useRundownCommands({ normalisedAtId = refElement.parent; } + if (entryCopyMode === 'cut') { + if (!normalisedAtId) { + const firstId = order[0]; + if (!firstId || firstId === entryCopyId) { + return; + } + reorderEntry(entryCopyId, firstId, 'before') + .then(() => setEntryCopyId(null)) + .catch(() => {}); + return; + } + if (normalisedAtId === entryCopyId) { + return; + } + const placement = above ? 'before' : 'after'; + reorderEntry(entryCopyId, normalisedAtId, placement) + .then(() => setEntryCopyId(null)) + .catch(() => {}); + return; + } + 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], + [entries, order, clone, reorderEntry], ); /** @@ -86,7 +112,7 @@ export function useRundownCommands({ const selectGroup = useCallback( (cursor: EntryId | null, direction: 'up' | 'down') => { if (order.length < 1) { - return; + return null; } let newCursor = cursor; if (cursor === null) { @@ -95,13 +121,13 @@ export function useRundownCommands({ if (isOntimeGroup(selected)) { setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); - return; + return selected.id; } newCursor = selected?.id ?? null; } if (newCursor === null) { - return; + return null; } // otherwise we select the next or previous @@ -112,7 +138,9 @@ export function useRundownCommands({ if (selected.entry !== null && selected.index !== null) { setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); + return selected.entry.id; } + return null; }, [order, entries, setSelectedEvents], ); @@ -123,7 +151,7 @@ export function useRundownCommands({ const selectEntry = useCallback( (cursor: EntryId | null, direction: 'up' | 'down') => { if (order.length < 1) { - return; + return null; } if (cursor === null) { @@ -131,8 +159,9 @@ export function useRundownCommands({ 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 selected.id; } - return; + return null; } // otherwise we select the next or previous @@ -141,7 +170,9 @@ export function useRundownCommands({ if (selected.entry !== null && selected.index !== null) { setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); + return selected.entry.id; } + return null; }, [order, entries, setSelectedEvents], ); @@ -161,12 +192,90 @@ export function useRundownCommands({ [handleCollapseGroup, move], ); + const cloneEntry = useCallback( + (cursor: EntryId | null) => { + if (!cursor) { + return; + } + clone(cursor, { after: cursor }); + }, + [clone], + ); + + const selectEdge = useCallback( + (direction: 'top' | 'bottom') => { + if (order.length < 1) { + return null; + } + + const selected = direction === 'top' ? getFirstNormal(entries, order) : getLastNormal(entries, order); + if (!selected) { + return null; + } + + const index = order.indexOf(selected.id); + if (index === -1) { + return null; + } + + setSelectedEvents({ id: selected.id, selectMode: 'click', index }); + return selected.id; + }, + [entries, order, setSelectedEvents], + ); + + const selectPage = useCallback( + (cursor: EntryId | null, direction: 'up' | 'down') => { + if (order.length < 1) { + return null; + } + + if (cursor === null) { + const selected = direction === 'down' ? getFirstNormal(entries, order) : getLastNormal(entries, order); + if (!selected) { + return null; + } + const index = order.indexOf(selected.id); + if (index !== -1) { + setSelectedEvents({ id: selected.id, selectMode: 'click', index }); + return selected.id; + } + return null; + } + + let nextCursor = cursor; + let target: { entry: OntimeEntry | null; index: number | null } | null = null; + + for (let step = 0; step < PAGE_SIZE; step += 1) { + const next = + direction === 'down' + ? getNextNormal(entries, order, nextCursor) + : getPreviousNormal(entries, order, nextCursor); + if (next.entry === null || next.index === null) { + break; + } + target = next; + nextCursor = next.entry.id; + } + + if (target?.entry && target.index !== null) { + setSelectedEvents({ id: target.entry.id, selectMode: 'click', index: target.index }); + return target.entry.id; + } + return null; + }, + [entries, order, setSelectedEvents], + ); + return { + cloneEntry, deleteAtCursor, insertCopyAtId, insertAtId, selectGroup, selectEntry, moveEntry, + selectEdge, + selectPage, }; } diff --git a/apps/client/src/features/rundown/hooks/useRundownKeyboard.ts b/apps/client/src/features/rundown/hooks/useRundownKeyboard.ts index efca596fc..13e2648a5 100644 --- a/apps/client/src/features/rundown/hooks/useRundownKeyboard.ts +++ b/apps/client/src/features/rundown/hooks/useRundownKeyboard.ts @@ -1,18 +1,38 @@ import { useHotkeys } from '@mantine/hooks'; import { type OntimeEntry, EntryId, SupportedEntry } from 'ontime-types'; +import { useEntryCopy } from '../../../common/stores/entryCopyStore'; +import { useEventSelection } from '../useEventSelection'; + interface UseRundownKeyboardOptions { cursor: EntryId | null; commands: { - selectEntry: (cursor: EntryId | null, direction: 'up' | 'down') => void; - selectGroup: (cursor: EntryId | null, direction: 'up' | 'down') => void; + selectEntry: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null; + selectGroup: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null; + selectEdge: (direction: 'top' | 'bottom') => EntryId | null; + selectPage: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null; + cloneEntry: (cursor: EntryId | null) => 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; + setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void; +} + +/** + * Returns true when a keyboard event target is a text input element. + * Use this to avoid intercepting browser-native copy/cut/paste shortcuts + * while users are typing in form fields. + */ +function isEditableElement(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + + const tagName = target.tagName.toLowerCase(); + return tagName === 'input' || tagName === 'textarea'; } export function useRundownKeyboard({ @@ -21,18 +41,89 @@ export function useRundownKeyboard({ clearSelectedEvents, setEntryCopyId, }: UseRundownKeyboardOptions) { + const scrollToEntry = useEventSelection((state) => state.scrollToEntry); + useHotkeys([ - ['alt + ArrowDown', () => commands.selectEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }], - ['alt + ArrowUp', () => commands.selectEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }], + [ + 'alt + ArrowDown', + () => { + const nextId = commands.selectEntry(cursor, 'down'); + if (nextId) { + scrollToEntry(nextId); + } + }, + { preventDefault: true, usePhysicalKeys: true }, + ], + [ + 'alt + ArrowUp', + () => { + const nextId = commands.selectEntry(cursor, 'up'); + if (nextId) { + scrollToEntry(nextId); + } + }, + { preventDefault: true, usePhysicalKeys: true }, + ], [ 'alt + shift + ArrowDown', - () => commands.selectGroup(cursor, 'down'), + () => { + const nextId = commands.selectGroup(cursor, 'down'); + if (nextId) { + scrollToEntry(nextId); + } + }, { preventDefault: true, usePhysicalKeys: true }, ], [ 'alt + shift + ArrowUp', - () => commands.selectGroup(cursor, 'up'), + () => { + const nextId = commands.selectGroup(cursor, 'up'); + if (nextId) { + scrollToEntry(nextId); + } + }, + { preventDefault: true, usePhysicalKeys: true }, + ], + + [ + 'Home', + () => { + const nextId = commands.selectEdge('top'); + if (nextId) { + scrollToEntry(nextId); + } + }, + { preventDefault: true, usePhysicalKeys: true }, + ], + [ + 'End', + () => { + const nextId = commands.selectEdge('bottom'); + if (nextId) { + scrollToEntry(nextId); + } + }, + { preventDefault: true, usePhysicalKeys: true }, + ], + [ + 'PageUp', + () => { + const nextId = commands.selectPage(cursor, 'up'); + if (nextId) { + scrollToEntry(nextId); + } + }, + { preventDefault: true, usePhysicalKeys: true }, + ], + [ + 'PageDown', + () => { + const nextId = commands.selectPage(cursor, 'down'); + if (nextId) { + scrollToEntry(nextId); + } + }, { preventDefault: true, usePhysicalKeys: true }, ], @@ -43,9 +134,16 @@ export function useRundownKeyboard({ ], ['alt + mod + ArrowUp', () => commands.moveEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }], - ['Escape', () => clearSelectedEvents(), { preventDefault: true, usePhysicalKeys: true }], + [ + 'Escape', + () => { + clearSelectedEvents(); + setEntryCopyId(null); + }, + { preventDefault: true, usePhysicalKeys: true }, + ], - ['mod + Backspace', () => commands.deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }], + ['alt + Backspace', () => commands.deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }], [ 'alt + E', @@ -91,10 +189,50 @@ export function useRundownKeyboard({ { 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 }], + [ + 'mod + C', + (event) => { + if (cursor === null || isEditableElement(event.target)) { + return; + } + event.preventDefault(); + setEntryCopyId(cursor); + }, + { usePhysicalKeys: true }, + ], + [ + 'mod + X', + (event) => { + if (cursor === null || isEditableElement(event.target)) { + return; + } + event.preventDefault(); + setEntryCopyId(cursor, 'cut'); + }, + { usePhysicalKeys: true }, + ], + [ + 'mod + V', + (event) => { + if (isEditableElement(event.target) || useEntryCopy.getState().entryCopyId === null) { + return; + } + event.preventDefault(); + commands.insertCopyAtId(cursor); + }, + { usePhysicalKeys: true }, + ], + ['mod + D', () => commands.cloneEntry(cursor), { preventDefault: true, usePhysicalKeys: true }], + [ + 'mod + shift + V', + (event) => { + if (isEditableElement(event.target) || useEntryCopy.getState().entryCopyId === null) { + return; + } + event.preventDefault(); + commands.insertCopyAtId(cursor, true); + }, + { usePhysicalKeys: true }, + ], ]); } diff --git a/apps/client/src/features/rundown/placements/FinderPlacement.tsx b/apps/client/src/features/rundown/placements/FinderPlacement.tsx index dc082a88b..a03ab9679 100644 --- a/apps/client/src/features/rundown/placements/FinderPlacement.tsx +++ b/apps/client/src/features/rundown/placements/FinderPlacement.tsx @@ -9,8 +9,8 @@ function FinderPlacement() { const [isOpen, handler] = useDisclosure(); useHotkeys([ - ['mod + f', handler.toggle], - ['Escape', handler.close], + ['mod + f', handler.toggle, { preventDefault: true }], + ['Escape', handler.close, { preventDefault: true }], ]); if (isOpen) { diff --git a/apps/client/src/features/rundown/rundown-delay/RundownDelay.module.scss b/apps/client/src/features/rundown/rundown-delay/RundownDelay.module.scss index d38aacdd0..3ea540f08 100644 --- a/apps/client/src/features/rundown/rundown-delay/RundownDelay.module.scss +++ b/apps/client/src/features/rundown/rundown-delay/RundownDelay.module.scss @@ -16,6 +16,11 @@ &.hasCursor { outline: 1px solid $block-cursor-color; } + + &.copyTarget { + outline: 1px dashed $blue-500; + outline-offset: -2px; + } } .drag { diff --git a/apps/client/src/features/rundown/rundown-delay/RundownDelay.tsx b/apps/client/src/features/rundown/rundown-delay/RundownDelay.tsx index b3bc8f67d..a1603d910 100644 --- a/apps/client/src/features/rundown/rundown-delay/RundownDelay.tsx +++ b/apps/client/src/features/rundown/rundown-delay/RundownDelay.tsx @@ -5,9 +5,11 @@ import { CSS } from '@dnd-kit/utilities'; import { OntimeDelay } from 'ontime-types'; import Button from '../../../common/components/buttons/Button'; -import DelayInput from './DelayInput'; -import { cx } from '../../../common/utils/styleUtils'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; +import { useEntryCopy } from '../../../common/stores/entryCopyStore'; +import { cx } from '../../../common/utils/styleUtils'; + +import DelayInput from './DelayInput'; import style from './RundownDelay.module.scss'; @@ -21,6 +23,7 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) { const { applyDelay, deleteEntry } = useEntryActionsContext(); const handleRef = useRef(null); + const entryCopyId = useEntryCopy((state) => state.entryCopyId); const { attributes: dragAttributes, @@ -59,7 +62,7 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) { return (
state.clearSelectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents); + const entryCopyId = useEntryCopy((state) => state.entryCopyId); const handleRef = useRef(null); @@ -143,6 +146,7 @@ export default function RundownEvent({ type: 'item', label: 'Delete', icon: IoTrash, + shortcut: `${deviceMod}+Del`, onClick: () => { clearSelectedEvents(); deleteEntry(Array.from(selectedEvents)); @@ -180,6 +184,7 @@ export default function RundownEvent({ type: 'item', label: 'Clone', icon: IoDuplicateOutline, + shortcut: `${deviceMod}+D`, onClick: () => clone(eventId, { after: eventId }), }, { type: 'divider' }, @@ -187,6 +192,7 @@ export default function RundownEvent({ type: 'item', label: 'Delete', icon: IoTrash, + shortcut: `${deviceMod}+Del`, onClick: () => { deleteEntry([eventId]); unselect(eventId); @@ -237,12 +243,13 @@ export default function RundownEvent({ const blockClasses = cx([ style.rundownEvent, - skip ? style.skip : null, - isPast ? style.past : null, - loaded ? style.loaded : null, - playback ? style[playback] : null, - isSelected ? style.selected : null, - hasCursor ? style.hasCursor : null, + skip && style.skip, + isPast && style.past, + loaded && style.loaded, + playback && style[playback], + isSelected && style.selected, + hasCursor && style.hasCursor, + entryCopyId === eventId && style.copyTarget, ]); const handleFocusClick = (event: MouseEvent) => { diff --git a/apps/client/src/features/rundown/rundown-group/RundownGroup.module.scss b/apps/client/src/features/rundown/rundown-group/RundownGroup.module.scss index 304b7788b..6b99d62f6 100644 --- a/apps/client/src/features/rundown/rundown-group/RundownGroup.module.scss +++ b/apps/client/src/features/rundown/rundown-group/RundownGroup.module.scss @@ -13,6 +13,10 @@ outline: 1px solid $block-cursor-color; } + &.copyTarget { + outline: 2px dashed $block-cursor-color; + } + &.expanded { margin-block: 0.5rem 0; border-radius: $block-border-radius $block-border-radius 0 0; diff --git a/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx b/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx index e272a0320..0e3980168 100644 --- a/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx +++ b/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx @@ -13,12 +13,14 @@ import { EntryId, OntimeGroup } from 'ontime-types'; import { MILLIS_PER_MINUTE, millisToString } from 'ontime-utils'; import IconButton from '../../../common/components/buttons/IconButton'; +import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useContextMenu } from '../../../common/hooks/useContextMenu'; +import { useEntryCopy } from '../../../common/stores/entryCopyStore'; +import { deviceMod } from '../../../common/utils/deviceUtils'; import { getOffsetState } from '../../../common/utils/offset'; import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils'; import { formatDuration } from '../../../common/utils/time'; import TitleEditor from '../common/TitleEditor'; -import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { canDrop } from '../rundown.utils'; import { useEventSelection } from '../useEventSelection'; @@ -40,12 +42,14 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }: const setSingleEntrySelection = useEventSelection((state) => state.setSingleEntrySelection); const selectedEvents = useEventSelection((state) => state.selectedEvents); + const entryCopyId = useEntryCopy((state) => state.entryCopyId); const [onContextMenu] = useContextMenu(() => [ { type: 'item', label: 'Clone Group', icon: IoDuplicateOutline, + shortcut: `${deviceMod}+D`, onClick: () => clone(data.id), }, { @@ -60,6 +64,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }: type: 'item', label: 'Delete Group', icon: IoTrash, + shortcut: `${deviceMod}+Del`, onClick: () => deleteEntry([data.id]), }, ]); @@ -123,7 +128,12 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }: return (
state.selectedEvents); const setSingleEntrySelection = useEventSelection((state) => state.setSingleEntrySelection); + const entryCopyId = useEntryCopy((state) => state.entryCopyId); const [onContextMenu] = useContextMenu(() => [ { type: 'item', label: 'Delete', icon: IoTrash, + shortcut: `${deviceMod}+Del`, onClick: () => deleteEntry([entryId]), }, ]); @@ -82,7 +86,11 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl return (
void) | null; + scrollHandlerSource: string | null; setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void; setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void; clearSelectedEvents: () => void; clearMultiSelect: () => void; unselect: (id: EntryId) => void; + setScrollHandler: (source: string, handler: ((id: EntryId) => void) | null) => void; + scrollToEntry: (id: EntryId) => void; } export const useEventSelection = create()((set, get) => ({ @@ -25,6 +29,8 @@ export const useEventSelection = create()((set, get) => ({ anchoredIndex: null, cursor: null, entryMode: null, + scrollHandler: null, + scrollHandlerSource: null, setSingleEntrySelection: ({ id }) => { set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' }); }, @@ -99,8 +105,7 @@ export const useEventSelection = create()((set, get) => ({ }); } }, - clearSelectedEvents: () => - set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }), + clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }), clearMultiSelect: () => { const { selectedEvents } = get(); const [firstSelected] = selectedEvents; @@ -118,6 +123,22 @@ export const useEventSelection = create()((set, get) => ({ entryMode: selectedEvents.size === 0 ? null : entryMode, }); }, + setScrollHandler: (source, handler) => + set((state) => { + if (handler) { + return { scrollHandler: handler, scrollHandlerSource: source }; + } + if (state.scrollHandlerSource !== source) { + return state; + } + return { scrollHandler: null, scrollHandlerSource: null }; + }), + scrollToEntry: (id: EntryId) => { + const handler = get().scrollHandler; + if (handler) { + handler(id); + } + }, })); export function getSelectionMode(event: MouseEvent): SelectionMode { diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx index e96e2a2f5..7aed9bcf6 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx @@ -45,6 +45,8 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues const selectedEventId = useSelectedEventId(); const cursor = useEventSelection((state) => state.cursor); + const scrollToEntry = useEventSelection((state) => state.scrollToEntry); + const setScrollHandler = useEventSelection((state) => state.setScrollHandler); const virtuosoRef = useRef(null); const { listeners } = useTableNav(); @@ -114,24 +116,34 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues setColumnSizing({}); }, [setColumnSizing]); - // Follow selection changes depending on mode + // Auto-scroll only in run mode, routed through the shared scroll handler useEffect(() => { - if (virtuosoRef.current === null) { + if (cuesheetMode !== AppMode.Run || !selectedEventId) { return; } - const targetId = cuesheetMode === AppMode.Run ? selectedEventId : cursor ?? selectedEventId; - if (!targetId) { - return; - } + scrollToEntry(selectedEventId); + }, [cuesheetMode, data, selectedEventId, scrollToEntry]); - const eventIndex = data.findIndex((event) => event.id === targetId); - if (eventIndex === -1) { - return; - } + // Provide an imperative scroll handler for explicit jumps (finder/keyboard) + useEffect(() => { + setScrollHandler(`cuesheet-table-${tableRoot}`, (entryId) => { + if (virtuosoRef.current === null) { + return; + } - virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'smooth', align: 'start', offset: -50 }); - }, [cuesheetMode, data, selectedEventId, cursor]); + const eventIndex = data.findIndex((event) => event.id === entryId); + if (eventIndex === -1) { + return; + } + + virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'smooth', align: 'start', offset: -50 }); + }); + + return () => { + setScrollHandler(`cuesheet-table-${tableRoot}`, null); + }; + }, [data, setScrollHandler, tableRoot]); /** * To improve performance on resizing, we memoise the column sizes diff --git a/apps/client/src/views/editor/finder/useFinder.tsx b/apps/client/src/views/editor/finder/useFinder.tsx index 764e4b656..a3bff61cf 100644 --- a/apps/client/src/views/editor/finder/useFinder.tsx +++ b/apps/client/src/views/editor/finder/useFinder.tsx @@ -45,6 +45,7 @@ export default function useFinder() { const lastSearchString = useRef(''); const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents); + const scrollToEntry = useEventSelection((state) => state.scrollToEntry); const [collapsedGroups, setCollapsedGroups] = useSessionStorage({ // we ensure that this is unique to the rundown @@ -234,8 +235,9 @@ export default function useFinder() { // Then select the event setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' }); + scrollToEntry(selectedEvent.id); }, - [collapsedGroups, setCollapsedGroups, setSelectedEvents], + [collapsedGroups, setCollapsedGroups, setSelectedEvents, scrollToEntry], ); /** clear results when source data changes */ diff --git a/docs/keyboard-shortcuts-plan.md b/docs/keyboard-shortcuts-plan.md deleted file mode 100644 index 5d1464dad..000000000 --- a/docs/keyboard-shortcuts-plan.md +++ /dev/null @@ -1,165 +0,0 @@ -# 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: () => { /* ... */ }, -} -``` diff --git a/e2e/tests/features/209-rundown-shortcuts.spec.ts b/e2e/tests/features/209-rundown-shortcuts.spec.ts index a32c6f3a8..d0e261384 100644 --- a/e2e/tests/features/209-rundown-shortcuts.spec.ts +++ b/e2e/tests/features/209-rundown-shortcuts.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from '@playwright/test'; test('Copy-paste', async ({ page }) => { await page.goto('http://localhost:4001/rundown'); + await page.getByRole('button', { name: 'Edit' }).click(); // clear rundown await page.getByRole('button', { name: 'Rundown menu' }).click(); @@ -21,19 +22,50 @@ test('Copy-paste', async ({ page }) => { // copy paste below await page.getByTestId('rundown-event').locator('div').filter({ hasText: '4' }).click(); - await page.getByTestId('rundown-event').locator('div').filter({ hasText: '4' }).press('Control+c'); - await page.getByTestId('rundown-event').locator('div').filter({ hasText: '4' }).press('Control+v'); + await page.getByTestId('rundown-event').locator('div').filter({ hasText: '4' }).press('ControlOrMeta+c'); + await page.getByTestId('rundown-event').locator('div').filter({ hasText: '4' }).press('ControlOrMeta+v'); // assert await expect(page.getByTestId('entry-2')).toBeVisible(); await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('test'); await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('4'); +}); - //TODO: reintroduce the past above test +test('Cut-paste', async ({ page }) => { + await page.goto('http://localhost:4001/rundown'); + await page.getByRole('button', { name: 'Edit' }).click(); + + // clear rundown + await page.getByRole('button', { name: 'Rundown menu' }).click(); + await page.getByRole('menuitem', { name: 'Clear all' }).click(); + await page.getByRole('button', { name: 'Delete all' }).click(); + + // create events + await page.getByRole('button', { name: 'Create Event' }).click(); + await page.getByTestId('entry-1').getByTestId('entry__title').click(); + await page.getByTestId('entry-1').getByTestId('entry__title').fill('first'); + await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter'); + + await page.getByRole('button', { name: 'Event' }).nth(4).click(); + await page.getByTestId('entry-2').getByTestId('entry__title').click(); + await page.getByTestId('entry-2').getByTestId('entry__title').fill('second'); + await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter'); + + // cut first event, paste below second + await page.getByTestId('entry-1').getByTestId('rundown-event').getByText('1').click(); + await page.getByTestId('entry-1').getByTestId('rundown-event').filter({ hasText: '1' }).press('ControlOrMeta+x'); + await page.getByTestId('entry-2').getByTestId('rundown-event').getByText('2').click(); + await page.getByTestId('entry-2').getByTestId('rundown-event').filter({ hasText: '2' }).press('ControlOrMeta+v'); + + // we can verify that the entries have swapped places + const events = await page.getByTestId('entry__title').all(); + await expect(events[0]).toHaveValue('second'); + await expect(events[1]).toHaveValue('first'); }); test('Move', async ({ page }) => { await page.goto('http://localhost:4001/rundown'); + await page.getByRole('button', { name: 'Edit' }).click(); // clear rundown await page.getByRole('button', { name: 'Rundown menu' }).click(); @@ -56,13 +88,22 @@ test('Move', async ({ page }) => { // copy move up await page.getByTestId('entry-3').getByTestId('rundown-event').getByText('3').click(); - await page.getByTestId('entry-3').getByTestId('rundown-event').filter({ hasText: '3' }).press('Alt+Control+ArrowUp'); - await page.getByTestId('entry-3').getByTestId('rundown-event').filter({ hasText: '3' }).press('Alt+Control+ArrowUp'); + await page + .getByTestId('entry-3') + .getByTestId('rundown-event') + .filter({ hasText: '3' }) + .press('Alt+ControlOrMeta+ArrowUp'); + await page + .getByTestId('entry-3') + .getByTestId('rundown-event') + .filter({ hasText: '3' }) + .press('Alt+ControlOrMeta+ArrowUp'); await expect(page.getByTestId('entry-1').getByTestId('rundown-event')).toContainText('3'); }); test('Add group', async ({ page }) => { await page.goto('http://localhost:4001/rundown'); + await page.getByRole('button', { name: 'Edit' }).click(); // clear rundown await page.getByRole('button', { name: 'Rundown menu' }).click(); @@ -97,6 +138,7 @@ test('Add group', async ({ page }) => { test('Add delay', async ({ page }) => { await page.goto('http://localhost:4001/rundown'); + await page.getByRole('button', { name: 'Edit' }).click(); // clear rundown await page.getByRole('button', { name: 'Rundown menu' }).click(); @@ -105,20 +147,20 @@ test('Add delay', async ({ page }) => { await expect(page.getByTestId('rundown-event')).toHaveCount(0); await expect(page.getByTestId('rundown-delay')).toHaveCount(0); - //create events + // create events await page.getByRole('button', { name: 'Create Event' }).click(); await expect(page.getByTestId('rundown-event')).toHaveCount(1); await expect(page.getByTestId('rundown-delay')).toHaveCount(0); await page.getByTestId('entry-1').click(); await page.getByTestId('entry__title').press('Escape'); - //add delay below + // add delay below await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+D'); await expect(page.getByTestId('rundown-event')).toHaveCount(1); await expect(page.getByTestId('rundown-delay')).toHaveCount(1); await expect(page.getByTestId('delay-input')).toBeVisible(); - //add delay above + // add delay above await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+Shift+D'); await expect(page.getByTestId('rundown-event')).toHaveCount(1); await expect(page.getByTestId('rundown-delay')).toHaveCount(2); @@ -127,6 +169,7 @@ test('Add delay', async ({ page }) => { test('Add event', async ({ page }) => { await page.goto('http://localhost:4001/rundown'); + await page.getByRole('button', { name: 'Edit' }).click(); // clear rundown await page.getByRole('button', { name: 'Rundown menu' }).click(); @@ -134,18 +177,18 @@ test('Add event', async ({ page }) => { await page.getByRole('button', { name: 'Delete all' }).click(); await expect(page.getByTestId('rundown-event')).toHaveCount(0); - //create events + // create events await page.getByRole('button', { name: 'Create Event' }).click(); await expect(page.getByTestId('rundown-event')).toHaveCount(1); await page.getByTestId('entry-1').click(); await page.getByTestId('entry__title').press('Escape'); - //add event below + // add event below await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+E'); await expect(page.getByTestId('rundown-event')).toHaveCount(2); await expect(page.getByTestId('entry-2').getByTestId('rundown-event').getByText('2')).toBeVisible(); - //add event above + // add event above await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+Shift+E'); await expect(page.getByTestId('rundown-event')).toHaveCount(3); await expect(page.getByTestId('entry-1').getByTestId('rundown-event')).toContainText('0.1'); @@ -153,6 +196,7 @@ test('Add event', async ({ page }) => { test('Delete event', async ({ page }) => { await page.goto('http://localhost:4001/rundown'); + await page.getByRole('button', { name: 'Edit' }).click(); // clear rundown await page.goto('http://localhost:4001/rundown'); @@ -161,14 +205,36 @@ test('Delete event', async ({ page }) => { await page.getByRole('button', { name: 'Delete all' }).click(); await expect(page.getByTestId('rundown-event')).toHaveCount(0); - //create event + // create event await page.getByRole('button', { name: 'Create Event' }).click(); await expect(page.getByTestId('rundown-event')).toHaveCount(1); - //delete event + // delete event await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).click(); await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+Backspace'); await expect(page.getByTestId('rundown-event')).toHaveCount(0); await expect(page.getByRole('button', { name: 'Create Event' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Create Group' })).toBeVisible(); }); + +test('Find in rundown', async ({ page }) => { + await page.goto('http://localhost:4001/rundown'); + await expect(page.getByTestId('panel-rundown')).toBeVisible(); + + await page.keyboard.press('ControlOrMeta+f'); + await expect(page.getByPlaceholder('Search...')).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.getByPlaceholder('Search...')).toBeHidden(); +}); + +test('Open settings', async ({ page }) => { + await page.goto('http://localhost:4001/editor'); + await expect(page.getByTestId('editor-container')).toBeVisible(); + + await page.keyboard.press('ControlOrMeta+,'); + await expect(page.getByRole('button', { name: 'Close settings' })).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.getByRole('button', { name: 'Close settings' })).toBeHidden(); +}); diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 285d67923..37d472706 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -12,9 +12,11 @@ export { getFirstEvent, getFirstEventNormal, getFirstNormal, + getFirstGroupNormal, getLastEvent, getLastEventNormal, getLastNormal, + getLastGroupNormal, getNext, getNextGroupNormal, getNextEvent, diff --git a/packages/utils/src/rundown-utils/rundownUtils.test.ts b/packages/utils/src/rundown-utils/rundownUtils.test.ts index 49665550b..44f9ffc3f 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.test.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.test.ts @@ -4,13 +4,20 @@ import { SupportedEntry } from 'ontime-types'; import { getLastEvent, getLastNormal, + getLastGroupNormal, getNext, getNextEvent, + getNextGroupNormal, + getNextNormal, + getFirstGroupNormal, getPrevious, getPreviousEvent, getPreviousGroup, + getPreviousGroupNormal, + getPreviousNormal, swapEventData, } from './rundownUtils'; +import { demoDb } from '../../../../apps/server/src/models/demoProject'; describe('getNext()', () => { it('returns the next event of type event', () => { @@ -241,6 +248,72 @@ describe('swapEventData', () => { }); }); +describe('getNextNormal / getPreviousNormal (flat order)', () => { + const demoRundown = demoDb.rundowns.default; + + it('steps forward using flat order', () => { + const { entry, index } = getNextNormal(demoRundown.entries, demoRundown.flatOrder, '7eaf99'); + expect(entry?.id).toBe('9bf60f'); + expect(index).toBe(2); + }); + + it('steps backward using flat order', () => { + const { entry, index } = getPreviousNormal(demoRundown.entries, demoRundown.flatOrder, '9bf60f'); + expect(entry?.id).toBe('7eaf99'); + expect(index).toBe(1); + }); + + it('uses start/end boundaries for null cursor', () => { + const next = getNextNormal(demoRundown.entries, demoRundown.flatOrder, null); + const previous = getPreviousNormal(demoRundown.entries, demoRundown.flatOrder, null); + expect(next.entry?.id).toBe('e2163f'); + expect(next.index).toBe(0); + expect(previous.entry?.id).toBe('07df89'); + expect(previous.index).toBe(demoRundown.flatOrder.length - 1); + }); +}); + +describe('getNextGroupNormal / getPreviousGroupNormal (flat order)', () => { + const demoRundown = demoDb.rundowns.default; + + it('finds the next group from inside a group', () => { + const { entry, index } = getNextGroupNormal(demoRundown.entries, demoRundown.flatOrder, '9bf60f'); + expect(entry?.id).toBe('f60403'); + expect(index).toBe(7); + }); + + it('finds the previous group from inside a group', () => { + const { entry, index } = getPreviousGroupNormal(demoRundown.entries, demoRundown.flatOrder, '9bf60f'); + expect(entry?.id).toBe('7eaf99'); + expect(index).toBe(1); + }); + + it('uses start/end boundaries for null cursor', () => { + const next = getNextGroupNormal(demoRundown.entries, demoRundown.flatOrder, null); + const previous = getPreviousGroupNormal(demoRundown.entries, demoRundown.flatOrder, null); + expect(next.entry?.id).toBe('7eaf99'); + expect(next.index).toBe(1); + expect(previous.entry?.id).toBe('6b0edb'); + expect(previous.index).toBe(9); + }); +}); + +describe('getFirstGroupNormal / getLastGroupNormal (flat order)', () => { + const demoRundown = demoDb.rundowns.default; + + it('finds the first group in flat order', () => { + const { entry, index } = getFirstGroupNormal(demoRundown.entries, demoRundown.flatOrder); + expect(entry?.id).toBe('7eaf99'); + expect(index).toBe(1); + }); + + it('finds the last group in flat order', () => { + const { entry, index } = getLastGroupNormal(demoRundown.entries, demoRundown.flatOrder); + expect(entry?.id).toBe('6b0edb'); + expect(index).toBe(9); + }); +}); + describe('getLastEvent', () => { it('returns the last event of type event', () => { const testRundown: OntimeEntry[] = [ diff --git a/packages/utils/src/rundown-utils/rundownUtils.ts b/packages/utils/src/rundown-utils/rundownUtils.ts index e21cade37..e2d15454f 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.ts @@ -10,6 +10,7 @@ import type { import { isOntimeEvent, isOntimeGroup, isPlayableEvent } from 'ontime-types'; type IndexAndEntry = { entry: OntimeEntry | null; index: number | null }; +type GroupIndexAndEntry = { entry: OntimeGroup | null; index: number | null }; /** * Gets first event in a normalised rundown, if it exists @@ -94,7 +95,7 @@ export function getLastEvent(rundown: OntimeEntry[]): { */ export function getLastEventNormal( rundown: RundownEntries, - order: string[], + order: EntryId[], ): { lastEvent: OntimeEvent | null; lastIndex: number | null; @@ -118,7 +119,7 @@ export function getLastEventNormal( */ export function getNext( rundown: Pick, - currentId: string, + currentId: EntryId, ): { nextEvent: OntimeEntry | null; nextIndex: number | null } { const index = rundown.order.findIndex((entryId) => entryId === currentId); if (index !== -1 && index + 1 < rundown.order.length) { @@ -134,16 +135,20 @@ export function getNext( /** * Gets next entry in rundown, if it exists */ -export function getNextNormal(rundown: RundownEntries, order: string[], currentId: string): IndexAndEntry { - const currentIndex = order.findIndex((id) => id === currentId); - if (currentIndex !== -1 && currentIndex + 1 < order.length) { +export function getNextNormal(rundown: RundownEntries, flatOrder: EntryId[], currentId: EntryId | null): IndexAndEntry { + if (currentId === null) { + const entry = getFirstNormal(rundown, flatOrder); + return { entry, index: entry ? 0 : null }; + } + + const currentIndex = flatOrder.findIndex((id) => id === currentId); + if (currentIndex !== -1 && currentIndex + 1 < flatOrder.length) { const index = currentIndex + 1; - const nextId = order[index]; + const nextId = flatOrder[index]; const entry = rundown[nextId]; return { entry, index }; - } else { - return { entry: null, index: null }; } + return { entry: null, index: null }; } /** @@ -151,7 +156,7 @@ export function getNextNormal(rundown: RundownEntries, order: string[], currentI */ export function getNextEvent( rundown: OntimeEntry[], - currentId: string, + currentId: EntryId, ): { nextEvent: OntimeEvent | null; nextIndex: number | null } { const index = rundown.findIndex((entry) => entry.id === currentId); if (index < 0) { @@ -173,7 +178,7 @@ export function getNextEvent( export function getNextEventNormal( entries: RundownEntries, order: EntryId[], - currentId: string, + currentId: EntryId, ): { nextEvent: OntimeEvent | null; nextIndex: number | null } { const index = order.findIndex((entryId) => entryId === currentId); if (index < 0) { @@ -193,7 +198,7 @@ export function getNextEventNormal( /** * Gets previous entry in rundown, if it exists */ -export function getPrevious(rundown: Pick, currentId: string): IndexAndEntry { +export function getPrevious(rundown: Pick, currentId: EntryId): IndexAndEntry { const currentIndex = rundown.order.findIndex((entryId) => entryId === currentId); if (currentIndex > 1) { @@ -209,17 +214,52 @@ export function getPrevious(rundown: Pick, current /** * Gets previous entry in a normalised rundown, if it exists */ -export function getPreviousNormal(entries: RundownEntries, order: string[], currentId: string): IndexAndEntry { - const currentIndex = order.findIndex((id) => id === currentId); +export function getPreviousNormal( + entries: RundownEntries, + flatOrder: EntryId[], + currentId: EntryId | null, +): IndexAndEntry { + if (currentId === null) { + const entry = getLastNormal(entries, flatOrder); + return { entry, index: entry ? flatOrder.length - 1 : null }; + } + const currentIndex = flatOrder.findIndex((id) => id === currentId); if (currentIndex !== -1 && currentIndex - 1 >= 0) { const index = currentIndex - 1; - const previousId = order[index]; + const previousId = flatOrder[index]; const entry = entries[previousId]; return { entry, index }; - } else { - return { entry: null, index: null }; } + return { entry: null, index: null }; +} + +/** + * Gets first group in a normalised rundown, if it exists + */ +export function getFirstGroupNormal(entries: RundownEntries, flatOrder: EntryId[]): GroupIndexAndEntry { + for (let index = 0; index < flatOrder.length; index++) { + const id = flatOrder[index]; + const entry = entries[id]; + if (isOntimeGroup(entry)) { + return { entry, index }; + } + } + return { entry: null, index: null }; +} + +/** + * Gets last group in a normalised rundown, if it exists + */ +export function getLastGroupNormal(entries: RundownEntries, flatOrder: EntryId[]): GroupIndexAndEntry { + for (let index = flatOrder.length - 1; index >= 0; index--) { + const id = flatOrder[index]; + const entry = entries[id]; + if (isOntimeGroup(entry)) { + return { entry, index }; + } + } + return { entry: null, index: null }; } /** @@ -227,7 +267,7 @@ export function getPreviousNormal(entries: RundownEntries, order: string[], curr */ export function getPreviousEvent( rundown: Pick, - currentId: string, + currentId: EntryId, ): { previousEvent: OntimeEvent | null; previousIndex: number | null } { const index = rundown.order.findIndex((entryId) => entryId === currentId); if (index < 0) { @@ -249,7 +289,7 @@ export function getPreviousEvent( export function getPreviousEventNormal( entries: RundownEntries, order: EntryId[], - currentId: string, + currentId: EntryId, ): { previousEvent: OntimeEvent | null; previousIndex: number | null } { const index = order.findIndex((entryId) => entryId === currentId); if (index < 0) { @@ -308,18 +348,26 @@ export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): [newA: return [newA, newB]; }; -export function getEventWithId(rundown: OntimeEntry[], id: string): OntimeEntry | undefined { +export function getEventWithId(rundown: OntimeEntry[], id: EntryId): OntimeEntry | undefined { return rundown.find((event) => event.id === id); } /** * Gets relevant group element for a given ID */ -export function getPreviousGroupNormal(rundown: RundownEntries, order: string[], currentId: string): IndexAndEntry { +export function getPreviousGroupNormal( + rundown: RundownEntries, + flatOrder: EntryId[], + currentId: EntryId | null, +): IndexAndEntry { + if (currentId === null) { + return getLastGroupNormal(rundown, flatOrder); + } + let foundCurrentEvent = false; // Iterate backwards through the rundown to find the current event - for (let index = order.length - 1; index >= 0; index--) { - const id = order[index]; + for (let index = flatOrder.length - 1; index >= 0; index--) { + const id = flatOrder[index]; if (!foundCurrentEvent && id === currentId) { // set the flag when the current event is found foundCurrentEvent = true; @@ -338,11 +386,19 @@ export function getPreviousGroupNormal(rundown: RundownEntries, order: string[], /** * Gets next group element for a given ID */ -export function getNextGroupNormal(rundown: RundownEntries, order: string[], currentId: string): IndexAndEntry { +export function getNextGroupNormal( + rundown: RundownEntries, + flatOrder: EntryId[], + currentId: EntryId | null, +): IndexAndEntry { + if (currentId === null) { + return getFirstGroupNormal(rundown, flatOrder); + } + let foundCurrentEvent = false; // Iterate backwards through the rundown to find the current event - for (let index = 0; index < order.length; index++) { - const id = order[index]; + for (let index = 0; index < flatOrder.length; index++) { + const id = flatOrder[index]; if (!foundCurrentEvent && id === currentId) { // set the flag when the current event is found foundCurrentEvent = true; @@ -364,8 +420,8 @@ export function getNextGroupNormal(rundown: RundownEntries, order: string[], cur export function getPreviousGroup(rundown: Pick, currentId: EntryId): OntimeGroup | null { const currentEvent = rundown.entries[currentId]; - // check if event is inside a group - if (isOntimeEvent(currentEvent) && currentEvent.parent) { + // check if entry is inside a group + if ('parent' in currentEvent && currentEvent.parent) { return rundown.entries[currentEvent.parent] as OntimeGroup; }