mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 18:03:47 +00:00
feat: event finder
This commit is contained in:
committed by
Carlos Valente
parent
a8756c8afe
commit
143917f11a
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Generated
+5
-29
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user