From 7a4efcf265ffb0adff05118eed170ddcd8ee7a6d Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 19 Oct 2024 23:14:37 +0200 Subject: [PATCH] feat: event finder --- apps/client/package.json | 2 +- apps/client/src/features/editors/Editor.tsx | 60 ++---- .../editors/finder/Finder.module.scss | 64 ++++++ .../src/features/editors/finder/Finder.tsx | 76 ++++++++ .../src/features/editors/finder/useFinder.tsx | 182 ++++++++++++++++++ .../rundown/event-editor/EventEditorEmpty.tsx | 17 ++ apps/client/src/theme/ontimeModal.ts | 1 + pnpm-lock.yaml | 64 +++--- 8 files changed, 392 insertions(+), 74 deletions(-) create mode 100644 apps/client/src/features/editors/finder/Finder.module.scss create mode 100644 apps/client/src/features/editors/finder/Finder.tsx create mode 100644 apps/client/src/features/editors/finder/useFinder.tsx diff --git a/apps/client/package.json b/apps/client/package.json index 859971265a..615c5cbda0 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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", diff --git a/apps/client/src/features/editors/Editor.tsx b/apps/client/src/features/editors/Editor.tsx index b32e39e3ff..3627dc924e 100644 --- a/apps/client/src/features/editors/Editor.tsx +++ b/apps/client/src/features/editors/Editor.tsx @@ -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 (
+ 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) => { + // 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') { + const selectedEvent = results[selected]; + setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' }); + onClose(); + } + }; + + return ( + + + + + +
    + {error &&
  • {error}
  • } + {results.length === 0 &&
  • No results
  • } + {results.length > 0 && + results.map((event, index) => { + const isSelected = selected === index; + const displayIndex = event.type === SupportedEvent.Block ? '-' : event.index; + return ( +
  • +
    +
    {displayIndex}
    + {isOntimeEvent(event) &&
    {event.cue}
    } +
    {event.title}
    +
    + {isSelected && Go ⏎} +
  • + ); + })} +
+
+ + Use the keywords cue, index or + title to filter search + +
+
+ ); +} diff --git a/apps/client/src/features/editors/finder/useFinder.tsx b/apps/client/src/features/editors/finder/useFinder.tsx new file mode 100644 index 0000000000..61d4973061 --- /dev/null +++ b/apps/client/src/features/editors/finder/useFinder.tsx @@ -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 = 15; + +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([]); + const [error, setError] = useState(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) => { + 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 }; +} diff --git a/apps/client/src/features/rundown/event-editor/EventEditorEmpty.tsx b/apps/client/src/features/rundown/event-editor/EventEditorEmpty.tsx index 7905f278e5..587d49bd59 100644 --- a/apps/client/src/features/rundown/event-editor/EventEditorEmpty.tsx +++ b/apps/client/src/features/rundown/event-editor/EventEditorEmpty.tsx @@ -14,6 +14,23 @@ function EventEditorEmpty() {
Rundown shortcuts:
+ + + + + + + + +
Open Finder + {deviceMod} + + + F +
Open Settings + {deviceMod} + + + , +
Select entry diff --git a/apps/client/src/theme/ontimeModal.ts b/apps/client/src/theme/ontimeModal.ts index 7845f18212..be0ebdca13 100644 --- a/apps/client/src/theme/ontimeModal.ts +++ b/apps/client/src/theme/ontimeModal.ts @@ -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', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6acde450e6..53ca06b975 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,33 @@ 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-config-prettier: + specifier: 9.1.0 + version: 9.1.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: .: @@ -78,8 +105,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) @@ -1649,8 +1676,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 @@ -5427,33 +5454,6 @@ packages: react: optional: true -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-config-prettier: - specifier: 9.1.0 - version: 9.1.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 - snapshots: 7zip-bin@5.2.0: {} @@ -6807,7 +6807,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