feat: add autocomplete to osc arguments

This commit is contained in:
Carlos Valente
2025-02-07 19:38:37 +01:00
committed by Carlos Valente
parent 31799f8fab
commit bac11adeb1
7 changed files with 246 additions and 47 deletions
@@ -25,6 +25,7 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
import TemplateInput from './template-input/TemplateInput';
import { isAutomation, makeFieldList } from './automationUtils';
import style from './AutomationForm.module.scss';
@@ -357,7 +358,7 @@ export default function AutomationForm(props: AutomationFormProps) {
</label>
<label>
Parameters
<Input
<TemplateInput
{...register(`outputs.${index}.args`)}
variant='ontime-filled'
size='sm'
@@ -0,0 +1,29 @@
.wrapper {
position: relative;
}
.suggestions {
background: $gray-1250;
color: $ui-white;
position: absolute;
top: 100%;
left: 0;
width: 100%;
margin: 0;
z-index: 5;
padding-block: 0.25rem;
max-height: 200px;
overflow-y: auto;
color: $label-gray;
li {
padding: 0.25rem;
}
li:hover {
color: $ui-white;
background: $blue-700;
}
}
@@ -0,0 +1,70 @@
import { useMemo, useState } from 'react';
import { type InputProps, Input } from '@chakra-ui/react';
import { useClickOutside } from '@mantine/hooks';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
import style from './TemplateInput.module.scss';
interface TemplateInputProps extends InputProps {}
export default function TemplateInput(props: TemplateInputProps) {
const { value, onChange, ...rest } = props;
const { data } = useCustomFields();
const ref = useClickOutside(() => setShowSuggestions(false));
const autocompleteList = useMemo(() => {
return makeAutoCompleteList(data);
}, [data]);
const [inputValue, setInputValue] = useState(value || '');
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const updateSuggestions = (value: string) => {
const template = selectFromLastTemplate(value);
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
if (event.target.value.endsWith('{{')) {
setShowSuggestions(true);
} else if (event.target.value === '' || event.target.value.endsWith('}}')) {
setShowSuggestions(false);
}
if (showSuggestions) {
const suggestions = updateSuggestions(event.target.value);
setSuggestions(suggestions);
}
onChange?.(event);
};
const handleSuggestion = (value: string) => {
setInputValue((prev) => {
const remaining = matchRemaining(prev as string, value);
return prev + remaining;
});
setShowSuggestions(false);
};
return (
<div className={style.wrapper} ref={ref}>
<Input {...rest} value={inputValue} onChange={handleInputChange} autoComplete='off' autoCorrect='off' />
{showSuggestions && suggestions.length > 0 && (
<ul className={style.suggestions}>
{suggestions.map((suggestion) => (
<li key={suggestion} onClick={() => handleSuggestion(suggestion)}>
{suggestion}
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,19 @@
import { matchRemaining } from '../templateInput.utils';
describe('matchRemaining()', () => {
it('should return a partial string needed for autocomplete', () => {
expect(matchRemaining('te', 'test')).toBe('st');
expect(matchRemaining('{{{hum', '{{human}}')).toBe('an}}');
expect(matchRemaining('send {', '{{human}}')).toBe('{human}}');
// we should be able to match the following
// however, the current implementation only needs to deal with strings that start with {{
// expect(matchRemaining('{', '{{human}}')).toBe('{human}}');
// expect(matchRemaining('{{', '{{human}}')).toBe('human}}');
});
it('should return an empty string if there are no matches or if it is complete', () => {
expect(matchRemaining('test', 'another')).toBe('');
expect(matchRemaining('test', 'test')).toBe('');
});
});
@@ -0,0 +1,105 @@
import { CustomFields } from 'ontime-types';
const humanConstants = [
'{{human.clock}}',
'{{human.duration}}',
'{{human.expectedEnd}}',
'{{human.runningTimer}}',
'{{human.elapsedTime}}',
'{{human.startedAt}}',
];
const staticAutocompleteOptions = [
'{{clock}}',
'{{timer.addedTime}}',
'{{timer.current}}',
'{{timer.duration}}',
'{{timer.elapsed}}',
'{{timer.expectedFinish}}',
'{{timer.finishedAt}}',
'{{timer.secondaryTimer}}',
'{{timer.startedAt}}',
'{{runtime.selectedEventIndex}}',
'{{runtime.numEvents}}',
'{{runtime.offset}}',
'{{runtime.plannedStart}}',
'{{runtime.plannedEnd}}',
'{{runtime.actualStart}}',
'{{runtime.expectedEnd}}',
'{{currentBlock.block}}',
'{{currentBlock.startedAt}}',
];
const eventStaticPropertiesNow = [
'{{eventNow.id}}',
'{{eventNow.cue}}',
'{{eventNow.title}}',
'{{eventNow.note}}',
'{{eventNow.timeStart}}',
'{{eventNow.timeEnd}}',
'{{eventNow.duration}}',
'{{eventNow.isPublic}}',
'{{eventNow.colour}}',
'{{eventNow.delay}}',
];
const eventStaticPropertiesNext = [
'{{eventNext.id}}',
'{{eventNext.cue}}',
'{{eventNext.title}}',
'{{eventNext.note}}',
'{{eventNext.timeStart}}',
'{{eventNext.timeEnd}}',
'{{eventNext.duration}}',
'{{eventNext.isPublic}}',
'{{eventNext.colour}}',
'{{eventNext.delay}}',
];
/**
* Creates a it of possible autocomplete suggestions
* Based on RuntimeState
* Appends the human readable variants to it
* It is manually kept in sync with the automation parseTemplate functions
*/
export function makeAutoCompleteList(customFields: CustomFields): string[] {
return [
...humanConstants,
...staticAutocompleteOptions,
...eventStaticPropertiesNow,
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
];
}
/**
* Returns the partial string b needed to autocomplete string a
* @example matchRemaining('te', 'test') -> 'st'
* @example matchRemaining('{{', '{{human}}') -> 'man}}'
*/
export function matchRemaining(a: string, b: string) {
if (a === b) {
return '';
}
for (let i = 0; i < b.length; i++) {
const searchString = b.substring(0, i + 1);
if (a.endsWith(searchString)) {
return b.substring(i + 1);
}
}
return '';
}
/**
* Selects the last starting template in a string
*/
export function selectFromLastTemplate(text: string) {
const lastBraceIndex = text.lastIndexOf('{{');
if (lastBraceIndex !== -1) {
return text.slice(lastBraceIndex);
}
return '';
}
+5 -30
View File
@@ -8,6 +8,7 @@ import {
PlayableEvent,
Playback,
Runtime,
runtimeStorePlaceholder,
TimerPhase,
TimerState,
} from 'ontime-types';
@@ -32,29 +33,6 @@ import {
import { timerConfig } from '../config/config.js';
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
const initialRuntime: Runtime = {
selectedEventIndex: null, // changes if rundown changes or we load a new event
numEvents: 0, // change initiated by user
offset: 0, // changes at runtime
plannedStart: 0, // only changes if event changes
plannedEnd: 0, // only changes if event changes, overflows over dayInMs
actualStart: null, // set once we start the timer
expectedEnd: null, // changes with runtime, based on offset, overflows over dayInMs
} as const;
const initialTimer: TimerState = {
addedTime: 0,
current: null, // changes on every update
duration: null, // only changes if event changes
elapsed: null, // changes on every update
expectedFinish: null, // change can only be initiated by user, can roll over midnight
finishedAt: null, // can change on update or user action
phase: TimerPhase.None, // can change on update or user action
playback: Playback.Stop, // change initiated by user
secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user
} as const;
export type RuntimeState = {
clock: number; // realtime clock
eventNow: PlayableEvent | null;
@@ -75,16 +53,13 @@ export type RuntimeState = {
const runtimeState: RuntimeState = {
clock: clock.timeNow(),
currentBlock: {
block: null,
startedAt: null,
},
currentBlock: { ...runtimeStorePlaceholder.currentBlock },
eventNow: null,
publicEventNow: null,
eventNext: null,
publicEventNext: null,
runtime: { ...initialRuntime },
timer: { ...initialTimer },
runtime: { ...runtimeStorePlaceholder.runtime },
timer: { ...runtimeStorePlaceholder.timer },
_timer: {
forceFinish: null,
totalDelay: 0,
@@ -124,7 +99,7 @@ export function clear() {
runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = clock.timeNow();
runtimeState.timer = { ...initialTimer };
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
runtimeState._timer.forceFinish = null;
@@ -7,15 +7,15 @@ export const runtimeStorePlaceholder: RuntimeStore = {
clock: 0,
timer: {
addedTime: 0,
current: null,
duration: null,
elapsed: null,
expectedFinish: null,
finishedAt: null,
phase: TimerPhase.None,
playback: Playback.Stop,
secondaryTimer: null,
startedAt: null,
current: null, // changes on every update
duration: null, // only changes if event changes
elapsed: null, // changes on every update
expectedFinish: null, // change can only be initiated by user, can roll over midnight
finishedAt: null, // can change on update or user action
phase: TimerPhase.None, // can change on update or user action
playback: Playback.Stop, // change initiated by user
secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user
},
onAir: false,
message: {
@@ -29,13 +29,13 @@ export const runtimeStorePlaceholder: RuntimeStore = {
external: '',
},
runtime: {
selectedEventIndex: null,
numEvents: 0,
offset: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
expectedEnd: null,
selectedEventIndex: null, // changes if rundown changes or we load a new event
numEvents: 0, // change initiated by user
offset: 0, // changes at runtime
plannedStart: 0, // only changes if event changes
plannedEnd: 0, // only changes if event changes, overflows over dayInMs
actualStart: null, // set once we start the timer
expectedEnd: null, // changes with runtime, based on offset, overflows over dayInMs
},
currentBlock: {
block: null,