diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index c3f0eee53..04745544d 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -2,8 +2,15 @@ import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react' import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { useHotkeys } from '@mantine/hooks'; -import { isOntimeEvent, MaybeNumber, Playback, RundownCached, SupportedEvent } from 'ontime-types'; -import { getFirstNormal, getLastNormal, getNextNormal, getPreviousNormal } from 'ontime-utils'; +import { isOntimeBlock, isOntimeEvent, MaybeNumber, Playback, RundownCached, SupportedEvent } from 'ontime-types'; +import { + getFirstNormal, + getLastNormal, + getNextBlockNormal, + getNextNormal, + getPreviousBlockNormal, + getPreviousNormal, +} from 'ontime-utils'; import { useEventAction } from '../../common/hooks/useEventAction'; import useFollowComponent from '../../common/hooks/useFollowComponent'; @@ -98,28 +105,61 @@ export default function Rundown({ data }: RundownProps) { [rundown, order, addEvent], ); + const selectBlock = useCallback( + (cursor: string | null, direction: 'up' | 'down') => { + if (order.length < 1) { + return; + } + let newCursor = cursor; + if (cursor === null) { + // there is no cursor, we select the first or last depending on direction + const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order); + + if (isOntimeBlock(selected)) { + setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); + return; + } + newCursor = selected?.id ?? null; + } + + if (newCursor === null) { + return; + } + + // otherwise we select the next or previous + const selected = + direction === 'up' + ? getPreviousBlockNormal(rundown, order, newCursor) + : getNextBlockNormal(rundown, order, newCursor); + + if (selected.entry !== null && selected.index !== null) { + setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); + } + }, + [order, rundown, setSelectedEvents], + ); + const selectEntry = useCallback( (cursor: string | null, direction: 'up' | 'down') => { if (order.length < 1) { return; } - let newCursor: string | null; - let newIndex: number | null; + if (cursor === null) { // there is no cursor, we select the first or last depending on direction if it exists - newCursor = - (direction === 'up' ? getLastNormal(rundown, order)?.id : getFirstNormal(rundown, order)?.id) ?? null; - newIndex = direction === 'up' ? order.length : 0; - } else { - // otherwise we select the next or previous - const selected = - direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor); - newCursor = selected.entry?.id ?? null; - newIndex = selected.index; + const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order); + if (selected !== null) { + setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); + } + return; } - if (newCursor && newIndex !== null) { - setSelectedEvents({ id: newCursor, selectMode: 'click', index: newIndex }); + // otherwise we select the next or previous + const selected = + direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor); + + if (selected.entry !== null && selected.index !== null) { + setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); } }, [order, rundown, setSelectedEvents], @@ -145,6 +185,10 @@ export default function Rundown({ data }: RundownProps) { useHotkeys([ ['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true }], ['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true }], + + ['alt + shift + ArrowDown', () => selectBlock(cursor, 'down'), { preventDefault: true }], + ['alt + shift + ArrowUp', () => selectBlock(cursor, 'up'), { preventDefault: true }], + ['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }], ['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }], diff --git a/apps/client/src/features/rundown/event-editor/EventEditorEmpty.module.scss b/apps/client/src/features/rundown/event-editor/EventEditorEmpty.module.scss index 4fdcb8fb2..9f443ccc9 100644 --- a/apps/client/src/features/rundown/event-editor/EventEditorEmpty.module.scss +++ b/apps/client/src/features/rundown/event-editor/EventEditorEmpty.module.scss @@ -28,6 +28,7 @@ } td:nth-child(even) { text-align: right; + white-space: nowrap; } } } diff --git a/apps/client/src/features/rundown/event-editor/EventEditorEmpty.tsx b/apps/client/src/features/rundown/event-editor/EventEditorEmpty.tsx index c6dbc762b..7905f278e 100644 --- a/apps/client/src/features/rundown/event-editor/EventEditorEmpty.tsx +++ b/apps/client/src/features/rundown/event-editor/EventEditorEmpty.tsx @@ -24,6 +24,18 @@ function EventEditorEmpty() { + + Select block + + {deviceAlt} + + + Shift + + + + / + + + Deselect entry diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 5eba5af9a..dce23f606 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -11,7 +11,7 @@ import { TimerPhase, TimerState, } from 'ontime-types'; -import { calculateDuration, checkIsNow, dayInMs, filterTimedEvents, getRelevantBlock } from 'ontime-utils'; +import { calculateDuration, checkIsNow, dayInMs, filterTimedEvents, getPreviousBlock } from 'ontime-utils'; import { clock } from '../services/Clock.js'; import { RestorePoint } from '../services/RestoreService.js'; @@ -664,7 +664,7 @@ function loadBlock(rundown: OntimeRundown) { return; } - const newCurrentBlock = getRelevantBlock(rundown, runtimeState.eventNow.id); + const newCurrentBlock = getPreviousBlock(rundown, runtimeState.eventNow.id); // update time only if the block has changed if (newCurrentBlock === null || newCurrentBlock.id !== runtimeState.currentBlock.block?.id) { diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 623666d6b..faaf9b656 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -8,9 +8,9 @@ export { sanitiseCue } from './src/cue-utils/cueUtils.js'; export { getCueCandidate } from './src/cue-utils/cueUtils.js'; export { generateId } from './src/generate-id/generateId.js'; export { - getEventWithId, filterPlayable, filterTimedEvents, + getEventWithId, getFirst, getFirstEvent, getFirstEventNormal, @@ -19,6 +19,7 @@ export { getLastEventNormal, getLastNormal, getNext, + getNextBlockNormal, getNextEvent, getNextEventNormal, getNextNormal, @@ -26,7 +27,8 @@ export { getPreviousEvent, getPreviousEventNormal, getPreviousNormal, - getRelevantBlock, + getPreviousBlock, + getPreviousBlockNormal, swapEventData, } from './src/rundown-utils/rundownUtils.js'; diff --git a/packages/utils/src/rundown-utils/rundownUtils.test.ts b/packages/utils/src/rundown-utils/rundownUtils.test.ts index c8ee9e29c..bafa7f52b 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.test.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.test.ts @@ -8,8 +8,8 @@ import { getNext, getNextEvent, getPrevious, + getPreviousBlock, getPreviousEvent, - getRelevantBlock, swapEventData, } from './rundownUtils'; @@ -278,23 +278,23 @@ describe('getLastEvent', () => { ]; it('returns the relevant block', () => { - const block = getRelevantBlock(testRundown as unknown as OntimeRundown, 'h'); + const block = getPreviousBlock(testRundown as unknown as OntimeRundown, 'h'); expect(block?.id).toBe('g'); }); it('returns the relevant block', () => { - const block = getRelevantBlock(testRundown as unknown as OntimeRundown, 'f'); + const block = getPreviousBlock(testRundown as unknown as OntimeRundown, 'f'); expect(block?.id).toBe('e'); }); it('returns the relevant block', () => { - const block = getRelevantBlock(testRundown as unknown as OntimeRundown, 'a'); + const block = getPreviousBlock(testRundown as unknown as OntimeRundown, 'a'); expect(block).toBeNull(); }); it('also works on index 0', () => { testRundown.unshift({ id: '0', type: SupportedEvent.Block }); - const block = getRelevantBlock(testRundown as unknown as OntimeRundown, 'a'); + const block = getPreviousBlock(testRundown as unknown as OntimeRundown, 'a'); expect(block?.id).toBe('0'); }); }); diff --git a/packages/utils/src/rundown-utils/rundownUtils.ts b/packages/utils/src/rundown-utils/rundownUtils.ts index 8dc4b7056..7b33b01ae 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.ts @@ -302,7 +302,53 @@ export function getEventWithId(rundown: OntimeRundown, id: string): OntimeRundow /** * Gets relevant block element for a given ID */ -export function getRelevantBlock(rundown: OntimeRundown, currentId: string): OntimeBlock | null { +export function getPreviousBlockNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry { + 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]; + if (!foundCurrentEvent && id === currentId) { + // set the flag when the current event is found + foundCurrentEvent = true; + continue; + } + // the first block before the current event is the relevant one + const entry = rundown[id]; + if (foundCurrentEvent && isOntimeBlock(entry)) { + return { entry, index }; + } + } + // no blocks exist before current event + return { entry: null, index: null }; +} + +/** + * Gets next block element for a given ID + */ +export function getNextBlockNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry { + 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]; + if (!foundCurrentEvent && id === currentId) { + // set the flag when the current event is found + foundCurrentEvent = true; + continue; + } + // the first block before the current event is the relevant one + const entry = rundown[id]; + if (foundCurrentEvent && isOntimeBlock(entry)) { + return { entry, index }; + } + } + // no blocks exist before current event + return { entry: null, index: null }; +} + +/** + * Gets relevant block element for a given ID + */ +export function getPreviousBlock(rundown: OntimeRundown, currentId: string): OntimeBlock | null { let foundCurrentEvent = false; // Iterate backwards through the rundown to find the current event for (let i = rundown.length - 1; i >= 0; i--) {