mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
slider for brush and eraser tool
This commit is contained in:
committed by
psychedelicious
parent
26a3a9130c
commit
3fd265c333
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user