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;
}