diff --git a/apps/client/package.json b/apps/client/package.json
index 28327d6969..e25e86c950 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') {
+ submit();
+ }
+ };
+
+ const submit = () => {
+ const selectedEvent = results[selected];
+ setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' });
+ onClose();
+ };
+
+ const handleMouseMoveEvent = (event: React.MouseEvent) => {
+ 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 (
+
+
+
+
+
+
+ {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;
+ const colour = event.type === SupportedEvent.Event ? event.colour : '';
+
+ 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..9ec95fa1a4
--- /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 = 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([]);
+ 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..e728348e47 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:
+
+ Find in rundown |
+
+ {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 cd15b70571..36ac7bc629 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
|