feat: edit mode (#344)

* style: add mode selection to menu

* style: rename rundown functions

* feat: edit mode
This commit is contained in:
Carlos Valente
2023-04-21 20:06:31 +02:00
committed by GitHub
parent da889a4825
commit 9c4c84905e
16 changed files with 272 additions and 218 deletions
+1
View File
@@ -27,6 +27,7 @@
"prettier" "prettier"
], ],
"rules": { "rules": {
"@typescript-eslint/no-non-null-assertion": "warn",
"prettier/prettier": [ "prettier/prettier": [
"error", "error",
{ {
@@ -0,0 +1,59 @@
import { create } from 'zustand';
export enum AppMode {
Run = 'run',
Edit = 'edit',
}
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: AppMode.Edit,
cursor: null,
editId: null,
setMode: (mode: AppMode) =>
set((state) => {
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,
};
}),
}));
@@ -1,24 +0,0 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
type CursorStore = {
cursor: number;
isCursorLocked: boolean;
toggleCursorLocked: (newValue?: boolean) => void;
moveCursorTo: (index: number) => void;
};
const cursorLockedKey = 'ontime-cursor-islocked';
export const useCursor = create<CursorStore>()((set) => ({
cursor: 0,
isCursorLocked: booleanFromLocalStorage(cursorLockedKey, false),
toggleCursorLocked: (newValue?: boolean) =>
set((state) => {
const val = typeof newValue === 'undefined' ? !state.isCursorLocked : newValue;
localStorage.setItem(cursorLockedKey, String(val));
return { isCursorLocked: val };
}),
moveCursorTo: (index: number) => set(() => ({ cursor: index })),
}));
@@ -1,13 +0,0 @@
import { create } from 'zustand';
type EventEditorStore = {
openId: string | null;
setOpenEvent: (eventId: string) => void;
removeOpenEvent: () => void;
};
export const useEventEditorStore = create<EventEditorStore>()((set) => ({
openId: null,
setOpenEvent: (eventId: string | null) => set({ openId: eventId }),
removeOpenEvent: () => set({ openId: null }),
}));
@@ -1,4 +1,4 @@
import { formatEventList, getEventsWithDelay, trimEventlist } from '../eventsManager'; import { formatEventList, getEventsWithDelay, trimRundown } from '../eventsManager';
describe('getEventsWithDelay function', () => { describe('getEventsWithDelay function', () => {
test('with positive delays', () => { test('with positive delays', () => {
@@ -365,7 +365,7 @@ describe('test trimEventlist function', () => {
{ id: '8' }, { id: '8' },
]; ];
const l = trimEventlist(testData, selectedId, limit); const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit); expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected); expect(l).toStrictEqual(expected);
}); });
@@ -383,7 +383,7 @@ describe('test trimEventlist function', () => {
{ id: '8' }, { id: '8' },
]; ];
const l = trimEventlist(testData, selectedId, limit); const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit); expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected); expect(l).toStrictEqual(expected);
}); });
@@ -401,7 +401,7 @@ describe('test trimEventlist function', () => {
{ id: '9' }, { id: '9' },
]; ];
const l = trimEventlist(testData, selectedId, limit); const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit); expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected); expect(l).toStrictEqual(expected);
}); });
@@ -419,7 +419,7 @@ describe('test trimEventlist function', () => {
{ id: '8' }, { id: '8' },
]; ];
const l = trimEventlist(testData, selectedId, limit); const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit); expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected); expect(l).toStrictEqual(expected);
}); });
+56 -17
View File
@@ -4,18 +4,18 @@ import { formatTime } from './time';
/** /**
* @description From a list of events, returns only events of type event with calculated delays * @description From a list of events, returns only events of type event with calculated delays
* @param {Object[]} events - given events * @param {Object[]} rundown - given rundown
* @returns {Object[]} Filtered events with calculated delays * @returns {Object[]} Filtered events with calculated delays
*/ */
export const getEventsWithDelay = (events: OntimeRundownEntry[]): OntimeEvent[] => { export const getEventsWithDelay = (rundown: OntimeRundownEntry[]): OntimeEvent[] => {
if (events == null) return []; if (rundown == null) return [];
const delayedEvents: OntimeEvent[] = []; const delayedEvents: OntimeEvent[] = [];
// Add running delay // Add running delay
let delay = 0; let delay = 0;
for (const event of events) { for (const event of rundown) {
if (event.type === SupportedEvent.Block) delay = 0; if (event.type === SupportedEvent.Block) delay = 0;
else if (event.type === SupportedEvent.Delay) { else if (event.type === SupportedEvent.Delay) {
if (typeof event.duration === 'number') { if (typeof event.duration === 'number') {
@@ -36,29 +36,29 @@ export const getEventsWithDelay = (events: OntimeRundownEntry[]): OntimeEvent[]
/** /**
* @description Returns trimmed event list array * @description Returns trimmed event list array
* @param {Object[]} events - given events * @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event * @param {string} selectedId - id of currently selected event
* @param {number} limit - max number of events to return * @param {number} limit - max number of events to return
* @returns {Object[]} Event list with maximum <limit> objects * @returns {Object[]} Event list with maximum <limit> objects
*/ */
export const trimEventlist = (events: OntimeRundownEntry[], selectedId: string, limit: number) => { export const trimRundown = (rundown: OntimeRundownEntry[], selectedId: string, limit: number) => {
if (events == null) return []; if (rundown == null) return [];
const BEFORE = 2; const BEFORE = 2;
const trimmedEvents = [...events]; const trimmedRundown = [...rundown];
// limit events length if necessary // limit events length if necessary
if (limit != null) { if (limit != null) {
while (trimmedEvents.length > limit) { while (trimmedRundown.length > limit) {
const idx = trimmedEvents.findIndex((e) => e.id === selectedId); const idx = trimmedRundown.findIndex((e) => e.id === selectedId);
if (idx <= BEFORE) { if (idx <= BEFORE) {
trimmedEvents.pop(); trimmedRundown.pop();
} else { } else {
trimmedEvents.shift(); trimmedRundown.shift();
} }
} }
} }
return trimmedEvents; return trimmedRundown;
}; };
type FormatEventListOptionsProp = { type FormatEventListOptionsProp = {
@@ -66,7 +66,7 @@ type FormatEventListOptionsProp = {
}; };
/** /**
* @description Returns list of events formatted to be displayed * @description Returns list of events formatted to be displayed
* @param {Object[]} events - given events * @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event * @param {string} selectedId - id of currently selected event
* @param {string} nextId - id of next event * @param {string} nextId - id of next event
* @param {object} [options] * @param {object} [options]
@@ -74,15 +74,15 @@ type FormatEventListOptionsProp = {
* @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}] * @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}]
*/ */
export const formatEventList = ( export const formatEventList = (
events: OntimeEvent[], rundown: OntimeEvent[],
selectedId: string, selectedId: string,
nextId: string, nextId: string,
options: FormatEventListOptionsProp, options: FormatEventListOptionsProp,
) => { ) => {
if (events == null) return []; if (rundown == null) return [];
const { showEnd = false } = options; const { showEnd = false } = options;
const givenEvents = [...events]; const givenEvents = [...rundown];
// format list // format list
const formattedEvents = []; const formattedEvents = [];
@@ -124,3 +124,42 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
after: after, after: after,
}; };
}; };
/**
* Gets first event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {OntimeEvent | null}
*/
export function getFirstEvent(rundown: OntimeRundownEntry[]) {
return rundown.length ? rundown[0] : null;
}
/**
* Gets next event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getNextEvent(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index + 1 < rundown.length) {
return rundown[index + 1];
} else {
return null;
}
}
/**
* Gets previous event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getPreviousEvent(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index - 1 >= 0) {
return rundown[index - 1];
} else {
return null;
}
}
@@ -3,7 +3,7 @@ import { OntimeEvent } from 'ontime-types';
import CopyTag from '../../common/components/copy-tag/CopyTag'; import CopyTag from '../../common/components/copy-tag/CopyTag';
import useRundown from '../../common/hooks-query/useRundown'; import useRundown from '../../common/hooks-query/useRundown';
import { useEventEditorStore } from '../../common/stores/eventEditor'; import { useAppMode } from '../../common/stores/appModeStore';
import getDelayTo from '../../common/utils/getDelayTo'; import getDelayTo from '../../common/utils/getDelayTo';
import EventEditorTimes from './composite/EventEditorTimes'; import EventEditorTimes from './composite/EventEditorTimes';
@@ -14,7 +14,7 @@ import style from './EventEditor.module.scss';
export type EventEditorSubmitActions = keyof OntimeEvent; export type EventEditorSubmitActions = keyof OntimeEvent;
export default function EventEditor() { export default function EventEditor() {
const { openId } = useEventEditorStore(); const openId = useAppMode((state) => state.editId);
const { data } = useRundown(); const { data } = useRundown();
const [event, setEvent] = useState<OntimeEvent | null>(null); const [event, setEvent] = useState<OntimeEvent | null>(null);
const [delay, setDelay] = useState(0); const [delay, setDelay] = useState(0);
@@ -3,7 +3,8 @@ import { Box, IconButton } from '@chakra-ui/react';
import { FiX } from '@react-icons/all-files/fi/FiX'; import { FiX } from '@react-icons/all-files/fi/FiX';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import { useEventEditorStore } from '../../common/stores/eventEditor'; import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { cx } from '../../common/utils/styleUtils';
import EventEditor from './EventEditor'; import EventEditor from './EventEditor';
@@ -18,15 +19,27 @@ const closeBtnStyle = {
}; };
const EventEditorExport = () => { const EventEditorExport = () => {
const { openId, removeOpenEvent } = useEventEditorStore(); const appMode = useAppMode((state) => state.mode);
const editId = useAppMode((state) => state.editId);
const setEditId = useAppMode((state) => state.setEditId);
const editorStyle = cx([style.eventEditor, !editId ? style.noEvent : null]);
const removeOpenEvent = () => setEditId(null);
const canRemoveOpenId = appMode === AppMode.Run;
return ( return (
<Box className={`${style.eventEditor} ${!openId ? style.noEvent : ''}`}> <Box className={editorStyle}>
<ErrorBoundary> <ErrorBoundary>
<div className={style.eventEditorLayout}> <div className={style.eventEditorLayout}>
<EventEditor /> <EventEditor />
<div className={style.header}> <div className={style.header}>
<IconButton aria-label='Close Menu' icon={<FiX />} onClick={removeOpenEvent} {...closeBtnStyle} /> <IconButton
aria-label='Close Menu'
icon={<FiX />}
onClick={removeOpenEvent}
isDisabled={!canRemoveOpenId}
{...closeBtnStyle}
/>
</div> </div>
</div> </div>
</ErrorBoundary> </ErrorBoundary>
+31 -27
View File
@@ -1,18 +1,19 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { VStack } from '@chakra-ui/react'; import { VStack } from '@chakra-ui/react';
import { FiHelpCircle } from '@react-icons/all-files/fi/FiHelpCircle';
import { FiMinimize } from '@react-icons/all-files/fi/FiMinimize';
import { FiSave } from '@react-icons/all-files/fi/FiSave'; import { FiSave } from '@react-icons/all-files/fi/FiSave';
import { FiUpload } from '@react-icons/all-files/fi/FiUpload'; import { FiUpload } from '@react-icons/all-files/fi/FiUpload';
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle'; import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline'; import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline';
import { IoScan } from '@react-icons/all-files/io5/IoScan'; import { IoHelpCircleOutline } from '@react-icons/all-files/io5/IoHelpCircleOutline';
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'; import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { downloadRundown } from '../../common/api/ontimeApi'; import { downloadRundown } from '../../common/api/ontimeApi';
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn'; import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn'; import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
import useElectronEvent from '../../common/hooks/useElectronEvent'; import useElectronEvent from '../../common/hooks/useElectronEvent';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import style from './MenuBar.module.scss'; import style from './MenuBar.module.scss';
@@ -52,21 +53,20 @@ export default function MenuBar(props: MenuBarProps) {
} = props; } = props;
const { isElectron, sendToElectron } = useElectronEvent(); const { isElectron, sendToElectron } = useElectronEvent();
const appMode = useAppMode((state) => state.mode);
const setAppMode = useAppMode((state) => state.setMode);
const setRunMode = () => setAppMode(AppMode.Run);
const setEditMode = () => setAppMode(AppMode.Edit);
const actionHandler = useCallback( const actionHandler = useCallback(
(action: Actions) => { (action: Actions) => {
// Stop crashes when testing locally
if (!isElectron) { if (!isElectron) {
if (action === 'help') { if (action === 'help') {
window.open('https://cpvalente.gitbook.io/ontime/'); window.open('https://cpvalente.gitbook.io/ontime/');
} }
} else { } else {
switch (action) { switch (action) {
case 'min':
sendToElectron('set-window', 'to-tray');
break;
case 'max':
sendToElectron('set-window', 'to-max');
break;
case 'shutdown': case 'shutdown':
sendToElectron('shutdown', 'now'); sendToElectron('shutdown', 'now');
break; break;
@@ -113,22 +113,7 @@ export default function MenuBar(props: MenuBarProps) {
return ( return (
<VStack> <VStack>
<QuitIconBtn clickHandler={() => actionHandler('shutdown')} /> <QuitIconBtn clickHandler={() => actionHandler('shutdown')} />
<TooltipActionBtn
{...buttonStyle}
icon={<IoScan />}
clickHandler={() => actionHandler('max')}
tooltip='Show full window'
aria-label='Show full window'
isDisabled={!isElectron}
/>
<TooltipActionBtn
{...buttonStyle}
icon={<FiMinimize />}
clickHandler={() => actionHandler('min')}
tooltip='Minimise to tray'
aria-label='Minimise to tray'
isDisabled={!isElectron}
/>
<div className={style.gap} /> <div className={style.gap} />
<TooltipActionBtn <TooltipActionBtn
{...buttonStyle} {...buttonStyle}
@@ -145,6 +130,25 @@ export default function MenuBar(props: MenuBarProps) {
tooltip='Export showfile' tooltip='Export showfile'
aria-label='Export showfile' aria-label='Export showfile'
/> />
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={<IoPlay />}
className={appMode === AppMode.Run ? style.open : ''}
clickHandler={setRunMode}
tooltip='Run mode'
aria-label='Run mode'
/>
<TooltipActionBtn
{...buttonStyle}
icon={<IoOptions />}
className={appMode === AppMode.Edit ? style.open : ''}
clickHandler={setEditMode}
tooltip='Edit mode'
aria-label='Edit mode'
/>
<div className={style.gap} /> <div className={style.gap} />
<TooltipActionBtn <TooltipActionBtn
{...buttonStyle} {...buttonStyle}
@@ -165,7 +169,7 @@ export default function MenuBar(props: MenuBarProps) {
<div className={style.gap} /> <div className={style.gap} />
<TooltipActionBtn <TooltipActionBtn
{...buttonStyle} {...buttonStyle}
icon={<FiHelpCircle />} icon={<IoHelpCircleOutline />}
clickHandler={() => actionHandler('help')} clickHandler={() => actionHandler('help')}
tooltip='Help' tooltip='Help'
aria-label='Help' aria-label='Help'
@@ -1,15 +1,4 @@
@use '../../theme/v2Styles' as *;
@use '../../theme/ontimeColours' as *;
.headerButtons { .headerButtons {
align-content: center; text-align: right;
justify-content: space-between;
padding-top: 24px; padding-top: 24px;
} }
.labelledSwitch {
display: flex;
align-items: center;
gap: $element-spacing;
color: $gray-100;
}
+9 -18
View File
@@ -1,5 +1,5 @@
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { Button, HStack, Menu, MenuButton, MenuDivider, MenuItem, MenuList, Switch } from '@chakra-ui/react'; import { Button, Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { FiMinusCircle } from '@react-icons/all-files/fi/FiMinusCircle'; import { FiMinusCircle } from '@react-icons/all-files/fi/FiMinusCircle';
import { FiTrash2 } from '@react-icons/all-files/fi/FiTrash2'; import { FiTrash2 } from '@react-icons/all-files/fi/FiTrash2';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
@@ -7,15 +7,13 @@ 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 { useCursor } from '../../common/stores/cursorStore'; import { useAppMode } from '../../common/stores/appModeStore';
import { useEventEditorStore } from '../../common/stores/eventEditor';
import style from './RundownMenu.module.scss'; import style from './RundownMenu.module.scss';
const RundownMenu = () => { const RundownMenu = () => {
const isCursorLocked = useCursor((state) => state.isCursorLocked); const setEditId = useAppMode((state) => state.setEditId);
const toggleCursorLocked = useCursor((state) => state.toggleCursorLocked); const setCursor = useAppMode((state) => state.setCursor);
const removeOpenEvent = useEventEditorStore((state) => state.removeOpenEvent);
const { addEvent, deleteAllEvents } = useEventAction(); const { addEvent, deleteAllEvents } = useEventAction();
@@ -33,19 +31,12 @@ const RundownMenu = () => {
const deleteAll = useCallback(() => { const deleteAll = useCallback(() => {
deleteAllEvents(); deleteAllEvents();
removeOpenEvent(); setEditId(null);
}, [deleteAllEvents, removeOpenEvent]); setCursor(null);
}, [deleteAllEvents, setCursor, setEditId]);
return ( return (
<HStack className={style.headerButtons}> <div className={style.headerButtons}>
<label className={style.labelledSwitch}>
<Switch
defaultChecked={isCursorLocked}
onChange={(event) => toggleCursorLocked(event.target.checked)}
variant='ontime'
/>
Follow loaded event
</label>
<Menu isLazy lazyBehavior='unmount' variant='ontime-on-dark'> <Menu isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton as={Button} leftIcon={<IoAdd />} size='sm' variant='ontime-subtle'> <MenuButton as={Button} leftIcon={<IoAdd />} size='sm' variant='ontime-subtle'>
Event... Event...
@@ -66,7 +57,7 @@ const RundownMenu = () => {
</MenuItem> </MenuItem>
</MenuList> </MenuList>
</Menu> </Menu>
</HStack> </div>
); );
}; };
+58 -63
View File
@@ -5,9 +5,9 @@ import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction'; import { useEventAction } from '../../common/hooks/useEventAction';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { useCursor } from '../../common/stores/cursorStore'; import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useLocalEvent } from '../../common/stores/localEvent'; import { useLocalEvent } from '../../common/stores/localEvent';
import { cloneEvent } from '../../common/utils/eventsManager'; import { cloneEvent, getFirstEvent, getNextEvent, getPreviousEvent } from '../../common/utils/eventsManager';
import QuickAddBlock from './quick-add-block/QuickAddBlock'; import QuickAddBlock from './quick-add-block/QuickAddBlock';
import RundownEmpty from './RundownEmpty'; import RundownEmpty from './RundownEmpty';
@@ -31,46 +31,47 @@ export default function Rundown(props: RundownProps) {
const showQuickEntry = eventSettings.showQuickEntry; const showQuickEntry = eventSettings.showQuickEntry;
// cursor // cursor
const cursor = useCursor((state) => state.cursor); const cursor = useAppMode((state) => state.cursor);
const isCursorLocked = useCursor((state) => state.isCursorLocked); const appMode = useAppMode((state) => state.mode);
const moveCursorTo = useCursor((state) => state.moveCursorTo); const viewFollowsCursor = appMode === AppMode.Run;
const moveCursorTo = useAppMode((state) => state.setCursor);
const cursorRef = useRef<HTMLDivElement>(); const cursorRef = useRef<HTMLDivElement>();
// DND KIT // DND KIT
const sensors = useSensors(useSensor(PointerSensor)); const sensors = useSensors(useSensor(PointerSensor));
const insertAtCursor = useCallback( const insertAtCursor = useCallback(
(type: SupportedEvent | 'clone', cursor: number) => { (type: SupportedEvent | 'clone', cursor: string | null) => {
if (cursor === -1) { if (cursor === null) {
// we cant clone without selection
if (type === 'clone') { if (type === 'clone') {
return; return;
} }
// the only thing to do is adding an event at top
addEvent({ type }); addEvent({ type });
} else { return;
const previousEvent = entries?.[cursor]; }
const nextEvent = entries?.[cursor + 1];
// prevent adding two non-event blocks consecutively if (type === 'clone') {
const isPreviousDifferent = previousEvent?.type !== type; const cursorEvent = entries.find((event) => event.id === cursor);
const isNextDifferent = nextEvent?.type !== type; if (cursorEvent?.type === SupportedEvent.Event) {
if (type === 'clone' && previousEvent?.type === SupportedEvent.Event) { const newEvent = cloneEvent(cursorEvent);
const newEvent = cloneEvent(previousEvent); newEvent.after = cursorEvent.id;
newEvent.after = previousEvent.id;
addEvent(newEvent); addEvent(newEvent);
} else if (type === SupportedEvent.Event) {
const newEvent = {
type: SupportedEvent.Event,
};
const options = {
defaultPublic: defaultPublic,
startTimeIsLastEnd: startTimeIsLastEnd,
lastEventId: previousEvent.id,
after: previousEvent.id,
};
addEvent(newEvent, options);
} else if (isPreviousDifferent && isNextDifferent && type !== 'clone') {
addEvent({ type }, { after: previousEvent.id });
} }
} else if (type === SupportedEvent.Event) {
const newEvent = {
type: SupportedEvent.Event,
};
const options = {
defaultPublic: defaultPublic,
startTimeIsLastEnd: startTimeIsLastEnd,
lastEventId: cursor,
after: cursor,
};
addEvent(newEvent, options);
} else {
addEvent({ type }, { after: cursor });
} }
}, },
[addEvent, defaultPublic, entries, startTimeIsLastEnd], [addEvent, defaultPublic, entries, startTimeIsLastEnd],
@@ -85,41 +86,50 @@ export default function Rundown(props: RundownProps) {
if (event.altKey && (!event.ctrlKey || !event.shiftKey)) { if (event.altKey && (!event.ctrlKey || !event.shiftKey)) {
switch (event.code) { switch (event.code) {
case 'ArrowDown': { case 'ArrowDown': {
if (cursor < entries.length - 1) moveCursorTo(cursor + 1); if (entries.length < 1) {
return;
}
const nextEvent = cursor == null ? getFirstEvent(entries) : getNextEvent(entries, cursor);
if (nextEvent) {
moveCursorTo(nextEvent.id, nextEvent.type === SupportedEvent.Event);
}
break; break;
} }
case 'ArrowUp': { case 'ArrowUp': {
if (cursor > 0) moveCursorTo(cursor - 1); if (entries.length < 1) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we check for this before
const previousEvent = cursor == null ? getFirstEvent(entries) : getPreviousEvent(entries, cursor);
if (previousEvent) {
moveCursorTo(previousEvent.id, previousEvent.type === SupportedEvent.Event);
}
break; break;
} }
case 'KeyE': { case 'KeyE': {
event.preventDefault(); event.preventDefault();
if (cursor === -1) return;
insertAtCursor(SupportedEvent.Event, cursor); insertAtCursor(SupportedEvent.Event, cursor);
break; break;
} }
case 'KeyD': { case 'KeyD': {
event.preventDefault(); event.preventDefault();
if (cursor < 0) return;
insertAtCursor(SupportedEvent.Delay, cursor); insertAtCursor(SupportedEvent.Delay, cursor);
break; break;
} }
case 'KeyB': { case 'KeyB': {
event.preventDefault(); event.preventDefault();
if (cursor < 0) return;
insertAtCursor(SupportedEvent.Block, cursor); insertAtCursor(SupportedEvent.Block, cursor);
break; break;
} }
case 'KeyC': { case 'KeyC': {
event.preventDefault(); event.preventDefault();
if (cursor < 0) return;
insertAtCursor('clone', cursor); insertAtCursor('clone', cursor);
break; break;
} }
} }
} }
}, },
[cursor, entries.length, insertAtCursor, moveCursorTo], [cursor, entries, insertAtCursor, moveCursorTo],
); );
// we copy the state from the store here // we copy the state from the store here
@@ -155,28 +165,13 @@ export default function Rundown(props: RundownProps) {
}); });
}, [cursorRef]); }, [cursorRef]);
// if selected event
// or cursor settings changed
useEffect(() => { useEffect(() => {
// and if we are locked // in run mode, we follow selection
if (!isCursorLocked || !featureData?.selectedEventId) { if (!viewFollowsCursor || !featureData?.selectedEventId) {
return; return;
} }
moveCursorTo(featureData.selectedEventId);
// move cursor }, [featureData?.selectedEventId, viewFollowsCursor, moveCursorTo]);
let gotoIndex = -1;
let found = false;
for (const entry of entries) {
gotoIndex++;
if (entry.id === featureData.selectedEventId) {
found = true;
break;
}
}
if (found) {
moveCursorTo(gotoIndex);
}
}, [featureData?.selectedEventId, entries, isCursorLocked, moveCursorTo]);
const handleOnDragEnd = (event: DragEndEvent) => { const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
@@ -195,7 +190,7 @@ export default function Rundown(props: RundownProps) {
}; };
if (statefulEntries?.length < 1) { if (statefulEntries?.length < 1) {
return <RundownEmpty handleAddNew={() => insertAtCursor(SupportedEvent.Event, -1)} />; return <RundownEmpty handleAddNew={() => insertAtCursor(SupportedEvent.Event, null)} />;
} }
let cumulativeDelay = 0; let cumulativeDelay = 0;
@@ -227,16 +222,16 @@ export default function Rundown(props: RundownProps) {
const isLast = index === entries.length - 1; const isLast = index === entries.length - 1;
const isSelected = featureData?.selectedEventId === entry.id; const isSelected = featureData?.selectedEventId === entry.id;
const isNext = featureData?.nextEventId === entry.id; const isNext = featureData?.nextEventId === entry.id;
const hasCursor = entry.id === cursor;
return ( return (
<div key={entry.id} ref={cursor === index ? cursorRef : undefined}> <div key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry <RundownEntry
type={entry.type} type={entry.type}
index={index}
eventIndex={eventIndex} eventIndex={eventIndex}
data={entry} data={entry}
selected={isSelected} selected={isSelected}
hasCursor={cursor === index} hasCursor={hasCursor}
next={isNext} next={isNext}
delay={cumulativeDelay} delay={cumulativeDelay}
previousEnd={previousEnd} previousEnd={previousEnd}
@@ -244,13 +239,13 @@ export default function Rundown(props: RundownProps) {
playback={isSelected ? featureData.playback : undefined} playback={isSelected ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll} isRolling={featureData.playback === Playback.Roll}
/> />
{((showQuickEntry && index === cursor) || isLast) && ( {((showQuickEntry && hasCursor) || isLast) && (
<QuickAddBlock <QuickAddBlock
showKbd={index === cursor} showKbd={hasCursor}
eventId={entry.id} eventId={entry.id}
previousEventId={previousEventId} previousEventId={previousEventId}
disableAddDelay={entry.type === 'delay'} disableAddDelay={entry.type === SupportedEvent.Delay}
disableAddBlock={entry.type === 'block'} disableAddBlock={entry.type === SupportedEvent.Block}
/> />
)} )}
</div> </div>
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types'; import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction'; import { useEventAction } from '../../common/hooks/useEventAction';
import { useEventEditorStore } from '../../common/stores/eventEditor'; import { useAppMode } from '../../common/stores/appModeStore';
import { useLocalEvent } from '../../common/stores/localEvent'; import { useLocalEvent } from '../../common/stores/localEvent';
import { useEmitLog } from '../../common/stores/logger'; import { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/eventsManager'; import { cloneEvent } from '../../common/utils/eventsManager';
@@ -16,7 +16,6 @@ export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'del
interface RundownEntryProps { interface RundownEntryProps {
type: SupportedEvent; type: SupportedEvent;
index: number;
eventIndex: number; eventIndex: number;
data: OntimeRundownEntry; data: OntimeRundownEntry;
selected: boolean; selected: boolean;
@@ -30,24 +29,25 @@ interface RundownEntryProps {
} }
export default function RundownEntry(props: RundownEntryProps) { export default function RundownEntry(props: RundownEntryProps) {
const { const { eventIndex, data, selected, hasCursor, next, delay, previousEnd, previousEventId, playback, isRolling } =
index, props;
eventIndex,
data,
selected,
hasCursor,
next,
delay,
previousEnd,
previousEventId,
playback,
isRolling,
} = props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const { addEvent, updateEvent, deleteEvent } = useEventAction(); const { addEvent, updateEvent, deleteEvent } = useEventAction();
const openId = useEventEditorStore((state) => state.openId); const cursor = useAppMode((state) => state.cursor);
const removeOpenEvent = useEventEditorStore((state) => state.removeOpenEvent); const setCursor = useAppMode((state) => state.setCursor);
const openId = useAppMode((state) => state.editId);
const setEditId = useAppMode((state) => state.setEditId);
const removeOpenEvent = useCallback(() => {
if (openId === data.id) {
setEditId(null);
}
if (cursor === data.id) {
setCursor(null);
}
}, [cursor, data.id, openId, setCursor, setEditId]);
const eventSettings = useLocalEvent((state) => state.eventSettings); const eventSettings = useLocalEvent((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic; const defaultPublic = eventSettings.defaultPublic;
@@ -146,7 +146,6 @@ export default function RundownEntry(props: RundownEntryProps) {
timeStart={data.timeStart} timeStart={data.timeStart}
timeEnd={data.timeEnd} timeEnd={data.timeEnd}
duration={data.duration} duration={data.duration}
index={index}
eventIndex={eventIndex + 1} eventIndex={eventIndex + 1}
eventId={data.id} eventId={data.id}
isPublic={data.isPublic} isPublic={data.isPublic}
@@ -4,8 +4,7 @@ import { CSS } from '@dnd-kit/utilities';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo'; import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { EndAction, OntimeEvent, Playback, TimerType } from 'ontime-types'; import { EndAction, OntimeEvent, Playback, TimerType } from 'ontime-types';
import { useCursor } from '../../../common/stores/cursorStore'; import { useAppMode } from '../../../common/stores/appModeStore';
import { useEventEditorStore } from '../../../common/stores/eventEditor';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { EventItemActions } from '../RundownEntry'; import { EventItemActions } from '../RundownEntry';
@@ -17,7 +16,6 @@ interface EventBlockProps {
timeStart: number; timeStart: number;
timeEnd: number; timeEnd: number;
duration: number; duration: number;
index: number;
eventIndex: number; eventIndex: number;
eventId: string; eventId: string;
isPublic: boolean; isPublic: boolean;
@@ -50,7 +48,6 @@ export default function EventBlock(props: EventBlockProps) {
timeStart, timeStart,
timeEnd, timeEnd,
duration, duration,
index,
eventIndex, eventIndex,
eventId, eventId,
isPublic = true, isPublic = true,
@@ -70,10 +67,10 @@ export default function EventBlock(props: EventBlockProps) {
actionHandler, actionHandler,
} = props; } = props;
const moveCursorTo = useCursor((state) => state.moveCursorTo); const moveCursorTo = useAppMode((state) => state.setCursor);
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const openId = useEventEditorStore((state) => state.openId); const openId = useAppMode((state) => state.editId);
const { const {
isDragging, isDragging,
@@ -136,7 +133,12 @@ export default function EventBlock(props: EventBlockProps) {
return ( return (
<div className={blockClasses} ref={setNodeRef} style={dragStyle}> <div className={blockClasses} ref={setNodeRef} style={dragStyle}>
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1} onClick={() => moveCursorTo(index)}> <div
className={style.binder}
style={{ ...binderColours }}
tabIndex={-1}
onClick={() => moveCursorTo(eventId, true)}
>
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}> <span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
<IoReorderTwo /> <IoReorderTwo />
</span> </span>
@@ -12,7 +12,7 @@ import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { EndAction, Playback, TimerType } from 'ontime-types'; import { EndAction, Playback, TimerType } from 'ontime-types';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn'; import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { useEventEditorStore } from '../../../common/stores/eventEditor'; import { useAppMode } from '../../../common/stores/appModeStore';
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';
@@ -76,8 +76,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
} = props; } = props;
const [renderInner, setRenderInner] = useState(false); const [renderInner, setRenderInner] = useState(false);
const setOpenEvent = useEventEditorStore((state) => state.setOpenEvent); const setEditId = useAppMode((state) => state.setEditId);
const removeOpenEvent = useEventEditorStore((state) => state.removeOpenEvent);
useEffect(() => { useEffect(() => {
setRenderInner(true); setRenderInner(true);
@@ -85,11 +84,11 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
const toggleOpenEvent = useCallback(() => { const toggleOpenEvent = useCallback(() => {
if (isOpen) { if (isOpen) {
removeOpenEvent(); setEditId(null);
} else { } else {
setOpenEvent(eventId); setEditId(eventId);
} }
}, [eventId, isOpen, removeOpenEvent, setOpenEvent]); }, [eventId, isOpen, setEditId]);
const eventIsPlaying = playback === Playback.Play; const eventIsPlaying = playback === Playback.Play;
const eventIsPaused = playback === Playback.Pause; const eventIsPaused = playback === Playback.Pause;
@@ -8,7 +8,7 @@ import NavigationMenu from '../../../common/components/navigation-menu/Navigatio
import useFitText from '../../../common/hooks/useFitText'; import useFitText from '../../../common/hooks/useFitText';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { formatDisplay } from '../../../common/utils/dateConfig'; import { formatDisplay } from '../../../common/utils/dateConfig';
import { formatEventList, getEventsWithDelay, trimEventlist } from '../../../common/utils/eventsManager'; import { formatEventList, getEventsWithDelay, trimRundown } from '../../../common/utils/eventsManager';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
import './StudioClock.scss'; import './StudioClock.scss';
@@ -59,7 +59,7 @@ export default function StudioClock(props) {
const delayed = getEventsWithDelay(backstageEvents); const delayed = getEventsWithDelay(backstageEvents);
const events = delayed.filter((e) => e.type === 'event'); const events = delayed.filter((e) => e.type === 'event');
const trimmed = trimEventlist(events, selectedId, MAX_TITLES); const trimmed = trimRundown(events, selectedId, MAX_TITLES);
const formatted = formatEventList(trimmed, selectedId, nextId, { const formatted = formatEventList(trimmed, selectedId, nextId, {
showEnd: false, showEnd: false,
}); });