diff --git a/webui/package.json b/webui/package.json index af7cc00d6..9d405cd68 100644 --- a/webui/package.json +++ b/webui/package.json @@ -59,7 +59,6 @@ "sanitize-html": "^2.11.0", "sass": "^1.71.0", "socket.io-client": "^4.7.4", - "tributejs": "^5.1.3", "typescript": "~5.3.3", "use-deep-compare": "^1.2.1", "usehooks-ts": "^2.14.0", diff --git a/webui/src/App.scss b/webui/src/App.scss index 181e040cd..f521ef485 100644 --- a/webui/src/App.scss +++ b/webui/src/App.scss @@ -1,7 +1,6 @@ @import 'scss/variables'; @import '@coreui/coreui/scss/coreui'; -@import 'tributejs/src/tribute'; @import 'scss/react-time-picker'; @import 'scss/layout'; diff --git a/webui/src/Components/TextInputField.tsx b/webui/src/Components/TextInputField.tsx index c8e92af74..bdae90766 100644 --- a/webui/src/Components/TextInputField.tsx +++ b/webui/src/Components/TextInputField.tsx @@ -1,7 +1,10 @@ -import Tribute from 'tributejs' -import React, { useEffect, useMemo, useState, useCallback, useContext, ChangeEvent } from 'react' +import React, { useEffect, useMemo, useState, useCallback, useContext, useRef } from 'react' import { CInput } from '@coreui/react' import { VariableDefinitionsContext } from '../util.js' +import Select, { ControlProps, OptionProps, components as SelectComponents, ValueContainerProps } from 'react-select' +import { MenuPortalContext } from './DropdownInputField.js' +import { DropdownChoiceId } from '@companion-module/base' +import { observer } from 'mobx-react-lite' interface TextInputFieldProps { regex?: string @@ -17,13 +20,7 @@ interface TextInputFieldProps { useLocationVariables?: boolean } -interface TributeSuggestion { - key: string - value: string - label: string -} - -export function TextInputField({ +export const TextInputField = observer(function TextInputField({ regex, required, tooltip, @@ -36,71 +33,8 @@ export function TextInputField({ useVariables, useLocationVariables, }: TextInputFieldProps) { - const variableDefinitionsContext = useContext(VariableDefinitionsContext) - const [tmpValue, setTmpValue] = useState(null) - const tribute = useMemo(() => { - // Create it once, then we attach and detach whenever the ref changes - // @ts-expect-error Tribute import is broken - return new Tribute({ - values: [], - trigger: '$(', - - // function called on select that returns the content to insert - selectTemplate: (item: any) => `$(${item.original.value})`, - - // template for displaying item in menu - menuItemTemplate: (item: any) => - `${item.original.value}${item.original.label}`, - }) - }, []) - - useEffect(() => { - // Update the suggestions list in tribute whenever anything changes - const suggestions: TributeSuggestion[] = [] - if (useVariables) { - for (const [connectionLabel, variables] of Object.entries(variableDefinitionsContext)) { - for (const [name, va] of Object.entries(variables || {})) { - if (!va) continue - const variableId = `${connectionLabel}:${name}` - suggestions.push({ - key: variableId + ')', - value: variableId, - label: va.label, - }) - } - } - } - - if (useLocationVariables) { - suggestions.push( - { - key: 'this:page)', - value: 'this:page', - label: 'This page', - }, - { - key: 'this:column)', - value: 'this:column', - label: 'This column', - }, - { - key: 'this:row)', - value: 'this:row', - label: 'This row', - }, - { - key: 'this:page_name)', - value: 'this:page_name', - label: 'This page name', - } - ) - } - - tribute.append(0, suggestions, true) - }, [variableDefinitionsContext, tribute, useVariables, useLocationVariables]) - // Compile the regex (and cache) const compiledRegex = useMemo(() => { if (regex) { @@ -143,55 +77,359 @@ export function TextInputField({ setValid?.(isValueValid(value)) }, [isValueValid, value, setValid]) - const doOnChange = useCallback( - (e: React.ChangeEvent) => { - // const newValue = decode(e.currentTarget.value, { scope: 'strict' }) - setTmpValue(e.currentTarget.value) - setValue(e.currentTarget.value) - setValid?.(isValueValid(e.currentTarget.value)) + const storeValue = useCallback( + (value: string) => { + setTmpValue(value) + setValue(value) + setValid?.(isValueValid(value)) }, [setValue, setValid, isValueValid] ) + const doOnChange = useCallback( + (e: React.ChangeEvent | React.FormEvent) => storeValue(e.currentTarget.value), + [storeValue] + ) - const [, setupTributePrevious] = useState< - [HTMLInputElement | null, ((e: React.ChangeEvent) => void) | null] - >([null, null]) - const setupTribute = useCallback( - (ref: HTMLInputElement) => { - // we need to detach, so need to track the value manually - setupTributePrevious(([oldRef, oldDoOnChange]) => { - if (oldRef) { - tribute.detach(oldRef) - if (oldDoOnChange) { - // @ts-expect-error - oldRef.removeEventListener('tribute-replaced', oldDoOnChange) - } - } - if (ref) { - tribute.attach(ref) - // @ts-expect-error - ref.addEventListener('tribute-replaced', doOnChange) + const currentValueRef = useRef() + currentValueRef.current = value ?? '' + const focusStoreValue = useCallback(() => setTmpValue(currentValueRef.current ?? ''), []) + const blurClearValue = useCallback(() => setTmpValue(null), []) + + const showValue = tmpValue ?? value ?? '' + + const extraStyle = useMemo( + () => ({ color: !isValueValid(showValue) ? 'red' : undefined, ...style }), + [isValueValid, showValue, style] + ) + + // Render the input + return ( + <> + {useVariables ? ( + + ) : ( + + )} + + ) +}) + +function useIsPickerOpen(showValue: string, cursorPosition: number | null) { + const [isForceHidden, setIsForceHidden] = useState(false) + + let isPickerOpen = false + let searchValue = '' + + if (cursorPosition != null) { + const lastOpenSequence = FindVariableStartIndexFromCursor(showValue, cursorPosition) + isPickerOpen = lastOpenSequence !== -1 + + searchValue = showValue.slice(lastOpenSequence + 2, cursorPosition) + } + + const previousIsPickerOpen = useRef(false) + if (isPickerOpen !== previousIsPickerOpen.current) { + // Clear the force hidden after a short delay (it doesn't work to call it directly) + setTimeout(() => setIsForceHidden(false), 1) + } + previousIsPickerOpen.current = isPickerOpen + + return { + searchValue, + isPickerOpen: !isForceHidden && isPickerOpen, + setIsForceHidden, + } +} + +interface DropdownChoiceInt { + value: string + label: DropdownChoiceId +} + +interface VariablesSelectProps { + showValue: string + style: React.CSSProperties + useLocationVariables: boolean + storeValue: (value: string) => void + focusStoreValue: () => void + blurClearValue: () => void + placeholder: string | undefined + title: string | undefined + disabled: boolean | undefined +} + +function VariablesSelect({ + showValue, + style, + useLocationVariables, + storeValue, + focusStoreValue, + blurClearValue, + placeholder, + title, + disabled, +}: VariablesSelectProps) { + const variableDefinitionsContext = useContext(VariableDefinitionsContext) + const menuPortal = useContext(MenuPortalContext) + + const options = useMemo(() => { + // Update the suggestions list in tribute whenever anything changes + const suggestions: DropdownChoiceInt[] = [] + for (const [connectionLabel, variables] of Object.entries(variableDefinitionsContext)) { + for (const [name, va] of Object.entries(variables || {})) { + if (!va) continue + suggestions.push({ + value: `${connectionLabel}:${name}`, + label: va.label, + }) + } + } + + if (useLocationVariables) { + suggestions.push( + { + value: 'this:page', + label: 'This page', + }, + { + value: 'this:column', + label: 'This column', + }, + { + value: 'this:row', + label: 'This row', + }, + { + value: 'this:page_name', + label: 'This page name', } - return [ref, doOnChange] - }) + ) + } + + return suggestions + }, [variableDefinitionsContext, useLocationVariables]) + + const [cursorPosition, setCursorPosition] = useState(null) + const { isPickerOpen, searchValue, setIsForceHidden } = useIsPickerOpen(showValue, cursorPosition) + + const valueRef = useRef() + valueRef.current = showValue + + const cursorPositionRef = useRef() + cursorPositionRef.current = cursorPosition + + const inputRef = useRef(null) + + const onVariableSelect = useCallback((variable: DropdownChoiceInt | null) => { + const oldValue = valueRef.current + if (!variable || !oldValue) return + + if (cursorPositionRef.current == null) return // Nothing selected + + const openIndex = FindVariableStartIndexFromCursor(oldValue, cursorPositionRef.current) + if (openIndex === -1) return + + // Propogate the new value + storeValue(oldValue.slice(0, openIndex) + `$(${variable.value})` + oldValue.slice(cursorPositionRef.current)) + + // This doesn't work properly, it causes the cursor to get a bit confused on where it is but avoids the glitch of setSelectionRange + // if (inputRef.current) + // inputRef.current.setRangeText(`$(${variable.value})`, openIndex, cursorPositionRef.current, 'end') + + // Update the selection after mutating the value. This needs to be defered, although this causes a 'glitch' in the drawing + // It needs to be delayed, so that react can re-render first + const newSelection = openIndex + variable.value.length + 3 + setTimeout(() => { + if (inputRef.current) inputRef.current.setSelectionRange(newSelection, newSelection) + }, 0) + }, []) + + const selectContext = { + value: showValue, + setValue: storeValue, + setCursorPosition: setCursorPosition, + extraStyle: style, + forceHideSuggestions: setIsForceHidden, + focusStoreValue, + blurClearValue, + title, + placeholder, + inputRef, + } + + return ( + +