mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 08:39:34 +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);
|
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 = {
|
export type ReorderEntry = {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
from: number;
|
from: number;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { MouseEvent } from 'react';
|
|||||||
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
|
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
|
||||||
|
|
||||||
interface TooltipActionBtnProps extends IconButtonProps {
|
interface TooltipActionBtnProps extends IconButtonProps {
|
||||||
clickHandler: (event?: MouseEvent) => void | Promise<void>;
|
clickHandler: (event: MouseEvent) => void | Promise<void>;
|
||||||
tooltip: string;
|
tooltip: string;
|
||||||
openDelay?: number;
|
openDelay?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
// logic (with some modifications) culled from:
|
// logic (with some modifications) culled from:
|
||||||
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
|
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
|
||||||
|
|
||||||
import { Fragment, ReactElement } from 'react';
|
import { ReactElement } from 'react';
|
||||||
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
|
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
|
||||||
import { IconType } from '@react-icons/all-files';
|
import { IconType } from '@react-icons/all-files';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
import { ContextMenuOption } from './ContextMenuOption';
|
||||||
|
|
||||||
import style from './ContextMenu.module.scss';
|
import style from './ContextMenu.module.scss';
|
||||||
|
|
||||||
type ContextMenuCoords = {
|
type ContextMenuCoords = {
|
||||||
@@ -13,14 +15,23 @@ type ContextMenuCoords = {
|
|||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Option = {
|
export type OptionWithoutGroup = {
|
||||||
label: string;
|
label: string;
|
||||||
|
isDisabled?: boolean;
|
||||||
icon: IconType;
|
icon: IconType;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
withDivider?: boolean;
|
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 = {
|
type ContextMenuStore = {
|
||||||
coords: ContextMenuCoords;
|
coords: ContextMenuCoords;
|
||||||
options: Option[];
|
options: Option[];
|
||||||
@@ -69,14 +80,17 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<MenuList>
|
<MenuList>
|
||||||
{options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => (
|
{options.map((option) =>
|
||||||
<Fragment key={label}>
|
isOptionWithGroup(option) ? (
|
||||||
{withDivider && <MenuDivider />}
|
<MenuGroup key={option.label} title={option.label}>
|
||||||
<MenuItem key={i} icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
|
{option.group.map((groupOption) => (
|
||||||
{label}
|
<ContextMenuOption key={groupOption.label} {...groupOption} />
|
||||||
</MenuItem>
|
))}
|
||||||
</Fragment>
|
</MenuGroup>
|
||||||
))}
|
) : (
|
||||||
|
<ContextMenuOption key={option.label} {...option} />
|
||||||
|
),
|
||||||
|
)}
|
||||||
</MenuList>
|
</MenuList>
|
||||||
</Menu>
|
</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 {
|
import {
|
||||||
ReorderEntry,
|
ReorderEntry,
|
||||||
requestApplyDelay,
|
requestApplyDelay,
|
||||||
|
requestBatchPutEvents,
|
||||||
requestDelete,
|
requestDelete,
|
||||||
requestDeleteAll,
|
requestDeleteAll,
|
||||||
requestEventSwap,
|
requestEventSwap,
|
||||||
@@ -163,6 +164,59 @@ export const useEventAction = () => {
|
|||||||
[_updateEventMutation],
|
[_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
|
* Calls mutation to delete an event
|
||||||
* @private
|
* @private
|
||||||
@@ -413,5 +467,6 @@ export const useEventAction = () => {
|
|||||||
applyDelay,
|
applyDelay,
|
||||||
reorderEvent,
|
reorderEvent,
|
||||||
swapEvents,
|
swapEvents,
|
||||||
|
batchUpdateEvents,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,63 +8,27 @@ export enum AppMode {
|
|||||||
const appModeKey = 'ontime-app-mode';
|
const appModeKey = 'ontime-app-mode';
|
||||||
|
|
||||||
function getModeFromSession() {
|
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) {
|
function persistModeToSession(mode: AppMode) {
|
||||||
localStorage.setItem(appModeKey, mode);
|
sessionStorage.setItem(appModeKey, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppModeStore = {
|
type AppModeStore = {
|
||||||
mode: AppMode;
|
mode: AppMode;
|
||||||
cursor: string | null;
|
cursor: string | null;
|
||||||
editId: string | null;
|
|
||||||
setMode: (mode: AppMode) => void;
|
setMode: (mode: AppMode) => void;
|
||||||
setCursor: (id: string | null, isEditable?: boolean) => void;
|
|
||||||
setEditId: (id: string | null) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAppMode = create<AppModeStore>()((set) => ({
|
export const useAppMode = create<AppModeStore>()((set) => ({
|
||||||
mode: getModeFromSession(),
|
mode: getModeFromSession(),
|
||||||
cursor: null,
|
cursor: null,
|
||||||
editId: null,
|
setMode: (mode: AppMode) => {
|
||||||
setMode: (mode: AppMode) =>
|
persistModeToSession(mode);
|
||||||
set((state) => {
|
|
||||||
persistModeToSession(mode);
|
return set(() => {
|
||||||
return mode === AppMode.Edit
|
return { mode };
|
||||||
? {
|
});
|
||||||
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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { isOntimeEvent, OntimeEvent } from 'ontime-types';
|
|||||||
import CopyTag from '../../common/components/copy-tag/CopyTag';
|
import CopyTag from '../../common/components/copy-tag/CopyTag';
|
||||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||||
import useRundown from '../../common/hooks-query/useRundown';
|
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 EventEditorDataLeft from './composite/EventEditorDataLeft';
|
||||||
import EventEditorDataRight from './composite/EventEditorDataRight';
|
import EventEditorDataRight from './composite/EventEditorDataRight';
|
||||||
@@ -16,23 +16,24 @@ export type EventEditorSubmitActions = keyof OntimeEvent;
|
|||||||
export type EditorUpdateFields = 'cue' | 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
|
export type EditorUpdateFields = 'cue' | 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
|
||||||
|
|
||||||
export default function EventEditor() {
|
export default function EventEditor() {
|
||||||
const openId = useAppMode((state) => state.editId);
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
const { data } = useRundown();
|
const { data } = useRundown();
|
||||||
const { updateEvent } = useEventAction();
|
const { updateEvent } = useEventAction();
|
||||||
|
|
||||||
const [event, setEvent] = useState<OntimeEvent | null>(null);
|
const [event, setEvent] = useState<OntimeEvent | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || !openId) {
|
if (!data) {
|
||||||
setEvent(null);
|
setEvent(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = data.find((event) => event.id === openId);
|
const event = data.find((event) => selectedEvents.has(event.id));
|
||||||
|
|
||||||
if (event && isOntimeEvent(event)) {
|
if (event && isOntimeEvent(event)) {
|
||||||
setEvent(event);
|
setEvent(event);
|
||||||
}
|
}
|
||||||
}, [data, openId]);
|
}, [data, selectedEvents]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(field: EditorUpdateFields, value: string) => {
|
(field: EditorUpdateFields, value: string) => {
|
||||||
|
|||||||
@@ -3,19 +3,22 @@ import { IconButton } from '@chakra-ui/react';
|
|||||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
||||||
|
|
||||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
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 { cx } from '../../common/utils/styleUtils';
|
||||||
|
import { useEventSelection } from '../rundown/useEventSelection';
|
||||||
|
|
||||||
import EventEditor from './EventEditor';
|
import EventEditor from './EventEditor';
|
||||||
|
|
||||||
import style from './EventEditor.module.scss';
|
import style from './EventEditor.module.scss';
|
||||||
|
|
||||||
const EventEditorExport = () => {
|
const EventEditorExport = () => {
|
||||||
const editId = useAppMode((state) => state.editId);
|
const { clearSelectedEvents, selectedEvents } = useEventSelection();
|
||||||
const setEditId = useAppMode((state) => state.setEditId);
|
const { mode } = useAppMode();
|
||||||
|
const editorStyle = cx([
|
||||||
const editorStyle = cx([style.eventEditorContainer, !editId ? style.noEvent : null]);
|
style.eventEditorContainer,
|
||||||
const removeOpenEvent = () => setEditId(null);
|
selectedEvents.size > 1 || selectedEvents.size === 0 || mode === AppMode.Run ? style.noEvent : null,
|
||||||
|
]);
|
||||||
|
const removeOpenEvent = () => clearSelectedEvents();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={editorStyle}>
|
<div className={editorStyle}>
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline';
|
|||||||
import { SupportedEvent } from 'ontime-types';
|
import { SupportedEvent } from 'ontime-types';
|
||||||
|
|
||||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||||
import { useAppMode } from '../../common/stores/appModeStore';
|
import { useEventSelection } from '../../features/rundown/useEventSelection';
|
||||||
|
|
||||||
import style from './RundownMenu.module.scss';
|
import style from './RundownMenu.module.scss';
|
||||||
|
|
||||||
const RundownMenu = () => {
|
const RundownMenu = () => {
|
||||||
const setEditId = useAppMode((state) => state.setEditId);
|
const { clearSelectedEvents } = useEventSelection();
|
||||||
const setCursor = useAppMode((state) => state.setCursor);
|
|
||||||
|
|
||||||
const { addEvent, deleteAllEvents } = useEventAction();
|
const { addEvent, deleteAllEvents } = useEventAction();
|
||||||
|
|
||||||
@@ -31,9 +30,9 @@ const RundownMenu = () => {
|
|||||||
|
|
||||||
const deleteAll = useCallback(() => {
|
const deleteAll = useCallback(() => {
|
||||||
deleteAllEvents();
|
deleteAllEvents();
|
||||||
setEditId(null);
|
clearSelectedEvents();
|
||||||
setCursor(null);
|
// setCursor(null);
|
||||||
}, [deleteAllEvents, setCursor, setEditId]);
|
}, [deleteAllEvents, clearSelectedEvents]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.headerButtons}>
|
<div className={style.headerButtons}>
|
||||||
|
|||||||
@@ -36,10 +36,8 @@ export default function Rundown(props: RundownProps) {
|
|||||||
const isExtracted = window.location.pathname.includes('/rundown');
|
const isExtracted = window.location.pathname.includes('/rundown');
|
||||||
|
|
||||||
// cursor
|
// cursor
|
||||||
const cursor = useAppMode((state) => state.cursor);
|
const { cursor, mode: appMode } = useAppMode();
|
||||||
const appMode = useAppMode((state) => state.mode);
|
|
||||||
const viewFollowsCursor = appMode === AppMode.Run;
|
const viewFollowsCursor = appMode === AppMode.Run;
|
||||||
const moveCursorTo = useAppMode((state) => state.setCursor);
|
|
||||||
const cursorRef = useRef<HTMLDivElement | null>(null);
|
const cursorRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
useFollowComponent({ followRef: cursorRef, scrollRef: scrollRef, doFollow: true });
|
useFollowComponent({ followRef: cursorRef, scrollRef: scrollRef, doFollow: true });
|
||||||
@@ -84,13 +82,14 @@ export default function Rundown(props: RundownProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Handle keyboard shortcuts
|
// Handle keyboard shortcuts
|
||||||
const handleKeyPress = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
// handle held key
|
// handle held key
|
||||||
if (event.repeat) return;
|
if (event.repeat) return;
|
||||||
// Check if the modifier combination
|
|
||||||
const modKeysAlt = event.altKey && !event.ctrlKey && !event.shiftKey;
|
const modKeysAlt = event.altKey && !event.ctrlKey && !event.shiftKey;
|
||||||
const modKeysCtrlAlt = event.altKey && event.ctrlKey && !event.shiftKey;
|
const modKeysCtrlAlt = event.altKey && event.ctrlKey && !event.shiftKey;
|
||||||
|
|
||||||
if (modKeysAlt) {
|
if (modKeysAlt) {
|
||||||
switch (event.code) {
|
switch (event.code) {
|
||||||
case 'ArrowDown': {
|
case 'ArrowDown': {
|
||||||
@@ -99,7 +98,7 @@ export default function Rundown(props: RundownProps) {
|
|||||||
}
|
}
|
||||||
const nextEvent = cursor == null ? getFirst(entries) : getNext(entries, cursor)?.nextEvent;
|
const nextEvent = cursor == null ? getFirst(entries) : getNext(entries, cursor)?.nextEvent;
|
||||||
if (nextEvent) {
|
if (nextEvent) {
|
||||||
moveCursorTo(nextEvent.id, nextEvent.type === SupportedEvent.Event);
|
// moveCursorTo(nextEvent.id, nextEvent.type === SupportedEvent.Event);
|
||||||
}
|
}
|
||||||
break;
|
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
|
// 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;
|
const previousEvent = cursor == null ? getFirst(entries) : getPrevious(entries, cursor).previousEvent;
|
||||||
if (previousEvent) {
|
if (previousEvent) {
|
||||||
moveCursorTo(previousEvent.id, previousEvent.type === SupportedEvent.Event);
|
// moveCursorTo(previousEvent.id, previousEvent.type === SupportedEvent.Event);
|
||||||
}
|
}
|
||||||
break;
|
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
|
// we copy the state from the store here
|
||||||
@@ -165,20 +164,20 @@ export default function Rundown(props: RundownProps) {
|
|||||||
|
|
||||||
// listen to keys
|
// listen to keys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.addEventListener('keydown', handleKeyPress);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyPress);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [handleKeyPress]);
|
}, [handleKeyDown]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// in run mode, we follow selection
|
// in run mode, we follow selection
|
||||||
if (!viewFollowsCursor || !featureData?.selectedEventId) {
|
if (!viewFollowsCursor || !featureData?.selectedEventId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
moveCursorTo(featureData.selectedEventId);
|
// moveCursorTo(featureData.selectedEventId);
|
||||||
}, [featureData?.selectedEventId, viewFollowsCursor, moveCursorTo]);
|
}, [featureData?.selectedEventId, viewFollowsCursor]);
|
||||||
|
|
||||||
const handleOnDragEnd = (event: DragEndEvent) => {
|
const handleOnDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
@@ -240,6 +239,7 @@ export default function Rundown(props: RundownProps) {
|
|||||||
type={entry.type}
|
type={entry.type}
|
||||||
isPast={isPast}
|
isPast={isPast}
|
||||||
isFirstEvent={isFirstEvent}
|
isFirstEvent={isFirstEvent}
|
||||||
|
eventIndex={eventIndex}
|
||||||
data={entry}
|
data={entry}
|
||||||
selected={isSelected}
|
selected={isSelected}
|
||||||
hasCursor={hasCursor}
|
hasCursor={hasCursor}
|
||||||
@@ -248,7 +248,7 @@ export default function Rundown(props: RundownProps) {
|
|||||||
previousEventId={previousEventId}
|
previousEventId={previousEventId}
|
||||||
playback={isSelected ? featureData.playback : undefined}
|
playback={isSelected ? featureData.playback : undefined}
|
||||||
isRolling={featureData.playback === Playback.Roll}
|
isRolling={featureData.playback === Playback.Roll}
|
||||||
disableEdit={isExtracted}
|
disableEdit={isExtracted || appMode === AppMode.Run}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useCallback } from 'react';
|
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 { calculateDuration, getCueCandidate } from 'ontime-utils';
|
||||||
|
|
||||||
import { RUNDOWN } from '../../common/api/apiConstants';
|
import { RUNDOWN } from '../../common/api/apiConstants';
|
||||||
@@ -14,6 +21,7 @@ import { cloneEvent } from '../../common/utils/eventsManager';
|
|||||||
import BlockBlock from './block-block/BlockBlock';
|
import BlockBlock from './block-block/BlockBlock';
|
||||||
import DelayBlock from './delay-block/DelayBlock';
|
import DelayBlock from './delay-block/DelayBlock';
|
||||||
import EventBlock from './event-block/EventBlock';
|
import EventBlock from './event-block/EventBlock';
|
||||||
|
import { useEventSelection } from './useEventSelection';
|
||||||
|
|
||||||
export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update' | 'swap';
|
export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update' | 'swap';
|
||||||
|
|
||||||
@@ -23,6 +31,7 @@ interface RundownEntryProps {
|
|||||||
isFirstEvent: boolean;
|
isFirstEvent: boolean;
|
||||||
data: OntimeRundownEntry;
|
data: OntimeRundownEntry;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
eventIndex: number;
|
||||||
hasCursor: boolean;
|
hasCursor: boolean;
|
||||||
next: boolean;
|
next: boolean;
|
||||||
previousEnd: number;
|
previousEnd: number;
|
||||||
@@ -45,24 +54,22 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
isRolling,
|
isRolling,
|
||||||
disableEdit,
|
disableEdit,
|
||||||
isFirstEvent,
|
isFirstEvent,
|
||||||
|
eventIndex,
|
||||||
} = props;
|
} = props;
|
||||||
const { emitError } = useEmitLog();
|
const { emitError } = useEmitLog();
|
||||||
const { addEvent, updateEvent, deleteEvent, swapEvents } = useEventAction();
|
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
|
||||||
|
const { cursor } = useAppMode();
|
||||||
const cursor = useAppMode((state) => state.cursor);
|
const { selectedEvents, clearSelectedEvents } = useEventSelection();
|
||||||
const setCursor = useAppMode((state) => state.setCursor);
|
|
||||||
const openId = useAppMode((state) => state.editId);
|
|
||||||
const setEditId = useAppMode((state) => state.setEditId);
|
|
||||||
|
|
||||||
const removeOpenEvent = useCallback(() => {
|
const removeOpenEvent = useCallback(() => {
|
||||||
if (openId === data.id) {
|
if (selectedEvents.has(data.id)) {
|
||||||
setEditId(null);
|
clearSelectedEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cursor === data.id) {
|
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 eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||||
const defaultPublic = eventSettings.defaultPublic;
|
const defaultPublic = eventSettings.defaultPublic;
|
||||||
@@ -84,29 +91,23 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
lastEventId: previousEventId,
|
lastEventId: previousEventId,
|
||||||
after: data.id,
|
after: data.id,
|
||||||
};
|
};
|
||||||
addEvent(newEvent, options);
|
return addEvent(newEvent, options);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case 'delay': {
|
case 'delay': {
|
||||||
addEvent({ type: SupportedEvent.Delay }, { after: data.id });
|
return addEvent({ type: SupportedEvent.Delay }, { after: data.id });
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case 'block': {
|
case 'block': {
|
||||||
addEvent({ type: SupportedEvent.Block }, { after: data.id });
|
return addEvent({ type: SupportedEvent.Block }, { after: data.id });
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case 'swap': {
|
case 'swap': {
|
||||||
const { value } = payload as FieldValue;
|
const { value } = payload as FieldValue;
|
||||||
swapEvents({ from: value as string, to: data.id });
|
return swapEvents({ from: value as string, to: data.id });
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case 'delete': {
|
case 'delete': {
|
||||||
if (openId === data.id) {
|
if (selectedEvents.has(data.id)) {
|
||||||
removeOpenEvent();
|
removeOpenEvent();
|
||||||
}
|
}
|
||||||
deleteEvent(data.id);
|
return deleteEvent(data.id);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case 'clone': {
|
case 'clone': {
|
||||||
const newEvent = cloneEvent(data as OntimeEvent, data.id);
|
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 { field, value } = payload as FieldValue;
|
||||||
const newData: Partial<OntimeEvent> = { id: data.id };
|
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) {
|
if (field === 'durationOverride' && data.type === SupportedEvent.Event) {
|
||||||
// duration defines timeEnd
|
// duration defines timeEnd
|
||||||
newData.duration = value as number;
|
newData.duration = value as number;
|
||||||
newData.timeEnd = data.timeStart + (value as number);
|
newData.timeEnd = data.timeStart + (value as number);
|
||||||
updateEvent(newData);
|
return updateEvent(newData);
|
||||||
} else if (field === 'timeStart' && data.type === SupportedEvent.Event) {
|
}
|
||||||
|
|
||||||
|
if (field === 'timeStart' && data.type === SupportedEvent.Event) {
|
||||||
newData.duration = calculateDuration(value as number, data.timeEnd);
|
newData.duration = calculateDuration(value as number, data.timeEnd);
|
||||||
newData.timeStart = value as number;
|
newData.timeStart = value as number;
|
||||||
updateEvent(newData);
|
return updateEvent(newData);
|
||||||
} else if (field === 'timeEnd' && data.type === SupportedEvent.Event) {
|
}
|
||||||
|
|
||||||
|
if (field === 'timeEnd' && data.type === SupportedEvent.Event) {
|
||||||
newData.duration = calculateDuration(data.timeStart, value as number);
|
newData.duration = calculateDuration(data.timeStart, value as number);
|
||||||
newData.timeEnd = value as number;
|
newData.timeEnd = value as number;
|
||||||
updateEvent(newData);
|
return updateEvent(newData);
|
||||||
} else if (field in data) {
|
}
|
||||||
|
|
||||||
|
if (field in data) {
|
||||||
// @ts-expect-error not sure how to type this
|
// @ts-expect-error not sure how to type this
|
||||||
newData[field] = value;
|
newData[field] = value;
|
||||||
updateEvent(newData);
|
return updateEvent(newData);
|
||||||
} else {
|
|
||||||
emitError(`Unknown field: ${field}`);
|
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
|
return emitError(`Unknown field: ${field}`);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unhandled event ${action}`);
|
throw new Error(`Unhandled event ${action}`);
|
||||||
@@ -150,6 +175,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
if (data.type === SupportedEvent.Event) {
|
if (data.type === SupportedEvent.Event) {
|
||||||
return (
|
return (
|
||||||
<EventBlock
|
<EventBlock
|
||||||
|
eventIndex={eventIndex}
|
||||||
cue={data.cue}
|
cue={data.cue}
|
||||||
timeStart={data.timeStart}
|
timeStart={data.timeStart}
|
||||||
timeEnd={data.timeEnd}
|
timeEnd={data.timeEnd}
|
||||||
|
|||||||
@@ -3,28 +3,44 @@ import { useSortable } from '@dnd-kit/sortable';
|
|||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||||
import { IoCopyOutline } from '@react-icons/all-files/io5/IoCopyOutline';
|
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 { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
|
||||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||||
import { EndAction, OntimeEvent, Playback, TimerType } from 'ontime-types';
|
import { EndAction, OntimeEvent, Playback, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
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 copyToClipboard from '../../../common/utils/copyToClipboard';
|
||||||
|
import { isMacOS } from '../../../common/utils/deviceUtils';
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import type { EventItemActions } from '../RundownEntry';
|
import type { EventItemActions } from '../RundownEntry';
|
||||||
import { useEventIdSwapping } from '../useEventIdSwapping';
|
import { useEventIdSwapping } from '../useEventIdSwapping';
|
||||||
|
import { EditMode, useEventSelection } from '../useEventSelection';
|
||||||
|
|
||||||
import EventBlockInner from './EventBlockInner';
|
import EventBlockInner from './EventBlockInner';
|
||||||
|
|
||||||
import style from './EventBlock.module.scss';
|
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 {
|
interface EventBlockProps {
|
||||||
cue: string;
|
cue: string;
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
timeEnd: number;
|
timeEnd: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
eventIndex: number;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
endAction: EndAction;
|
endAction: EndAction;
|
||||||
timerType: TimerType;
|
timerType: TimerType;
|
||||||
@@ -61,6 +77,7 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
timeEnd,
|
timeEnd,
|
||||||
duration,
|
duration,
|
||||||
isPublic = true,
|
isPublic = true,
|
||||||
|
eventIndex,
|
||||||
endAction,
|
endAction,
|
||||||
timerType,
|
timerType,
|
||||||
title,
|
title,
|
||||||
@@ -80,37 +97,66 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
isFirstEvent,
|
isFirstEvent,
|
||||||
} = props;
|
} = props;
|
||||||
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
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 handleRef = useRef<null | HTMLSpanElement>(null);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const openId = useAppMode((state) => state.editId);
|
|
||||||
const [onContextMenu] = useContextMenu<HTMLDivElement>([
|
const [onContextMenu] = useContextMenu<HTMLDivElement>(
|
||||||
{ label: `Copy ID: ${eventId}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) },
|
selectedEvents.size > 1
|
||||||
{
|
? [
|
||||||
label: 'Toggle public',
|
{
|
||||||
icon: IoPeopleOutline,
|
label: 'Visiblity',
|
||||||
onClick: () =>
|
group: [
|
||||||
actionHandler('update', {
|
{
|
||||||
field: 'isPublic',
|
label: 'Make public',
|
||||||
value: !isPublic,
|
icon: IoPeople,
|
||||||
}),
|
onClick: () =>
|
||||||
},
|
actionHandler('update', {
|
||||||
{
|
field: 'isPublic',
|
||||||
label: 'Add to swap',
|
value: true,
|
||||||
icon: IoAdd,
|
}),
|
||||||
onClick: () => setSelectedEventId(eventId),
|
},
|
||||||
withDivider: true,
|
{
|
||||||
},
|
label: 'Make private',
|
||||||
{
|
icon: IoPeopleOutline,
|
||||||
label: `Swap this event with ${selectedEventId ?? ''}`,
|
onClick: () =>
|
||||||
icon: IoSwapVertical,
|
actionHandler('update', {
|
||||||
onClick: () => {
|
field: 'isPublic',
|
||||||
actionHandler('swap', { field: 'id', value: selectedEventId });
|
value: false,
|
||||||
clearSelectedEventId();
|
}),
|
||||||
},
|
},
|
||||||
isDisabled: selectedEventId == null || selectedEventId === eventId,
|
],
|
||||||
},
|
},
|
||||||
]);
|
]
|
||||||
|
: [
|
||||||
|
{ 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 {
|
const {
|
||||||
isDragging,
|
isDragging,
|
||||||
@@ -179,12 +225,23 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
isPast ? style.past : null,
|
isPast ? style.past : null,
|
||||||
selected ? style.selected : null,
|
selected ? style.selected : null,
|
||||||
playback ? style[playback] : null,
|
playback ? style[playback] : null,
|
||||||
hasCursor ? style.hasCursor : null,
|
selectedEvents.has(eventId) ? style.hasCursor : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleFocusClick = (event: MouseEvent) => {
|
const handleFocusClick = (event: MouseEvent) => {
|
||||||
event.stopPropagation();
|
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 (
|
return (
|
||||||
@@ -192,7 +249,7 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
className={blockClasses}
|
className={blockClasses}
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={dragStyle}
|
style={dragStyle}
|
||||||
onClick={handleFocusClick}
|
onMouseDown={handleFocusClick}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
id='event-block'
|
id='event-block'
|
||||||
>
|
>
|
||||||
@@ -204,11 +261,11 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
</div>
|
</div>
|
||||||
{isVisible && (
|
{isVisible && (
|
||||||
<EventBlockInner
|
<EventBlockInner
|
||||||
isOpen={openId === eventId}
|
|
||||||
timeStart={timeStart}
|
timeStart={timeStart}
|
||||||
timeEnd={timeEnd}
|
timeEnd={timeEnd}
|
||||||
duration={duration}
|
duration={duration}
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
|
eventIndex={eventIndex}
|
||||||
isPublic={isPublic}
|
isPublic={isPublic}
|
||||||
endAction={endAction}
|
endAction={endAction}
|
||||||
timerType={timerType}
|
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 { Tooltip } from '@chakra-ui/react';
|
||||||
import { BiArrowToBottom } from '@react-icons/all-files/bi/BiArrowToBottom';
|
import { BiArrowToBottom } from '@react-icons/all-files/bi/BiArrowToBottom';
|
||||||
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
|
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 { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||||
import { useAppMode } from '../../../common/stores/appModeStore';
|
|
||||||
import { millisToDelayString } from '../../../common/utils/dateConfig';
|
import { millisToDelayString } from '../../../common/utils/dateConfig';
|
||||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||||
import { EventItemActions } from '../RundownEntry';
|
import { EventItemActions } from '../RundownEntry';
|
||||||
|
import { useEventSelection } from '../useEventSelection';
|
||||||
|
|
||||||
import BlockActionMenu from './composite/BlockActionMenu';
|
import BlockActionMenu from './composite/BlockActionMenu';
|
||||||
import EventBlockPlayback from './composite/EventBlockPlayback';
|
import EventBlockPlayback from './composite/EventBlockPlayback';
|
||||||
@@ -36,11 +36,11 @@ const tooltipProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface EventBlockInnerProps {
|
interface EventBlockInnerProps {
|
||||||
isOpen: boolean;
|
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
timeEnd: number;
|
timeEnd: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
eventIndex: number;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
endAction: EndAction;
|
endAction: EndAction;
|
||||||
timerType: TimerType;
|
timerType: TimerType;
|
||||||
@@ -60,7 +60,6 @@ interface EventBlockInnerProps {
|
|||||||
|
|
||||||
const EventBlockInner = (props: EventBlockInnerProps) => {
|
const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||||
const {
|
const {
|
||||||
isOpen,
|
|
||||||
timeStart,
|
timeStart,
|
||||||
timeEnd,
|
timeEnd,
|
||||||
duration,
|
duration,
|
||||||
@@ -83,19 +82,24 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [renderInner, setRenderInner] = useState(false);
|
const [renderInner, setRenderInner] = useState(false);
|
||||||
const setEditId = useAppMode((state) => state.setEditId);
|
const { clearSelectedEvents, selectedEvents } = useEventSelection();
|
||||||
|
|
||||||
|
const isOpen = selectedEvents.size === 1 && selectedEvents.has(eventId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRenderInner(true);
|
setRenderInner(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleOpenEvent = useCallback(() => {
|
//TODO: fix this
|
||||||
if (isOpen) {
|
const toggleOpenEvent = useCallback(
|
||||||
setEditId(null);
|
(_event: MouseEvent) => {
|
||||||
} else {
|
// if (isOpen) {
|
||||||
setEditId(eventId);
|
// event.stopPropagation();
|
||||||
}
|
// clearSelectedEvents();
|
||||||
}, [eventId, isOpen, setEditId]);
|
// }
|
||||||
|
},
|
||||||
|
[clearSelectedEvents, isOpen],
|
||||||
|
);
|
||||||
|
|
||||||
const eventIsPlaying = playback === Playback.Play;
|
const eventIsPlaying = playback === Playback.Play;
|
||||||
const eventIsPaused = playback === Playback.Pause;
|
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 {
|
import {
|
||||||
addEvent,
|
addEvent,
|
||||||
applyDelay,
|
applyDelay,
|
||||||
|
batchEditEvents,
|
||||||
deleteAllEvents,
|
deleteAllEvents,
|
||||||
deleteEvent,
|
deleteEvent,
|
||||||
editEvent,
|
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) => {
|
export const rundownReorder: RequestHandler = async (req, res) => {
|
||||||
if (failEmptyObjects(req.body, res)) {
|
if (failEmptyObjects(req.body, res)) {
|
||||||
return;
|
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 = [
|
export const rundownReorderValidator = [
|
||||||
body('eventId').isString().exists(),
|
body('eventId').isString().exists(),
|
||||||
body('from').isNumeric().exists(),
|
body('from').isNumeric().exists(),
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
rundownPut,
|
rundownPut,
|
||||||
rundownReorder,
|
rundownReorder,
|
||||||
rundownSwap,
|
rundownSwap,
|
||||||
|
rundownBatchPut,
|
||||||
} from '../controllers/rundownController.js';
|
} from '../controllers/rundownController.js';
|
||||||
import {
|
import {
|
||||||
paramsMustHaveEventId,
|
paramsMustHaveEventId,
|
||||||
|
rundownBatchPutValidator,
|
||||||
rundownPostValidator,
|
rundownPostValidator,
|
||||||
rundownPutValidator,
|
rundownPutValidator,
|
||||||
rundownReorderValidator,
|
rundownReorderValidator,
|
||||||
@@ -32,6 +34,8 @@ router.post('/', rundownPostValidator, rundownPost);
|
|||||||
// create route between controller and '/events/' endpoint
|
// create route between controller and '/events/' endpoint
|
||||||
router.put('/', rundownPutValidator, rundownPut);
|
router.put('/', rundownPutValidator, rundownPut);
|
||||||
|
|
||||||
|
router.put('/batchEdit', rundownBatchPutValidator, rundownBatchPut);
|
||||||
|
|
||||||
// create route between controller and '/events/reorder' endpoint
|
// create route between controller and '/events/reorder' endpoint
|
||||||
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
|
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
cachedClear,
|
cachedClear,
|
||||||
cachedDelete,
|
cachedDelete,
|
||||||
cachedEdit,
|
cachedEdit,
|
||||||
|
cachedBatchEdit,
|
||||||
cachedReorder,
|
cachedReorder,
|
||||||
cachedSwap,
|
cachedSwap,
|
||||||
delayedRundownCacheKey,
|
delayedRundownCacheKey,
|
||||||
@@ -212,6 +213,16 @@ export async function editEvent(eventData: Partial<OntimeEvent> | Partial<Ontime
|
|||||||
return newEvent;
|
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
|
* deletes event by its ID
|
||||||
* @param eventId
|
* @param eventId
|
||||||
|
|||||||
@@ -115,7 +115,17 @@ export async function cachedEdit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedRundown = DataProvider.getRundown();
|
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)) {
|
if (isOntimeEvent(newEvent)) {
|
||||||
newEvent.revision++;
|
newEvent.revision++;
|
||||||
}
|
}
|
||||||
@@ -144,6 +154,12 @@ export async function cachedEdit(
|
|||||||
return newEvent;
|
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
|
* Deletes an event with given id from rundown, ensuring replication to delayed rundown cache
|
||||||
* @param eventId
|
* @param eventId
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { OntimeRundownEntry } from '../definitions/core/Rundown.type.js';
|
import { OntimeRundownEntry } from '../definitions/core/Rundown.type.js';
|
||||||
import { OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent } from '../definitions/core/OntimeEvent.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 {
|
export function isOntimeEvent(event: MaybeEvent): event is OntimeEvent {
|
||||||
return event?.type === SupportedEvent.Event;
|
return event?.type === SupportedEvent.Event;
|
||||||
|
|||||||
Reference in New Issue
Block a user