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,27 +1,25 @@
import { useEffect, useRef } from 'react';
import { RefObject, useEffect } from 'react';
import { Textarea, TextareaProps } from '@chakra-ui/react';
// @ts-expect-error no types from library
import autosize from 'autosize/dist/autosize';
export const AutoTextArea = (props: TextareaProps) => {
const { value } = props;
const ref = useRef<HTMLTextAreaElement>(null);
export const AutoTextArea = (props: TextareaProps & { inputref: RefObject<unknown> }) => {
const { value, inputref } = props;
useEffect(() => {
const node = ref.current;
autosize(ref.current);
const node = inputref.current;
autosize(inputref.current);
return () => {
autosize.destroy(node);
};
}, [value]);
}, [inputref, value]);
return (
<Textarea
overflow='hidden'
w='100%'
ref={inputref}
resize='none'
ref={ref}
transition='height none'
variant='ontime-transparent'
{...props}
@@ -1,58 +0,0 @@
import { useCallback, useRef } from 'react';
import { Input, Textarea } from '@chakra-ui/react';
import { EventEditorSubmitActions } from '../../../../features/rundown/event-editor/EventEditor';
import { Size } from '../../../models/Util.type';
import useReactiveTextInput from './useReactiveTextInput';
interface BaseProps {
isTextArea?: boolean;
isFullHeight?: boolean;
size?: Size;
field: EventEditorSubmitActions;
initialText?: string;
submitHandler: (field: EventEditorSubmitActions, newValue: string) => void;
}
interface TextInputProps extends BaseProps {
isTextArea?: false;
}
type ResizeOptions = 'horizontal' | 'vertical' | 'none';
interface TextAreaProps extends BaseProps {
isTextArea: true;
resize?: ResizeOptions;
}
type InputProps = TextInputProps | TextAreaProps;
export default function TextInput(props: InputProps) {
const { isTextArea, isFullHeight, size = 'sm', field, initialText = '', submitHandler } = props;
const inputRef = useRef(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const textInputProps = useReactiveTextInput(initialText, submitCallback, { submitOnEnter: true });
const textAreaProps = useReactiveTextInput(initialText, submitCallback);
let resize: ResizeOptions = 'none';
if (isTextArea) {
resize = (props as TextAreaProps)?.resize ?? 'none';
}
return isTextArea ? (
<Textarea
ref={inputRef}
size={size}
resize={resize}
variant='ontime-filled'
{...textAreaProps}
style={{ height: isFullHeight ? '100%' : undefined }}
data-testid='input-textarea'
/>
) : (
<Input ref={inputRef} size={size} variant='ontime-filled' {...textInputProps} data-testid='input-textfield' />
);
}
@@ -1,17 +1,20 @@
import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useState } from 'react';
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks';
interface UseReactiveTextInputReturn {
value: string;
onChange: (event: ChangeEvent) => void;
onBlur: (event: ChangeEvent) => void;
onKeyDown: (event: KeyboardEvent) => void;
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
}
export default function useReactiveTextInput(
initialText: string,
submitCallback: (newValue: string) => void,
ref: RefObject<HTMLInputElement>,
options?: {
submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean;
},
): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText);
@@ -44,43 +47,45 @@ export default function useReactiveTextInput(
const handleSubmit = useCallback(
(valueToSubmit: string) => {
// No need to update if it hasn't changed
if (valueToSubmit === initialText) {
return;
}
const cleanVal = valueToSubmit.trim();
submitCallback(cleanVal);
if (cleanVal !== valueToSubmit) {
setText(cleanVal);
if (valueToSubmit !== initialText) {
const cleanVal = valueToSubmit.trim();
submitCallback(cleanVal);
if (cleanVal !== valueToSubmit) {
setText(cleanVal);
}
}
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
},
[initialText, submitCallback],
[initialText, ref, submitCallback],
);
/**
* @description Handles common keys for submit and cancel
* @param {string} key
* @description Handles escape events
* @param {string} valueToSubmit
*/
const keyHandler = useCallback(
(key: string) => {
switch (key) {
case 'Escape':
setText(initialText);
break;
case 'Enter':
if (options?.submitOnEnter) {
handleSubmit(text);
}
break;
}
},
[initialText, options?.submitOnEnter, handleSubmit, text],
);
const handleEscape = useCallback(() => {
// No need to update if it hasn't changed
setText(initialText);
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
}, [initialText, ref]);
const keyHandler = useMemo(() => {
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
if (options?.submitOnEnter) {
hotKeys.push(['Enter', () => handleSubmit(text)]);
}
if (options?.submitOnCtrlEnter) {
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
}
return getHotkeyHandler(hotKeys);
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
return {
value: text,
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
onBlur: (event: ChangeEvent) => handleSubmit((event.target as HTMLInputElement).value),
onKeyDown: (event: KeyboardEvent) => keyHandler(event.key),
onKeyDown: keyHandler,
};
}
@@ -21,7 +21,7 @@ import { useEditorSettings } from '../stores/editorSettings';
import { forgivingStringToMillis } from '../utils/dateConfig';
/**
* @description Set of utilities for events
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
*/
export const useEventAction = () => {
const queryClient = useQueryClient();
@@ -33,5 +33,5 @@ export const useAppMode = create<AppModeStore>()((set) => ({
return { mode };
});
},
setCursor: (cursor: string | null) => set(() => ({ cursor })),
setCursor: (cursor: string | null) => set({ cursor }),
}));
@@ -0,0 +1,11 @@
import { create } from 'zustand';
type EntryCopyStore = {
entryCopyId: string | null;
setEntryCopyId: (eventId: string | null) => void;
};
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null,
setEntryCopyId: (entryCopyId: string | null) => set({ entryCopyId }),
}));
@@ -4,3 +4,5 @@ export function isMacOS() {
}
export const deviceAlt = isMacOS() ? '⌥' : 'Alt';
export const deviceMod = isMacOS() ? '⌘' : 'Ctrl';
@@ -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'
+8
View File
@@ -80,6 +80,14 @@ const theme = extendTheme({
'ontime-ghosted': { ...ontimeInputGhosted },
},
},
Kbd: {
baseStyle: {
borderRadius: '2px',
border: '1px solid rgba(255, 255, 255, 0.20)',
background: 'rgba(255, 255, 255, 0.10)',
color: 'white',
},
},
Menu: {
variants: {
'ontime-on-dark': { ...ontimeMenuOnDark },
+1
View File
@@ -14,6 +14,7 @@ export {
getFirstNormal,
getLastEvent,
getLastEventNormal,
getLastNormal,
getNext,
getNextEvent,
getNextEventNormal,
@@ -1,7 +1,15 @@
import type { OntimeEvent, OntimeRundown } from 'ontime-types';
import type { NormalisedRundown, OntimeEvent, OntimeRundown } from 'ontime-types';
import { SupportedEvent } from 'ontime-types';
import { getLastEvent, getNext, getNextEvent, getPrevious, getPreviousEvent, swapEventData } from './rundownUtils';
import {
getLastEvent,
getLastNormal,
getNext,
getNextEvent,
getPrevious,
getPreviousEvent,
swapEventData,
} from './rundownUtils';
describe('getNext()', () => {
it('returns the next event of type event', () => {
@@ -85,9 +93,9 @@ describe('getPrevious()', () => {
{ id: '3', type: SupportedEvent.Event },
];
const { previousEvent, previousIndex } = getPrevious(testRundown as OntimeRundown, '3');
expect(previousEvent?.id).toBe('2');
expect(previousIndex).toBe(1);
const { entry, index } = getPrevious(testRundown as OntimeRundown, '3');
expect(entry?.id).toBe('2');
expect(index).toBe(1);
});
it('allow other event types', () => {
const testRundown = [
@@ -97,9 +105,9 @@ describe('getPrevious()', () => {
{ id: '4', type: SupportedEvent.Event },
];
const { previousEvent, previousIndex } = getPrevious(testRundown as OntimeRundown, '3');
expect(previousEvent?.id).toBe('2');
expect(previousIndex).toBe(1);
const { entry, index } = getPrevious(testRundown as OntimeRundown, '3');
expect(entry?.id).toBe('2');
expect(index).toBe(1);
});
it('returns null if none found', () => {
const testRundown = [
@@ -108,9 +116,9 @@ describe('getPrevious()', () => {
{ id: '4', type: SupportedEvent.Event },
];
const { previousEvent, previousIndex } = getPrevious(testRundown as OntimeRundown, '2');
expect(previousEvent).toBe(null);
expect(previousIndex).toBe(null);
const { entry, index } = getPrevious(testRundown as OntimeRundown, '2');
expect(entry).toBe(null);
expect(index).toBe(null);
});
});
@@ -209,4 +217,49 @@ describe('getLastEvent', () => {
const { lastEvent } = getLastEvent(testRundown as OntimeRundown);
expect(lastEvent?.id).toBe('1');
});
describe('getLastNormal', () => {
it('returns the last entry', () => {
const testRundown = {
4: { id: '4', type: SupportedEvent.Block },
1: { id: '1', type: SupportedEvent.Event },
3: { id: '3', type: SupportedEvent.Event },
2: { id: '2', type: SupportedEvent.Delay },
};
const order = ['1', '2', '3', '4'];
const lastEntry = getLastNormal(testRundown as unknown as NormalisedRundown, order);
expect(lastEntry?.id).toBe('4');
});
it('handles rundowns with a single event', () => {
const testRundown = [{ id: '1', type: SupportedEvent.Event }];
const { lastEvent } = getLastEvent(testRundown as OntimeRundown);
expect(lastEvent?.id).toBe('1');
});
it('handles empty order', () => {
const testRundown = {
4: { id: '4', type: SupportedEvent.Block },
1: { id: '1', type: SupportedEvent.Event },
3: { id: '3', type: SupportedEvent.Event },
2: { id: '2', type: SupportedEvent.Delay },
};
const order: string[] = [];
const lastEntry = getLastNormal(testRundown as unknown as NormalisedRundown, order);
expect(lastEntry).toBe(null);
});
it('handles empty rundown', () => {
const testRundown = {};
const order = ['1', '2', '3', '4'];
const lastEntry = getLastNormal(testRundown as unknown as NormalisedRundown, order);
expect(lastEntry).toBe(null);
});
});
});
@@ -1,6 +1,8 @@
import type { NormalisedRundown, OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { isOntimeEvent } from 'ontime-types';
type IndexAndEntry = { entry: OntimeRundownEntry | null; index: number | null };
/**
* Gets first event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
@@ -62,6 +64,20 @@ export function getFirstEventNormal(
return { firstEvent: null, firstIndex: null };
}
/**
* Gets last event in a normalised rundown, if it exists
* @param rundown
* @param order
* @returns
*/
export function getLastNormal(rundown: NormalisedRundown, order: string[]): OntimeRundownEntry | null {
const lastId = order.at(-1);
if (lastId === undefined) {
return null;
}
return rundown[lastId] ?? null;
}
/**
* Gets last scheduled event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
@@ -138,19 +154,15 @@ export function getNext(
* @param currentId
* @returns
*/
export function getNextNormal(
rundown: NormalisedRundown,
order: string[],
currentId: string,
): { nextEvent: OntimeRundownEntry | null; nextIndex: number | null } {
const index = order.findIndex((id) => id === currentId);
if (index !== -1 && index + 1 < order.length) {
const nextIndex = index + 1;
const nextId = order[nextIndex];
const nextEvent = rundown[nextId];
return { nextEvent, nextIndex };
export function getNextNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry {
const currentIndex = order.findIndex((id) => id === currentId);
if (currentIndex !== -1 && currentIndex + 1 < order.length) {
const index = currentIndex + 1;
const nextId = order[index];
const entry = rundown[nextId];
return { entry, index };
} else {
return { nextEvent: null, nextIndex: null };
return { entry: null, index: null };
}
}
@@ -209,19 +221,15 @@ export function getNextEventNormal(
* Gets previous entry in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {{ previousEvent: OntimeRundownEntry | null; previousIndex: number | null } }
*/
export function getPrevious(
rundown: OntimeRundownEntry[],
currentId: string,
): { previousEvent: OntimeRundownEntry | null; previousIndex: number | null } {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index - 1 >= 0) {
const previousIndex = index - 1;
const previousEvent = rundown[previousIndex];
return { previousEvent, previousIndex };
export function getPrevious(rundown: OntimeRundownEntry[], currentId: string): IndexAndEntry {
const currentIndex = rundown.findIndex((event) => event.id === currentId);
if (currentIndex !== -1 && currentIndex - 1 >= 0) {
const index = currentIndex - 1;
const entry = rundown[index];
return { entry, index };
} else {
return { previousEvent: null, previousIndex: null };
return { entry: null, index: null };
}
}
@@ -230,21 +238,16 @@ export function getPrevious(
* @param rundown
* @param order
* @param {string} currentId
* @return {{ previousEvent: OntimeRundownEntry | null; previousIndex: number | null } }
*/
export function getPreviousNormal(
rundown: NormalisedRundown,
order: string[],
currentId: string,
): { previousEvent: OntimeRundownEntry | null; previousIndex: number | null } {
const index = order.findIndex((id) => id === currentId);
if (index !== -1 && index - 1 >= 0) {
const previousIndex = index - 1;
const previousId = order[previousIndex];
const previousEvent = rundown[previousId];
return { previousEvent, previousIndex };
export function getPreviousNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry {
const currentIndex = order.findIndex((id) => id === currentId);
if (currentIndex !== -1 && currentIndex - 1 >= 0) {
const index = currentIndex - 1;
const previousId = order[index];
const entry = rundown[previousId];
return { entry, index };
} else {
return { previousEvent: null, previousIndex: null };
return { entry: null, index: null };
}
}