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
@@ -3,7 +3,7 @@ import { OntimeEvent } from 'ontime-types';
import CopyTag from '../../common/components/copy-tag/CopyTag';
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 EventEditorTimes from './composite/EventEditorTimes';
@@ -14,7 +14,7 @@ import style from './EventEditor.module.scss';
export type EventEditorSubmitActions = keyof OntimeEvent;
export default function EventEditor() {
const { openId } = useEventEditorStore();
const openId = useAppMode((state) => state.editId);
const { data } = useRundown();
const [event, setEvent] = useState<OntimeEvent | null>(null);
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 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';
@@ -18,15 +19,27 @@ const closeBtnStyle = {
};
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 (
<Box className={`${style.eventEditor} ${!openId ? style.noEvent : ''}`}>
<Box className={editorStyle}>
<ErrorBoundary>
<div className={style.eventEditorLayout}>
<EventEditor />
<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>
</ErrorBoundary>
+31 -27
View File
@@ -1,18 +1,19 @@
import { useCallback, useEffect } from '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 { FiUpload } from '@react-icons/all-files/fi/FiUpload';
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
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 { downloadRundown } from '../../common/api/ontimeApi';
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
import useElectronEvent from '../../common/hooks/useElectronEvent';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import style from './MenuBar.module.scss';
@@ -52,21 +53,20 @@ export default function MenuBar(props: MenuBarProps) {
} = props;
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(
(action: Actions) => {
// Stop crashes when testing locally
if (!isElectron) {
if (action === 'help') {
window.open('https://cpvalente.gitbook.io/ontime/');
}
} else {
switch (action) {
case 'min':
sendToElectron('set-window', 'to-tray');
break;
case 'max':
sendToElectron('set-window', 'to-max');
break;
case 'shutdown':
sendToElectron('shutdown', 'now');
break;
@@ -113,22 +113,7 @@ export default function MenuBar(props: MenuBarProps) {
return (
<VStack>
<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} />
<TooltipActionBtn
{...buttonStyle}
@@ -145,6 +130,25 @@ export default function MenuBar(props: MenuBarProps) {
tooltip='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} />
<TooltipActionBtn
{...buttonStyle}
@@ -165,7 +169,7 @@ export default function MenuBar(props: MenuBarProps) {
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={<FiHelpCircle />}
icon={<IoHelpCircleOutline />}
clickHandler={() => actionHandler('help')}
tooltip='Help'
aria-label='Help'
@@ -1,15 +1,4 @@
@use '../../theme/v2Styles' as *;
@use '../../theme/ontimeColours' as *;
.headerButtons {
align-content: center;
justify-content: space-between;
text-align: right;
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 { 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 { FiTrash2 } from '@react-icons/all-files/fi/FiTrash2';
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 { useEventAction } from '../../common/hooks/useEventAction';
import { useCursor } from '../../common/stores/cursorStore';
import { useEventEditorStore } from '../../common/stores/eventEditor';
import { useAppMode } from '../../common/stores/appModeStore';
import style from './RundownMenu.module.scss';
const RundownMenu = () => {
const isCursorLocked = useCursor((state) => state.isCursorLocked);
const toggleCursorLocked = useCursor((state) => state.toggleCursorLocked);
const removeOpenEvent = useEventEditorStore((state) => state.removeOpenEvent);
const setEditId = useAppMode((state) => state.setEditId);
const setCursor = useAppMode((state) => state.setCursor);
const { addEvent, deleteAllEvents } = useEventAction();
@@ -33,19 +31,12 @@ const RundownMenu = () => {
const deleteAll = useCallback(() => {
deleteAllEvents();
removeOpenEvent();
}, [deleteAllEvents, removeOpenEvent]);
setEditId(null);
setCursor(null);
}, [deleteAllEvents, setCursor, setEditId]);
return (
<HStack className={style.headerButtons}>
<label className={style.labelledSwitch}>
<Switch
defaultChecked={isCursorLocked}
onChange={(event) => toggleCursorLocked(event.target.checked)}
variant='ontime'
/>
Follow loaded event
</label>
<div className={style.headerButtons}>
<Menu isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton as={Button} leftIcon={<IoAdd />} size='sm' variant='ontime-subtle'>
Event...
@@ -66,7 +57,7 @@ const RundownMenu = () => {
</MenuItem>
</MenuList>
</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 { 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 { cloneEvent } from '../../common/utils/eventsManager';
import { cloneEvent, getFirstEvent, getNextEvent, getPreviousEvent } from '../../common/utils/eventsManager';
import QuickAddBlock from './quick-add-block/QuickAddBlock';
import RundownEmpty from './RundownEmpty';
@@ -31,46 +31,47 @@ export default function Rundown(props: RundownProps) {
const showQuickEntry = eventSettings.showQuickEntry;
// cursor
const cursor = useCursor((state) => state.cursor);
const isCursorLocked = useCursor((state) => state.isCursorLocked);
const moveCursorTo = useCursor((state) => state.moveCursorTo);
const cursor = useAppMode((state) => state.cursor);
const appMode = useAppMode((state) => state.mode);
const viewFollowsCursor = appMode === AppMode.Run;
const moveCursorTo = useAppMode((state) => state.setCursor);
const cursorRef = useRef<HTMLDivElement>();
// DND KIT
const sensors = useSensors(useSensor(PointerSensor));
const insertAtCursor = useCallback(
(type: SupportedEvent | 'clone', cursor: number) => {
if (cursor === -1) {
(type: SupportedEvent | 'clone', cursor: string | null) => {
if (cursor === null) {
// we cant clone without selection
if (type === 'clone') {
return;
}
// the only thing to do is adding an event at top
addEvent({ type });
} else {
const previousEvent = entries?.[cursor];
const nextEvent = entries?.[cursor + 1];
return;
}
// prevent adding two non-event blocks consecutively
const isPreviousDifferent = previousEvent?.type !== type;
const isNextDifferent = nextEvent?.type !== type;
if (type === 'clone' && previousEvent?.type === SupportedEvent.Event) {
const newEvent = cloneEvent(previousEvent);
newEvent.after = previousEvent.id;
if (type === 'clone') {
const cursorEvent = entries.find((event) => event.id === cursor);
if (cursorEvent?.type === SupportedEvent.Event) {
const newEvent = cloneEvent(cursorEvent);
newEvent.after = cursorEvent.id;
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],
@@ -85,41 +86,50 @@ export default function Rundown(props: RundownProps) {
if (event.altKey && (!event.ctrlKey || !event.shiftKey)) {
switch (event.code) {
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;
}
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;
}
case 'KeyE': {
event.preventDefault();
if (cursor === -1) return;
insertAtCursor(SupportedEvent.Event, cursor);
break;
}
case 'KeyD': {
event.preventDefault();
if (cursor < 0) return;
insertAtCursor(SupportedEvent.Delay, cursor);
break;
}
case 'KeyB': {
event.preventDefault();
if (cursor < 0) return;
insertAtCursor(SupportedEvent.Block, cursor);
break;
}
case 'KeyC': {
event.preventDefault();
if (cursor < 0) return;
insertAtCursor('clone', cursor);
break;
}
}
}
},
[cursor, entries.length, insertAtCursor, moveCursorTo],
[cursor, entries, insertAtCursor, moveCursorTo],
);
// we copy the state from the store here
@@ -155,28 +165,13 @@ export default function Rundown(props: RundownProps) {
});
}, [cursorRef]);
// if selected event
// or cursor settings changed
useEffect(() => {
// and if we are locked
if (!isCursorLocked || !featureData?.selectedEventId) {
// in run mode, we follow selection
if (!viewFollowsCursor || !featureData?.selectedEventId) {
return;
}
// move cursor
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]);
moveCursorTo(featureData.selectedEventId);
}, [featureData?.selectedEventId, viewFollowsCursor, moveCursorTo]);
const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
@@ -195,7 +190,7 @@ export default function Rundown(props: RundownProps) {
};
if (statefulEntries?.length < 1) {
return <RundownEmpty handleAddNew={() => insertAtCursor(SupportedEvent.Event, -1)} />;
return <RundownEmpty handleAddNew={() => insertAtCursor(SupportedEvent.Event, null)} />;
}
let cumulativeDelay = 0;
@@ -227,16 +222,16 @@ export default function Rundown(props: RundownProps) {
const isLast = index === entries.length - 1;
const isSelected = featureData?.selectedEventId === entry.id;
const isNext = featureData?.nextEventId === entry.id;
const hasCursor = entry.id === cursor;
return (
<div key={entry.id} ref={cursor === index ? cursorRef : undefined}>
<div key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
index={index}
eventIndex={eventIndex}
data={entry}
selected={isSelected}
hasCursor={cursor === index}
hasCursor={hasCursor}
next={isNext}
delay={cumulativeDelay}
previousEnd={previousEnd}
@@ -244,13 +239,13 @@ export default function Rundown(props: RundownProps) {
playback={isSelected ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
/>
{((showQuickEntry && index === cursor) || isLast) && (
{((showQuickEntry && hasCursor) || isLast) && (
<QuickAddBlock
showKbd={index === cursor}
showKbd={hasCursor}
eventId={entry.id}
previousEventId={previousEventId}
disableAddDelay={entry.type === 'delay'}
disableAddBlock={entry.type === 'block'}
disableAddDelay={entry.type === SupportedEvent.Delay}
disableAddBlock={entry.type === SupportedEvent.Block}
/>
)}
</div>
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
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 { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/eventsManager';
@@ -16,7 +16,6 @@ export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'del
interface RundownEntryProps {
type: SupportedEvent;
index: number;
eventIndex: number;
data: OntimeRundownEntry;
selected: boolean;
@@ -30,24 +29,25 @@ interface RundownEntryProps {
}
export default function RundownEntry(props: RundownEntryProps) {
const {
index,
eventIndex,
data,
selected,
hasCursor,
next,
delay,
previousEnd,
previousEventId,
playback,
isRolling,
} = props;
const { eventIndex, data, selected, hasCursor, next, delay, previousEnd, previousEventId, playback, isRolling } =
props;
const { emitError } = useEmitLog();
const { addEvent, updateEvent, deleteEvent } = useEventAction();
const openId = useEventEditorStore((state) => state.openId);
const removeOpenEvent = useEventEditorStore((state) => state.removeOpenEvent);
const cursor = useAppMode((state) => state.cursor);
const setCursor = useAppMode((state) => state.setCursor);
const openId = useAppMode((state) => state.editId);
const setEditId = useAppMode((state) => state.setEditId);
const 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 defaultPublic = eventSettings.defaultPublic;
@@ -146,7 +146,6 @@ export default function RundownEntry(props: RundownEntryProps) {
timeStart={data.timeStart}
timeEnd={data.timeEnd}
duration={data.duration}
index={index}
eventIndex={eventIndex + 1}
eventId={data.id}
isPublic={data.isPublic}
@@ -4,8 +4,7 @@ import { CSS } from '@dnd-kit/utilities';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { EndAction, OntimeEvent, Playback, TimerType } from 'ontime-types';
import { useCursor } from '../../../common/stores/cursorStore';
import { useEventEditorStore } from '../../../common/stores/eventEditor';
import { useAppMode } from '../../../common/stores/appModeStore';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { EventItemActions } from '../RundownEntry';
@@ -17,7 +16,6 @@ interface EventBlockProps {
timeStart: number;
timeEnd: number;
duration: number;
index: number;
eventIndex: number;
eventId: string;
isPublic: boolean;
@@ -50,7 +48,6 @@ export default function EventBlock(props: EventBlockProps) {
timeStart,
timeEnd,
duration,
index,
eventIndex,
eventId,
isPublic = true,
@@ -70,10 +67,10 @@ export default function EventBlock(props: EventBlockProps) {
actionHandler,
} = props;
const moveCursorTo = useCursor((state) => state.moveCursorTo);
const moveCursorTo = useAppMode((state) => state.setCursor);
const handleRef = useRef<null | HTMLSpanElement>(null);
const [isVisible, setIsVisible] = useState(false);
const openId = useEventEditorStore((state) => state.openId);
const openId = useAppMode((state) => state.editId);
const {
isDragging,
@@ -136,7 +133,12 @@ export default function EventBlock(props: EventBlockProps) {
return (
<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}>
<IoReorderTwo />
</span>
@@ -12,7 +12,7 @@ import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { EndAction, Playback, TimerType } from 'ontime-types';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { useEventEditorStore } from '../../../common/stores/eventEditor';
import { useAppMode } from '../../../common/stores/appModeStore';
import { tooltipDelayMid } from '../../../ontimeConfig';
import EditableBlockTitle from '../common/EditableBlockTitle';
import { EventItemActions } from '../RundownEntry';
@@ -76,8 +76,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
} = props;
const [renderInner, setRenderInner] = useState(false);
const setOpenEvent = useEventEditorStore((state) => state.setOpenEvent);
const removeOpenEvent = useEventEditorStore((state) => state.removeOpenEvent);
const setEditId = useAppMode((state) => state.setEditId);
useEffect(() => {
setRenderInner(true);
@@ -85,11 +84,11 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
const toggleOpenEvent = useCallback(() => {
if (isOpen) {
removeOpenEvent();
setEditId(null);
} else {
setOpenEvent(eventId);
setEditId(eventId);
}
}, [eventId, isOpen, removeOpenEvent, setOpenEvent]);
}, [eventId, isOpen, setEditId]);
const eventIsPlaying = playback === Playback.Play;
const eventIsPaused = playback === Playback.Pause;
@@ -8,7 +8,7 @@ import NavigationMenu from '../../../common/components/navigation-menu/Navigatio
import useFitText from '../../../common/hooks/useFitText';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
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 './StudioClock.scss';
@@ -59,7 +59,7 @@ export default function StudioClock(props) {
const delayed = getEventsWithDelay(backstageEvents);
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, {
showEnd: false,
});