slider for brush and eraser tool

This commit is contained in:
Attila Cseh
2025-09-03 17:15:58 +02:00
committed by psychedelicious
parent 26a3a9130c
commit 3fd265c333
7 changed files with 357 additions and 354 deletions

View File

@@ -2224,6 +2224,9 @@
"uploadOrDragAnImage": "Drag an image from the gallery or <UploadButton>upload an image</UploadButton>.",
"imageNoise": "Image Noise",
"denoiseLimit": "Denoise Limit",
"toolWidthSelector": "Tool Width Selector",
"toolWidthSelectorDropDown": "Drop-down",
"toolWidthSelectorSlider": "Slider",
"warnings": {
"problemsFound": "Problems found",
"unsupportedModel": "layer not supported for selected base model",

View File

@@ -33,6 +33,8 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCodeFill, PiEyeFill, PiGearSixFill, PiPencilFill, PiSquaresFourFill } from 'react-icons/pi';
import { CanvasSettingsToolWidthSelectorDropdown } from './CanvasSettingsToolWidthSelectorDropdown';
export const CanvasSettingsPopover = memo(() => {
const { t } = useTranslation();
return (
@@ -80,6 +82,7 @@ export const CanvasSettingsPopover = memo(() => {
<CanvasSettingsIsolatedLayerPreviewSwitch />
<CanvasSettingsBboxOverlaySwitch />
<CanvasSettingsShowHUDSwitch />
<CanvasSettingsToolWidthSelectorDropdown />
</Flex>
<Divider />

View File

@@ -0,0 +1,52 @@
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import type { ToolWidthSelector } from 'features/controlLayers/store/canvasSettingsSlice';
import {
selectToolWidthSelector,
settingsToolWidthSelectorChanged,
zToolWidthSelector,
} from 'features/controlLayers/store/canvasSettingsSlice';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
const isToolWidthSelector = (v: unknown): v is ToolWidthSelector => zToolWidthSelector.safeParse(v).success;
export const CanvasSettingsToolWidthSelectorDropdown = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const toolWidthSelector = useAppSelector(selectToolWidthSelector);
const OPTIONS: ComboboxOption[] = useMemo(
() => [
{ value: 'dropDown', label: t('controlLayers.toolWidthSelectorDropDown') },
{ value: 'slider', label: t('controlLayers.toolWidthSelectorSlider') },
],
[t]
);
const value = useMemo(() => {
return OPTIONS.find((o) => o.value === toolWidthSelector) || OPTIONS[0];
}, [toolWidthSelector, OPTIONS]);
const onChange = useCallback<ComboboxOnChange>(
(option) => {
if (!isToolWidthSelector(option?.value) || option.value === toolWidthSelector) {
return;
}
dispatch(settingsToolWidthSelectorChanged(option.value));
},
[toolWidthSelector, dispatch]
);
return (
<FormControl>
<FormLabel m={0} flexGrow={1}>
{t('controlLayers.toolWidthSelector')}
</FormLabel>
<Combobox isSearchable={false} value={value} options={OPTIONS} onChange={onChange} />
</FormControl>
);
});
CanvasSettingsToolWidthSelectorDropdown.displayName = 'CanvasSettingsToolWidthSelectorDropdown';

View File

@@ -1,195 +1,26 @@
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';
import { memo, useCallback } from 'react';
import { ToolWidth } from './ToolWidth';
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)));
const onValueChange = useCallback(
(value: number) => {
dispatch(settingsBrushWidthChanged(value));
},
[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<HTMLInputElement>) => {
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 (
<Popover>
<FormControl w="min-content" gap={2}>
<PopoverAnchor>
<NumberInput
variant="outline"
display="flex"
alignItems="center"
min={1}
max={600}
value={localValue}
onChange={onChangeNumberInput}
onBlur={onBlur}
w="76px"
format={formatPx}
defaultValue={50}
onKeyDown={onKeyDown}
clampValueOnBlur={false}
>
<NumberInputField _focusVisible={{ zIndex: 0 }} title="" paddingInlineEnd={7} />
<PopoverTrigger>
<IconButton
aria-label="open-slider"
icon={<PiCaretDownBold />}
size="sm"
variant="link"
position="absolute"
insetInlineEnd={0}
h="full"
/>
</PopoverTrigger>
</NumberInput>
</PopoverAnchor>
</FormControl>
<PopoverContent w={200} pt={0} pb={2} px={4}>
<PopoverArrow />
<PopoverBody>
<CompositeSlider
min={0}
max={100}
value={mapRawValueToSliderValue(localValue)}
onChange={onChangeSlider}
defaultValue={sliderDefaultValue}
marks={marks}
formatValue={formatSliderValue}
alwaysShowMarks
/>
</PopoverBody>
</PopoverContent>
</Popover>
);
return <ToolWidth isSelected={isSelected} width={width} onValueChange={onValueChange} />;
});
ToolBrushWidth.displayName = 'ToolBrushWidth';

