Files
ontime/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx
T
2026-08-09 16:45:47 +02:00

355 lines
10 KiB
TypeScript

import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Day, EndAction, EntryId, Maybe, OntimeGroup, Playback, TimeStrategy, TimerType } from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils';
import { MouseEvent, useEffect, useRef } from 'react';
import {
IoAdd,
IoDuplicateOutline,
IoFolder,
IoLink,
IoReorderTwo,
IoSwapVertical,
IoTrash,
IoUnlink,
} from 'react-icons/io5';
import { TbClockPin, TbFlagFilled, TbListNumbers } from 'react-icons/tb';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useEntry } from '../../../common/hooks-query/useRundown';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog';
import { useEventIdSwapping } from '../useEventIdSwapping';
import { getSelectionMode, useEventSelection } from '../useEventSelection';
import RundownEventInner from './RundownEventInner';
import RundownIndicators from './RundownIndicators';
import style from './RundownEvent.module.scss';
interface RundownEventProps {
eventId: EntryId;
cue: string;
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: boolean;
flag: boolean;
countToEnd: boolean;
eventIndex: number;
endAction: EndAction;
timerType: TimerType;
title: string;
note: string;
delay: number;
automationsEnabled?: boolean;
colour: string;
isPast: boolean;
isNext: boolean;
skip: boolean;
parent: EntryId | null;
loaded: boolean;
hasCursor: boolean;
playback?: Playback;
isRolling: boolean;
gap: number;
isNextDay: boolean;
dayOffset: Day;
totalGap: number;
isLinkedToLoaded: boolean;
hasTriggers: boolean;
}
export default function RundownEvent({
eventId,
cue,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
flag,
countToEnd,
eventIndex,
endAction,
timerType,
title,
note,
delay,
automationsEnabled,
colour,
isPast,
isNext,
skip = false,
parent,
loaded,
hasCursor,
playback,
isRolling,
gap,
isNextDay,
dayOffset,
totalGap,
isLinkedToLoaded,
hasTriggers,
}: RundownEventProps) {
'use memo';
const selectedEventId = useEventIdSwapping((state) => state.selectedEventId);
const setSelectedEventId = useEventIdSwapping((state) => state.setSelectedEventId);
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen);
const parentGroup = useEntry(parent) as Maybe<OntimeGroup>;
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents, matchGroupDuration } =
useEntryActionsContext();
const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId));
const unselect = useEventSelection((state) => state.unselect);
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
const handleRef = useRef<null | HTMLSpanElement>(null);
const [enableMatchDuration, groupTargetDurationDescription] = (() => {
if (!parentGroup || parentGroup.targetDuration === null || parentGroup.duration === parentGroup.targetDuration)
return [false, ''];
const { targetDuration, duration } = parentGroup;
return targetDuration > duration
? [true, 'Increase event duration to fit the group target']
: [true, 'Decrease event duration to fit the group target'];
})();
const [onContextMenu] = useContextMenu<HTMLDivElement>(() =>
selectedEvents.size > 1
? [
{
type: 'item',
label: 'Link to previous',
icon: IoLink,
onClick: () => {
batchUpdateEvents({ linkStart: true }, Array.from(selectedEvents));
},
},
{
type: 'item',
label: 'Unlink from previous',
icon: IoUnlink,
onClick: () => {
batchUpdateEvents({ linkStart: false }, Array.from(selectedEvents));
},
},
{ type: 'divider' },
{
type: 'item',
label: 'Group',
icon: IoFolder,
onClick: () => {
groupEntries(Array.from(selectedEvents));
clearSelectedEvents();
},
disabled: parent !== null,
},
{ type: 'divider' },
{
type: 'item',
label: 'Renumber cues',
icon: TbListNumbers,
onClick: openRenumberDialog,
},
{ type: 'divider' },
{
type: 'item',
label: 'Delete',
icon: IoTrash,
shortcut: `${deviceAlt}+Backspace`,
onClick: () => {
clearSelectedEvents();
deleteEntry(Array.from(selectedEvents));
},
},
]
: [
{
type: 'item',
label: flag ? 'Remove flag' : 'Add flag',
icon: TbFlagFilled,
onClick: () => {
updateEntry({ id: eventId, flag: !flag });
},
},
{
type: 'item',
label: 'Match Group Target Duration',
description: groupTargetDurationDescription,
icon: TbClockPin,
onClick: () => {
if (!parent) return;
matchGroupDuration(eventId);
},
disabled: !enableMatchDuration,
},
{ type: 'divider' },
{
type: 'item',
label: 'Add to swap',
icon: IoAdd,
onClick: () => setSelectedEventId(eventId),
},
{
type: 'item',
label: `Swap this event with ${selectedEventId ?? ''}`,
icon: IoSwapVertical,
onClick: () => {
if (!selectedEventId) return;
swapEvents(selectedEventId, eventId);
clearSelectedEventId();
},
disabled: selectedEventId == null || selectedEventId === eventId,
},
{
type: 'item',
label: 'Clone',
icon: IoDuplicateOutline,
shortcut: `${deviceMod}+D`,
onClick: () => clone(eventId, { after: eventId }),
},
{ type: 'divider' },
{
type: 'item',
label: 'Delete',
icon: IoTrash,
shortcut: `${deviceAlt}+Backspace`,
onClick: () => {
deleteEntry([eventId]);
unselect(eventId);
},
},
],
);
const {
isDragging,
attributes: dragAttributes,
listeners: dragListeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: eventId,
data: {
type: 'event',
parent,
},
animateLayoutChanges: () => false,
});
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform),
transition,
};
const binderColours = colour && getAccessibleColour(colour);
// move focus to element if necessary
useEffect(() => {
if (!hasCursor || handleRef?.current == null) {
return;
}
const elementInFocus = document.activeElement;
// we know the group is the grandparent of our binder
const blockElement = handleRef.current.closest('#event-group');
// we only move focus if the block doesnt already contain focus
if (blockElement && !blockElement.contains(elementInFocus)) {
handleRef.current.focus();
}
}, [hasCursor]);
const blockClasses = cx([
style.rundownEvent,
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) => {
event.stopPropagation();
// event.button === 2 is a right-click
// disable selection if the user selected events and right clicks
// so the context menu shows up
if (selectedEvents.size > 1 && event.button === 2) {
return;
}
// UI indexes are 1 based
const index = eventIndex - 1;
const editMode = getSelectionMode(event);
selectEntry({ id: eventId, index, selectMode: editMode });
};
const isPlaying = playback ? isPlaybackActive(playback) : false;
return (
<div
className={blockClasses}
ref={setNodeRef}
style={dragStyle}
onClick={handleFocusClick}
onContextMenu={onContextMenu}
data-testid='rundown-event'
{...(isPlaying ? { 'data-running': true } : {})}
>
<RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
<IoReorderTwo />
</span>
<span className={style.cue}>{cue}</span>
</div>
<RundownEventInner
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
linkStart={linkStart}
countToEnd={countToEnd}
timeStrategy={timeStrategy}
eventId={eventId}
eventIndex={eventIndex}
endAction={endAction}
timerType={timerType}
title={title}
note={note}
delay={delay}
automationsEnabled={automationsEnabled}
isNext={isNext}
skip={skip}
loaded={loaded}
playback={playback}
isRolling={isRolling}
dayOffset={dayOffset}
isPast={isPast}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
hasTriggers={hasTriggers}
/>
</div>
);
}