diff --git a/invokeai/frontend/web/src/common/components/ColorPicker/RgbaColorPicker.tsx b/invokeai/frontend/web/src/common/components/ColorPicker/RgbaColorPicker.tsx index fb3b1da2a01..6bcf546fd34 100644 --- a/invokeai/frontend/web/src/common/components/ColorPicker/RgbaColorPicker.tsx +++ b/invokeai/frontend/web/src/common/components/ColorPicker/RgbaColorPicker.tsx @@ -1,9 +1,9 @@ import type { ChakraProps } from '@invoke-ai/ui-library'; -import { Box, CompositeNumberInput, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { Box, Button, CompositeNumberInput, Flex, FormControl, FormLabel, Input } from '@invoke-ai/ui-library'; import { RGBA_COLOR_SWATCHES } from 'common/components/ColorPicker/swatches'; -import { rgbaColorToString } from 'common/util/colorCodeTransformers'; -import type { CSSProperties } from 'react'; -import { memo, useCallback } from 'react'; +import { hexToRGBA, rgbaColorToString, rgbaToHex } from 'common/util/colorCodeTransformers'; +import type { ChangeEvent, CSSProperties } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; import { RgbaColorPicker as ColorfulRgbaColorPicker } from 'react-colorful'; import type { RgbaColor } from 'react-colorful/dist/types'; import { useTranslation } from 'react-i18next'; @@ -40,61 +40,131 @@ const RgbaColorPicker = (props: Props) => { const handleChangeG = useCallback((g: number) => onChange({ ...color, g }), [color, onChange]); const handleChangeB = useCallback((b: number) => onChange({ ...color, b }), [color, onChange]); const handleChangeA = useCallback((a: number) => onChange({ ...color, a }), [color, onChange]); + const [mode, setMode] = useState<'rgb' | 'hex'>('rgb'); + const [hex, setHex] = useState(rgbaToHex(color, true)); + useEffect(() => { + setHex(rgbaToHex(color, true)); + }, [color]); + const onToggleMode = useCallback(() => setMode((m) => (m === 'rgb' ? 'hex' : 'rgb')), []); + const onChangeHex = useCallback( + (e: ChangeEvent) => { + let value = e.target.value.trim(); + if (!value.startsWith('#')) { + value = `#${value}`; + } + const cleaned = value.replace(/[^#0-9a-fA-F]/g, '').slice(0, 9); + setHex(cleaned); + const hexBody = cleaned.replace('#', ''); + if (hexBody.length === 6 || hexBody.length === 8) { + const a = hexBody.length === 8 ? parseInt(hexBody.slice(6, 8), 16) / 255 : color.a; + const next = hexToRGBA(hexBody.slice(0, 6).padEnd(6, '0'), a); + onChange(next); + } + }, + [color.a, onChange] + ); + const onChangeAlpha = useCallback( + (a: number) => { + const next = { ...color, a: Math.max(0, Math.min(1, a)) }; + onChange(next); + setHex(rgbaToHex(next, true)); + }, + [color, onChange] + ); return ( - {withNumberInput && ( - - - {t('common.red')[0]} - - - - {t('common.green')[0]} - - - - {t('common.blue')[0]} - - - - {t('common.alpha')[0]} - - - - )} + {withNumberInput && + (mode === 'rgb' ? ( + + + + {t('common.red')[0]} + + + + {t('common.green')[0]} + + + + {t('common.blue')[0]} + + + + {t('common.alpha')[0]} + + + + ) : ( + + + + {t('common.hex', { defaultValue: 'Hex' })} + + + + {t('common.alpha')[0]} + + + + ))} {withSwatches && ( {RGBA_COLOR_SWATCHES.map((color, i) => ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/PinnedFillColorPickerOverlay.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/PinnedFillColorPickerOverlay.tsx new file mode 100644 index 00000000000..92f7320fbc1 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/PinnedFillColorPickerOverlay.tsx @@ -0,0 +1,95 @@ +import { Flex, IconButton, Text } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import RgbaColorPicker from 'common/components/ColorPicker/RgbaColorPicker'; +import { + selectCanvasSettingsSlice, + settingsActiveColorToggled, + settingsBgColorChanged, + settingsFgColorChanged, + settingsFillColorPickerPinnedSet, +} from 'features/controlLayers/store/canvasSettingsSlice'; +import type { RgbaColor } from 'features/controlLayers/store/types'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiArrowsLeftRightBold, PiPushPinSlashBold } from 'react-icons/pi'; + +const selectActiveColor = createSelector(selectCanvasSettingsSlice, (settings) => settings.activeColor); +const selectBgColor = createSelector(selectCanvasSettingsSlice, (settings) => settings.bgColor); +const selectFgColor = createSelector(selectCanvasSettingsSlice, (settings) => settings.fgColor); +const selectPinned = createSelector(selectCanvasSettingsSlice, (settings) => settings.fillColorPickerPinned); + +export const PinnedFillColorPickerOverlay = memo(() => { + const { t } = useTranslation(); + const isPinned = useAppSelector(selectPinned); + const activeColorType = useAppSelector(selectActiveColor); + const bgColor = useAppSelector(selectBgColor); + const fgColor = useAppSelector(selectFgColor); + const dispatch = useAppDispatch(); + + const activeColor = useMemo( + () => (activeColorType === 'bgColor' ? bgColor : fgColor), + [activeColorType, bgColor, fgColor] + ); + + const onColorChange = useCallback( + (color: RgbaColor) => { + if (activeColorType === 'bgColor') { + dispatch(settingsBgColorChanged(color)); + } else { + dispatch(settingsFgColorChanged(color)); + } + }, + [activeColorType, dispatch] + ); + + const onUnpin = useCallback(() => dispatch(settingsFillColorPickerPinnedSet(false)), [dispatch]); + const onToggleActive = useCallback(() => dispatch(settingsActiveColorToggled()), [dispatch]); + + if (!isPinned) { + return null; + } + + return ( + + + + + {t('controlLayers.fill.fillColor')} + + + } + /> + } + /> + + + + + + ); +}); + +PinnedFillColorPickerOverlay.displayName = 'PinnedFillColorPickerOverlay'; 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 c192687e2e9..231296eec7b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx @@ -1,6 +1,7 @@ import { Box, Flex, + IconButton, Popover, PopoverArrow, PopoverBody, @@ -8,6 +9,7 @@ import { PopoverTrigger, Portal, Tooltip, + useDisclosure, } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; @@ -15,15 +17,18 @@ import RgbaColorPicker from 'common/components/ColorPicker/RgbaColorPicker'; import { rgbaColorToString } from 'common/util/colorCodeTransformers'; import { selectCanvasSettingsSlice, + selectFillColorPickerPinned, settingsActiveColorToggled, settingsBgColorChanged, settingsColorsSetToDefault, settingsFgColorChanged, + settingsFillColorPickerPinnedSet, } from 'features/controlLayers/store/canvasSettingsSlice'; import type { RgbaColor } from 'features/controlLayers/store/types'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { PiPushPinBold } from 'react-icons/pi'; const selectActiveColor = createSelector(selectCanvasSettingsSlice, (settings) => settings.activeColor); const selectBgColor = createSelector(selectCanvasSettingsSlice, (settings) => settings.bgColor); @@ -31,6 +36,8 @@ const selectFgColor = createSelector(selectCanvasSettingsSlice, (settings) => se export const ToolFillColorPicker = memo(() => { const { t } = useTranslation(); + const disclosure = useDisclosure(); + const isPinned = useAppSelector(selectFillColorPickerPinned); const activeColorType = useAppSelector(selectActiveColor); const bgColor = useAppSelector(selectBgColor); const fgColor = useAppSelector(selectFgColor); @@ -53,6 +60,20 @@ export const ToolFillColorPicker = memo(() => { [activeColorType, dispatch] ); + const handlePopoverClose = useCallback(() => { + disclosure.onClose(); + }, [disclosure]); + const handlePinClick = useCallback(() => { + if (!isPinned) { + dispatch(settingsFillColorPickerPinnedSet(true)); + disclosure.onClose(); + } else { + dispatch(settingsFillColorPickerPinnedSet(false)); + } + }, [dispatch, disclosure, isPinned]); + + // Note: when pinned, the persistent color picker renders in the canvas overlay instead. + useRegisteredHotkeys({ id: 'setFillColorsToDefault', category: 'canvas', @@ -70,7 +91,15 @@ export const ToolFillColorPicker = memo(() => { }); return ( - + @@ -104,10 +133,22 @@ export const ToolFillColorPicker = memo(() => { - + - + + + } + /> + + + diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts index bbeac05a1d2..0307f958079 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts @@ -93,6 +93,10 @@ const zCanvasSettingsState = z.object({ * The auto-switch mode for the canvas staging area. */ stagingAreaAutoSwitch: zAutoSwitchMode, + /** + * Whether the fill color picker UI is pinned (persistently shown in the canvas overlay). + */ + fillColorPickerPinned: z.boolean(), }); type CanvasSettingsState = z.infer; @@ -118,6 +122,7 @@ const getInitialState = (): CanvasSettingsState => ({ ruleOfThirds: false, saveAllImagesToGallery: false, stagingAreaAutoSwitch: 'switch_on_start', + fillColorPickerPinned: false, }); const slice = createSlice({ @@ -197,6 +202,9 @@ const slice = createSlice({ ) => { state.stagingAreaAutoSwitch = action.payload; }, + settingsFillColorPickerPinnedSet: (state, action: PayloadAction) => { + state.fillColorPickerPinned = action.payload; + }, }, }); @@ -223,6 +231,7 @@ export const { settingsRuleOfThirdsToggled, settingsSaveAllImagesToGalleryToggled, settingsStagingAreaAutoSwitchChanged, + settingsFillColorPickerPinnedSet, } = slice.actions; export const canvasSettingsSliceConfig: SliceConfig = { @@ -238,6 +247,8 @@ export const selectCanvasSettingsSlice = (s: RootState) => s.canvasSettings; const createCanvasSettingsSelector = (selector: Selector) => createSelector(selectCanvasSettingsSlice, selector); +export const selectFillColorPickerPinned = createCanvasSettingsSelector((s) => s.fillColorPickerPinned); + export const selectPreserveMask = createCanvasSettingsSelector((settings) => settings.preserveMask); export const selectOutputOnlyMaskedRegions = createCanvasSettingsSelector( (settings) => settings.outputOnlyMaskedRegions diff --git a/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx index 2c8a516f982..6b46d4ce803 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx @@ -14,6 +14,7 @@ import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD'; import { InvokeCanvasComponent } from 'features/controlLayers/components/InvokeCanvasComponent'; import { SelectObject } from 'features/controlLayers/components/SelectObject/SelectObject'; import { StagingAreaContextProvider } from 'features/controlLayers/components/StagingArea/context'; +import { PinnedFillColorPickerOverlay } from 'features/controlLayers/components/Tool/PinnedFillColorPickerOverlay'; import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; import { Transform } from 'features/controlLayers/components/Transform/Transform'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; @@ -88,6 +89,7 @@ export const CanvasWorkspacePanel = memo(() => { gap={2} alignItems="flex-start" > + {showHUD && }