mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-05 15:33:59 +00:00
feat: extend rundown shortcuts
This commit is contained in:
committed by
Carlos Valente
parent
d7af73d9ed
commit
967e92e2c8
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user