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 */