feat: multiple selection (#703)

feat: multiple event selection

---------

Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Alex <ac@omnivox.dk>
This commit is contained in:
Carlos Valente
2024-01-12 13:07:56 +01:00
committed by GitHub
parent f965db148c
commit 7f63cde7a5
20 changed files with 460 additions and 171 deletions
+13
View File
@@ -38,6 +38,19 @@ export async function requestPutEvent(data: Partial<OntimeRundownEntry>) {
return axios.put(rundownURL, data);
}
type BatchEditEntry = {
data: Partial<OntimeRundownEntry>;
ids: string[];
};
/**
* @description HTTP request to put multiple events
* @returns {Promise}
*/
export async function requestBatchPutEvents(data: BatchEditEntry) {
return axios.put(`${rundownURL}/batchEdit`, data);
}
export type ReorderEntry = {
eventId: string;
from: number;
@@ -2,7 +2,7 @@ import { MouseEvent } from 'react';
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
interface TooltipActionBtnProps extends IconButtonProps {
clickHandler: (event?: MouseEvent) => void | Promise<void>;
clickHandler: (event: MouseEvent) => void | Promise<void>;
tooltip: string;
openDelay?: number;
}
@@ -1,11 +1,13 @@
// logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { Fragment, ReactElement } from 'react';
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { ReactElement } from 'react';
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import { create } from 'zustand';
import { ContextMenuOption } from './ContextMenuOption';
import style from './ContextMenu.module.scss';
type ContextMenuCoords = {
@@ -13,14 +15,23 @@ type ContextMenuCoords = {
y: number;
};
export type Option = {
export type OptionWithoutGroup = {
label: string;
isDisabled?: boolean;
icon: IconType;
onClick: () => void;
withDivider?: boolean;
isDisabled?: boolean;
};
export type OptionWithGroup = {
label: string;
group: Omit<OptionWithoutGroup, 'isGroup'>[];
};
export type Option = OptionWithoutGroup | OptionWithGroup;
const isOptionWithGroup = (option: Option): option is OptionWithGroup => 'group' in option;
type ContextMenuStore = {
coords: ContextMenuCoords;
options: Option[];
@@ -69,14 +80,17 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
}}
/>
<MenuList>
{options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => (
<Fragment key={label}>
{withDivider && <MenuDivider />}
<MenuItem key={i} icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</Fragment>
))}
{options.map((option) =>
isOptionWithGroup(option) ? (
<MenuGroup key={option.label} title={option.label}>
{option.group.map((groupOption) => (
<ContextMenuOption key={groupOption.label} {...groupOption} />
))}
</MenuGroup>
) : (
<ContextMenuOption key={option.label} {...option} />
),
)}
</MenuList>
</Menu>
</>
@@ -0,0 +1,12 @@
import { MenuDivider, MenuItem } from '@chakra-ui/react';
import { OptionWithoutGroup } from './ContextMenu';
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
<>
{withDivider && <MenuDivider />}
<MenuItem icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</>
);
@@ -8,6 +8,7 @@ import { logAxiosError } from '../api/apiUtils';
import {
ReorderEntry,
requestApplyDelay,
requestBatchPutEvents,
requestDelete,
requestDeleteAll,
requestEventSwap,
@@ -163,6 +164,59 @@ export const useEventAction = () => {
[_updateEventMutation],
);
/**
* Calls mutation to edit multiple events
* @private
*/
const _batchUpdateEventsMutation = useMutation({
mutationFn: requestBatchPutEvents,
onMutate: async ({ ids, data }) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
if (previousEvents) {
const updatedEvents = previousEvents.rundown.map((event) => {
const isEventEdited = ids.includes(event.id);
if (isEventEdited && isOntimeEvent(event)) {
return {
...event,
...data,
};
}
return event;
});
queryClient.setQueryData(RUNDOWN, { rundown: updatedEvents, revision: -1 });
}
// Return a context with the previous and new events
return { previousEvents };
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousEvents);
},
networkMode: 'always',
});
const batchUpdateEvents = useCallback(
async (data: Partial<OntimeRundownEntry>, eventIds: string[]) => {
try {
await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data });
} catch (error) {
logAxiosError('Error updating events', error);
}
},
[_batchUpdateEventsMutation],
);
/**
* Calls mutation to delete an event
* @private
@@ -413,5 +467,6 @@ export const useEventAction = () => {
applyDelay,
reorderEvent,
swapEvents,
batchUpdateEvents,
};
};
+10 -46
View File
@@ -8,63 +8,27 @@ export enum AppMode {
const appModeKey = 'ontime-app-mode';
function getModeFromSession() {
return localStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
return sessionStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
}
async function persistModeToSession(mode: AppMode) {
localStorage.setItem(appModeKey, mode);
function persistModeToSession(mode: AppMode) {
sessionStorage.setItem(appModeKey, mode);
}
type AppModeStore = {
mode: AppMode;
cursor: string | null;
editId: string | null;
setMode: (mode: AppMode) => void;
setCursor: (id: string | null, isEditable?: boolean) => void;
setEditId: (id: string | null) => void;
};
export const useAppMode = create<AppModeStore>()((set) => ({
mode: getModeFromSession(),
cursor: null,
editId: null,
setMode: (mode: AppMode) =>
set((state) => {
persistModeToSession(mode);
return mode === AppMode.Edit
? {
editId: state.cursor,
mode: mode,
}
: {
editId: null,
mode: mode,
};
}),
setCursor: (id: string | null, isEditable?: boolean) =>
set((state) => {
if (isEditable) {
return state.mode === AppMode.Edit
? {
cursor: id,
editId: id,
}
: {
cursor: id,
};
} else {
return { cursor: id, editId: null };
}
}),
setEditId: (id: string | null) =>
set((state) => {
return state.mode === AppMode.Edit
? {
cursor: id,
editId: id,
}
: {
editId: id,
};
}),
setMode: (mode: AppMode) => {
persistModeToSession(mode);
return set(() => {
return { mode };
});
},
}));
@@ -4,7 +4,7 @@ import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import CopyTag from '../../common/components/copy-tag/CopyTag';
import { useEventAction } from '../../common/hooks/useEventAction';
import useRundown from '../../common/hooks-query/useRundown';
import { useAppMode } from '../../common/stores/appModeStore';
import { useEventSelection } from '../../features/rundown/useEventSelection';
import EventEditorDataLeft from './composite/EventEditorDataLeft';
import EventEditorDataRight from './composite/EventEditorDataRight';
@@ -16,23 +16,24 @@ export type EventEditorSubmitActions = keyof OntimeEvent;
export type EditorUpdateFields = 'cue' | 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
export default function EventEditor() {
const openId = useAppMode((state) => state.editId);
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown();
const { updateEvent } = useEventAction();
const [event, setEvent] = useState<OntimeEvent | null>(null);
useEffect(() => {
if (!data || !openId) {
if (!data) {
setEvent(null);
return;
}
const event = data.find((event) => event.id === openId);
const event = data.find((event) => selectedEvents.has(event.id));
if (event && isOntimeEvent(event)) {
setEvent(event);
}
}, [data, openId]);
}, [data, selectedEvents]);
const handleSubmit = useCallback(
(field: EditorUpdateFields, value: string) => {
@@ -3,19 +3,22 @@ import { IconButton } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import { useAppMode } from '../../common/stores/appModeStore';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { cx } from '../../common/utils/styleUtils';
import { useEventSelection } from '../rundown/useEventSelection';
import EventEditor from './EventEditor';
import style from './EventEditor.module.scss';
const EventEditorExport = () => {
const editId = useAppMode((state) => state.editId);
const setEditId = useAppMode((state) => state.setEditId);
const editorStyle = cx([style.eventEditorContainer, !editId ? style.noEvent : null]);
const removeOpenEvent = () => setEditId(null);
const { clearSelectedEvents, selectedEvents } = useEventSelection();
const { mode } = useAppMode();
const editorStyle = cx([
style.eventEditorContainer,
selectedEvents.size > 1 || selectedEvents.size === 0 || mode === AppMode.Run ? style.noEvent : null,
]);
const removeOpenEvent = () => clearSelectedEvents();
return (
<div className={editorStyle}>
@@ -7,13 +7,12 @@ import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline';
import { SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useAppMode } from '../../common/stores/appModeStore';
import { useEventSelection } from '../../features/rundown/useEventSelection';
import style from './RundownMenu.module.scss';
const RundownMenu = () => {
const setEditId = useAppMode((state) => state.setEditId);
const setCursor = useAppMode((state) => state.setCursor);
const { clearSelectedEvents } = useEventSelection();
const { addEvent, deleteAllEvents } = useEventAction();
@@ -31,9 +30,9 @@ const RundownMenu = () => {
const deleteAll = useCallback(() => {
deleteAllEvents();
setEditId(null);
setCursor(null);
}, [deleteAllEvents, setCursor, setEditId]);
clearSelectedEvents();
// setCursor(null);
}, [deleteAllEvents, clearSelectedEvents]);
return (
<div className={style.headerButtons}>
+14 -14
View File
@@ -36,10 +36,8 @@ export default function Rundown(props: RundownProps) {
const isExtracted = window.location.pathname.includes('/rundown');
// cursor
const cursor = useAppMode((state) => state.cursor);
const appMode = useAppMode((state) => state.mode);
const { cursor, mode: appMode } = useAppMode();
const viewFollowsCursor = appMode === AppMode.Run;
const moveCursorTo = useAppMode((state) => state.setCursor);
const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: cursorRef, scrollRef: scrollRef, doFollow: true });
@@ -84,13 +82,14 @@ export default function Rundown(props: RundownProps) {
);
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// handle held key
if (event.repeat) return;
// Check if the modifier combination
const modKeysAlt = event.altKey && !event.ctrlKey && !event.shiftKey;
const modKeysCtrlAlt = event.altKey && event.ctrlKey && !event.shiftKey;
if (modKeysAlt) {
switch (event.code) {
case 'ArrowDown': {
@@ -99,7 +98,7 @@ export default function Rundown(props: RundownProps) {
}
const nextEvent = cursor == null ? getFirst(entries) : getNext(entries, cursor)?.nextEvent;
if (nextEvent) {
moveCursorTo(nextEvent.id, nextEvent.type === SupportedEvent.Event);
// moveCursorTo(nextEvent.id, nextEvent.type === SupportedEvent.Event);
}
break;
}
@@ -110,7 +109,7 @@ export default function Rundown(props: RundownProps) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we check for this before
const previousEvent = cursor == null ? getFirst(entries) : getPrevious(entries, cursor).previousEvent;
if (previousEvent) {
moveCursorTo(previousEvent.id, previousEvent.type === SupportedEvent.Event);
// moveCursorTo(previousEvent.id, previousEvent.type === SupportedEvent.Event);
}
break;
}
@@ -152,7 +151,7 @@ export default function Rundown(props: RundownProps) {
}
}
},
[cursor, entries, insertAtCursor, moveCursorTo, reorderEvent],
[cursor, entries, insertAtCursor, reorderEvent],
);
// we copy the state from the store here
@@ -165,20 +164,20 @@ export default function Rundown(props: RundownProps) {
// listen to keys
useEffect(() => {
document.addEventListener('keydown', handleKeyPress);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyPress);
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyPress]);
}, [handleKeyDown]);
useEffect(() => {
// in run mode, we follow selection
if (!viewFollowsCursor || !featureData?.selectedEventId) {
return;
}
moveCursorTo(featureData.selectedEventId);
}, [featureData?.selectedEventId, viewFollowsCursor, moveCursorTo]);
// moveCursorTo(featureData.selectedEventId);
}, [featureData?.selectedEventId, viewFollowsCursor]);
const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
@@ -240,6 +239,7 @@ export default function Rundown(props: RundownProps) {
type={entry.type}
isPast={isPast}
isFirstEvent={isFirstEvent}
eventIndex={eventIndex}
data={entry}
selected={isSelected}
hasCursor={hasCursor}
@@ -248,7 +248,7 @@ export default function Rundown(props: RundownProps) {
previousEventId={previousEventId}
playback={isSelected ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
disableEdit={isExtracted}
disableEdit={isExtracted || appMode === AppMode.Run}
/>
</div>
</div>
@@ -1,5 +1,12 @@
import { useCallback } from 'react';
import { GetRundownCached, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import {
GetRundownCached,
isOntimeEvent,
OntimeEvent,
OntimeRundownEntry,
Playback,
SupportedEvent,
} from 'ontime-types';
import { calculateDuration, getCueCandidate } from 'ontime-utils';
import { RUNDOWN } from '../../common/api/apiConstants';
@@ -14,6 +21,7 @@ import { cloneEvent } from '../../common/utils/eventsManager';
import BlockBlock from './block-block/BlockBlock';
import DelayBlock from './delay-block/DelayBlock';
import EventBlock from './event-block/EventBlock';
import { useEventSelection } from './useEventSelection';
export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update' | 'swap';
@@ -23,6 +31,7 @@ interface RundownEntryProps {
isFirstEvent: boolean;
data: OntimeRundownEntry;
selected: boolean;
eventIndex: number;
hasCursor: boolean;
next: boolean;
previousEnd: number;
@@ -45,24 +54,22 @@ export default function RundownEntry(props: RundownEntryProps) {
isRolling,
disableEdit,
isFirstEvent,
eventIndex,
} = props;
const { emitError } = useEmitLog();
const { addEvent, updateEvent, deleteEvent, swapEvents } = useEventAction();
const cursor = useAppMode((state) => state.cursor);
const setCursor = useAppMode((state) => state.setCursor);
const openId = useAppMode((state) => state.editId);
const setEditId = useAppMode((state) => state.setEditId);
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
const { cursor } = useAppMode();
const { selectedEvents, clearSelectedEvents } = useEventSelection();
const removeOpenEvent = useCallback(() => {
if (openId === data.id) {
setEditId(null);
if (selectedEvents.has(data.id)) {
clearSelectedEvents();
}
if (cursor === data.id) {
setCursor(null);
// setCursor(null);
}
}, [cursor, data.id, openId, setCursor, setEditId]);
}, [cursor, data.id, selectedEvents, clearSelectedEvents]);
const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic;
@@ -84,29 +91,23 @@ export default function RundownEntry(props: RundownEntryProps) {
lastEventId: previousEventId,
after: data.id,
};
addEvent(newEvent, options);
break;
return addEvent(newEvent, options);
}
case 'delay': {
addEvent({ type: SupportedEvent.Delay }, { after: data.id });
break;
return addEvent({ type: SupportedEvent.Delay }, { after: data.id });
}
case 'block': {
addEvent({ type: SupportedEvent.Block }, { after: data.id });
break;
return addEvent({ type: SupportedEvent.Block }, { after: data.id });
}
case 'swap': {
const { value } = payload as FieldValue;
swapEvents({ from: value as string, to: data.id });
break;
return swapEvents({ from: value as string, to: data.id });
}
case 'delete': {
if (openId === data.id) {
if (selectedEvents.has(data.id)) {
removeOpenEvent();
}
deleteEvent(data.id);
break;
return deleteEvent(data.id);
}
case 'clone': {
const newEvent = cloneEvent(data as OntimeEvent, data.id);
@@ -120,27 +121,51 @@ export default function RundownEntry(props: RundownEntryProps) {
const { field, value } = payload as FieldValue;
const newData: Partial<OntimeEvent> = { id: data.id };
// if selected events are more than one
// we need to bulk edit
if (selectedEvents.size > 1) {
const changes: Partial<OntimeEvent> = { [field]: value };
const rundown = ontimeQueryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
const idsOfRundownEvents = rundown.filter(isOntimeEvent).map((event) => event.id);
const eventIds = [...selectedEvents.keys()];
// check every selected event id to see if they match rundown event ids
const areIdsValid = eventIds.every((eventId) => idsOfRundownEvents.includes(eventId));
if (!areIdsValid) {
return;
}
batchUpdateEvents(changes, eventIds);
return clearSelectedEvents();
}
if (field === 'durationOverride' && data.type === SupportedEvent.Event) {
// duration defines timeEnd
newData.duration = value as number;
newData.timeEnd = data.timeStart + (value as number);
updateEvent(newData);
} else if (field === 'timeStart' && data.type === SupportedEvent.Event) {
return updateEvent(newData);
}
if (field === 'timeStart' && data.type === SupportedEvent.Event) {
newData.duration = calculateDuration(value as number, data.timeEnd);
newData.timeStart = value as number;
updateEvent(newData);
} else if (field === 'timeEnd' && data.type === SupportedEvent.Event) {
return updateEvent(newData);
}
if (field === 'timeEnd' && data.type === SupportedEvent.Event) {
newData.duration = calculateDuration(data.timeStart, value as number);
newData.timeEnd = value as number;
updateEvent(newData);
} else if (field in data) {
return updateEvent(newData);
}
if (field in data) {
// @ts-expect-error not sure how to type this
newData[field] = value;
updateEvent(newData);
} else {
emitError(`Unknown field: ${field}`);
return updateEvent(newData);
}
break;
return emitError(`Unknown field: ${field}`);
}
default:
throw new Error(`Unhandled event ${action}`);
@@ -150,6 +175,7 @@ export default function RundownEntry(props: RundownEntryProps) {
if (data.type === SupportedEvent.Event) {
return (
<EventBlock
eventIndex={eventIndex}
cue={data.cue}
timeStart={data.timeStart}
timeEnd={data.timeEnd}
@@ -3,28 +3,44 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoCopyOutline } from '@react-icons/all-files/io5/IoCopyOutline';
import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { EndAction, OntimeEvent, Playback, TimerType } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useAppMode } from '../../../common/stores/appModeStore';
import useRundown from '../../../common/hooks-query/useRundown';
import copyToClipboard from '../../../common/utils/copyToClipboard';
import { isMacOS } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import type { EventItemActions } from '../RundownEntry';
import { useEventIdSwapping } from '../useEventIdSwapping';
import { EditMode, useEventSelection } from '../useEventSelection';
import EventBlockInner from './EventBlockInner';
import style from './EventBlock.module.scss';
const getEditMode = (event: MouseEvent): EditMode => {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
};
interface EventBlockProps {
cue: string;
timeStart: number;
timeEnd: number;
duration: number;
eventId: string;
eventIndex: number;
isPublic: boolean;
endAction: EndAction;
timerType: TimerType;
@@ -61,6 +77,7 @@ export default function EventBlock(props: EventBlockProps) {
timeEnd,
duration,
isPublic = true,
eventIndex,
endAction,
timerType,
title,
@@ -80,37 +97,66 @@ export default function EventBlock(props: EventBlockProps) {
isFirstEvent,
} = props;
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const moveCursorTo = useAppMode((state) => state.setCursor);
const { selectedEvents, setSelectedEvents } = useEventSelection();
const { data: rundown = [] } = useRundown();
const handleRef = useRef<null | HTMLSpanElement>(null);
const [isVisible, setIsVisible] = useState(false);
const openId = useAppMode((state) => state.editId);
const [onContextMenu] = useContextMenu<HTMLDivElement>([
{ label: `Copy ID: ${eventId}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) },
{
label: 'Toggle public',
icon: IoPeopleOutline,
onClick: () =>
actionHandler('update', {
field: 'isPublic',
value: !isPublic,
}),
},
{
label: 'Add to swap',
icon: IoAdd,
onClick: () => setSelectedEventId(eventId),
withDivider: true,
},
{
label: `Swap this event with ${selectedEventId ?? ''}`,
icon: IoSwapVertical,
onClick: () => {
actionHandler('swap', { field: 'id', value: selectedEventId });
clearSelectedEventId();
},
isDisabled: selectedEventId == null || selectedEventId === eventId,
},
]);
const [onContextMenu] = useContextMenu<HTMLDivElement>(
selectedEvents.size > 1
? [
{
label: 'Visiblity',
group: [
{
label: 'Make public',
icon: IoPeople,
onClick: () =>
actionHandler('update', {
field: 'isPublic',
value: true,
}),
},
{
label: 'Make private',
icon: IoPeopleOutline,
onClick: () =>
actionHandler('update', {
field: 'isPublic',
value: false,
}),
},
],
},
]
: [
{ label: `Copy ID: ${eventId}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) },
{
label: 'Toggle public',
icon: IoPeopleOutline,
onClick: () =>
actionHandler('update', {
field: 'isPublic',
value: !isPublic,
}),
},
{
label: 'Add to swap',
icon: IoAdd,
onClick: () => setSelectedEventId(eventId),
withDivider: true,
},
{
label: `Swap this event with ${selectedEventId ?? ''}`,
icon: IoSwapVertical,
onClick: () => {
actionHandler('swap', { field: 'id', value: selectedEventId });
clearSelectedEventId();
},
isDisabled: selectedEventId == null || selectedEventId === eventId,
},
],
);
const {
isDragging,
@@ -179,12 +225,23 @@ export default function EventBlock(props: EventBlockProps) {
isPast ? style.past : null,
selected ? style.selected : null,
playback ? style[playback] : null,
hasCursor ? style.hasCursor : null,
selectedEvents.has(eventId) ? style.hasCursor : null,
]);
const handleFocusClick = (event: MouseEvent) => {
event.stopPropagation();
moveCursorTo(eventId, true);
// 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;
}
const editMode = getEditMode(event);
return setSelectedEvents({ id: eventId, index: eventIndex, rundown, editMode });
// moveCursorTo(eventId, true);
};
return (
@@ -192,7 +249,7 @@ export default function EventBlock(props: EventBlockProps) {
className={blockClasses}
ref={setNodeRef}
style={dragStyle}
onClick={handleFocusClick}
onMouseDown={handleFocusClick}
onContextMenu={onContextMenu}
id='event-block'
>
@@ -204,11 +261,11 @@ export default function EventBlock(props: EventBlockProps) {
</div>
{isVisible && (
<EventBlockInner
isOpen={openId === eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
eventId={eventId}
eventIndex={eventIndex}
isPublic={isPublic}
endAction={endAction}
timerType={timerType}
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useState } from 'react';
import { memo, MouseEvent, useCallback, useEffect, useState } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { BiArrowToBottom } from '@react-icons/all-files/bi/BiArrowToBottom';
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
@@ -14,11 +14,11 @@ import { EndAction, Playback, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { useAppMode } from '../../../common/stores/appModeStore';
import { millisToDelayString } from '../../../common/utils/dateConfig';
import { tooltipDelayMid } from '../../../ontimeConfig';
import EditableBlockTitle from '../common/EditableBlockTitle';
import { EventItemActions } from '../RundownEntry';
import { useEventSelection } from '../useEventSelection';
import BlockActionMenu from './composite/BlockActionMenu';
import EventBlockPlayback from './composite/EventBlockPlayback';
@@ -36,11 +36,11 @@ const tooltipProps = {
};
interface EventBlockInnerProps {
isOpen: boolean;
timeStart: number;
timeEnd: number;
duration: number;
eventId: string;
eventIndex: number;
isPublic: boolean;
endAction: EndAction;
timerType: TimerType;
@@ -60,7 +60,6 @@ interface EventBlockInnerProps {
const EventBlockInner = (props: EventBlockInnerProps) => {
const {
isOpen,
timeStart,
timeEnd,
duration,
@@ -83,19 +82,24 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
} = props;
const [renderInner, setRenderInner] = useState(false);
const setEditId = useAppMode((state) => state.setEditId);
const { clearSelectedEvents, selectedEvents } = useEventSelection();
const isOpen = selectedEvents.size === 1 && selectedEvents.has(eventId);
useEffect(() => {
setRenderInner(true);
}, []);
const toggleOpenEvent = useCallback(() => {
if (isOpen) {
setEditId(null);
} else {
setEditId(eventId);
}
}, [eventId, isOpen, setEditId]);
//TODO: fix this
const toggleOpenEvent = useCallback(
(_event: MouseEvent) => {
// if (isOpen) {
// event.stopPropagation();
// clearSelectedEvents();
// }
},
[clearSelectedEvents, isOpen],
);
const eventIsPlaying = playback === Playback.Play;
const eventIsPaused = playback === Playback.Pause;
@@ -0,0 +1,85 @@
import { isOntimeEvent, OntimeRundown } from 'ontime-types';
import { create } from 'zustand';
export type EditMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore {
selectedEvents: Set<string>;
anchoredIndex: number | null;
setSelectedEvents: (selectionArgs: { id: string; index: number; rundown: OntimeRundown; editMode: EditMode }) => void;
clearSelectedEvents: () => void;
}
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
setSelectedEvents: (selectionArgs) => {
const { id, index: eventIndex, rundown, editMode } = selectionArgs;
// event indexes are not 0 based
const index = eventIndex - 1;
const { selectedEvents, anchoredIndex } = get();
if (editMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index });
}
if (editMode === 'ctrl') {
if (selectedEvents.has(id)) {
const eventIds = rundown.reduce(
(newRundown, event, i) => {
if (isOntimeEvent(event) && selectedEvents.has(id)) {
return newRundown.concat({ id: event.id, index: i });
}
return newRundown;
},
[] as { id: string; index: number }[],
);
// find the next available higher index
// if unavailable, then grab the last index of events
const newAnchoredIndex = eventIds.find(({ index: eventIndex }) => eventIndex > index) ?? eventIds.at(-1);
selectedEvents.delete(id);
return set({
selectedEvents: selectedEvents,
anchoredIndex: newAnchoredIndex?.index ?? 0,
});
}
return set({
selectedEvents: selectedEvents.add(id),
anchoredIndex: index,
});
}
if (editMode === 'shift') {
const eventIds = rundown.filter(isOntimeEvent);
if (anchoredIndex === null) {
const eventsUntilIndex = eventIds.slice(0, eventIndex).map((event) => event.id);
return set({ selectedEvents: new Set(eventsUntilIndex), anchoredIndex: index });
}
if (anchoredIndex > index) {
const eventsFromIndex = eventIds.slice(index, anchoredIndex + 1).map((event) => event.id);
return set({
selectedEvents: new Set([...selectedEvents, ...eventsFromIndex]),
anchoredIndex: index,
});
}
const eventsUntilIndex = eventIds.slice(anchoredIndex, eventIndex).map((event) => event.id);
return set({
selectedEvents: new Set([...selectedEvents, ...eventsUntilIndex]),
anchoredIndex: index,
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set() }),
}));
@@ -6,6 +6,7 @@ import { failEmptyObjects } from '../utils/routerUtils.js';
import {
addEvent,
applyDelay,
batchEditEvents,
deleteAllEvents,
deleteEvent,
editEvent,
@@ -58,6 +59,20 @@ export const rundownPut: RequestHandler = async (req, res) => {
}
};
export const rundownBatchPut: RequestHandler = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return res.status(404);
}
try {
const { data, ids } = req.body;
await batchEditEvents(ids, data);
res.status(200);
} catch (error) {
res.status(400).send(error);
}
};
export const rundownReorder: RequestHandler = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
@@ -18,6 +18,16 @@ export const rundownPutValidator = [
},
];
export const rundownBatchPutValidator = [
body('data').isObject().exists(),
body('ids').isArray().exists(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const rundownReorderValidator = [
body('eventId').isString().exists(),
body('from').isNumeric().exists(),
+4
View File
@@ -9,9 +9,11 @@ import {
rundownPut,
rundownReorder,
rundownSwap,
rundownBatchPut,
} from '../controllers/rundownController.js';
import {
paramsMustHaveEventId,
rundownBatchPutValidator,
rundownPostValidator,
rundownPutValidator,
rundownReorderValidator,
@@ -32,6 +34,8 @@ router.post('/', rundownPostValidator, rundownPost);
// create route between controller and '/events/' endpoint
router.put('/', rundownPutValidator, rundownPut);
router.put('/batchEdit', rundownBatchPutValidator, rundownBatchPut);
// create route between controller and '/events/reorder' endpoint
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
@@ -21,6 +21,7 @@ import {
cachedClear,
cachedDelete,
cachedEdit,
cachedBatchEdit,
cachedReorder,
cachedSwap,
delayedRundownCacheKey,
@@ -212,6 +213,16 @@ export async function editEvent(eventData: Partial<OntimeEvent> | Partial<Ontime
return newEvent;
}
export async function batchEditEvents(ids: string[], data: Partial<OntimeEvent>) {
await cachedBatchEdit(ids, data);
// notify timer service of changed events
updateTimer(ids);
// advice socket subscribers of change
sendRefetch();
}
/**
* deletes event by its ID
* @param eventId
@@ -115,7 +115,17 @@ export async function cachedEdit(
}
const updatedRundown = DataProvider.getRundown();
const newEvent = { ...updatedRundown[indexInMemory], ...patchObject } as OntimeRundownEntry;
const eventFromRundown = updatedRundown[indexInMemory];
const isPatchObjectDifferentFromRundownEvent = Object.entries(patchObject).some(
([key, value]) => eventFromRundown[key] !== value,
);
if (!isPatchObjectDifferentFromRundownEvent) {
return eventFromRundown;
}
const newEvent = { ...eventFromRundown, ...patchObject } as OntimeRundownEntry;
if (isOntimeEvent(newEvent)) {
newEvent.revision++;
}
@@ -144,6 +154,12 @@ export async function cachedEdit(
return newEvent;
}
export async function cachedBatchEdit(ids: string[], patchObject: Partial<OntimeEvent>) {
const cachedEdits = ids.map((id) => cachedEdit(id, patchObject));
await Promise.allSettled(cachedEdits);
}
/**
* Deletes an event with given id from rundown, ensuring replication to delayed rundown cache
* @param eventId
+1 -1
View File
@@ -1,7 +1,7 @@
import { OntimeRundownEntry } from '../definitions/core/Rundown.type.js';
import { OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent } from '../definitions/core/OntimeEvent.type.js';
type MaybeEvent = Partial<OntimeRundownEntry> | null | undefined;
type MaybeEvent = OntimeRundownEntry | Partial<OntimeRundownEntry> | null | undefined;
export function isOntimeEvent(event: MaybeEvent): event is OntimeEvent {
return event?.type === SupportedEvent.Event;