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:
psychedelicious
2024-09-01 16:52:38 +10:00
parent 4f76f5f848
commit dd8b25260d
2 changed files with 253 additions and 44 deletions

View File

@@ -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>
);
});

View File

@@ -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>
);
});