feat: extend rundown shortcuts

This commit is contained in:
Carlos Valente
2026-01-16 16:45:54 +01:00
committed by Carlos Valente
parent d7af73d9ed
commit 967e92e2c8
27 changed files with 726 additions and 307 deletions
@@ -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;
@@ -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.icon />}
{item.label}
<span className={style.label}>
{item.icon && <item.icon />}
{item.label}
</span>
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
</BaseMenu.Item>
);
})}
@@ -75,8 +79,11 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos
}
return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon && <item.icon />}
{item.label}
<span className={style.label}>
{item.icon && <item.icon />}
{item.label}
</span>
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
</BaseMenu.Item>
);
})}
@@ -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<EntryCopyStore>()((set) => ({
entryCopyId: null,
setEntryCopyId: (entryCopyId: string | null) => set({ entryCopyId }),
entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyMode: mode }),
}));
+37 -33
View File
@@ -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<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(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(() => {
@@ -20,6 +20,7 @@ function RundownList() {
return (
<Rundown
order={data.order}
flatOrder={data.flatOrder}
entries={data.entries}
id={data.id}
rundownMetadata={rundownMetadata}
@@ -12,8 +12,8 @@
.shortcutSection {
flex: 1;
display: grid;
place-content: center;
margin-top: 15vh;
margin-inline: auto;
gap: 1rem;
}
@@ -23,12 +23,12 @@
border-spacing: 4rem 0;
tr {
white-space: nowrap;
td:nth-child(odd) {
text-align: left;
}
td:nth-child(even) {
text-align: right;
white-space: nowrap;
}
}
}
@@ -53,6 +53,22 @@ function EventEditorEmpty() {
<Kbd></Kbd>
</td>
</tr>
<tr>
<td>Jump to top / bottom</td>
<td>
<Kbd>Home</Kbd>
<AuxKey>/</AuxKey>
<Kbd>End</Kbd>
</td>
</tr>
<tr>
<td>Page up / down</td>
<td>
<Kbd>PgUp</Kbd>
<AuxKey>/</AuxKey>
<Kbd>PgDn</Kbd>
</td>
</tr>
<tr>
<td>Deselect entry</td>
<td>
@@ -80,6 +96,14 @@ function EventEditorEmpty() {
<Kbd>C</Kbd>
</td>
</tr>
<tr>
<td>Cut selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>X</Kbd>
</td>
</tr>
<tr>
<td>Paste above</td>
<td>
@@ -99,10 +123,18 @@ function EventEditorEmpty() {
</td>
</tr>
<tr>
<td>Delete selected entry</td>
<td>Clone selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
<tr>
<td>Delete selected entry</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Backspace</Kbd>
</td>
</tr>
@@ -140,7 +172,7 @@ function EventEditorEmpty() {
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>M</Kbd>
<Kbd>G</Kbd>
</td>
</tr>
<tr>
@@ -148,7 +180,7 @@ function EventEditorEmpty() {
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>G</Kbd>
<Kbd>M</Kbd>
</td>
</tr>
<tr>
@@ -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,
};
}
@@ -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<OntimeEntry> & { 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 },
],
]);
}
@@ -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) {
@@ -16,6 +16,11 @@
&.hasCursor {
outline: 1px solid $block-cursor-color;
}
&.copyTarget {
outline: 1px dashed $blue-500;
outline-offset: -2px;
}
}
.drag {
@@ -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 | HTMLSpanElement>(null);
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
const {
attributes: dragAttributes,
@@ -59,7 +62,7 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
return (
<div
className={cx([style.delay, hasCursor ? style.hasCursor : null])}
className={cx([style.delay, hasCursor && style.hasCursor, entryCopyId === data.id && style.copyTarget])}
ref={setNodeRef}
style={dragStyle}
data-testid='rundown-delay'
@@ -58,6 +58,10 @@ $skip-opacity: 0.2;
outline: 1px solid $block-cursor-color;
}
&.copyTarget {
outline: 2px dashed $block-cursor-color;
}
&.past:not(.skip) {
.timerNote,
.statusElements,
@@ -17,6 +17,8 @@ import { isPlaybackActive } from 'ontime-utils';
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 { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useEventIdSwapping } from '../useEventIdSwapping';
import { getSelectionMode, useEventSelection } from '../useEventSelection';
@@ -105,6 +107,7 @@ export default function RundownEvent({
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
const handleRef = useRef<null | HTMLSpanElement>(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) => {
@@ -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;
@@ -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<HTMLDivElement>(() => [
{
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 (
<div
className={cx([style.group, hasCursor && style.hasCursor, !collapsed && style.expanded])}
className={cx([
style.group,
hasCursor && style.hasCursor,
!collapsed && style.expanded,
entryCopyId === data.id && style.copyTarget,
])}
ref={setNodeRef}
onClick={handleFocusClick}
onContextMenu={onContextMenu}
@@ -17,6 +17,10 @@
&.hasCursor {
outline: 1px solid $block-cursor-color;
}
&.copyTarget {
outline: 2px dashed $block-cursor-color;
}
}
.binder {
@@ -6,9 +6,11 @@ import { EntryId } from 'ontime-types';
import Input from '../../../common/components/input/input/Input';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
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 { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useEventSelection } from '../useEventSelection';
import style from './RundownMilestone.module.scss';
@@ -29,12 +31,14 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const setSingleEntrySelection = useEventSelection((state) => state.setSingleEntrySelection);
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
{
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 (
<div
className={cx([style.milestone, hasCursor ? style.hasCursor : null])}
className={cx([
style.milestone,
hasCursor ? style.hasCursor : null,
entryCopyId === entryId ? style.copyTarget : null,
])}
ref={setNodeRef}
onClick={handleFocusClick}
onContextMenu={onContextMenu}
@@ -152,7 +152,7 @@ export function moveUp(
}
// 4. moving into the same group as previous entry
if (isOntimeEvent(previousEntry) && previousEntry.parent !== null && currentEntryParent === null) {
if ('parent' in previousEntry && previousEntry.parent !== null && currentEntryParent === null) {
return { destinationId: previousEntryId, order: 'after' };
}
@@ -227,7 +227,7 @@ export function moveDown(
}
// 5. handle moving between group and top level
const nextEntryParent = isOntimeEvent(nextEntry) ? nextEntry.parent : null;
const nextEntryParent = 'parent' in nextEntry ? nextEntry.parent : null;
if (nextEntryParent !== null && currentEntryParent === null) {
return { destinationId: nextEntryId, order: 'after' };
}
@@ -13,11 +13,15 @@ interface EventSelectionStore {
anchoredIndex: MaybeNumber;
cursor: EntryId | null;
entryMode: 'event' | 'single' | null;
scrollHandler: ((id: EntryId) => 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<EventSelectionStore>()((set, get) => ({
@@ -25,6 +29,8 @@ export const useEventSelection = create<EventSelectionStore>()((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<EventSelectionStore>()((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<EventSelectionStore>()((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 {
@@ -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<TableVirtuosoHandle | null>(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
@@ -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<EntryId[]>({
// 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 */
-165
View File
@@ -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 `<table>` with `div` based grid.
* **Visuals**: Use subtle headers for groups (Navigation, Editing, System).
* **Refinement**: Ensure `<Kbd>` components use a flat, minimal design.
### 2. Global Shortcuts Dialog
* **Decision**: Implement a Global Shortcuts Dialog triggered by `?` (Shift + /).
* **Rationale**: Users lose the `EventEditorEmpty` reference once they add content. A persistent dialog ensures "recognition over recall".
### 3. Context Menu Implementation Guide
We will enhance the context menu to display shortcuts inline.
**A. Update Type Definition**
Modify `apps/client/src/common/components/dropdown-menu/DropdownMenu.tsx`:
```typescript
type DropdownMenuItem = {
// ... existing fields
shortcut?: string; // New field
};
```
**B. Update Component Rendering**
In `apps/client/src/common/components/dropdown-menu/DropdownMenu.tsx`, update the render loop to display the shortcut:
```tsx
<BaseMenu.Item className={style.item}>
<span className={style.labelContainer}>
{item.icon && <item.icon />}
{item.label}
</span>
{item.type === 'item' && item.shortcut && (
<span className={style.shortcut}>{item.shortcut}</span>
)}
</BaseMenu.Item>
```
*Note*: Update `DropdownMenu.module.scss` to use `justify-content: space-between` on the item.
**C. Update Usage in `RundownEvent.tsx`**
Add shortcuts to the context menu options:
```tsx
{
type: 'item',
label: 'Clone',
icon: IoDuplicateOutline,
shortcut: `${deviceMod}+D`,
onClick: () => clone(eventId, { after: eventId }),
},
{
type: 'item',
label: 'Delete',
icon: IoTrash,
shortcut: 'Del',
onClick: () => { /* ... */ },
}
```
@@ -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();
});
+2
View File
@@ -12,9 +12,11 @@ export {
getFirstEvent,
getFirstEventNormal,
getFirstNormal,
getFirstGroupNormal,
getLastEvent,
getLastEventNormal,
getLastNormal,
getLastGroupNormal,
getNext,
getNextGroupNormal,
getNextEvent,
@@ -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[] = [
@@ -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<Rundown, 'entries' | 'order'>,
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<Rundown, 'entries' | 'order'>, currentId: string): IndexAndEntry {
export function getPrevious(rundown: Pick<Rundown, 'entries' | 'order'>, currentId: EntryId): IndexAndEntry {
const currentIndex = rundown.order.findIndex((entryId) => entryId === currentId);
if (currentIndex > 1) {
@@ -209,17 +214,52 @@ export function getPrevious(rundown: Pick<Rundown, 'entries' | 'order'>, 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<Rundown, 'entries' | 'order'>,
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<Rundown, 'entries' | 'order'>, 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;
}