From 7f63cde7a50d96a58a35290ab49192256c1c14f0 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Fri, 12 Jan 2024 13:07:56 +0100 Subject: [PATCH] feat: multiple selection (#703) feat: multiple event selection --------- Co-authored-by: asharonbaltazar Co-authored-by: Alex --- apps/client/src/common/api/eventsApi.ts | 13 ++ .../components/buttons/TooltipActionBtn.tsx | 2 +- .../components/context-menu/ContextMenu.tsx | 38 ++++-- .../context-menu/ContextMenuOption.tsx | 12 ++ .../client/src/common/hooks/useEventAction.ts | 55 ++++++++ apps/client/src/common/stores/appModeStore.ts | 56 ++------ .../src/features/event-editor/EventEditor.tsx | 11 +- .../event-editor/EventEditorExport.tsx | 15 ++- apps/client/src/features/menu/RundownMenu.tsx | 11 +- apps/client/src/features/rundown/Rundown.tsx | 28 ++-- .../src/features/rundown/RundownEntry.tsx | 92 ++++++++----- .../rundown/event-block/EventBlock.tsx | 125 +++++++++++++----- .../rundown/event-block/EventBlockInner.tsx | 28 ++-- .../src/features/rundown/useEventSelection.ts | 85 ++++++++++++ .../src/controllers/rundownController.ts | 15 +++ .../controllers/rundownController.validate.ts | 10 ++ apps/server/src/routes/rundownRouter.ts | 4 + .../rundown-service/RundownService.ts | 11 ++ .../rundown-service/delayedRundown.utils.ts | 18 ++- packages/types/src/utils/guards.ts | 2 +- 20 files changed, 460 insertions(+), 171 deletions(-) create mode 100644 apps/client/src/common/components/context-menu/ContextMenuOption.tsx create mode 100644 apps/client/src/features/rundown/useEventSelection.ts diff --git a/apps/client/src/common/api/eventsApi.ts b/apps/client/src/common/api/eventsApi.ts index 8196853e2..dacf16c43 100644 --- a/apps/client/src/common/api/eventsApi.ts +++ b/apps/client/src/common/api/eventsApi.ts @@ -38,6 +38,19 @@ export async function requestPutEvent(data: Partial) { return axios.put(rundownURL, data); } +type BatchEditEntry = { + data: Partial; + 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; diff --git a/apps/client/src/common/components/buttons/TooltipActionBtn.tsx b/apps/client/src/common/components/buttons/TooltipActionBtn.tsx index 2fcc65f8f..81f4de703 100644 --- a/apps/client/src/common/components/buttons/TooltipActionBtn.tsx +++ b/apps/client/src/common/components/buttons/TooltipActionBtn.tsx @@ -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; + clickHandler: (event: MouseEvent) => void | Promise; tooltip: string; openDelay?: number; } diff --git a/apps/client/src/common/components/context-menu/ContextMenu.tsx b/apps/client/src/common/components/context-menu/ContextMenu.tsx index 68d1932df..1726bafaa 100644 --- a/apps/client/src/common/components/context-menu/ContextMenu.tsx +++ b/apps/client/src/common/components/context-menu/ContextMenu.tsx @@ -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[]; +}; + +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) => { }} /> - {options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => ( - - {withDivider && } - } onClick={onClick} isDisabled={isDisabled}> - {label} - - - ))} + {options.map((option) => + isOptionWithGroup(option) ? ( + + {option.group.map((groupOption) => ( + + ))} + + ) : ( + + ), + )} diff --git a/apps/client/src/common/components/context-menu/ContextMenuOption.tsx b/apps/client/src/common/components/context-menu/ContextMenuOption.tsx new file mode 100644 index 000000000..8e8a6bfe9 --- /dev/null +++ b/apps/client/src/common/components/context-menu/ContextMenuOption.tsx @@ -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 && } + } onClick={onClick} isDisabled={isDisabled}> + {label} + + +); diff --git a/apps/client/src/common/hooks/useEventAction.ts b/apps/client/src/common/hooks/useEventAction.ts index cc54112a7..c695fe8f2 100644 --- a/apps/client/src/common/hooks/useEventAction.ts +++ b/apps/client/src/common/hooks/useEventAction.ts @@ -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(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, 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, }; }; diff --git a/apps/client/src/common/stores/appModeStore.ts b/apps/client/src/common/stores/appModeStore.ts index 3698443d3..ffbd0731d 100644 --- a/apps/client/src/common/stores/appModeStore.ts +++ b/apps/client/src/common/stores/appModeStore.ts @@ -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()((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 }; + }); + }, })); diff --git a/apps/client/src/features/event-editor/EventEditor.tsx b/apps/client/src/features/event-editor/EventEditor.tsx index 813fa9bde..6f0a79d34 100644 --- a/apps/client/src/features/event-editor/EventEditor.tsx +++ b/apps/client/src/features/event-editor/EventEditor.tsx @@ -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(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) => { diff --git a/apps/client/src/features/event-editor/EventEditorExport.tsx b/apps/client/src/features/event-editor/EventEditorExport.tsx index a845c342a..4605f5ce0 100644 --- a/apps/client/src/features/event-editor/EventEditorExport.tsx +++ b/apps/client/src/features/event-editor/EventEditorExport.tsx @@ -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 (
diff --git a/apps/client/src/features/menu/RundownMenu.tsx b/apps/client/src/features/menu/RundownMenu.tsx index 01ea78e5f..fe05c9363 100644 --- a/apps/client/src/features/menu/RundownMenu.tsx +++ b/apps/client/src/features/menu/RundownMenu.tsx @@ -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 (
diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 97fcf46ac..05ec9bbe0 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -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(null); const scrollRef = useRef(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} />
diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index b16969756..b6e9ccf02 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -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 = { id: data.id }; + // if selected events are more than one + // we need to bulk edit + if (selectedEvents.size > 1) { + const changes: Partial = { [field]: value }; + const rundown = ontimeQueryClient.getQueryData(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 ( { + 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); const [isVisible, setIsVisible] = useState(false); - const openId = useAppMode((state) => state.editId); - const [onContextMenu] = useContextMenu([ - { 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( + 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) { {isVisible && ( { 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; diff --git a/apps/client/src/features/rundown/useEventSelection.ts b/apps/client/src/features/rundown/useEventSelection.ts new file mode 100644 index 000000000..fad93bffd --- /dev/null +++ b/apps/client/src/features/rundown/useEventSelection.ts @@ -0,0 +1,85 @@ +import { isOntimeEvent, OntimeRundown } from 'ontime-types'; +import { create } from 'zustand'; + +export type EditMode = 'shift' | 'click' | 'ctrl'; + +interface EventSelectionStore { + selectedEvents: Set; + anchoredIndex: number | null; + setSelectedEvents: (selectionArgs: { id: string; index: number; rundown: OntimeRundown; editMode: EditMode }) => void; + clearSelectedEvents: () => void; +} + +export const useEventSelection = create()((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() }), +})); diff --git a/apps/server/src/controllers/rundownController.ts b/apps/server/src/controllers/rundownController.ts index b5d1bba5b..157e81e82 100644 --- a/apps/server/src/controllers/rundownController.ts +++ b/apps/server/src/controllers/rundownController.ts @@ -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; diff --git a/apps/server/src/controllers/rundownController.validate.ts b/apps/server/src/controllers/rundownController.validate.ts index 31d3230d4..aba619090 100644 --- a/apps/server/src/controllers/rundownController.validate.ts +++ b/apps/server/src/controllers/rundownController.validate.ts @@ -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(), diff --git a/apps/server/src/routes/rundownRouter.ts b/apps/server/src/routes/rundownRouter.ts index 86b134b88..cfc423640 100644 --- a/apps/server/src/routes/rundownRouter.ts +++ b/apps/server/src/routes/rundownRouter.ts @@ -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); diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 060a50c2c..9c45c3740 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -21,6 +21,7 @@ import { cachedClear, cachedDelete, cachedEdit, + cachedBatchEdit, cachedReorder, cachedSwap, delayedRundownCacheKey, @@ -212,6 +213,16 @@ export async function editEvent(eventData: Partial | Partial) { + 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 diff --git a/apps/server/src/services/rundown-service/delayedRundown.utils.ts b/apps/server/src/services/rundown-service/delayedRundown.utils.ts index 64ef7f877..067c96dfe 100644 --- a/apps/server/src/services/rundown-service/delayedRundown.utils.ts +++ b/apps/server/src/services/rundown-service/delayedRundown.utils.ts @@ -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) { + 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 diff --git a/packages/types/src/utils/guards.ts b/packages/types/src/utils/guards.ts index d1a596718..4d302ac70 100644 --- a/packages/types/src/utils/guards.ts +++ b/packages/types/src/utils/guards.ts @@ -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 | null | undefined; +type MaybeEvent = OntimeRundownEntry | Partial | null | undefined; export function isOntimeEvent(event: MaybeEvent): event is OntimeEvent { return event?.type === SupportedEvent.Event;