shortcuts using useHotkeys from mantine/hooks (#907)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
Alex Christoffer Rasmussen
2024-04-27 17:00:41 +01:00
committed by GitHub
parent 0c3c08ac98
commit 9e04a1dcfa
19 changed files with 424 additions and 253 deletions
@@ -1,4 +1,5 @@
import { ChangeEvent, memo, useCallback, useEffect, useState } from 'react';
import { ChangeEvent, memo, useCallback, useEffect, useRef, useState } from 'react';
import { getHotkeyHandler } from '@mantine/hooks';
import { AutoTextArea } from '../../../common/components/input/auto-text-area/AutoTextArea';
@@ -12,12 +13,24 @@ const EditableCell = (props: EditableCellProps) => {
// We need to keep and update the state of the cell normally
const [value, setValue] = useState(initialValue);
const ref = useRef<HTMLAreaElement>();
const onChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => setValue(event.target.value), []);
// We'll only update the external data when the input is blurred
const onBlur = useCallback(() => handleUpdate(value), [handleUpdate, value]);
//TODO: maybe we can unify this with `useReactiveTextInput`
const onKeyDown = getHotkeyHandler([
['mod + Enter', () => ref.current?.blur()],
[
'Escape',
() => {
setValue(initialValue);
setTimeout(() => ref.current?.blur());
},
],
]);
// If the initialValue is changed external, sync it up with our state
useEffect(() => {
setValue(initialValue);
@@ -27,9 +40,11 @@ const EditableCell = (props: EditableCellProps) => {
<AutoTextArea
size='sm'
value={value}
inputref={ref}
onChange={onChange}
onBlur={onBlur}
rows={1}
onKeyDown={onKeyDown}
transition='none'
spellCheck={false}
style={{ padding: 0 }}
+87 -92
View File
@@ -1,13 +1,15 @@
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useHotkeys } from '@mantine/hooks';
import { isOntimeEvent, MaybeNumber, Playback, RundownCached, SupportedEvent } from 'ontime-types';
import { getFirstNormal, getNextNormal, getPreviousNormal } from 'ontime-utils';
import { getFirstNormal, getLastNormal, getNextNormal, getPreviousNormal } from 'ontime-utils';
import { useEventAction } from '../../common/hooks/useEventAction';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/eventsManager';
import QuickAddBlock from './quick-add-block/QuickAddBlock';
@@ -26,7 +28,9 @@ export default function Rundown({ data }: RundownProps) {
const [statefulEntries, setStatefulEntries] = useState(order);
const featureData = useRundownEditor();
const { addEvent, reorderEvent } = useEventAction();
const { addEvent, reorderEvent, deleteEvent } = useEventAction();
const { entryCopyId, setEntryCopyId } = useEntryCopy();
// cursor
const { cursor, mode: appMode, setCursor } = useAppMode();
@@ -37,9 +41,21 @@ export default function Rundown({ data }: RundownProps) {
// DND KIT
const sensors = useSensors(useSensor(PointerSensor));
const deleteAtCursor = useCallback(
(cursor: string | null) => {
if (!cursor) return;
const previous = getPreviousNormal(rundown, order, cursor).entry?.id ?? null;
deleteEvent(cursor);
setCursor(previous);
},
[deleteEvent, order, rundown, setCursor],
);
const insertAtCursor = useCallback(
(type: SupportedEvent | 'clone', cursor: string | null) => {
if (cursor === null) {
(type: SupportedEvent | 'clone', cursor: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(rundown, order, cursor ?? '').entry?.id ?? null : cursor;
if (adjustedCursor === null) {
// we cant clone without selection
if (type === 'clone') {
return;
@@ -50,7 +66,7 @@ export default function Rundown({ data }: RundownProps) {
}
if (type === 'clone') {
const cursorEvent = rundown[cursor];
const cursorEvent = rundown[adjustedCursor];
if (cursorEvent?.type === SupportedEvent.Event) {
const newEvent = cloneEvent(cursorEvent, cursorEvent.id);
addEvent(newEvent);
@@ -60,111 +76,90 @@ export default function Rundown({ data }: RundownProps) {
type: SupportedEvent.Event,
};
const options = {
after: cursor,
lastEventId: cursor,
after: adjustedCursor,
lastEventId: adjustedCursor,
};
addEvent(newEvent, options);
} else {
addEvent({ type }, { after: cursor });
addEvent({ type }, { after: adjustedCursor });
}
},
[addEvent, rundown],
[rundown, order, addEvent],
);
// Handle keyboard shortcuts
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// handle held key
if (event.repeat) return;
const selectEntry = useCallback(
(cursor: string | null, direction: 'up' | 'down') => {
if (order.length < 1) {
return;
}
let newCursor: string | undefined;
if (cursor === null) {
// there is no cursor, we select the first or last depending on direction if it exists
newCursor = direction === 'up' ? getLastNormal(rundown, order)?.id : getFirstNormal(rundown, order)?.id;
} else {
// otherwise we select the next or previous
newCursor =
direction === 'up'
? getPreviousNormal(rundown, order, cursor).entry?.id
: getNextNormal(rundown, order, cursor).entry?.id;
}
const modKeysAlt = event.altKey && !event.ctrlKey && !event.shiftKey;
const modKeysCtrlAlt = event.altKey && event.ctrlKey && !event.shiftKey;
if (modKeysAlt) {
switch (event.code) {
case 'ArrowDown': {
if (order.length < 1) {
return;
}
const nextEvent =
cursor === null ? getFirstNormal(rundown, order) : getNextNormal(rundown, order, cursor)?.nextEvent;
if (nextEvent) {
setCursor(nextEvent.id);
}
break;
}
case 'ArrowUp': {
if (order.length < 1) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we check for this before
const previousEvent =
cursor === null
? getFirstNormal(rundown, order)
: getPreviousNormal(rundown, order, cursor).previousEvent;
if (previousEvent) {
setCursor(previousEvent.id);
}
break;
}
case 'KeyE': {
event.preventDefault();
insertAtCursor(SupportedEvent.Event, cursor);
break;
}
case 'KeyD': {
event.preventDefault();
insertAtCursor(SupportedEvent.Delay, cursor);
break;
}
case 'KeyB': {
event.preventDefault();
insertAtCursor(SupportedEvent.Block, cursor);
break;
}
case 'KeyC': {
event.preventDefault();
insertAtCursor('clone', cursor);
break;
}
}
} else if (modKeysCtrlAlt) {
if (order.length < 2 || cursor == null) {
return;
}
// Alt + Ctrl + Arrow Down
if (event.code == 'ArrowDown') {
const { nextEvent, nextIndex } = getNextNormal(rundown, order, cursor);
if (nextEvent && nextIndex !== null) {
reorderEvent(cursor, nextIndex - 1, nextIndex);
}
// Alt + Ctrl + Arrow Up
} else if (event.code == 'ArrowUp') {
const { previousEvent, previousIndex } = getPreviousNormal(rundown, order, cursor);
if (previousEvent && previousIndex !== null) {
reorderEvent(cursor, previousIndex + 1, previousIndex);
}
}
if (newCursor) {
setCursor(newCursor);
}
},
[order, cursor, rundown, setCursor, insertAtCursor, reorderEvent],
[order, rundown, setCursor],
);
const moveEntry = useCallback(
(cursor: string | null, direction: 'up' | 'down') => {
if (order.length < 2 || cursor == null) {
return;
}
const { index } =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
if (index !== null) {
const offsetIndex = direction === 'up' ? index + 1 : index - 1;
reorderEvent(cursor, offsetIndex, index);
}
},
[order, reorderEvent, rundown],
);
// shortcuts
useHotkeys([
['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true }],
['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true }],
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }],
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }],
['Escape', () => setCursor(null), { preventDefault: true }],
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
['alt + E', () => insertAtCursor(SupportedEvent.Event, cursor), { preventDefault: true }],
['alt + shift + E', () => insertAtCursor(SupportedEvent.Event, cursor, true), { preventDefault: true }],
['alt + B', () => insertAtCursor(SupportedEvent.Block, cursor), { preventDefault: true }],
['alt + shift + B', () => insertAtCursor(SupportedEvent.Block, cursor, true), { preventDefault: true }],
['alt + D', () => insertAtCursor(SupportedEvent.Delay, cursor), { preventDefault: true }],
['alt + shift + D', () => insertAtCursor(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
['mod + C', () => setEntryCopyId(cursor), { preventDefault: true }],
['mod + V', () => insertAtCursor('clone', entryCopyId), { preventDefault: true }],
['mod + shift + V', () => insertAtCursor('clone', entryCopyId, true), { preventDefault: true }],
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
]);
// we copy the state from the store here
// to workaround async updates on the drag mutations
useEffect(() => {
setStatefulEntries(order);
}, [order]);
// listen to keys
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
useEffect(() => {
// in run mode, we follow selection
if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import { Input } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
@@ -17,7 +17,7 @@ interface TitleEditorProps {
export default function EditableBlockTitle(props: TitleEditorProps) {
const { title, eventId, placeholder, className } = props;
const { updateEvent } = useEventAction();
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback(
(text: string) => {
if (text === title) {
@@ -30,7 +30,7 @@ export default function EditableBlockTitle(props: TitleEditorProps) {
[title, updateEvent, eventId],
);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(title, submitCallback, {
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(title, submitCallback, ref, {
submitOnEnter: true,
});
@@ -40,6 +40,7 @@ export default function EditableBlockTitle(props: TitleEditorProps) {
<Input
data-testid='block__title'
variant='ontime-ghosted'
ref={ref}
value={value}
className={classes}
placeholder={placeholder}
@@ -13,6 +13,7 @@ import { useEventSelection } from '../useEventSelection';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
import EventTextArea from './composite/EventTextArea';
import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss';
@@ -67,11 +68,7 @@ export default function EventEditor() {
};
if (!event) {
return (
<div className={style.eventEditor} data-testid='editor-container'>
Select an event to edit
</div>
);
return <EventEditorEmpty />;
}
return (
@@ -0,0 +1,34 @@
.eventEditor {
color: $label-gray;
height: 100%;
max-height: 100%;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
overflow-x: auto;
}
.shortcutSection {
flex: 1;
display: grid;
place-content: center;
font-size: calc(1rem - 2px);
}
.shortcuts {
margin-top: 0.5rem;
border-collapse: separate;
border-spacing: 1rem 0;
tr {
td:nth-child(odd) {
text-align: right;
}
}
}
.prompt {
font-size: 1rem;
}
@@ -0,0 +1,102 @@
import { memo } from 'react';
import { Kbd } from '@chakra-ui/react';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import style from './EventEditorEmpty.module.scss';
export default memo(EventEditorEmpty);
function EventEditorEmpty() {
return (
<div className={style.eventEditor} data-testid='editor-container'>
<div className={style.prompt}>Select an event to edit</div>
<div className={style.shortcutSection}>
<div className={style.prompt}>Shortcut navigation</div>
<table className={style.shortcuts}>
<tbody>
<tr>
<td>
<Kbd>{deviceAlt}</Kbd> + <Kbd></Kbd> / <Kbd></Kbd>
</td>
<td>Select entry</td>
</tr>
<tr>
<td>
<Kbd>{deviceAlt}</Kbd> + <Kbd>{deviceMod}</Kbd> + <Kbd></Kbd> / <Kbd></Kbd>
</td>
<td>Reorder selected entry</td>
</tr>
<tr>
<td>
<Kbd>{deviceAlt}</Kbd> + <Kbd>E</Kbd>
</td>
<td>Add event below</td>
</tr>
<tr>
<td>
<Kbd>{deviceAlt}</Kbd> + <Kbd></Kbd> + <Kbd>E</Kbd>
</td>
<td>Add event above</td>
</tr>
<tr>
<td>
<Kbd>{deviceAlt}</Kbd> + <Kbd>B</Kbd>
</td>
<td>Add block below</td>
</tr>
<tr>
<td>
<Kbd>{deviceAlt}</Kbd> + <Kbd></Kbd> + <Kbd>B</Kbd>
</td>
<td>Add block above</td>
</tr>
<tr>
<td>
<Kbd>{deviceAlt}</Kbd> + <Kbd>D</Kbd>
</td>
<td>Add delay below</td>
</tr>
<tr>
<td>
<Kbd>{deviceAlt}</Kbd> + <Kbd></Kbd> + <Kbd>D</Kbd>
</td>
<td>Add delay above</td>
</tr>
<tr>
<td>
<Kbd>Esc</Kbd>
</td>
<td>Deselect entry</td>
</tr>
<tr>
<td>
<Kbd>{deviceMod}</Kbd> + <Kbd></Kbd>
</td>
<td>Delete selected entry</td>
</tr>
<tr>
<td>
<Kbd>{deviceMod}</Kbd> + <Kbd>C</Kbd>
</td>
<td>Copy selected entry</td>
</tr>
<tr>
<td>
<Kbd>{deviceMod}</Kbd> + <Kbd></Kbd> + <Kbd>V</Kbd>
</td>
<td>Paste above</td>
</tr>
<tr>
<td>
<Kbd>{deviceMod}</Kbd> + <Kbd>V</Kbd>
</td>
<td>Paste below</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { CSSProperties, useCallback } from 'react';
import { CSSProperties, useCallback, useRef } from 'react';
import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
@@ -18,10 +18,12 @@ interface CountedTextAreaProps {
export default function EventTextArea(props: CountedTextAreaProps) {
const { className, field, label, initialValue, style: givenStyles, submitHandler } = props;
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
submitOnCtrlEnter: true,
});
const classes = cx([style.inputLabel, className]);
return (
@@ -31,6 +33,7 @@ export default function EventTextArea(props: CountedTextAreaProps) {
</label>
<AutoTextArea
id={field}
inputref={ref}
rows={1}
size='sm'
resize='none'
@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import { Input, InputProps } from '@chakra-ui/react';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
@@ -15,10 +15,10 @@ interface CountedTextInputProps extends InputProps {
export default function EventTextInput(props: CountedTextInputProps) {
const { field, label, initialValue, submitHandler, maxLength } = props;
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, {
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
submitOnEnter: true,
});
@@ -29,6 +29,7 @@ export default function EventTextInput(props: CountedTextInputProps) {
</label>
<Input
id={field}
ref={ref}
size='sm'
variant='ontime-filled'
data-testid='input-textfield'