diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushWidth.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushWidth.tsx deleted file mode 100644 index 8811d37f19d..00000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushWidth.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { - CompositeSlider, - FormControl, - IconButton, - NumberInput, - NumberInputField, - Popover, - PopoverAnchor, - PopoverArrow, - PopoverBody, - PopoverContent, - PopoverTrigger, -} from '@invoke-ai/ui-library'; -import { createSelector } from '@reduxjs/toolkit'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { clamp } from 'es-toolkit/compat'; -import { useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; -import { selectCanvasSettingsSlice, settingsBrushWidthChanged } from 'features/controlLayers/store/canvasSettingsSlice'; -import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; -import type { KeyboardEvent } from 'react'; -import { memo, useCallback, useEffect, useState } from 'react'; -import { PiCaretDownBold } from 'react-icons/pi'; - -const selectBrushWidth = createSelector(selectCanvasSettingsSlice, (settings) => settings.brushWidth); -const formatPx = (v: number | string) => `${v} px`; - -function mapSliderValueToRawValue(value: number) { - if (value <= 40) { - // 0 to 40 on the slider -> 1px to 50px - return 1 + (49 * value) / 40; - } else if (value <= 70) { - // 40 to 70 on the slider -> 50px to 200px - return 50 + (150 * (value - 40)) / 30; - } else { - // 70 to 100 on the slider -> 200px to 600px - return 200 + (400 * (value - 70)) / 30; - } -} - -function mapRawValueToSliderValue(value: number) { - if (value <= 50) { - // 1px to 50px -> 0 to 40 on the slider - return ((value - 1) * 40) / 49; - } else if (value <= 200) { - // 50px to 200px -> 40 to 70 on the slider - return 40 + ((value - 50) * 30) / 150; - } else { - // 200px to 600px -> 70 to 100 on the slider - return 70 + ((value - 200) * 30) / 400; - } -} - -function formatSliderValue(value: number) { - return `${String(mapSliderValueToRawValue(value))} px`; -} - -const marks = [ - mapRawValueToSliderValue(1), - mapRawValueToSliderValue(50), - mapRawValueToSliderValue(200), - mapRawValueToSliderValue(600), -]; - -const sliderDefaultValue = mapRawValueToSliderValue(50); - -export const ToolBrushWidth = memo(() => { - const dispatch = useAppDispatch(); - const isSelected = useToolIsSelected('brush'); - const width = useAppSelector(selectBrushWidth); - const [localValue, setLocalValue] = useState(width); - const onChange = useCallback( - (v: number) => { - dispatch(settingsBrushWidthChanged(clamp(Math.round(v), 1, 600))); - }, - [dispatch] - ); - - const increment = useCallback(() => { - let newWidth = Math.round(width * 1.15); - if (newWidth === width) { - newWidth += 1; - } - onChange(newWidth); - }, [onChange, width]); - - const decrement = useCallback(() => { - let newWidth = Math.round(width * 0.85); - if (newWidth === width) { - newWidth -= 1; - } - onChange(newWidth); - }, [onChange, width]); - - const onChangeSlider = useCallback( - (value: number) => { - onChange(mapSliderValueToRawValue(value)); - }, - [onChange] - ); - - const onBlur = useCallback(() => { - if (isNaN(Number(localValue))) { - onChange(50); - setLocalValue(50); - } else { - onChange(localValue); - } - }, [localValue, onChange]); - - const onChangeNumberInput = useCallback((valueAsString: string, valueAsNumber: number) => { - setLocalValue(valueAsNumber); - }, []); - - const onKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === 'Enter') { - onBlur(); - } - }, - [onBlur] - ); - - useEffect(() => { - setLocalValue(width); - }, [width]); - - useRegisteredHotkeys({ - id: 'decrementToolWidth', - category: 'canvas', - callback: decrement, - options: { enabled: isSelected }, - dependencies: [decrement, isSelected], - }); - useRegisteredHotkeys({ - id: 'incrementToolWidth', - category: 'canvas', - callback: increment, - options: { enabled: isSelected }, - dependencies: [increment, isSelected], - }); - - return ( - - - - - - - } - size="sm" - variant="link" - position="absolute" - insetInlineEnd={0} - h="full" - /> - - - - - - - - - - - - ); -}); - -ToolBrushWidth.displayName = 'ToolBrushWidth'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserWidth.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserWidth.tsx deleted file mode 100644 index 059b0631221..00000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserWidth.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { - CompositeSlider, - FormControl, - IconButton, - NumberInput, - NumberInputField, - Popover, - PopoverAnchor, - PopoverArrow, - PopoverBody, - PopoverContent, - PopoverTrigger, -} from '@invoke-ai/ui-library'; -import { createSelector } from '@reduxjs/toolkit'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { clamp } from 'es-toolkit/compat'; -import { useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; -import { - selectCanvasSettingsSlice, - settingsEraserWidthChanged, -} from 'features/controlLayers/store/canvasSettingsSlice'; -import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; -import type { KeyboardEvent } from 'react'; -import { memo, useCallback, useEffect, useState } from 'react'; -import { PiCaretDownBold } from 'react-icons/pi'; - -const selectEraserWidth = createSelector(selectCanvasSettingsSlice, (settings) => settings.eraserWidth); -const formatPx = (v: number | string) => `${v} px`; - -function mapSliderValueToRawValue(value: number) { - if (value <= 40) { - // 0 to 40 on the slider -> 1px to 50px - return 1 + (49 * value) / 40; - } else if (value <= 70) { - // 40 to 70 on the slider -> 50px to 200px - return 50 + (150 * (value - 40)) / 30; - } else { - // 70 to 100 on the slider -> 200px to 600px - return 200 + (400 * (value - 70)) / 30; - } -} - -function mapRawValueToSliderValue(value: number) { - if (value <= 50) { - // 1px to 50px -> 0 to 40 on the slider - return ((value - 1) * 40) / 49; - } else if (value <= 200) { - // 50px to 200px -> 40 to 70 on the slider - return 40 + ((value - 50) * 30) / 150; - } else { - // 200px to 600px -> 70 to 100 on the slider - return 70 + ((value - 200) * 30) / 400; - } -} - -function formatSliderValue(value: number) { - return `${String(mapSliderValueToRawValue(value))} px`; -} - -const marks = [ - mapRawValueToSliderValue(1), - mapRawValueToSliderValue(50), - mapRawValueToSliderValue(200), - mapRawValueToSliderValue(600), -]; - -const sliderDefaultValue = mapRawValueToSliderValue(50); - -export const ToolEraserWidth = memo(() => { - const dispatch = useAppDispatch(); - const isSelected = useToolIsSelected('eraser'); - const width = useAppSelector(selectEraserWidth); - const [localValue, setLocalValue] = useState(width); - const onChange = useCallback( - (v: number) => { - dispatch(settingsEraserWidthChanged(clamp(Math.round(v), 1, 600))); - }, - [dispatch] - ); - - const increment = useCallback(() => { - let newWidth = Math.round(width * 1.15); - if (newWidth === width) { - newWidth += 1; - } - onChange(newWidth); - }, [onChange, width]); - - const decrement = useCallback(() => { - let newWidth = Math.round(width * 0.85); - if (newWidth === width) { - newWidth -= 1; - } - onChange(newWidth); - }, [onChange, width]); - - const onChangeSlider = useCallback( - (value: number) => { - onChange(mapSliderValueToRawValue(value)); - }, - [onChange] - ); - - const onBlur = useCallback(() => { - if (isNaN(Number(localValue))) { - onChange(50); - setLocalValue(50); - } else { - onChange(localValue); - } - }, [localValue, onChange]); - - const onChangeNumberInput = useCallback((valueAsString: string, valueAsNumber: number) => { - setLocalValue(valueAsNumber); - }, []); - - const onKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === 'Enter') { - onBlur(); - } - }, - [onBlur] - ); - - useEffect(() => { - setLocalValue(width); - }, [width]); - - useRegisteredHotkeys({ - id: 'decrementToolWidth', - category: 'canvas', - callback: decrement, - options: { enabled: isSelected }, - dependencies: [decrement, isSelected], - }); - useRegisteredHotkeys({ - id: 'incrementToolWidth', - category: 'canvas', - callback: increment, - options: { enabled: isSelected }, - dependencies: [increment, isSelected], - }); - - return ( - - - - - - - } - size="sm" - variant="link" - position="absolute" - insetInlineEnd={0} - h="full" - /> - - - - - - - - - - - - ); -}); - -ToolEraserWidth.displayName = 'ToolEraserWidth'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx index cbe3e4c081e..1fa70434dfa 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx @@ -71,7 +71,7 @@ export const ToolFillColorPicker = memo(() => { return ( - + { - const canvasManager = useCanvasManager(); - const tool = useStore(canvasManager.tool.$tool); - if (tool === 'brush') { - return ; - } - if (tool === 'eraser') { - return ; - } - return null; -}); - -ToolSettings.displayName = 'ToolSettings'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolWidthPicker.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolWidthPicker.tsx new file mode 100644 index 00000000000..a74d750ae02 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolWidthPicker.tsx @@ -0,0 +1,332 @@ +import { + CompositeNumberInput, + CompositeSlider, + Flex, + FormControl, + IconButton, + NumberInput, + NumberInputField, + Popover, + PopoverAnchor, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverTrigger, +} from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { clamp } from 'es-toolkit/compat'; +import { + selectCanvasSettingsSlice, + settingsBrushWidthChanged, + settingsEraserWidthChanged, +} from 'features/controlLayers/store/canvasSettingsSlice'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import type { KeyboardEvent } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { PiCaretDownBold } from 'react-icons/pi'; + +import { useToolIsSelected } from './hooks'; + +const formatPx = (v: number | string) => `${v} px`; + +function mapSliderValueToRawValue(value: number) { + if (value <= 40) { + // 0 to 40 on the slider -> 1px to 50px + return 1 + (49 * value) / 40; + } else if (value <= 70) { + // 40 to 70 on the slider -> 50px to 200px + return 50 + (150 * (value - 40)) / 30; + } else { + // 70 to 100 on the slider -> 200px to 600px + return 200 + (400 * (value - 70)) / 30; + } +} + +function mapRawValueToSliderValue(value: number) { + if (value <= 50) { + // 1px to 50px -> 0 to 40 on the slider + return ((value - 1) * 40) / 49; + } else if (value <= 200) { + // 50px to 200px -> 40 to 70 on the slider + return 40 + ((value - 50) * 30) / 150; + } else { + // 200px to 600px -> 70 to 100 on the slider + return 70 + ((value - 200) * 30) / 400; + } +} + +function formatSliderValue(value: number) { + return `${String(mapSliderValueToRawValue(value))} px`; +} + +const marks = [ + mapRawValueToSliderValue(1), + mapRawValueToSliderValue(50), + mapRawValueToSliderValue(200), + mapRawValueToSliderValue(600), +]; + +const sliderDefaultValue = mapRawValueToSliderValue(50); + +const SLIDER_VS_DROPDOWN_CONTAINER_WIDTH_THRESHOLD = 280; + +interface ToolWidthPickerComponentProps { + localValue: number; + onChangeSlider: (value: number) => void; + onChangeInput: (value: number) => void; + onBlur: () => void; + onKeyDown: (value: KeyboardEvent) => void; +} + +const DropDownToolWidthPickerComponent = memo( + ({ localValue, onChangeSlider, onChangeInput, onKeyDown, onBlur }: ToolWidthPickerComponentProps) => { + const onChangeNumberInput = useCallback( + (valueAsString: string, valueAsNumber: number) => { + onChangeInput(valueAsNumber); + }, + [onChangeInput] + ); + + return ( + + + + + + + } + size="sm" + variant="link" + position="absolute" + insetInlineEnd={0} + h="full" + /> + + + + + + + + + + + + ); + } +); +DropDownToolWidthPickerComponent.displayName = 'DropDownToolWidthPickerComponent'; + +const SliderToolWidthPickerComponent = memo( + ({ localValue, onChangeSlider, onChangeInput, onKeyDown, onBlur }: ToolWidthPickerComponentProps) => { + return ( + + + + + ); + } +); +SliderToolWidthPickerComponent.displayName = 'SliderToolWidthPickerComponent'; + +const selectBrushWidth = createSelector(selectCanvasSettingsSlice, (settings) => settings.brushWidth); +const selectEraserWidth = createSelector(selectCanvasSettingsSlice, (settings) => settings.eraserWidth); + +export const ToolWidthPicker = memo(() => { + const ref = useRef(null); + const dispatch = useAppDispatch(); + const isBrushSelected = useToolIsSelected('brush'); + const isEraserSelected = useToolIsSelected('eraser'); + const isToolSelected = useMemo(() => { + return isBrushSelected || isEraserSelected; + }, [isBrushSelected, isEraserSelected]); + const brushWidth = useAppSelector(selectBrushWidth); + const eraserWidth = useAppSelector(selectEraserWidth); + const width = useMemo(() => { + if (isBrushSelected) { + return brushWidth; + } + if (isEraserSelected) { + return eraserWidth; + } + return 0; + }, [isBrushSelected, isEraserSelected, brushWidth, eraserWidth]); + const [localValue, setLocalValue] = useState(width); + const [componentType, setComponentType] = useState<'slider' | 'dropdown' | null>(null); + + useEffect(() => { + const el = ref.current; + if (!el) { + return; + } + const observer = new ResizeObserver((entries) => { + for (let entry of entries) { + if (entry.contentRect.width > SLIDER_VS_DROPDOWN_CONTAINER_WIDTH_THRESHOLD) { + setComponentType('slider'); + } else { + setComponentType('dropdown'); + } + } + }); + observer.observe(el); + + return () => { + observer.disconnect(); + }; + }, []); + + const onValueChange = useCallback( + (value: number) => { + if (isBrushSelected) { + dispatch(settingsBrushWidthChanged(value)); + } else if (isEraserSelected) { + dispatch(settingsEraserWidthChanged(value)); + } + }, + [isBrushSelected, isEraserSelected, dispatch] + ); + + const onChange = useCallback( + (value: number) => { + onValueChange(clamp(Math.round(value), 1, 600)); + }, + [onValueChange] + ); + + const increment = useCallback(() => { + let newWidth = Math.round(width * 1.15); + if (newWidth === width) { + newWidth += 1; + } + onChange(newWidth); + }, [onChange, width]); + + const decrement = useCallback(() => { + let newWidth = Math.round(width * 0.85); + if (newWidth === width) { + newWidth -= 1; + } + onChange(newWidth); + }, [onChange, width]); + + const onChangeSlider = useCallback( + (value: number) => { + onChange(mapSliderValueToRawValue(value)); + }, + [onChange] + ); + + const onChangeInput = useCallback((value: number) => { + setLocalValue(value); + }, []); + + const onBlur = useCallback(() => { + if (isNaN(Number(localValue))) { + onChange(50); + setLocalValue(50); + } else { + onChange(localValue); + } + }, [localValue, onChange]); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + onBlur(); + } + }, + [onBlur] + ); + + useEffect(() => { + setLocalValue(width); + }, [width]); + + useRegisteredHotkeys({ + id: 'decrementToolWidth', + category: 'canvas', + callback: decrement, + options: { enabled: isToolSelected }, + dependencies: [decrement, isToolSelected], + }); + useRegisteredHotkeys({ + id: 'incrementToolWidth', + category: 'canvas', + callback: increment, + options: { enabled: isToolSelected }, + dependencies: [increment, isToolSelected], + }); + + return ( + + {componentType === 'slider' && ( + + )} + {componentType === 'dropdown' && ( + + )} + + ); +}); + +ToolWidthPicker.displayName = 'ToolWidthPicker'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx index a5e79335b03..979eea95d76 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx @@ -1,7 +1,8 @@ import { Divider, Flex } from '@invoke-ai/ui-library'; import { CanvasSettingsPopover } from 'features/controlLayers/components/Settings/CanvasSettingsPopover'; +import { useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; import { ToolFillColorPicker } from 'features/controlLayers/components/Tool/ToolFillColorPicker'; -import { ToolSettings } from 'features/controlLayers/components/Tool/ToolSettings'; +import { ToolWidthPicker } from 'features/controlLayers/components/Tool/ToolWidthPicker'; import { CanvasToolbarFitBboxToLayersButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarFitBboxToLayersButton'; import { CanvasToolbarFitBboxToMasksButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarFitBboxToMasksButton'; import { CanvasToolbarNewSessionMenuButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarNewSessionMenuButton'; @@ -20,9 +21,15 @@ import { useCanvasToggleNonRasterLayersHotkey } from 'features/controlLayers/hoo import { useCanvasTransformHotkey } from 'features/controlLayers/hooks/useCanvasTransformHotkey'; import { useCanvasUndoRedoHotkeys } from 'features/controlLayers/hooks/useCanvasUndoRedoHotkeys'; import { useNextPrevEntityHotkeys } from 'features/controlLayers/hooks/useNextPrevEntity'; -import { memo } from 'react'; +import { memo, useMemo } from 'react'; export const CanvasToolbar = memo(() => { + const isBrushSelected = useToolIsSelected('brush'); + const isEraserSelected = useToolIsSelected('eraser'); + const showToolWithPicker = useMemo(() => { + return isBrushSelected || isEraserSelected; + }, [isBrushSelected, isEraserSelected]); + useCanvasResetLayerHotkey(); useCanvasDeleteLayerHotkey(); useCanvasUndoRedoHotkeys(); @@ -36,9 +43,11 @@ export const CanvasToolbar = memo(() => { return ( - - - + + + {showToolWithPicker && } + +