mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-21 15:09:10 +00:00
feat: unify rundown features
This commit is contained in:
@@ -1,49 +1,24 @@
|
|||||||
import { Fragment, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { type HTMLProps, forwardRef, Fragment, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { TbFlagFilled } from 'react-icons/tb';
|
import { TbFlagFilled } from 'react-icons/tb';
|
||||||
import {
|
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||||
closestCenter,
|
import { closestCenter, DndContext } from '@dnd-kit/core';
|
||||||
DndContext,
|
|
||||||
DragEndEvent,
|
|
||||||
DragOverEvent,
|
|
||||||
DragStartEvent,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { useHotkeys, useSessionStorage } from '@mantine/hooks';
|
import { useSessionStorage } from '@mantine/hooks';
|
||||||
import {
|
import { type EntryId, type Rundown, isOntimeEvent, isOntimeGroup, Playback, SupportedEntry } from 'ontime-types';
|
||||||
type EntryId,
|
|
||||||
type MaybeString,
|
|
||||||
type Rundown,
|
|
||||||
isOntimeEvent,
|
|
||||||
isOntimeGroup,
|
|
||||||
OntimeEntry,
|
|
||||||
Playback,
|
|
||||||
SupportedEntry,
|
|
||||||
} from 'ontime-types';
|
|
||||||
import {
|
|
||||||
getFirstNormal,
|
|
||||||
getLastNormal,
|
|
||||||
getNextGroupNormal,
|
|
||||||
getNextNormal,
|
|
||||||
getPreviousGroupNormal,
|
|
||||||
getPreviousNormal,
|
|
||||||
reorderArray,
|
|
||||||
} from 'ontime-utils';
|
|
||||||
|
|
||||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
|
||||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
|
||||||
import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
||||||
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
|
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
|
||||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||||
|
|
||||||
import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
|
|
||||||
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
|
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
|
||||||
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
|
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
|
||||||
|
import { useRundownCommands } from './hooks/useRundownCommands';
|
||||||
|
import { useRundownDnd } from './hooks/useRundownDnd';
|
||||||
|
import { useRundownKeyboard } from './hooks/useRundownKeyboard';
|
||||||
import RundownGroup from './rundown-group/RundownGroup';
|
import RundownGroup from './rundown-group/RundownGroup';
|
||||||
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
|
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
|
||||||
import { canDrop, makeSortableList } from './rundown.utils';
|
import { filterVisibleEntries, makeSortableList } from './rundown.utils';
|
||||||
import RundownEmpty from './RundownEmpty';
|
import RundownEmpty from './RundownEmpty';
|
||||||
import { useEventSelection } from './useEventSelection';
|
import { useEventSelection } from './useEventSelection';
|
||||||
|
|
||||||
@@ -52,19 +27,33 @@ import style from './Rundown.module.scss';
|
|||||||
const RundownEntry = lazy(() => import('./RundownEntry'));
|
const RundownEntry = lazy(() => import('./RundownEntry'));
|
||||||
|
|
||||||
interface RundownProps {
|
interface RundownProps {
|
||||||
data: Rundown;
|
entries: Rundown['entries'];
|
||||||
|
id: Rundown['id'];
|
||||||
|
order: Rundown['order'];
|
||||||
rundownMetadata: RundownMetadataObject;
|
rundownMetadata: RundownMetadataObject;
|
||||||
|
featureData: {
|
||||||
|
playback: Playback;
|
||||||
|
selectedEventId: EntryId | null;
|
||||||
|
nextEventId: EntryId | null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
export default function Rundown({ order, entries, id, rundownMetadata, featureData }: RundownProps) {
|
||||||
// invoke the compiler for the component
|
// invoke the compiler for the component
|
||||||
'use memo';
|
'use memo';
|
||||||
|
|
||||||
const { order, entries, id } = data;
|
|
||||||
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs
|
|
||||||
const featureData = useRundownEditor();
|
|
||||||
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
|
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
|
||||||
const [metadata, setMetadata] = useState(rundownMetadata);
|
const [metadata, setMetadata] = useState<RundownMetadataObject>(rundownMetadata);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We need to keep a copy of the events we receive to
|
||||||
|
* workaround issues with dnd-kit optimistic updates
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
setSortableData(makeSortableList(order, entries));
|
||||||
|
setMetadata(rundownMetadata);
|
||||||
|
}, [order, entries, rundownMetadata]);
|
||||||
|
|
||||||
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
|
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
|
||||||
// we ensure that this is unique to the rundown
|
// we ensure that this is unique to the rundown
|
||||||
key: `rundown.${id}-editor-collapsed-groups`,
|
key: `rundown.${id}-editor-collapsed-groups`,
|
||||||
@@ -72,7 +61,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
|||||||
});
|
});
|
||||||
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
|
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
|
||||||
|
|
||||||
const { addEntry, clone, deleteEntry, move, reorderEntry } = useEntryActionsContext();
|
const entryActions = useEntryActionsContext();
|
||||||
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
|
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
|
||||||
|
|
||||||
// cursor
|
// cursor
|
||||||
@@ -87,128 +76,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
|||||||
|
|
||||||
const cursorRef = useRef<HTMLDivElement | null>(null);
|
const cursorRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
useFollowComponent({
|
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||||
followRef: cursorRef,
|
|
||||||
scrollRef,
|
|
||||||
doFollow: true,
|
|
||||||
followTrigger: editorMode === AppMode.Edit ? cursor : featureData?.selectedEventId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// DND KIT
|
|
||||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
|
|
||||||
|
|
||||||
const deleteAtCursor = useCallback(
|
|
||||||
(cursor: string | null) => {
|
|
||||||
if (!cursor) return;
|
|
||||||
const { entry, index } = getPreviousNormal(entries, order, cursor);
|
|
||||||
deleteEntry([cursor]);
|
|
||||||
if (entry && index !== null) {
|
|
||||||
setSelectedEvents({ id: entry.id, selectMode: 'click', index });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[entries, order, deleteEntry, setSelectedEvents],
|
|
||||||
);
|
|
||||||
|
|
||||||
const insertCopyAtId = useCallback(
|
|
||||||
(atId: string | null, above = false) => {
|
|
||||||
// lazily get the value from the store
|
|
||||||
const { entryCopyId } = useEntryCopy.getState();
|
|
||||||
if (entryCopyId === null || !entries[entryCopyId]) {
|
|
||||||
// we cant clone without selection
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalisedAtId = atId;
|
|
||||||
|
|
||||||
const elementToCopy = entries[entryCopyId];
|
|
||||||
const refElement = atId ? entries[atId] : undefined;
|
|
||||||
|
|
||||||
if (refElement && 'parent' in refElement && refElement.parent && elementToCopy.type === SupportedEntry.Group) {
|
|
||||||
normalisedAtId = refElement.parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a new item referring to an existing one
|
|
||||||
*/
|
|
||||||
const insertAtId = useCallback(
|
|
||||||
(patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: MaybeString, above = false) => {
|
|
||||||
addEntry(patch, {
|
|
||||||
after: id && !above ? id : undefined,
|
|
||||||
before: id && above ? id : undefined,
|
|
||||||
lastEventId: !above && id ? id : undefined,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[addEntry],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectGroup = useCallback(
|
|
||||||
(cursor: string | null, direction: 'up' | 'down') => {
|
|
||||||
if (order.length < 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let newCursor = cursor;
|
|
||||||
if (cursor === null) {
|
|
||||||
// there is no cursor, we select the first or last depending on direction
|
|
||||||
const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
|
|
||||||
|
|
||||||
if (isOntimeGroup(selected)) {
|
|
||||||
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
newCursor = selected?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newCursor === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// otherwise we select the next or previous
|
|
||||||
const selected =
|
|
||||||
direction === 'up'
|
|
||||||
? getPreviousGroupNormal(entries, order, newCursor)
|
|
||||||
: getNextGroupNormal(entries, order, newCursor);
|
|
||||||
|
|
||||||
if (selected.entry !== null && selected.index !== null) {
|
|
||||||
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[order, entries, setSelectedEvents],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectEntry = useCallback(
|
|
||||||
(cursor: string | null, direction: 'up' | 'down') => {
|
|
||||||
if (order.length < 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cursor === null) {
|
|
||||||
// there is no cursor, we select the first or last depending on direction if it exists
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// otherwise we select the next or previous
|
|
||||||
const selected =
|
|
||||||
direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor);
|
|
||||||
|
|
||||||
if (selected.entry !== null && selected.index !== null) {
|
|
||||||
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[order, entries, setSelectedEvents],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether a group is collapsed
|
* Checks whether a group is collapsed
|
||||||
@@ -240,93 +108,75 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
|||||||
[getIsCollapsed, setCollapsedGroups],
|
[getIsCollapsed, setCollapsedGroups],
|
||||||
);
|
);
|
||||||
|
|
||||||
const moveEntry = useCallback(
|
// Commands layer - business logic
|
||||||
async (cursor: EntryId | null, direction: 'up' | 'down') => {
|
const commands = useRundownCommands({
|
||||||
if (cursor == null) {
|
entries,
|
||||||
return;
|
order,
|
||||||
}
|
entryActions,
|
||||||
|
setSelectedEvents,
|
||||||
|
handleCollapseGroup,
|
||||||
|
});
|
||||||
|
|
||||||
const movedIntoGroupId = await move(cursor, direction);
|
// Keyboard shortcuts
|
||||||
// if we are moving into a group, we need to make sure it is expanded
|
useRundownKeyboard({
|
||||||
if (movedIntoGroupId) {
|
cursor,
|
||||||
handleCollapseGroup(false, movedIntoGroupId);
|
commands,
|
||||||
}
|
clearSelectedEvents,
|
||||||
},
|
setEntryCopyId,
|
||||||
[handleCollapseGroup, move],
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// shortcuts
|
// DND handlers
|
||||||
useHotkeys([
|
const dnd = useRundownDnd({
|
||||||
['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
|
entries,
|
||||||
['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
|
sortableData,
|
||||||
|
setSortableData,
|
||||||
|
getIsCollapsed,
|
||||||
|
handleCollapseGroup,
|
||||||
|
entryActions,
|
||||||
|
});
|
||||||
|
|
||||||
['alt + shift + ArrowDown', () => selectGroup(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
|
// Filter visible data to exclude collapsed items
|
||||||
['alt + shift + ArrowUp', () => selectGroup(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
|
// DND-kit (SortableContext) needs the full sortableData for drag calculations
|
||||||
|
// Virtuoso only renders visibleData to avoid null items that reserve space
|
||||||
|
const visibleData = useMemo(() => {
|
||||||
|
return filterVisibleEntries(sortableData, entries, getIsCollapsed);
|
||||||
|
}, [sortableData, entries, getIsCollapsed]);
|
||||||
|
|
||||||
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
|
// Follow-scroll with Virtuoso in run mode
|
||||||
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
|
// Always scrolls when playback selection changes, not during drag operations
|
||||||
|
|
||||||
['Escape', () => clearSelectedEvents(), { preventDefault: true, usePhysicalKeys: true }],
|
|
||||||
|
|
||||||
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
|
|
||||||
|
|
||||||
[
|
|
||||||
'alt + E',
|
|
||||||
() => insertAtId({ type: SupportedEntry.Event }, cursor),
|
|
||||||
{ preventDefault: true, usePhysicalKeys: true },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'alt + shift + E',
|
|
||||||
() => insertAtId({ type: SupportedEntry.Event }, cursor, true),
|
|
||||||
{ preventDefault: true, usePhysicalKeys: true },
|
|
||||||
],
|
|
||||||
|
|
||||||
[
|
|
||||||
'alt + G',
|
|
||||||
() => insertAtId({ type: SupportedEntry.Group }, cursor),
|
|
||||||
{ preventDefault: true, usePhysicalKeys: true },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'alt + shift + G',
|
|
||||||
() => insertAtId({ type: SupportedEntry.Group }, cursor, true),
|
|
||||||
{ preventDefault: true, usePhysicalKeys: true },
|
|
||||||
],
|
|
||||||
|
|
||||||
[
|
|
||||||
'alt + D',
|
|
||||||
() => insertAtId({ type: SupportedEntry.Delay }, cursor),
|
|
||||||
{ preventDefault: true, usePhysicalKeys: true },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'alt + shift + D',
|
|
||||||
() => insertAtId({ type: SupportedEntry.Delay }, cursor, true),
|
|
||||||
{ preventDefault: true, usePhysicalKeys: true },
|
|
||||||
],
|
|
||||||
|
|
||||||
[
|
|
||||||
'alt + M',
|
|
||||||
() => insertAtId({ type: SupportedEntry.Milestone }, cursor),
|
|
||||||
{ preventDefault: true, usePhysicalKeys: true },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'alt + shift + M',
|
|
||||||
() => insertAtId({ type: SupportedEntry.Milestone }, cursor, true),
|
|
||||||
{ preventDefault: true, usePhysicalKeys: true },
|
|
||||||
],
|
|
||||||
|
|
||||||
['mod + C', () => setEntryCopyId(cursor)],
|
|
||||||
['mod + V', () => insertCopyAtId(cursor)],
|
|
||||||
['mod + shift + V', () => insertCopyAtId(cursor, true), { preventDefault: true, usePhysicalKeys: true }],
|
|
||||||
|
|
||||||
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// we copy the state from the store here
|
|
||||||
// to workaround async updates on the drag mutations
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSortableData(makeSortableList(order, entries));
|
if (editorMode !== AppMode.Run || !virtuosoRef.current || dnd.isDraggingRef.current) return;
|
||||||
setMetadata(rundownMetadata);
|
|
||||||
}, [order, entries, rundownMetadata]);
|
const targetId = featureData?.selectedEventId;
|
||||||
|
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]);
|
||||||
|
|
||||||
// in run mode, we follow the playback selection and open groups as needed
|
// in run mode, we follow the playback selection and open groups as needed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -344,268 +194,154 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
|
|||||||
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
|
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
|
||||||
}, [editorMode, entries, featureData.selectedEventId, order, setCollapsedGroups, setSelectedEvents]);
|
}, [editorMode, entries, featureData.selectedEventId, order, setCollapsedGroups, setSelectedEvents]);
|
||||||
|
|
||||||
/**
|
|
||||||
* On drag end, we reorder the events
|
|
||||||
*/
|
|
||||||
const handleOnDragEnd = (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
|
|
||||||
if (!over?.id || active.id === over.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!active.data.current || !over.data.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromIndex: number = active.data.current.sortable.index;
|
|
||||||
const toIndex: number = over.data.current.sortable.index;
|
|
||||||
let placement: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
|
|
||||||
|
|
||||||
let destinationId = over.id as EntryId;
|
|
||||||
const isDraggingGroup = active.data.current?.type === SupportedEntry.Group;
|
|
||||||
|
|
||||||
// prevent dropping a group inside another
|
|
||||||
if (
|
|
||||||
isDraggingGroup &&
|
|
||||||
!canDrop(over.data.current.type, over.data.current.parent, placement, getIsCollapsed(destinationId))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We need to specially handle the end-group
|
|
||||||
* Dragging before a end-group will add the entry to the end of the group
|
|
||||||
* Dragging after a end-group will add the event after the group itself
|
|
||||||
* Dragging to the top of a group either place before first entry or if no entries do insert
|
|
||||||
*/
|
|
||||||
if (destinationId.startsWith('end-')) {
|
|
||||||
destinationId = destinationId.replace('end-', '');
|
|
||||||
// if we are moving before the end, we use the insert operation
|
|
||||||
if (placement === 'before') {
|
|
||||||
placement = 'insert';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const group = data.entries[destinationId];
|
|
||||||
// if dragging into a group
|
|
||||||
if (isOntimeGroup(group) && placement === 'after') {
|
|
||||||
if (isDraggingGroup) {
|
|
||||||
// ... and the dragged entry is a group, we know that the group is collapsed, because of the safe check canDrop from before
|
|
||||||
// so we can safely push the dragged event after the group
|
|
||||||
destinationId = group.id;
|
|
||||||
} else if (group.entries.length === 0) {
|
|
||||||
// ... and the group is entry, we insert
|
|
||||||
destinationId = group.id;
|
|
||||||
placement = 'insert';
|
|
||||||
} else {
|
|
||||||
// otherwise we add it to before the first group child
|
|
||||||
destinationId = group.entries[0];
|
|
||||||
placement = 'before';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// keep copy of the current state in case we need to revert
|
|
||||||
const currentEntries = [...sortableData];
|
|
||||||
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
|
|
||||||
setSortableData((currentEntries) => {
|
|
||||||
return reorderArray(currentEntries, fromIndex, toIndex);
|
|
||||||
});
|
|
||||||
reorderEntry(active.id as EntryId, destinationId, placement).catch((_) => {
|
|
||||||
setSortableData(currentEntries);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When we drag a group, we force collapse it
|
|
||||||
* This avoids strange scenarios like dropping a group inside itself
|
|
||||||
*/
|
|
||||||
const collapseDraggedGroups = (event: DragStartEvent) => {
|
|
||||||
const isGroup = event.active.data.current?.type === SupportedEntry.Group;
|
|
||||||
if (isGroup) {
|
|
||||||
handleCollapseGroup(true, event.active.id as EntryId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When we drag over a group, we expand it if it is collapsed
|
|
||||||
*/
|
|
||||||
const expandOverGroup = (event: DragOverEvent) => {
|
|
||||||
// if we are dragging a group, the drop operation is invalid so we dont expand
|
|
||||||
if (event.active.data.current?.type === 'group') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.over?.data.current?.type !== 'group') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const groupId = event.over?.id as EntryId;
|
|
||||||
const isCollapsed = getIsCollapsed(groupId);
|
|
||||||
if (isCollapsed) {
|
|
||||||
handleCollapseGroup(false, groupId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (sortableData.length < 1) {
|
|
||||||
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// gather presentation options
|
// gather presentation options
|
||||||
const isEditMode = editorMode === AppMode.Edit;
|
const isEditMode = editorMode === AppMode.Edit;
|
||||||
|
|
||||||
// gather rundown wide data
|
// gather rundown wide data
|
||||||
const lastEntryId = order.at(-1);
|
const lastEntryId = order.at(-1);
|
||||||
|
|
||||||
|
// Extract primitive values from featureData to avoid unnecessary callback recreations
|
||||||
|
const nextEventId = featureData?.nextEventId;
|
||||||
|
const playback = featureData?.playback;
|
||||||
|
|
||||||
|
// Virtuoso item renderer
|
||||||
|
const itemContent = useCallback(
|
||||||
|
(index: number, entryId: EntryId) => {
|
||||||
|
// Handle end-group pseudo entries
|
||||||
|
const isEndGroup = entryId.startsWith('end-');
|
||||||
|
|
||||||
|
if (isEndGroup) {
|
||||||
|
const parentId = entryId.split('end-')[1];
|
||||||
|
const parentMetadata = metadata[parentId];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={entryId}>
|
||||||
|
{isEditMode && parentMetadata?.groupEntries === 0 && (
|
||||||
|
<QuickAddButtons
|
||||||
|
previousEventId={null}
|
||||||
|
parentGroup={parentId}
|
||||||
|
backgroundColor={parentMetadata?.groupColour}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<RundownGroupEnd key={entryId} id={entryId} colour={parentMetadata?.groupColour} />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular entry handling - compute all values upfront
|
||||||
|
const entry = entries[entryId];
|
||||||
|
const entryMetadata = metadata[entryId];
|
||||||
|
|
||||||
|
// Null check after computing - return null if missing
|
||||||
|
if (!entry || !entryMetadata) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNext = nextEventId === entry.id;
|
||||||
|
const hasCursor = entry.id === cursor;
|
||||||
|
const isGroup = isOntimeGroup(entry);
|
||||||
|
const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
|
||||||
|
const isFirst = index === 0;
|
||||||
|
const isLast = entryId === lastEntryId;
|
||||||
|
const parentIdForBefore = entryMetadata.thisId !== entryMetadata.groupId ? entryMetadata.groupId : null;
|
||||||
|
const parentIdForAfter = entryMetadata.groupId;
|
||||||
|
const collapsed = isGroup ? getIsCollapsed(entry.id) : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={entry.id}>
|
||||||
|
{/* QuickAddInline before the entry - edit mode only, if there is a cursor, if it is not the first entry */}
|
||||||
|
{isEditMode && hasCursor && !isFirst && (
|
||||||
|
<QuickAddInline placement='before' referenceEntryId={entry.id} parentGroup={parentIdForBefore} />
|
||||||
|
)}
|
||||||
|
{isGroup ? (
|
||||||
|
<RundownGroup data={entry} hasCursor={hasCursor} collapsed={collapsed} onCollapse={handleCollapseGroup} />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={style.entryWrapper}
|
||||||
|
data-testid={`entry-${entryMetadata.eventIndex}`}
|
||||||
|
style={groupColour ? { '--user-bg': groupColour } : {}}
|
||||||
|
>
|
||||||
|
{isOntimeEvent(entry) && (
|
||||||
|
<div className={style.entryIndex}>
|
||||||
|
{entry.flag && <TbFlagFilled className={style.flag} />}
|
||||||
|
<div className={style.index}>{entryMetadata.eventIndex}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
||||||
|
<RundownEntry
|
||||||
|
type={entry.type}
|
||||||
|
isPast={entryMetadata.isPast}
|
||||||
|
eventIndex={entryMetadata.eventIndex}
|
||||||
|
data={entry}
|
||||||
|
loaded={entryMetadata.isLoaded}
|
||||||
|
hasCursor={hasCursor}
|
||||||
|
isNext={isNext}
|
||||||
|
isNextDay={entryMetadata.isNextDay}
|
||||||
|
playback={entryMetadata.isLoaded ? playback : undefined}
|
||||||
|
isRolling={playback === Playback.Roll}
|
||||||
|
totalGap={entryMetadata.totalGap}
|
||||||
|
isLinkedToLoaded={entryMetadata.isLinkedToLoaded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* QuickAddInline after the entry - edit mode only, if there is a cursor, if it is not the last entry */}
|
||||||
|
{isEditMode && hasCursor && !isLast && (
|
||||||
|
<QuickAddInline placement='after' referenceEntryId={entry.id} parentGroup={parentIdForAfter} />
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[entries, metadata, getIsCollapsed, isEditMode, cursor, nextEventId, playback, lastEntryId, handleCollapseGroup],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sortableData.length < 1) {
|
||||||
|
return <RundownEmpty handleAddNew={(type: SupportedEntry) => entryActions.addEntry({ type })} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
||||||
<DndContext
|
<DndContext
|
||||||
onDragEnd={handleOnDragEnd}
|
onDragEnd={dnd.handleOnDragEnd}
|
||||||
onDragStart={collapseDraggedGroups}
|
onDragStart={dnd.collapseDraggedGroups}
|
||||||
onDragOver={expandOverGroup}
|
onDragOver={dnd.expandOverGroup}
|
||||||
sensors={sensors}
|
sensors={dnd.sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
>
|
>
|
||||||
<SortableContext items={sortableData} strategy={verticalListSortingStrategy}>
|
<SortableContext items={sortableData} strategy={verticalListSortingStrategy}>
|
||||||
<div className={style.list}>
|
<Virtuoso
|
||||||
{isEditMode && <QuickAddButtons previousEventId={null} parentGroup={null} />}
|
ref={virtuosoRef}
|
||||||
{sortableData.map((entryId, index) => {
|
data={visibleData}
|
||||||
// the entry might be a pseudo end-group which does not generate metadata and should not be processed
|
computeItemKey={(_index, entryId) => entryId}
|
||||||
if (entryId.startsWith('end-')) {
|
itemContent={itemContent}
|
||||||
const parentId = entryId.split('end-')[1];
|
increaseViewportBy={{ top: 200, bottom: 400 }}
|
||||||
const isGroupCollapsed = getIsCollapsed(parentId);
|
style={{ height: '100%' }}
|
||||||
const parentMetadata = metadata[parentId];
|
components={{
|
||||||
|
Header: isEditMode ? () => <QuickAddButtons previousEventId={null} parentGroup={null} /> : undefined,
|
||||||
if (isGroupCollapsed) {
|
Footer: () => (
|
||||||
return null;
|
<>
|
||||||
}
|
{isEditMode && (
|
||||||
|
<QuickAddButtons
|
||||||
// if the previous element is selected, it will have its own QuickAddInline
|
previousEventId={metadata[lastMetadataKey]?.groupId ?? metadata[lastMetadataKey]?.thisId}
|
||||||
// we use thisId instead of previousEntryId because the end-group does not process
|
parentGroup={null}
|
||||||
// and it does not cause the reassignment of the iteration id to the previous entry
|
|
||||||
return (
|
|
||||||
<Fragment key={entryId}>
|
|
||||||
{isEditMode && parentMetadata?.groupEntries === 0 && (
|
|
||||||
<QuickAddButtons
|
|
||||||
previousEventId={null}
|
|
||||||
parentGroup={parentId}
|
|
||||||
backgroundColor={parentMetadata?.groupColour}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<RundownGroupEnd key={entryId} id={entryId} colour={parentMetadata?.groupColour} />
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// we iterate through a stateful copy of order to make the dnd operations smoother
|
|
||||||
// this means that this can be out of sync with order until the useEffect runs
|
|
||||||
// instead of writing all the logic guards, we simply short circuit rendering here
|
|
||||||
const entry = entries[entryId];
|
|
||||||
const entryMetadata = metadata[entryId];
|
|
||||||
if (!entry || !entryMetadata) return null;
|
|
||||||
|
|
||||||
// if the entry has a parent, and it is collapsed, render nothing
|
|
||||||
if (
|
|
||||||
entry.type !== SupportedEntry.Group &&
|
|
||||||
entryMetadata.groupId !== null &&
|
|
||||||
getIsCollapsed(entryMetadata.groupId)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isNext = featureData?.nextEventId === entry.id;
|
|
||||||
const hasCursor = entry.id === cursor;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Outside a group, the value will be undefined
|
|
||||||
* If the colour is empty string ''
|
|
||||||
* ie: we are inside a group, but there is no defined colour
|
|
||||||
* we default to $gray-500 #9d9d9d
|
|
||||||
*/
|
|
||||||
const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
|
|
||||||
|
|
||||||
const isFirst = index === 0;
|
|
||||||
const isLast = entryId === lastEntryId;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We need to provide the parent ID for the QuickAdd components
|
|
||||||
* This should be different depending on whether we are adding before or after an element
|
|
||||||
* - when adding before, we need to avoid a group referencing itself as the parent
|
|
||||||
* - when adding after, we can use the group ID directly to insert at the top of the group
|
|
||||||
*/
|
|
||||||
|
|
||||||
const parentIdForBefore = entryMetadata.thisId !== entryMetadata.groupId ? entryMetadata.groupId : null;
|
|
||||||
const parentIdForAfter = entryMetadata.groupId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment key={entry.id}>
|
|
||||||
{/**
|
|
||||||
* Before the entry
|
|
||||||
* - edit mode only
|
|
||||||
* - if there is a cursor
|
|
||||||
* - if it is not the first entry (the buttons would be there)
|
|
||||||
*/}
|
|
||||||
{isEditMode && hasCursor && !isFirst && (
|
|
||||||
<QuickAddInline placement='before' referenceEntryId={entry.id} parentGroup={parentIdForBefore} />
|
|
||||||
)}
|
|
||||||
{isOntimeGroup(entry) ? (
|
|
||||||
<RundownGroup
|
|
||||||
data={entry}
|
|
||||||
hasCursor={hasCursor}
|
|
||||||
collapsed={getIsCollapsed(entry.id)}
|
|
||||||
onCollapse={handleCollapseGroup}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={style.entryWrapper}
|
|
||||||
data-testid={`entry-${entryMetadata.eventIndex}`}
|
|
||||||
style={groupColour ? { '--user-bg': groupColour } : {}}
|
|
||||||
>
|
|
||||||
{isOntimeEvent(entry) && (
|
|
||||||
<div className={style.entryIndex}>
|
|
||||||
{entry.flag && <TbFlagFilled className={style.flag} />}
|
|
||||||
<div className={style.index}>{entryMetadata.eventIndex}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
|
||||||
<RundownEntry
|
|
||||||
type={entry.type}
|
|
||||||
isPast={entryMetadata.isPast}
|
|
||||||
eventIndex={entryMetadata.eventIndex}
|
|
||||||
data={entry}
|
|
||||||
loaded={entryMetadata.isLoaded}
|
|
||||||
hasCursor={hasCursor}
|
|
||||||
isNext={isNext}
|
|
||||||
isNextDay={entryMetadata.isNextDay}
|
|
||||||
playback={entryMetadata.isLoaded ? featureData.playback : undefined}
|
|
||||||
isRolling={featureData.playback === Playback.Roll}
|
|
||||||
totalGap={entryMetadata.totalGap}
|
|
||||||
isLinkedToLoaded={entryMetadata.isLinkedToLoaded}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{/**
|
<div className={style.spacer} />
|
||||||
* After the entry
|
</>
|
||||||
* - edit mode only
|
),
|
||||||
* - if there is a cursor
|
List: VirtuosoListComponent,
|
||||||
* - if it is not the last entry (the buttons would be there)
|
}}
|
||||||
* - if the entry is not the group header
|
/>
|
||||||
*/}
|
|
||||||
{isEditMode && hasCursor && !isLast && (
|
|
||||||
<QuickAddInline placement='after' referenceEntryId={entry.id} parentGroup={parentIdForAfter} />
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{isEditMode && (
|
|
||||||
<QuickAddButtons
|
|
||||||
previousEventId={metadata[lastMetadataKey]?.groupId ?? metadata[lastMetadataKey].thisId}
|
|
||||||
parentGroup={null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className={style.spacer} />
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Virtuoso components - extracted to prevent recreation on every render
|
||||||
|
const VirtuosoListComponent = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(({ children, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={style.list} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
VirtuosoListComponent.displayName = 'VirtuosoListComponent';
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { type EntryId, type OntimeEntry, type Rundown, isOntimeGroup, SupportedEntry } from 'ontime-types';
|
||||||
|
import {
|
||||||
|
getFirstNormal,
|
||||||
|
getLastNormal,
|
||||||
|
getNextGroupNormal,
|
||||||
|
getNextNormal,
|
||||||
|
getPreviousGroupNormal,
|
||||||
|
getPreviousNormal,
|
||||||
|
} from 'ontime-utils';
|
||||||
|
|
||||||
|
import type { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||||
|
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||||
|
|
||||||
|
type SelectionMode = 'shift' | 'click' | 'ctrl';
|
||||||
|
|
||||||
|
interface UseRundownCommandsOptions {
|
||||||
|
entries: Rundown['entries'];
|
||||||
|
order: Rundown['order'];
|
||||||
|
entryActions: ReturnType<typeof useEntryActions>;
|
||||||
|
setSelectedEvents: (selection: { id: EntryId; selectMode: SelectionMode; index: number }) => void;
|
||||||
|
handleCollapseGroup: (collapsed: boolean, groupId: EntryId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRundownCommands({
|
||||||
|
entries,
|
||||||
|
order,
|
||||||
|
entryActions,
|
||||||
|
setSelectedEvents,
|
||||||
|
handleCollapseGroup,
|
||||||
|
}: UseRundownCommandsOptions) {
|
||||||
|
const { addEntry, clone, deleteEntry, move } = entryActions;
|
||||||
|
const deleteAtCursor = useCallback(
|
||||||
|
(cursor: string | null) => {
|
||||||
|
if (!cursor) return;
|
||||||
|
const { entry, index } = getPreviousNormal(entries, order, cursor);
|
||||||
|
deleteEntry([cursor]);
|
||||||
|
if (entry && index !== null) {
|
||||||
|
setSelectedEvents({ id: entry.id, selectMode: 'click', index });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[entries, order, deleteEntry, setSelectedEvents],
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertCopyAtId = useCallback(
|
||||||
|
(atId: EntryId | null, above = false) => {
|
||||||
|
// lazily get the value from the store
|
||||||
|
const { entryCopyId } = useEntryCopy.getState();
|
||||||
|
if (entryCopyId === null || !entries[entryCopyId]) {
|
||||||
|
// we cant clone without selection
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalisedAtId = atId;
|
||||||
|
|
||||||
|
const elementToCopy = entries[entryCopyId];
|
||||||
|
const refElement = atId ? entries[atId] : undefined;
|
||||||
|
|
||||||
|
if (refElement && 'parent' in refElement && refElement.parent && elementToCopy.type === SupportedEntry.Group) {
|
||||||
|
normalisedAtId = refElement.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new item referring to an existing one
|
||||||
|
*/
|
||||||
|
const insertAtId = useCallback(
|
||||||
|
(patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: EntryId | null, above = false) => {
|
||||||
|
addEntry(patch, {
|
||||||
|
after: id && !above ? id : undefined,
|
||||||
|
before: id && above ? id : undefined,
|
||||||
|
lastEventId: !above && id ? id : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[addEntry],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectGroup = useCallback(
|
||||||
|
(cursor: EntryId | null, direction: 'up' | 'down') => {
|
||||||
|
if (order.length < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let newCursor = cursor;
|
||||||
|
if (cursor === null) {
|
||||||
|
// there is no cursor, we select the first or last depending on direction
|
||||||
|
const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
|
||||||
|
|
||||||
|
if (isOntimeGroup(selected)) {
|
||||||
|
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newCursor = selected?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newCursor === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise we select the next or previous
|
||||||
|
const selected =
|
||||||
|
direction === 'up'
|
||||||
|
? getPreviousGroupNormal(entries, order, newCursor)
|
||||||
|
: getNextGroupNormal(entries, order, newCursor);
|
||||||
|
|
||||||
|
if (selected.entry !== null && selected.index !== null) {
|
||||||
|
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[order, entries, setSelectedEvents],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: getPreviousNormal and getNextNormal do not work across group boundaries
|
||||||
|
*/
|
||||||
|
const selectEntry = useCallback(
|
||||||
|
(cursor: EntryId | null, direction: 'up' | 'down') => {
|
||||||
|
if (order.length < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor === null) {
|
||||||
|
// there is no cursor, we select the first or last depending on direction if it exists
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise we select the next or previous
|
||||||
|
const selected =
|
||||||
|
direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor);
|
||||||
|
|
||||||
|
if (selected.entry !== null && selected.index !== null) {
|
||||||
|
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[order, entries, setSelectedEvents],
|
||||||
|
);
|
||||||
|
|
||||||
|
const moveEntry = useCallback(
|
||||||
|
async (cursor: EntryId | null, direction: 'up' | 'down') => {
|
||||||
|
if (cursor == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedIntoGroupId = await move(cursor, direction);
|
||||||
|
// if we are moving into a group, we need to make sure it is expanded
|
||||||
|
if (movedIntoGroupId) {
|
||||||
|
handleCollapseGroup(false, movedIntoGroupId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleCollapseGroup, move],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleteAtCursor,
|
||||||
|
insertCopyAtId,
|
||||||
|
insertAtId,
|
||||||
|
selectGroup,
|
||||||
|
selectEntry,
|
||||||
|
moveEntry,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { Dispatch, SetStateAction, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { DragEndEvent, DragOverEvent, DragStartEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||||
|
import { type EntryId, type Rundown, isOntimeGroup, SupportedEntry } from 'ontime-types';
|
||||||
|
import { reorderArray } from 'ontime-utils';
|
||||||
|
|
||||||
|
import type { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||||
|
import { canDrop } from '../rundown.utils';
|
||||||
|
|
||||||
|
interface UseRundownDndOptions {
|
||||||
|
entries: Rundown['entries'];
|
||||||
|
sortableData: EntryId[];
|
||||||
|
setSortableData: Dispatch<SetStateAction<EntryId[]>>;
|
||||||
|
getIsCollapsed: (groupId: EntryId) => boolean;
|
||||||
|
handleCollapseGroup: (collapsed: boolean, groupId: EntryId) => void;
|
||||||
|
entryActions: ReturnType<typeof useEntryActions>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRundownDnd({
|
||||||
|
entries,
|
||||||
|
sortableData,
|
||||||
|
setSortableData,
|
||||||
|
getIsCollapsed,
|
||||||
|
handleCollapseGroup,
|
||||||
|
entryActions,
|
||||||
|
}: UseRundownDndOptions) {
|
||||||
|
const { reorderEntry } = entryActions;
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
|
||||||
|
const isDraggingRef = useRef(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On drag end, we reorder the events
|
||||||
|
*/
|
||||||
|
const handleOnDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
isDraggingRef.current = false;
|
||||||
|
|
||||||
|
if (!over?.id || active.id === over.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!active.data.current || !over.data.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromIndex: number = active.data.current.sortable.index;
|
||||||
|
const toIndex: number = over.data.current.sortable.index;
|
||||||
|
let placement: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
|
||||||
|
|
||||||
|
let destinationId = over.id as EntryId;
|
||||||
|
const isDraggingGroup = active.data.current?.type === SupportedEntry.Group;
|
||||||
|
|
||||||
|
// prevent dropping a group inside another
|
||||||
|
if (
|
||||||
|
isDraggingGroup &&
|
||||||
|
!canDrop(over.data.current.type, over.data.current.parent, placement, getIsCollapsed(destinationId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We need to specially handle the end-group
|
||||||
|
* Dragging before a end-group will add the entry to the end of the group
|
||||||
|
* Dragging after a end-group will add the event after the group itself
|
||||||
|
* Dragging to the top of a group either place before first entry or if no entries do insert
|
||||||
|
*/
|
||||||
|
if (destinationId.startsWith('end-')) {
|
||||||
|
destinationId = destinationId.replace('end-', '');
|
||||||
|
// if we are moving before the end, we use the insert operation
|
||||||
|
if (placement === 'before') {
|
||||||
|
placement = 'insert';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const group = entries[destinationId];
|
||||||
|
// if dragging into a group
|
||||||
|
if (isOntimeGroup(group) && placement === 'after') {
|
||||||
|
if (isDraggingGroup) {
|
||||||
|
// ... and the dragged entry is a group, we know that the group is collapsed, because of the safe check canDrop from before
|
||||||
|
// so we can safely push the dragged event after the group
|
||||||
|
destinationId = group.id;
|
||||||
|
} else if (group.entries.length === 0) {
|
||||||
|
// ... and the group is entry, we insert
|
||||||
|
destinationId = group.id;
|
||||||
|
placement = 'insert';
|
||||||
|
} else {
|
||||||
|
// otherwise we add it to before the first group child
|
||||||
|
destinationId = group.entries[0];
|
||||||
|
placement = 'before';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic update pattern to keep DND responsive
|
||||||
|
// 1. Keep copy of current state in case we need to revert
|
||||||
|
const currentEntries = [...sortableData];
|
||||||
|
// 2. Immediately update local state for responsive UI
|
||||||
|
setSortableData((currentEntries) => {
|
||||||
|
return reorderArray(currentEntries, fromIndex, toIndex);
|
||||||
|
});
|
||||||
|
// 3. Trigger async mutation, revert on error
|
||||||
|
reorderEntry(active.id as EntryId, destinationId, placement).catch((_) => {
|
||||||
|
setSortableData(currentEntries);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[entries, sortableData, setSortableData, getIsCollapsed, reorderEntry],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When we drag a group, we force collapse it
|
||||||
|
* This avoids strange scenarios like dropping a group inside itself
|
||||||
|
*/
|
||||||
|
const collapseDraggedGroups = useCallback(
|
||||||
|
(event: DragStartEvent) => {
|
||||||
|
isDraggingRef.current = true;
|
||||||
|
const isGroup = event.active.data.current?.type === SupportedEntry.Group;
|
||||||
|
if (isGroup) {
|
||||||
|
handleCollapseGroup(true, event.active.id as EntryId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleCollapseGroup],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When we drag over a group, we expand it if it is collapsed
|
||||||
|
*/
|
||||||
|
const expandOverGroup = useCallback(
|
||||||
|
(event: DragOverEvent) => {
|
||||||
|
// if we are dragging a group, the drop operation is invalid so we dont expand
|
||||||
|
if (event.active.data.current?.type === SupportedEntry.Group) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.over?.data.current?.type !== SupportedEntry.Group) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const groupId = event.over?.id as EntryId;
|
||||||
|
const isCollapsed = getIsCollapsed(groupId);
|
||||||
|
if (isCollapsed) {
|
||||||
|
handleCollapseGroup(false, groupId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getIsCollapsed, handleCollapseGroup],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
sensors,
|
||||||
|
isDraggingRef,
|
||||||
|
handleOnDragEnd,
|
||||||
|
collapseDraggedGroups,
|
||||||
|
expandOverGroup,
|
||||||
|
}),
|
||||||
|
[sensors, handleOnDragEnd, collapseDraggedGroups, expandOverGroup],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useHotkeys } from '@mantine/hooks';
|
||||||
|
import { type OntimeEntry, EntryId, SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
|
interface UseRundownKeyboardOptions {
|
||||||
|
cursor: EntryId | null;
|
||||||
|
commands: {
|
||||||
|
selectEntry: (cursor: EntryId | null, direction: 'up' | 'down') => void;
|
||||||
|
selectGroup: (cursor: EntryId | null, direction: 'up' | 'down') => 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRundownKeyboard({
|
||||||
|
cursor,
|
||||||
|
commands,
|
||||||
|
clearSelectedEvents,
|
||||||
|
setEntryCopyId,
|
||||||
|
}: UseRundownKeyboardOptions) {
|
||||||
|
useHotkeys([
|
||||||
|
['alt + ArrowDown', () => commands.selectEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
|
||||||
|
['alt + ArrowUp', () => commands.selectEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
|
||||||
|
|
||||||
|
[
|
||||||
|
'alt + shift + ArrowDown',
|
||||||
|
() => commands.selectGroup(cursor, 'down'),
|
||||||
|
{ preventDefault: true, usePhysicalKeys: true },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'alt + shift + ArrowUp',
|
||||||
|
() => commands.selectGroup(cursor, 'up'),
|
||||||
|
{ preventDefault: true, usePhysicalKeys: true },
|
||||||
|
],
|
||||||
|
|
||||||
|
[
|
||||||
|
'alt + mod + ArrowDown',
|
||||||
|
() => commands.moveEntry(cursor, 'down'),
|
||||||
|
{ preventDefault: true, usePhysicalKeys: true },
|
||||||
|
],
|
||||||
|
['alt + mod + ArrowUp', () => commands.moveEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
|
||||||
|
|
||||||
|
['Escape', () => clearSelectedEvents(), { preventDefault: true, usePhysicalKeys: true }],
|
||||||
|
|
||||||
|
['mod + Backspace', () => commands.deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
|
||||||
|
|
||||||
|
[
|
||||||
|
'alt + E',
|
||||||
|
() => commands.insertAtId({ type: SupportedEntry.Event }, cursor),
|
||||||
|
{ preventDefault: true, usePhysicalKeys: true },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'alt + shift + E',
|
||||||
|
() => commands.insertAtId({ type: SupportedEntry.Event }, cursor, true),
|
||||||
|
{ preventDefault: true, usePhysicalKeys: true },
|
||||||
|
],
|
||||||
|
|
||||||
|
[
|
||||||
|
'alt + G',
|
||||||
|
() => commands.insertAtId({ type: SupportedEntry.Group }, cursor),
|
||||||
|
{ preventDefault: true, usePhysicalKeys: true },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'alt + shift + G',
|
||||||
|
() => commands.insertAtId({ type: SupportedEntry.Group }, cursor, true),
|
||||||
|
{ preventDefault: true, usePhysicalKeys: true },
|
||||||
|
],
|
||||||
|
|
||||||
|
[
|
||||||
|
'alt + D',
|
||||||
|
() => commands.insertAtId({ type: SupportedEntry.Delay }, cursor),
|
||||||
|
{ preventDefault: true, usePhysicalKeys: true },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'alt + shift + D',
|
||||||
|
() => commands.insertAtId({ type: SupportedEntry.Delay }, cursor, true),
|
||||||
|
{ preventDefault: true, usePhysicalKeys: true },
|
||||||
|
],
|
||||||
|
|
||||||
|
[
|
||||||
|
'alt + M',
|
||||||
|
() => commands.insertAtId({ type: SupportedEntry.Milestone }, cursor),
|
||||||
|
{ preventDefault: true, usePhysicalKeys: true },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'alt + shift + M',
|
||||||
|
() => commands.insertAtId({ type: SupportedEntry.Milestone }, cursor, true),
|
||||||
|
{ 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 }],
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MouseEvent } from 'react';
|
import { MouseEvent } from 'react';
|
||||||
import { EntryId, isOntimeEvent, MaybeNumber, MaybeString, Rundown } from 'ontime-types';
|
import { EntryId, isOntimeEvent, MaybeNumber, Rundown } from 'ontime-types';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
import { RUNDOWN } from '../../common/api/constants';
|
import { RUNDOWN } from '../../common/api/constants';
|
||||||
@@ -11,13 +11,10 @@ type SelectionMode = 'shift' | 'click' | 'ctrl';
|
|||||||
interface EventSelectionStore {
|
interface EventSelectionStore {
|
||||||
selectedEvents: Set<EntryId>;
|
selectedEvents: Set<EntryId>;
|
||||||
anchoredIndex: MaybeNumber;
|
anchoredIndex: MaybeNumber;
|
||||||
cursor: MaybeString;
|
cursor: EntryId | null;
|
||||||
scrollTargetId: MaybeString;
|
|
||||||
entryMode: 'event' | 'single' | null;
|
entryMode: 'event' | 'single' | null;
|
||||||
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
|
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
|
||||||
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
|
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
|
||||||
setScrollTargetId: (id: EntryId | null) => void;
|
|
||||||
clearScrollTargetId: () => void;
|
|
||||||
clearSelectedEvents: () => void;
|
clearSelectedEvents: () => void;
|
||||||
clearMultiSelect: () => void;
|
clearMultiSelect: () => void;
|
||||||
unselect: (id: EntryId) => void;
|
unselect: (id: EntryId) => void;
|
||||||
@@ -27,7 +24,6 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
|||||||
selectedEvents: new Set(),
|
selectedEvents: new Set(),
|
||||||
anchoredIndex: null,
|
anchoredIndex: null,
|
||||||
cursor: null,
|
cursor: null,
|
||||||
scrollTargetId: null,
|
|
||||||
entryMode: null,
|
entryMode: null,
|
||||||
setSingleEntrySelection: ({ id }) => {
|
setSingleEntrySelection: ({ id }) => {
|
||||||
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
|
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
|
||||||
@@ -103,10 +99,8 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setScrollTargetId: (id) => set({ scrollTargetId: id }),
|
|
||||||
clearScrollTargetId: () => set({ scrollTargetId: null }),
|
|
||||||
clearSelectedEvents: () =>
|
clearSelectedEvents: () =>
|
||||||
set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null, scrollTargetId: null }),
|
set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
|
||||||
clearMultiSelect: () => {
|
clearMultiSelect: () => {
|
||||||
const { selectedEvents } = get();
|
const { selectedEvents } = get();
|
||||||
const [firstSelected] = selectedEvents;
|
const [firstSelected] = selectedEvents;
|
||||||
|
|||||||
@@ -114,21 +114,23 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
|||||||
setColumnSizing({});
|
setColumnSizing({});
|
||||||
}, [setColumnSizing]);
|
}, [setColumnSizing]);
|
||||||
|
|
||||||
// in edit mode, we follow the cursor (e.g. from finder)
|
// Follow selection changes depending on mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (virtuosoRef.current === null) {
|
if (virtuosoRef.current === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetId = cuesheetMode === AppMode.Edit ? cursor : selectedEventId;
|
const targetId = cuesheetMode === AppMode.Run ? selectedEventId : cursor ?? selectedEventId;
|
||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventIndex = data.findIndex((event) => event.id === targetId);
|
const eventIndex = data.findIndex((event) => event.id === targetId);
|
||||||
if (eventIndex !== -1) {
|
if (eventIndex === -1) {
|
||||||
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'smooth', align: 'center' });
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'smooth', align: 'start', offset: -50 });
|
||||||
}, [cuesheetMode, data, selectedEventId, cursor]);
|
}, [cuesheetMode, data, selectedEventId, cursor]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export default function useFinder() {
|
|||||||
const lastSearchString = useRef('');
|
const lastSearchString = useRef('');
|
||||||
|
|
||||||
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
|
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
|
||||||
const setScrollTargetId = useEventSelection((state) => state.setScrollTargetId);
|
|
||||||
|
|
||||||
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
|
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
|
||||||
// we ensure that this is unique to the rundown
|
// we ensure that this is unique to the rundown
|
||||||
@@ -235,9 +234,8 @@ export default function useFinder() {
|
|||||||
|
|
||||||
// Then select the event
|
// Then select the event
|
||||||
setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' });
|
setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' });
|
||||||
setScrollTargetId(selectedEvent.id);
|
|
||||||
},
|
},
|
||||||
[collapsedGroups, setCollapsedGroups, setSelectedEvents, setScrollTargetId],
|
[collapsedGroups, setCollapsedGroups, setSelectedEvents],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** clear results when source data changes */
|
/** clear results when source data changes */
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# 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: () => { /* ... */ },
|
||||||
|
}
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user