diff --git a/frontend/src/components/NumberComponent.tsx b/frontend/src/components/NumberComponent.tsx index 37fa7cd..e7d034c 100644 --- a/frontend/src/components/NumberComponent.tsx +++ b/frontend/src/components/NumberComponent.tsx @@ -1,11 +1,10 @@ -import React, { useContext, useEffect, useRef, useState } from 'react'; +import React, { useContext, useEffect, useRef } from 'react'; import { WebSettingsContext } from '../WebSettings'; -import { Form, InputGroup } from 'react-bootstrap'; -import { setAttribute } from '../socket'; -import { DocStringComponent } from './DocStringComponent'; import '../App.css'; import { getIdFromFullAccessPath } from '../utils/stringUtils'; import { LevelName } from './NotificationsComponent'; +import { NumberInputField } from './NumberInputField'; +import { setAttribute } from '../socket'; // TODO: add button functionality @@ -45,92 +44,10 @@ type NumberComponentProps = { addNotification: (message: string, levelname?: LevelName) => void; }; -// TODO: highlight the digit that is being changed by setting both selectionStart and -// selectionEnd -const handleArrowKey = ( - key: string, - value: string, - selectionStart: number - // selectionEnd: number -) => { - // Split the input value into the integer part and decimal part - const parts = value.split('.'); - const beforeDecimalCount = parts[0].length; // Count digits before the decimal - const afterDecimalCount = parts[1] ? parts[1].length : 0; // Count digits after the decimal - - const isCursorAfterDecimal = selectionStart > beforeDecimalCount; - - // Calculate the increment/decrement value based on the cursor position - let increment = 0; - if (isCursorAfterDecimal) { - increment = Math.pow(10, beforeDecimalCount + 1 - selectionStart); - } else { - increment = Math.pow(10, beforeDecimalCount - selectionStart); - } - - // Convert the input value to a number, increment or decrement it based on the - // arrow key - const numValue = parseFloat(value) + (key === 'ArrowUp' ? increment : -increment); - - // Convert the resulting number to a string, maintaining the same number of digits - // after the decimal - const newValue = numValue.toFixed(afterDecimalCount); - - // Check if the length of the integer part of the number string has in-/decreased - const newBeforeDecimalCount = newValue.split('.')[0].length; - if (newBeforeDecimalCount > beforeDecimalCount) { - // Move the cursor one position to the right - selectionStart += 1; - } else if (newBeforeDecimalCount < beforeDecimalCount) { - // Move the cursor one position to the left - selectionStart -= 1; - } - return { value: newValue, selectionStart }; -}; - -const handleBackspaceKey = ( - value: string, - selectionStart: number, - selectionEnd: number -) => { - if (selectionEnd > selectionStart) { - // If there is a selection, delete all characters in the selection - return { - value: value.slice(0, selectionStart) + value.slice(selectionEnd), - selectionStart - }; - } else if (selectionStart > 0) { - return { - value: value.slice(0, selectionStart - 1) + value.slice(selectionStart), - selectionStart: selectionStart - 1 - }; - } - return { value, selectionStart }; -}; - -const handleDeleteKey = ( - value: string, - selectionStart: number, - selectionEnd: number -) => { - if (selectionEnd > selectionStart) { - // If there is a selection, delete all characters in the selection - return { - value: value.slice(0, selectionStart) + value.slice(selectionEnd), - selectionStart - }; - } else if (selectionStart < value.length) { - return { - value: value.slice(0, selectionStart) + value.slice(selectionStart + 1), - selectionStart - }; - } - return { value, selectionStart }; -}; - export const NumberComponent = React.memo((props: NumberComponentProps) => { const { name, + value, parentPath, readOnly, docString, @@ -139,14 +56,13 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => { addNotification } = props; + function changeCallback(value: number) { + setAttribute(name, parentPath, value); + } // Whether to show the name infront of the component (false if used with a slider) const showName = props.showName !== undefined ? props.showName : true; const renderCount = useRef(0); - // Create a state for the cursor position - const [cursorPosition, setCursorPosition] = useState(null); - // Create a state for the input string - const [inputString, setInputString] = useState(props.value.toString()); const fullAccessPath = [parentPath, name].filter((element) => element).join('.'); const id = getIdFromFullAccessPath(fullAccessPath); const webSettings = useContext(WebSettingsContext); @@ -157,26 +73,6 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => { } useEffect(() => { - renderCount.current++; - - // Set the cursor position after the component re-renders - const inputElement = document.getElementsByName( - fullAccessPath - )[0] as HTMLInputElement; - if (inputElement && cursorPosition !== null) { - inputElement.setSelectionRange(cursorPosition, cursorPosition); - } - }); - - useEffect(() => { - // Parse the input string to a number for comparison - const numericInputString = - props.type === 'int' ? parseInt(inputString) : parseFloat(inputString); - // Only update the inputString if it's different from the prop value - if (props.value !== numericInputString) { - setInputString(props.value.toString()); - } - // emitting notification let notificationMsg = `${parentPath}.${name} changed to ${props.value}`; if (unit === undefined) { @@ -187,152 +83,22 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => { addNotification(notificationMsg); }, [props.value]); - const handleNumericKey = ( - key: string, - value: string, - selectionStart: number, - selectionEnd: number - ) => { - // Check if a number key or a decimal point key is pressed - if (key === '.' && (value.includes('.') || props.type === 'int')) { - // Check if value already contains a decimal. If so, ignore input. - // eslint-disable-next-line no-console - console.warn('Invalid input! Ignoring...'); - return { value, selectionStart }; - } - - let newValue = value; - - // Add the new key at the cursor's position - if (selectionEnd > selectionStart) { - // If there is a selection, replace it with the key - newValue = value.slice(0, selectionStart) + key + value.slice(selectionEnd); - } else { - // otherwise, append the key after the selection start - newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart); - } - - return { value: newValue, selectionStart: selectionStart + 1 }; - }; - const handleKeyDown = (event) => { - const { key, target } = event; - if ( - key === 'F1' || - key === 'F5' || - key === 'F12' || - key === 'Tab' || - key === 'ArrowRight' || - key === 'ArrowLeft' - ) { - return; - } - event.preventDefault(); - - // Get the current input value and cursor position - const { value } = target; - let { selectionStart } = target; - const { selectionEnd } = target; - - let newValue: string = value; - if (event.ctrlKey && key === 'a') { - // Select everything when pressing Ctrl + a - target.setSelectionRange(0, target.value.length); - return; - } else if (key === '-') { - if (selectionStart === 0 && !value.startsWith('-')) { - newValue = '-' + value; - selectionStart++; - } else if (value.startsWith('-') && selectionStart === 1) { - newValue = value.substring(1); // remove minus sign - selectionStart--; - } else { - return; // Ignore "-" pressed in other positions - } - } else if (!isNaN(key) && key !== ' ') { - // Check if a number key or a decimal point key is pressed - ({ value: newValue, selectionStart } = handleNumericKey( - key, - value, - selectionStart, - selectionEnd - )); - } else if (key === '.') { - ({ value: newValue, selectionStart } = handleNumericKey( - key, - value, - selectionStart, - selectionEnd - )); - } else if (key === 'ArrowUp' || key === 'ArrowDown') { - ({ value: newValue, selectionStart } = handleArrowKey( - key, - value, - selectionStart - // selectionEnd - )); - } else if (key === 'Backspace') { - ({ value: newValue, selectionStart } = handleBackspaceKey( - value, - selectionStart, - selectionEnd - )); - } else if (key === 'Delete') { - ({ value: newValue, selectionStart } = handleDeleteKey( - value, - selectionStart, - selectionEnd - )); - } else if (key === 'Enter' && !isInstantUpdate) { - setAttribute(name, parentPath, Number(newValue)); - return; - } else { - console.debug(key); - return; - } - - // Update the input value and maintain the cursor position - if (isInstantUpdate) { - setAttribute(name, parentPath, Number(newValue)); - } - - setInputString(newValue); - - // Save the current cursor position before the component re-renders - setCursorPosition(selectionStart); - }; - - const handleBlur = () => { - if (!isInstantUpdate) { - // If not in "instant update" mode, emit an update when the input field loses focus - setAttribute(name, parentPath, Number(inputString)); - } - }; - return (
{process.env.NODE_ENV === 'development' && (
Render count: {renderCount.current}
)} -
- - {showName && ( - - {displayName} - - - )} - - {unit && {unit}} - -
+
); }); diff --git a/frontend/src/components/NumberInputField.tsx b/frontend/src/components/NumberInputField.tsx new file mode 100644 index 0000000..220d6f4 --- /dev/null +++ b/frontend/src/components/NumberInputField.tsx @@ -0,0 +1,285 @@ +import React, { useEffect, useState } from 'react'; +import { Form, InputGroup } from 'react-bootstrap'; +import { DocStringComponent } from './DocStringComponent'; +import '../App.css'; + +// TODO: add button functionality + +type NumberInputFieldProps = { + name: string; + value: number; + type?: 'float' | 'int'; + displayName?: string; + readOnly?: boolean; + docString?: string; + isInstantUpdate?: boolean; + unit?: string; + changeCallback?: (value: number) => void; +}; + +// TODO: highlight the digit that is being changed by setting both selectionStart and +// selectionEnd +const handleArrowKey = ( + key: string, + value: string, + selectionStart: number + // selectionEnd: number +) => { + // Split the input value into the integer part and decimal part + const parts = value.split('.'); + const beforeDecimalCount = parts[0].length; // Count digits before the decimal + const afterDecimalCount = parts[1] ? parts[1].length : 0; // Count digits after the decimal + + const isCursorAfterDecimal = selectionStart > beforeDecimalCount; + + // Calculate the increment/decrement value based on the cursor position + let increment = 0; + if (isCursorAfterDecimal) { + increment = Math.pow(10, beforeDecimalCount + 1 - selectionStart); + } else { + increment = Math.pow(10, beforeDecimalCount - selectionStart); + } + + // Convert the input value to a number, increment or decrement it based on the + // arrow key + const numValue = parseFloat(value) + (key === 'ArrowUp' ? increment : -increment); + + // Convert the resulting number to a string, maintaining the same number of digits + // after the decimal + const newValue = numValue.toFixed(afterDecimalCount); + + // Check if the length of the integer part of the number string has in-/decreased + const newBeforeDecimalCount = newValue.split('.')[0].length; + if (newBeforeDecimalCount > beforeDecimalCount) { + // Move the cursor one position to the right + selectionStart += 1; + } else if (newBeforeDecimalCount < beforeDecimalCount) { + // Move the cursor one position to the left + selectionStart -= 1; + } + return { value: newValue, selectionStart }; +}; + +const handleBackspaceKey = ( + value: string, + selectionStart: number, + selectionEnd: number +) => { + if (selectionEnd > selectionStart) { + // If there is a selection, delete all characters in the selection + return { + value: value.slice(0, selectionStart) + value.slice(selectionEnd), + selectionStart + }; + } else if (selectionStart > 0) { + return { + value: value.slice(0, selectionStart - 1) + value.slice(selectionStart), + selectionStart: selectionStart - 1 + }; + } + return { value, selectionStart }; +}; + +const handleDeleteKey = ( + value: string, + selectionStart: number, + selectionEnd: number +) => { + if (selectionEnd > selectionStart) { + // If there is a selection, delete all characters in the selection + return { + value: value.slice(0, selectionStart) + value.slice(selectionEnd), + selectionStart + }; + } else if (selectionStart < value.length) { + return { + value: value.slice(0, selectionStart) + value.slice(selectionStart + 1), + selectionStart + }; + } + return { value, selectionStart }; +}; + +const handleNumericKey = ( + key: string, + value: string, + selectionStart: number, + selectionEnd: number +) => { + // Check if a number key or a decimal point key is pressed + if (key === '.' && value.includes('.')) { + // Check if value already contains a decimal. If so, ignore input. + console.warn('Invalid input! Ignoring...'); + return { value, selectionStart }; + } + + let newValue = value; + + // Add the new key at the cursor's position + if (selectionEnd > selectionStart) { + // If there is a selection, replace it with the key + newValue = value.slice(0, selectionStart) + key + value.slice(selectionEnd); + } else { + // otherwise, append the key after the selection start + newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart); + } + + return { value: newValue, selectionStart: selectionStart + 1 }; +}; +export const NumberInputField = React.memo((props: NumberInputFieldProps) => { + const { + name, + value, + unit, + type = 'float', + displayName, + docString = null, + readOnly = false, + isInstantUpdate = false + } = props; + + // Create a state for the cursor position + const [cursorPosition, setCursorPosition] = useState(null); + // Create a state for the input string + const [inputString, setInputString] = useState(value.toString()); + + useEffect(() => { + // Set the cursor position after the component re-renders + const inputElement = document.getElementsByName(name)[0] as HTMLInputElement; + if (inputElement && cursorPosition !== null) { + inputElement.setSelectionRange(cursorPosition, cursorPosition); + } + }); + + useEffect(() => { + // Parse the input string to a number for comparison + const numericInputString = + type === 'int' ? parseInt(inputString) : parseFloat(inputString); + // Only update the inputString if it's different from the prop value + if (value !== numericInputString) { + setInputString(value.toString()); + } + }, [value]); + + const handleKeyDown = (event) => { + const { key, target } = event; + if ( + key === 'F1' || + key === 'F5' || + key === 'F12' || + key === 'Tab' || + key === 'ArrowRight' || + key === 'ArrowLeft' + ) { + return; + } + event.preventDefault(); + + // Get the current input value and cursor position + const { value } = target; + let { selectionStart } = target; + const { selectionEnd } = target; + + let newValue: string = value; + if (event.ctrlKey && key === 'a') { + // Select everything when pressing Ctrl + a + target.setSelectionRange(0, target.value.length); + return; + } else if (key === '-') { + if (selectionStart === 0 && !value.startsWith('-')) { + newValue = '-' + value; + selectionStart++; + } else if (value.startsWith('-') && selectionStart === 1) { + newValue = value.substring(1); // remove minus sign + selectionStart--; + } else { + return; // Ignore "-" pressed in other positions + } + } else if (!isNaN(key) && key !== ' ') { + // Check if a number key or a decimal point key is pressed + ({ value: newValue, selectionStart } = handleNumericKey( + key, + value, + selectionStart, + selectionEnd + )); + } else if (key === '.' && type === 'float') { + ({ value: newValue, selectionStart } = handleNumericKey( + key, + value, + selectionStart, + selectionEnd + )); + } else if (key === 'ArrowUp' || key === 'ArrowDown') { + ({ value: newValue, selectionStart } = handleArrowKey( + key, + value, + selectionStart + // selectionEnd + )); + } else if (key === 'Backspace') { + ({ value: newValue, selectionStart } = handleBackspaceKey( + value, + selectionStart, + selectionEnd + )); + } else if (key === 'Delete') { + ({ value: newValue, selectionStart } = handleDeleteKey( + value, + selectionStart, + selectionEnd + )); + } else if (key === 'Enter' && !isInstantUpdate) { + if (props.changeCallback !== undefined) { + props.changeCallback(Number(newValue)); + } + return; + } else { + console.debug(key); + return; + } + + // Update the input value and maintain the cursor position + if (isInstantUpdate) { + if (props.changeCallback !== undefined) { + props.changeCallback(Number(newValue)); + } + } + + setInputString(newValue); + + // Save the current cursor position before the component re-renders + setCursorPosition(selectionStart); + }; + + const handleBlur = () => { + if (!isInstantUpdate) { + // If not in "instant update" mode, emit an update when the input field loses focus + if (props.changeCallback !== undefined) { + props.changeCallback(Number(inputString)); + } + } + }; + return ( +
+ + {displayName && ( + + {displayName} + + + )} + + {unit && {unit}} + +
+ ); +});