mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-21 06:59:09 +00:00
355 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|