View File

@@ -1,198 +1,29 @@
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';
import { memo, useCallback } from 'react';
import { ToolWidth } from './ToolWidth';
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)));
const onValueChange = useCallback(
(value: number) => {
dispatch(settingsEraserWidthChanged(value));
},
[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<HTMLInputElement>) => {
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 (
<Popover>
<FormControl w="min-content" gap={2}>
<PopoverAnchor>
<NumberInput
variant="outline"
display="flex"
alignItems="center"
min={1}
max={600}
value={localValue}
onChange={onChangeNumberInput}
onBlur={onBlur}
w="76px"
format={formatPx}
defaultValue={50}
onKeyDown={onKeyDown}
clampValueOnBlur={false}
>
<NumberInputField _focusVisible={{ zIndex: 0 }} title="" paddingInlineEnd={7} />
<PopoverTrigger>
<IconButton
aria-label="open-slider"
icon={<PiCaretDownBold />}
size="sm"
variant="link"
position="absolute"
insetInlineEnd={0}
h="full"
/>
</PopoverTrigger>
</NumberInput>
</PopoverAnchor>
</FormControl>
<PopoverContent w={200} pt={0} pb={2} px={4}>
<PopoverArrow />
<PopoverBody>
<CompositeSlider
min={0}
max={100}
value={mapRawValueToSliderValue(localValue)}
onChange={onChangeSlider}
defaultValue={sliderDefaultValue}
marks={marks}
formatValue={formatSliderValue}
alwaysShowMarks
/>
</PopoverBody>
</PopoverContent>
</Popover>
);
return <ToolWidth isSelected={isSelected} width={width} onValueChange={onValueChange} />;
});
ToolEraserWidth.displayName = 'ToolEraserWidth';

View File

@@ -0,0 +1,268 @@
import {
CompositeNumberInput,
CompositeSlider,
Flex,
FormControl,
IconButton,
NumberInput,
NumberInputField,
Popover,
PopoverAnchor,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
} from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { clamp } from 'es-toolkit/compat';
import { selectToolWidthSelector } 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 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);
interface ToolWidthSelectorProps {
localValue: number;
onChangeSlider: (value: number) => void;
onChangeInput: (value: number) => void;
onBlur: () => void;
onKeyDown: (value: KeyboardEvent<HTMLInputElement>) => void;
}
const DropDownToolWidthSelector = memo(
({ localValue, onChangeSlider, onChangeInput, onKeyDown, onBlur }: ToolWidthSelectorProps) => {
const onChangeNumberInput = useCallback(
(valueAsString: string, valueAsNumber: number) => {
onChangeInput(valueAsNumber);
},
[onChangeInput]
);
return (
<Popover>
<FormControl w="min-content" gap={2}>
<PopoverAnchor>
<NumberInput
variant="outline"
display="flex"
alignItems="center"
min={1}
max={600}
value={localValue}
onChange={onChangeNumberInput}
onBlur={onBlur}
w="76px"
format={formatPx}
defaultValue={50}
onKeyDown={onKeyDown}
clampValueOnBlur={false}
>
<NumberInputField _focusVisible={{ zIndex: 0 }} title="" paddingInlineEnd={7} />
<PopoverTrigger>
<IconButton
aria-label="open-slider"
icon={<PiCaretDownBold />}
size="sm"
variant="link"
position="absolute"
insetInlineEnd={0}
h="full"
/>
</PopoverTrigger>
</NumberInput>
</PopoverAnchor>
</FormControl>
<PopoverContent w={200} pt={0} pb={2} px={4}>
<PopoverArrow />
<PopoverBody>
<CompositeSlider
min={0}
max={100}
value={mapRawValueToSliderValue(localValue)}
onChange={onChangeSlider}
defaultValue={sliderDefaultValue}
marks={marks}
formatValue={formatSliderValue}
alwaysShowMarks
/>
</PopoverBody>
</PopoverContent>
</Popover>
);
}
);
DropDownToolWidthSelector.displayName = 'DropDownToolWidthSelector';
const SliderToolWidthSelector = memo(
({ localValue, onChangeSlider, onChangeInput, onKeyDown, onBlur }: ToolWidthSelectorProps) => {
return (
<Flex w="full" gap={4} alignItems="center" px={4}>
<CompositeSlider
w={200}
min={0}
max={100}
value={mapRawValueToSliderValue(localValue)}
onChange={onChangeSlider}
defaultValue={sliderDefaultValue}
marks={marks}
formatValue={formatSliderValue}
alwaysShowMarks
/>
<CompositeNumberInput
min={1}
max={600}
value={localValue}
onChange={onChangeInput}
onBlur={onBlur}
onKeyDown={onKeyDown}
w={24}
format={formatPx}
defaultValue={50}
/>
</Flex>
);
}
);
SliderToolWidthSelector.displayName = 'SliderToolWidthSelector';
const selectorComponents = {
dropDown: DropDownToolWidthSelector,
slider: SliderToolWidthSelector,
} as const;
interface ToolWidthProps {
isSelected: boolean;
width: number;
onValueChange: (value: number) => void;
}
export const ToolWidth = memo(({ isSelected, width, onValueChange }: ToolWidthProps) => {
const toolWidthSelector = useAppSelector(selectToolWidthSelector);
const [localValue, setLocalValue] = useState(width);
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<HTMLInputElement>) => {
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],
});
const Component = selectorComponents[toolWidthSelector];
return (
<Component
localValue={localValue}
onChangeSlider={onChangeSlider}
onChangeInput={onChangeInput}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
);
});
ToolWidth.displayName = 'ToolWidth';

