mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-13 19:33:46 +00:00
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:
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
Reference in New Issue
Block a user