mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-22 07:29:08 +00:00
feat: add autocomplete to osc arguments
This commit is contained in:
committed by
Carlos Valente
parent
31799f8fab
commit
bac11adeb1
@@ -25,6 +25,7 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
|
|||||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
import TemplateInput from './template-input/TemplateInput';
|
||||||
import { isAutomation, makeFieldList } from './automationUtils';
|
import { isAutomation, makeFieldList } from './automationUtils';
|
||||||
|
|
||||||
import style from './AutomationForm.module.scss';
|
import style from './AutomationForm.module.scss';
|
||||||
@@ -357,7 +358,7 @@ export default function AutomationForm(props: AutomationFormProps) {
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Parameters
|
Parameters
|
||||||
<Input
|
<TemplateInput
|
||||||
{...register(`outputs.${index}.args`)}
|
{...register(`outputs.${index}.args`)}
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
size='sm'
|
size='sm'
|
||||||
|
|||||||
+29
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+19
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
+105
@@ -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 '';
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
PlayableEvent,
|
PlayableEvent,
|
||||||
Playback,
|
Playback,
|
||||||
Runtime,
|
Runtime,
|
||||||
|
runtimeStorePlaceholder,
|
||||||
TimerPhase,
|
TimerPhase,
|
||||||
TimerState,
|
TimerState,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
@@ -32,29 +33,6 @@ import {
|
|||||||
import { timerConfig } from '../config/config.js';
|
import { timerConfig } from '../config/config.js';
|
||||||
import { loadRoll, normaliseRollStart } from '../services/rollUtils.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 = {
|
export type RuntimeState = {
|
||||||
clock: number; // realtime clock
|
clock: number; // realtime clock
|
||||||
eventNow: PlayableEvent | null;
|
eventNow: PlayableEvent | null;
|
||||||
@@ -75,16 +53,13 @@ export type RuntimeState = {
|
|||||||
|
|
||||||
const runtimeState: RuntimeState = {
|
const runtimeState: RuntimeState = {
|
||||||
clock: clock.timeNow(),
|
clock: clock.timeNow(),
|
||||||
currentBlock: {
|
currentBlock: { ...runtimeStorePlaceholder.currentBlock },
|
||||||
block: null,
|
|
||||||
startedAt: null,
|
|
||||||
},
|
|
||||||
eventNow: null,
|
eventNow: null,
|
||||||
publicEventNow: null,
|
publicEventNow: null,
|
||||||
eventNext: null,
|
eventNext: null,
|
||||||
publicEventNext: null,
|
publicEventNext: null,
|
||||||
runtime: { ...initialRuntime },
|
runtime: { ...runtimeStorePlaceholder.runtime },
|
||||||
timer: { ...initialTimer },
|
timer: { ...runtimeStorePlaceholder.timer },
|
||||||
_timer: {
|
_timer: {
|
||||||
forceFinish: null,
|
forceFinish: null,
|
||||||
totalDelay: 0,
|
totalDelay: 0,
|
||||||
@@ -124,7 +99,7 @@ export function clear() {
|
|||||||
|
|
||||||
runtimeState.timer.playback = Playback.Stop;
|
runtimeState.timer.playback = Playback.Stop;
|
||||||
runtimeState.clock = clock.timeNow();
|
runtimeState.clock = clock.timeNow();
|
||||||
runtimeState.timer = { ...initialTimer };
|
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||||
|
|
||||||
// when clearing, we maintain the total delay from the rundown
|
// when clearing, we maintain the total delay from the rundown
|
||||||
runtimeState._timer.forceFinish = null;
|
runtimeState._timer.forceFinish = null;
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
|||||||
clock: 0,
|
clock: 0,
|
||||||
timer: {
|
timer: {
|
||||||
addedTime: 0,
|
addedTime: 0,
|
||||||
current: null,
|
current: null, // changes on every update
|
||||||
duration: null,
|
duration: null, // only changes if event changes
|
||||||
elapsed: null,
|
elapsed: null, // changes on every update
|
||||||
expectedFinish: null,
|
expectedFinish: null, // change can only be initiated by user, can roll over midnight
|
||||||
finishedAt: null,
|
finishedAt: null, // can change on update or user action
|
||||||
phase: TimerPhase.None,
|
phase: TimerPhase.None, // can change on update or user action
|
||||||
playback: Playback.Stop,
|
playback: Playback.Stop, // change initiated by user
|
||||||
secondaryTimer: null,
|
secondaryTimer: null, // change on every update
|
||||||
startedAt: null,
|
startedAt: null, // change can only be initiated by user
|
||||||
},
|
},
|
||||||
onAir: false,
|
onAir: false,
|
||||||
message: {
|
message: {
|
||||||
@@ -29,13 +29,13 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
|||||||
external: '',
|
external: '',
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
selectedEventIndex: null,
|
selectedEventIndex: null, // changes if rundown changes or we load a new event
|
||||||
numEvents: 0,
|
numEvents: 0, // change initiated by user
|
||||||
offset: 0,
|
offset: 0, // changes at runtime
|
||||||
plannedStart: 0,
|
plannedStart: 0, // only changes if event changes
|
||||||
plannedEnd: 0,
|
plannedEnd: 0, // only changes if event changes, overflows over dayInMs
|
||||||
actualStart: null,
|
actualStart: null, // set once we start the timer
|
||||||
expectedEnd: null,
|
expectedEnd: null, // changes with runtime, based on offset, overflows over dayInMs
|
||||||
},
|
},
|
||||||
currentBlock: {
|
currentBlock: {
|
||||||
block: null,
|
block: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user