View File

@@ -9,6 +9,9 @@ import { z } from 'zod';
const zAutoSwitchMode = z.enum(['off', 'switch_on_start', 'switch_on_finish']);
export type AutoSwitchMode = z.infer<typeof zAutoSwitchMode>;
export const zToolWidthSelector = z.enum(['dropDown', 'slider']);
export type ToolWidthSelector = z.infer<typeof zToolWidthSelector>;
const zCanvasSettingsState = z.object({
/**
* Whether to show HUD (Heads-Up Display) on the canvas.
@@ -93,6 +96,10 @@ const zCanvasSettingsState = z.object({
* The auto-switch mode for the canvas staging area.
*/
stagingAreaAutoSwitch: zAutoSwitchMode,
/**
* The tool width selector
*/
toolWidthSelector: zToolWidthSelector,
});
type CanvasSettingsState = z.infer<typeof zCanvasSettingsState>;
@@ -118,6 +125,7 @@ const getInitialState = (): CanvasSettingsState => ({
ruleOfThirds: false,
saveAllImagesToGallery: false,
stagingAreaAutoSwitch: 'switch_on_start',
toolWidthSelector: 'dropDown',
});
const slice = createSlice({
@@ -133,6 +141,9 @@ const slice = createSlice({
settingsShowHUDToggled: (state) => {
state.showHUD = !state.showHUD;
},
settingsToolWidthSelectorChanged: (state, action: PayloadAction<ToolWidthSelector>) => {
state.toolWidthSelector = action.payload;
},
settingsBrushWidthChanged: (state, action: PayloadAction<CanvasSettingsState['brushWidth']>) => {
state.brushWidth = Math.round(action.payload);
},
@@ -204,6 +215,7 @@ export const {
settingsClipToBboxChanged,
settingsDynamicGridToggled,
settingsShowHUDToggled,
settingsToolWidthSelectorChanged,
settingsBrushWidthChanged,
settingsEraserWidthChanged,
settingsActiveColorToggled,
@@ -256,3 +268,6 @@ export const selectPressureSensitivity = createCanvasSettingsSelector((settings)
export const selectRuleOfThirds = createCanvasSettingsSelector((settings) => settings.ruleOfThirds);
export const selectSaveAllImagesToGallery = createCanvasSettingsSelector((settings) => settings.saveAllImagesToGallery);
export const selectStagingAreaAutoSwitch = createCanvasSettingsSelector((settings) => settings.stagingAreaAutoSwitch);
export const selectToolWidthSelector = createCanvasSettingsSelector(
(canvasSettings) => canvasSettings.toolWidthSelector
);