mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
feat(ui): brush & eraser width ui/ux
Use same pattern as canvas scale & opacity sliders w/ scaled slider values for precision at low values.
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
CompositeNumberInput,
|
||||
CompositeSlider,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverArrow,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
@@ -14,19 +17,60 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
|
||||
import { brushWidthChanged, selectToolSlice } from 'features/controlLayers/store/toolSlice';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { memo, useCallback } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiCaretDownBold } from 'react-icons/pi';
|
||||
|
||||
const marks = [1, 100, 200, 300];
|
||||
const formatPx = (v: number | string) => `${v} px`;
|
||||
const selectBrushWidth = createSelector(selectToolSlice, (tool) => tool.brush.width);
|
||||
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 { t } = useTranslation();
|
||||
const isSelected = useToolIsSelected('brush');
|
||||
const width = useAppSelector(selectBrushWidth);
|
||||
const [localValue, setLocalValue] = useState(width);
|
||||
const onChange = useCallback(
|
||||
(v: number) => {
|
||||
dispatch(brushWidthChanged(clamp(Math.round(v), 1, 600)));
|
||||
@@ -50,32 +94,92 @@ export const ToolBrushWidth = memo(() => {
|
||||
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]);
|
||||
|
||||
useHotkeys('[', decrement, { enabled: isSelected }, [decrement, isSelected]);
|
||||
useHotkeys(']', increment, { enabled: isSelected }, [increment, isSelected]);
|
||||
|
||||
return (
|
||||
<FormControl w="min-content" gap={2}>
|
||||
<FormLabel m={0}>{t('controlLayers.width')}</FormLabel>
|
||||
<Popover isLazy>
|
||||
<PopoverTrigger>
|
||||
<CompositeNumberInput
|
||||
<Popover>
|
||||
<FormControl w="min-content" gap={2}>
|
||||
<FormLabel m={0}>{t('controlLayers.width')}</FormLabel>
|
||||
<PopoverAnchor>
|
||||
<NumberInput
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
min={1}
|
||||
max={600}
|
||||
defaultValue={50}
|
||||
value={width}
|
||||
onChange={onChange}
|
||||
w={24}
|
||||
value={localValue}
|
||||
onChange={onChangeNumberInput}
|
||||
onBlur={onBlur}
|
||||
w="76px"
|
||||
format={formatPx}
|
||||
defaultValue={50}
|
||||
onKeyDown={onKeyDown}
|
||||
clampValueOnBlur={false}
|
||||
>
|
||||
<NumberInputField 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
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent w={200} py={2} px={4}>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<CompositeSlider min={1} max={300} defaultValue={50} value={width} onChange={onChange} marks={marks} />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormControl>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
CompositeNumberInput,
|
||||
CompositeSlider,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverArrow,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
@@ -14,25 +17,67 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
|
||||
import { eraserWidthChanged, selectToolSlice } from 'features/controlLayers/store/toolSlice';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { memo, useCallback } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiCaretDownBold } from 'react-icons/pi';
|
||||
|
||||
const marks = [1, 100, 200, 300];
|
||||
const formatPx = (v: number | string) => `${v} px`;
|
||||
const selectEraserWidth = createSelector(selectToolSlice, (tool) => tool.eraser.width);
|
||||
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 { t } = useTranslation();
|
||||
const isSelected = useToolIsSelected('eraser');
|
||||
const width = useAppSelector(selectEraserWidth);
|
||||
const [localValue, setLocalValue] = useState(width);
|
||||
const onChange = useCallback(
|
||||
(v: number) => {
|
||||
dispatch(eraserWidthChanged(clamp(Math.round(v), 1, 600)));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const increment = useCallback(() => {
|
||||
let newWidth = Math.round(width * 1.15);
|
||||
if (newWidth === width) {
|
||||
@@ -49,32 +94,92 @@ export const ToolEraserWidth = memo(() => {
|
||||
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]);
|
||||
|
||||
useHotkeys('[', decrement, { enabled: isSelected }, [decrement, isSelected]);
|
||||
useHotkeys(']', increment, { enabled: isSelected }, [increment, isSelected]);
|
||||
|
||||
return (
|
||||
<FormControl w="min-content" gap={2}>
|
||||
<FormLabel m={0}>{t('controlLayers.width')}</FormLabel>
|
||||
<Popover isLazy>
|
||||
<PopoverTrigger>
|
||||
<CompositeNumberInput
|
||||
<Popover>
|
||||
<FormControl w="min-content" gap={2}>
|
||||
<FormLabel m={0}>{t('controlLayers.width')}</FormLabel>
|
||||
<PopoverAnchor>
|
||||
<NumberInput
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
min={1}
|
||||
max={600}
|
||||
defaultValue={50}
|
||||
value={width}
|
||||
onChange={onChange}
|
||||
w={24}
|
||||
value={localValue}
|
||||
onChange={onChangeNumberInput}
|
||||
onBlur={onBlur}
|
||||
w="76px"
|
||||
format={formatPx}
|
||||
defaultValue={50}
|
||||
onKeyDown={onKeyDown}
|
||||
clampValueOnBlur={false}
|
||||
>
|
||||
<NumberInputField 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
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent w={200} py={2} px={4}>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<CompositeSlider min={1} max={300} defaultValue={50} value={width} onChange={onChange} marks={marks} />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormControl>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user