feat: event finder

This commit is contained in:
Carlos Valente
2024-10-19 23:14:37 +02:00
committed by Carlos Valente
parent a8756c8afe
commit 143917f11a
8 changed files with 395 additions and 71 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.6.2",
"@mantine/hooks": "^7.13.3",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^8.19.0",
"@tanstack/react-query": "^5.17.9",
+19 -41
View File
@@ -1,16 +1,18 @@
import { lazy, useCallback, useEffect } from 'react';
import { IconButton, useDisclosure } from '@chakra-ui/react';
import { useHotkeys } from '@mantine/hooks';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import useElectronEvent from '../../common/hooks/useElectronEvent';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import AppSettings from '../app-settings/AppSettings';
import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
import { EditorOverview } from '../overview/Overview';
import Finder from './finder/Finder';
import styles from './Editor.module.scss';
const Rundown = lazy(() => import('../rundown/RundownExport'));
@@ -19,47 +21,8 @@ const MessageControl = lazy(() => import('../control/message/MessageControlExpor
export default function Editor() {
const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation();
const { isElectron } = useElectronEvent();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const toggleSettings = useCallback(() => {
if (isSettingsOpen) {
close();
} else {
setLocation('project');
}
}, [close, isSettingsOpen, setLocation]);
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
(event: KeyboardEvent) => {
// handle held key
if (event.repeat) return;
// check if the ctrl key is pressed
if (event.ctrlKey || event.metaKey) {
// ctrl + , (settings)
if (event.key === ',') {
toggleSettings();
event.preventDefault();
event.stopPropagation();
}
}
},
[toggleSettings],
);
// register ctrl + , to open settings
useEffect(() => {
if (isElectron) {
document.addEventListener('keydown', handleKeyPress);
}
return () => {
if (isElectron) {
document.removeEventListener('keydown', handleKeyPress);
}
};
}, [handleKeyPress, isElectron]);
const { isOpen: isFinderOpen, onToggle: onFinderToggle, onClose: onFinderClose } = useDisclosure();
useWindowTitle('Editor');
@@ -72,8 +35,23 @@ export default function Editor() {
}
}, [setLocation]);
const toggleSettings = useCallback(() => {
if (isSettingsOpen) {
close();
} else {
setLocation('project');
}
}, [close, isSettingsOpen, setLocation]);
useHotkeys([
['mod + ,', toggleSettings],
['mod + f', onFinderToggle],
['Escape', onFinderClose],
]);
return (
<div className={styles.mainContainer} data-testid='event-editor'>
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<EditorOverview>
<IconButton
@@ -0,0 +1,69 @@
.entry,
.empty,
.error {
padding-inline: 0.5rem;
font-size: 1rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.entry[data-selected='true'] {
background-color: $blue-700;
}
.empty {
color: $label-gray;
}
.error {
color: $error-red;
}
.data {
display: grid;
grid-template-areas:
'index cue'
'index title';
column-gap: 1rem;
grid-template-rows: min-content 1fr;
.index {
grid-area: index;
background-color: var(--color, $gray-1000);
border-radius: 2px;
padding-block: 0.25rem;
width: 3.5rem;
align-self: center;
text-align: center;
}
.title {
grid-area: title;
}
.cue {
grid-area: cue;
font-size: calc(1rem - 2px);
color: $label-gray;
max-height: 1em;
min-height: 0;
}
}
.footer {
font-size: calc(1rem - 2px);
color: $label-gray;
}
.em {
color: $ui-white;
margin-inline: 0.25rem;
}
.scrollContainer {
max-height: 70vh;
overflow: auto;
}
@@ -0,0 +1,101 @@
import { KeyboardEvent, useState } from 'react';
import { Input, Modal, ModalBody, ModalContent, ModalFooter, ModalOverlay } from '@chakra-ui/react';
import { useDebouncedCallback } from '@mantine/hooks';
import { isOntimeEvent, SupportedEvent } from 'ontime-types';
import { useEventSelection } from '../../rundown/useEventSelection';
import useFinder from './useFinder';
import style from './Finder.module.scss';
interface FinderProps {
isOpen: boolean;
onClose: () => void;
}
export default function Finder(props: FinderProps) {
const { isOpen, onClose } = props;
const { find, results, error } = useFinder();
const [selected, setSelected] = useState(0);
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
const debouncedFind = useDebouncedCallback(find, 100);
const navigate = (event: KeyboardEvent<HTMLDivElement>) => {
// all operations need results
if (results.length === 0) {
return;
}
if (event.key === 'ArrowDown') {
setSelected((prev) => (prev + 1) % results.length);
}
if (event.key === 'ArrowUp') {
setSelected((prev) => (prev - 1 + results.length) % results.length);
}
if (event.key === 'Enter') {
submit();
}
};
const submit = () => {
const selectedEvent = results[selected];
setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' });
onClose();
};
const handleMouseMoveEvent = (event: React.MouseEvent<HTMLUListElement>) => {
const target = event.target as HTMLElement;
const li = target.closest('li');
if (li) {
const index = Number(li.dataset.index);
if (!isNaN(index)) {
setSelected(index);
}
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay />
<ModalContent maxWidth='40vw'>
<ModalBody onKeyDown={navigate}>
<Input size='lg' onChange={debouncedFind} variant='ontime-filled' placeholder='Search...' />
<ul className={style.scrollContainer} onMouseMove={handleMouseMoveEvent}>
{error && <li className={style.error}>{error}</li>}
{results.length === 0 && <li className={style.empty}>No results</li>}
{results.length > 0 &&
results.map((event, index) => {
const isSelected = selected === index;
const displayIndex = event.type === SupportedEvent.Block ? '-' : event.index;
const colour = event.type === SupportedEvent.Event ? event.colour : '';
return (
<li
key={event.id}
className={style.entry}
data-selected={isSelected}
data-index={index}
onClick={submit}
>
<div className={style.data}>
<div className={style.index} style={{ '--color': colour }}>
{displayIndex}
</div>
{isOntimeEvent(event) && <div className={style.cue}>{event.cue}</div>}
<div className={style.title}>{event.title}</div>
</div>
{isSelected && <span>Go </span>}
</li>
);
})}
</ul>
</ModalBody>
<ModalFooter className={style.footer}>
Use the keywords <span className={style.em}>cue</span>, <span className={style.em}>index</span> or
<span className={style.em}>title</span> to filter search
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,182 @@
import { ChangeEvent, useState } from 'react';
import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEvent } from 'ontime-types';
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
const maxResults = 12;
type FilterableBlock = {
type: SupportedEvent.Block;
id: string;
index: number;
title: string;
};
type FilterableEvent = {
type: SupportedEvent.Event;
id: string;
index: number;
eventIndex: number;
title: string;
cue: string;
colour: string;
};
type FilterableEntry = FilterableBlock | FilterableEvent;
export default function useFinder() {
const { data } = useFlatRundown();
const [results, setResults] = useState<FilterableEntry[]>([]);
const [error, setError] = useState<MaybeString>(null);
/** Returns a single item with a matching index */
const searchByIndex = (searchString: string) => {
const searchIndex = Number(searchString);
if (isNaN(searchIndex) || searchIndex < 1) {
return { results: [], error: 'Invalid index' };
}
if (searchIndex > data.length) {
return { results: [], error: null };
}
// indexes exposed to the UI are 1-based
let eventIndex = 1;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
const event = data[i];
if (isOntimeEvent(event)) {
if (eventIndex === searchIndex) {
results.push({
type: SupportedEvent.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
} satisfies FilterableEvent);
break;
}
eventIndex++;
}
}
return { results, error: null };
};
/** Returns maxResults of OntimeEvents that match the cue field */
const searchByCue = (searchString: string) => {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.cue.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEvent.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
} satisfies FilterableEvent);
}
eventIndex++;
}
}
return { results, error: null };
};
/** Returns maxResults of OntimeEvents that match the title field*/
const searchByTitle = (searchString: string) => {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEntry[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEvent.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
} satisfies FilterableEvent);
}
eventIndex++;
}
if (isOntimeBlock(event)) {
if (event.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEvent.Block,
id: event.id,
index: i,
title: event.title,
} satisfies FilterableBlock);
}
}
}
return { results, error: null };
};
/** Filters the rundown to a given evaluation */
const find = (event: ChangeEvent<HTMLInputElement>) => {
if (!data || data.length === 0) {
setError('No data');
return;
}
setError(null);
if (event.target.value === '') {
setResults([]);
return;
}
const searchValue = event.target.value.toLowerCase();
if (searchValue.startsWith('index ')) {
const searchString = searchValue.replace('index ', '').trim();
const { results, error } = searchByIndex(searchString);
setResults(results);
setError(error);
return;
}
if (searchValue.startsWith('cue ')) {
const searchString = searchValue.replace('cue ', '').trim();
const { results, error } = searchByCue(searchString);
setResults(results);
setError(error);
return;
}
const searchString = searchValue.replace('title ', '').trim();
const { results, error } = searchByTitle(searchString);
setResults(results);
setError(error);
};
return { find, results, error };
}
@@ -14,6 +14,23 @@ function EventEditorEmpty() {
<div className={style.prompt}>Rundown shortcuts:</div>
<table className={style.shortcuts}>
<tbody>
<tr>
<td>Find in rundown</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>F</Kbd>
</td>
</tr>
<tr>
<td>Open Settings</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>,</Kbd>
</td>
</tr>
<tr className={style.spacer} />
<tr>
<td>Select entry</td>
<td>
+1
View File
@@ -12,6 +12,7 @@ export const ontimeModal = {
minHeight: 'min(200px, 10vh)',
backgroundColor: '#202020', // $gray-1250
color: '#fefefe', // $gray-50
border: '1px solid #2d2d2d', // $gray-1100
},
body: {
padding: '1rem',
+5 -29
View File
@@ -4,30 +4,6 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
catalogs:
default:
'@types/node':
specifier: 20.14.10
version: 20.14.10
'@typescript-eslint/eslint-plugin':
specifier: 7.16.1
version: 7.16.1
'@typescript-eslint/parser':
specifier: 7.16.1
version: 7.16.1
eslint:
specifier: 8.56.0
version: 8.56.0
eslint-plugin-prettier:
specifier: 5.1.3
version: 5.1.3
prettier:
specifier: 3.3.1
version: 3.3.1
typescript:
specifier: 5.5.3
version: 5.5.3
importers:
.:
@@ -102,8 +78,8 @@ importers:
specifier: ^5.0.28
version: 5.0.28
'@mantine/hooks':
specifier: ^7.6.2
version: 7.6.2(react@18.3.1)
specifier: ^7.13.3
version: 7.13.3(react@18.3.1)
'@react-icons/all-files':
specifier: ^4.1.0
version: 4.1.0(react@18.3.1)
@@ -1673,8 +1649,8 @@ packages:
resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
engines: {node: '>= 10.0.0'}
'@mantine/hooks@7.6.2':
resolution: {integrity: sha512-ZrOgrZHoIGCDKrr2/9njDgK0al+jjusYQFlmR0YyEFyRtgY6eNSI4zuYLcAPx1haHmUm5RsLBrqY6Iy/TLdGXA==}
'@mantine/hooks@7.13.3':
resolution: {integrity: sha512-r2c+Z8CdvPKFeOwg6mSJmxOp9K/ave5ZFR7eJbgv4wQU8K1CAS5f5ven9K5uUX8Vf9B5dFnSaSgYp9UY3vOWTw==}
peerDependencies:
react: ^18.2.0
@@ -6796,7 +6772,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@mantine/hooks@7.6.2(react@18.3.1)':
'@mantine/hooks@7.13.3(react@18.3.1)':
dependencies:
react: 18.3.1