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; 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); 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(() => 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 (
{cue}
); }