diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolWidthPicker.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolWidthPicker.tsx index 3fa270893a3..f71af3818e7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolWidthPicker.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolWidthPicker.tsx @@ -23,7 +23,7 @@ import { settingsEraserWidthChanged, } from 'features/controlLayers/store/canvasSettingsSlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; -import type { KeyboardEvent } from 'react'; +import type { FocusEvent, KeyboardEvent, PointerEvent } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { PiCaretDownBold } from 'react-icons/pi'; @@ -71,17 +71,40 @@ const marks = [ const sliderDefaultValue = mapRawValueToSliderValue(50); const SLIDER_VS_DROPDOWN_CONTAINER_WIDTH_THRESHOLD = 280; +const DEFAULT_TOOL_WIDTH = 50; +const parseInputValue = (value: string) => Number.parseFloat(value); +const getInputValueFromEvent = ( + event?: Pick | KeyboardEvent, 'target' | 'currentTarget'> +) => { + const target = event?.target as HTMLInputElement | null; + if (target?.tagName === 'INPUT') { + return { input: target, parsed: parseInputValue(target.value) }; + } + const currentTarget = event?.currentTarget as HTMLElement | null; + const input = currentTarget?.querySelector('input') ?? null; + return { input, parsed: input ? parseInputValue(input.value) : NaN }; +}; interface ToolWidthPickerComponentProps { localValue: number; onChangeSlider: (value: number) => void; onChangeInput: (value: number) => void; - onBlur: () => void; + onBlur: (event?: FocusEvent) => void; onKeyDown: (value: KeyboardEvent) => void; + onPointerDownCapture: (value: PointerEvent) => void; + onPointerUpCapture: (value: PointerEvent) => void; } const DropDownToolWidthPickerComponent = memo( - ({ localValue, onChangeSlider, onChangeInput, onKeyDown, onBlur }: ToolWidthPickerComponentProps) => { + ({ + localValue, + onChangeSlider, + onChangeInput, + onKeyDown, + onPointerDownCapture, + onPointerUpCapture, + onBlur, + }: ToolWidthPickerComponentProps) => { const onChangeNumberInput = useCallback( (valueAsString: string, valueAsNumber: number) => { onChangeInput(valueAsNumber); @@ -106,6 +129,8 @@ const DropDownToolWidthPickerComponent = memo( format={formatPx} defaultValue={50} onKeyDown={onKeyDown} + onPointerDownCapture={onPointerDownCapture} + onPointerUpCapture={onPointerUpCapture} clampValueOnBlur={false} > @@ -147,7 +172,15 @@ const DropDownToolWidthPickerComponent = memo( DropDownToolWidthPickerComponent.displayName = 'DropDownToolWidthPickerComponent'; const SliderToolWidthPickerComponent = memo( - ({ localValue, onChangeSlider, onChangeInput, onKeyDown, onBlur }: ToolWidthPickerComponentProps) => { + ({ + localValue, + onChangeSlider, + onChangeInput, + onKeyDown, + onPointerDownCapture, + onPointerUpCapture, + onBlur, + }: ToolWidthPickerComponentProps) => { return ( @@ -204,6 +239,8 @@ export const ToolWidthPicker = memo(() => { }, [isBrushSelected, isEraserSelected, brushWidth, eraserWidth]); const [localValue, setLocalValue] = useState(width); const [componentType, setComponentType] = useState<'slider' | 'dropdown' | null>(null); + const isTypingRef = useRef(false); + const inputPollRef = useRef(null); useEffect(() => { const el = ref.current; @@ -244,6 +281,65 @@ export const ToolWidthPicker = memo(() => { [onValueChange] ); + const syncFromInputElement = useCallback( + (input: HTMLInputElement | null) => { + if (!input) { + return; + } + const parsed = parseInputValue(input.value); + if (Number.isNaN(parsed)) { + return; + } + setLocalValue(parsed); + onChange(parsed); + }, + [onChange] + ); + + const stopPollingInput = useCallback(() => { + if (inputPollRef.current !== null) { + window.clearInterval(inputPollRef.current); + inputPollRef.current = null; + } + }, []); + + const startPollingInput = useCallback( + (container: HTMLElement | null) => { + stopPollingInput(); + if (!container) { + return; + } + inputPollRef.current = window.setInterval(() => { + const input = container.querySelector('input'); + if (!input) { + return; + } + const parsed = parseInputValue(input.value); + if (Number.isNaN(parsed)) { + return; + } + setLocalValue(parsed); + if (!isTypingRef.current) { + onChange(parsed); + } + }, 50); + }, + [onChange, stopPollingInput] + ); + + const commitValue = useCallback( + (value: number) => { + if (isNaN(Number(value))) { + onChange(DEFAULT_TOOL_WIDTH); + setLocalValue(DEFAULT_TOOL_WIDTH); + } else { + onChange(value); + setLocalValue(value); + } + }, + [onChange] + ); + const increment = useCallback(() => { let newWidth = Math.round(width * 1.15); if (newWidth === width) { @@ -267,32 +363,75 @@ export const ToolWidthPicker = memo(() => { [onChange] ); - const onChangeInput = useCallback((value: number) => { - setLocalValue(value); - }, []); + const onChangeInput = useCallback( + (value: number) => { + setLocalValue(value); + if (!isNaN(value) && !isTypingRef.current) { + onChange(value); + } + }, + [onChange] + ); - const onBlur = useCallback(() => { - if (isNaN(Number(localValue))) { - onChange(50); - setLocalValue(50); - } else { - onChange(localValue); - } - }, [localValue, onChange]); + const onBlur = useCallback( + (event?: FocusEvent) => { + const { parsed } = getInputValueFromEvent(event); + commitValue(Number.isNaN(parsed) ? localValue : parsed); + isTypingRef.current = false; + }, + [commitValue, localValue] + ); const onKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === 'Enter') { - onBlur(); + const { parsed } = getInputValueFromEvent(e); + commitValue(Number.isNaN(parsed) ? localValue : parsed); + isTypingRef.current = false; + return; + } + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + isTypingRef.current = false; + const { input } = getInputValueFromEvent(e); + window.requestAnimationFrame(() => { + syncFromInputElement(input); + }); + return; + } + if (e.key === 'Backspace' || e.key === 'Delete' || e.key.length === 1) { + isTypingRef.current = true; + } + }, + [commitValue, localValue, syncFromInputElement] + ); + + const onPointerDownCapture = useCallback( + (_e: PointerEvent) => { + isTypingRef.current = false; + const target = _e.target as HTMLElement | null; + if (target && target.tagName !== 'INPUT') { + startPollingInput(_e.currentTarget); + } else { + stopPollingInput(); } }, - [onBlur] + [startPollingInput, stopPollingInput] ); + const onPointerUpCapture = useCallback(() => { + stopPollingInput(); + }, [stopPollingInput]); + useEffect(() => { setLocalValue(width); }, [width]); + useEffect(() => { + return () => { + stopPollingInput(); + }; + }, [stopPollingInput]); + useRegisteredHotkeys({ id: 'decrementToolWidth', category: 'canvas', @@ -317,6 +456,8 @@ export const ToolWidthPicker = memo(() => { onChangeInput={onChangeInput} onBlur={onBlur} onKeyDown={onKeyDown} + onPointerDownCapture={onPointerDownCapture} + onPointerUpCapture={onPointerUpCapture} /> )} {componentType === 'dropdown' && ( @@ -326,6 +467,8 @@ export const ToolWidthPicker = memo(() => { onChangeInput={onChangeInput} onBlur={onBlur} onKeyDown={onKeyDown} + onPointerDownCapture={onPointerDownCapture} + onPointerUpCapture={onPointerUpCapture} /> )}