diff --git a/frontend/src/features/canvas/components/IAICanvas.tsx b/frontend/src/features/canvas/components/IAICanvas.tsx index cf8dd6e0ff..f6d0ed215c 100644 --- a/frontend/src/features/canvas/components/IAICanvas.tsx +++ b/frontend/src/features/canvas/components/IAICanvas.tsx @@ -7,7 +7,7 @@ import { isStagingSelector, } from 'features/canvas/store/canvasSelectors'; import IAICanvasMaskLines from './IAICanvasMaskLines'; -import IAICanvasBrushPreview from './IAICanvasBrushPreview'; +import IAICanvasToolPreview from './IAICanvasToolPreview'; import { Vector2d } from 'konva/lib/types'; import IAICanvasBoundingBox from './IAICanvasToolbar/IAICanvasBoundingBox'; import useCanvasHotkeys from '../hooks/useCanvasHotkeys'; @@ -183,7 +183,7 @@ const IAICanvas = () => { {!isStaging && ( - diff --git a/frontend/src/features/canvas/components/IAICanvasBrushPreview.tsx b/frontend/src/features/canvas/components/IAICanvasBrushPreview.tsx deleted file mode 100644 index 05599a1ad2..0000000000 --- a/frontend/src/features/canvas/components/IAICanvasBrushPreview.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { GroupConfig } from 'konva/lib/Group'; -import _ from 'lodash'; -import { Circle, Group } from 'react-konva'; -import { useAppSelector } from 'app/store'; -import { canvasSelector } from 'features/canvas/store/canvasSelectors'; -import { rgbaColorToString } from 'features/canvas/util/colorToString'; - -const canvasBrushPreviewSelector = createSelector( - canvasSelector, - (canvas) => { - const { - cursorPosition, - stageDimensions: { width, height }, - brushSize, - maskColor, - brushColor, - tool, - layer, - shouldShowBrush, - isMovingBoundingBox, - isTransformingBoundingBox, - stageScale, - } = canvas; - - return { - cursorPosition, - width, - height, - radius: brushSize / 2, - brushColorString: rgbaColorToString( - layer === 'mask' ? { ...maskColor, a: 0.5 } : brushColor - ), - tool, - shouldShowBrush, - shouldDrawBrushPreview: - !( - isMovingBoundingBox || - isTransformingBoundingBox || - !cursorPosition - ) && shouldShowBrush, - strokeWidth: 1.5 / stageScale, - dotRadius: 1.5 / stageScale, - }; - }, - { - memoizeOptions: { - resultEqualityCheck: _.isEqual, - }, - } -); - -/** - * Draws a black circle around the canvas brush preview. - */ -const IAICanvasBrushPreview = (props: GroupConfig) => { - const { ...rest } = props; - const { - cursorPosition, - width, - height, - radius, - brushColorString, - tool, - shouldDrawBrushPreview, - dotRadius, - strokeWidth, - } = useAppSelector(canvasBrushPreviewSelector); - - if (!shouldDrawBrushPreview) return null; - - return ( - - - - - - - - ); -}; - -export default IAICanvasBrushPreview; diff --git a/frontend/src/features/canvas/components/IAICanvasToolPreview.tsx b/frontend/src/features/canvas/components/IAICanvasToolPreview.tsx new file mode 100644 index 0000000000..34924f8966 --- /dev/null +++ b/frontend/src/features/canvas/components/IAICanvasToolPreview.tsx @@ -0,0 +1,185 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { GroupConfig } from 'konva/lib/Group'; +import _ from 'lodash'; +import { Circle, Group, Rect } from 'react-konva'; +import { useAppDispatch, useAppSelector } from 'app/store'; +import { canvasSelector } from 'features/canvas/store/canvasSelectors'; +import { rgbaColorToString } from 'features/canvas/util/colorToString'; +import { COLOR_PICKER_SIZE } from '../util/constants'; + +const canvasBrushPreviewSelector = createSelector( + canvasSelector, + (canvas) => { + const { + cursorPosition, + stageDimensions: { width, height }, + brushSize, + colorPickerColor, + maskColor, + brushColor, + tool, + layer, + shouldShowBrush, + isMovingBoundingBox, + isTransformingBoundingBox, + stageScale, + } = canvas; + + let fill = ''; + + if (layer === 'mask') { + fill = rgbaColorToString({ ...maskColor, a: 0.5 }); + } else if (tool === 'colorPicker') { + fill = rgbaColorToString(colorPickerColor); + } else { + fill = rgbaColorToString(brushColor); + } + + return { + cursorPosition, + width, + height, + radius: brushSize / 2, + colorPickerSize: COLOR_PICKER_SIZE / stageScale, + colorPickerOffset: COLOR_PICKER_SIZE / 2 / stageScale, + colorPickerCornerRadius: COLOR_PICKER_SIZE / 5 / stageScale, + brushColorString: fill, + tool, + shouldShowBrush, + shouldDrawBrushPreview: + !( + isMovingBoundingBox || + isTransformingBoundingBox || + !cursorPosition + ) && shouldShowBrush, + strokeWidth: 1.5 / stageScale, + dotRadius: 1.5 / stageScale, + }; + }, + { + memoizeOptions: { + resultEqualityCheck: _.isEqual, + }, + } +); + +/** + * Draws a black circle around the canvas brush preview. + */ +const IAICanvasToolPreview = (props: GroupConfig) => { + const { ...rest } = props; + const { + cursorPosition, + width, + height, + radius, + brushColorString, + tool, + shouldDrawBrushPreview, + dotRadius, + strokeWidth, + colorPickerSize, + colorPickerOffset, + colorPickerCornerRadius, + } = useAppSelector(canvasBrushPreviewSelector); + + if (!shouldDrawBrushPreview) return null; + + return ( + + {tool === 'colorPicker' ? ( + <> + + + + + ) : ( + <> + + + + + )} + + + + ); +}; + +export default IAICanvasToolPreview; diff --git a/frontend/src/features/canvas/components/IAICanvasToolbar/IAICanvasToolChooserOptions.tsx b/frontend/src/features/canvas/components/IAICanvasToolbar/IAICanvasToolChooserOptions.tsx index cd53ebb5a8..f32fcf04d3 100644 --- a/frontend/src/features/canvas/components/IAICanvasToolbar/IAICanvasToolChooserOptions.tsx +++ b/frontend/src/features/canvas/components/IAICanvasToolbar/IAICanvasToolChooserOptions.tsx @@ -8,7 +8,12 @@ import { import { useAppDispatch, useAppSelector } from 'app/store'; import _ from 'lodash'; import IAIIconButton from 'common/components/IAIIconButton'; -import { FaEraser, FaPaintBrush, FaSlidersH } from 'react-icons/fa'; +import { + FaEraser, + FaEyeDropper, + FaPaintBrush, + FaSlidersH, +} from 'react-icons/fa'; import { canvasSelector, isStagingSelector, @@ -68,6 +73,18 @@ const IAICanvasToolChooserOptions = () => { [tool] ); + useHotkeys( + ['c'], + () => { + handleSelectColorPickerTool(); + }, + { + enabled: () => true, + preventDefault: true, + }, + [tool] + ); + useHotkeys( ['['], () => { @@ -94,6 +111,7 @@ const IAICanvasToolChooserOptions = () => { const handleSelectBrushTool = () => dispatch(setTool('brush')); const handleSelectEraserTool = () => dispatch(setTool('eraser')); + const handleSelectColorPickerTool = () => dispatch(setTool('colorPicker')); return ( @@ -111,7 +129,15 @@ const IAICanvasToolChooserOptions = () => { icon={} data-selected={tool === 'eraser' && !isStaging} isDisabled={isStaging} - onClick={() => dispatch(setTool('eraser'))} + onClick={handleSelectEraserTool} + /> + } + data-selected={tool === 'colorPicker' && !isStaging} + isDisabled={isStaging} + onClick={handleSelectColorPickerTool} /> ) => { const dispatch = useAppDispatch(); const { tool, isStaging } = useAppSelector(selector); + const { commitColorUnderCursor } = useColorPicker(); return useCallback( (e: KonvaEventObject) => { @@ -41,6 +48,11 @@ const useCanvasMouseDown = (stageRef: MutableRefObject) => { return; } + if (tool === 'colorPicker') { + commitColorUnderCursor(); + return; + } + const scaledCursorPosition = getScaledCursorPosition(stageRef.current); if (!scaledCursorPosition) return; diff --git a/frontend/src/features/canvas/hooks/useCanvasMouseMove.ts b/frontend/src/features/canvas/hooks/useCanvasMouseMove.ts index 02749cf7d3..ae42ca90a6 100644 --- a/frontend/src/features/canvas/hooks/useCanvasMouseMove.ts +++ b/frontend/src/features/canvas/hooks/useCanvasMouseMove.ts @@ -5,12 +5,16 @@ import Konva from 'konva'; import { Vector2d } from 'konva/lib/types'; import _ from 'lodash'; import { MutableRefObject, useCallback } from 'react'; -import { canvasSelector, isStagingSelector } from 'features/canvas/store/canvasSelectors'; +import { + canvasSelector, + isStagingSelector, +} from 'features/canvas/store/canvasSelectors'; import { addPointToCurrentLine, setCursorPosition, } from 'features/canvas/store/canvasSlice'; import getScaledCursorPosition from '../util/getScaledCursorPosition'; +import useColorPicker from './useColorUnderCursor'; const selector = createSelector( [activeTabNameSelector, canvasSelector, isStagingSelector], @@ -33,6 +37,7 @@ const useCanvasMouseMove = ( ) => { const dispatch = useAppDispatch(); const { isDrawing, tool, isStaging } = useAppSelector(selector); + const { updateColorUnderCursor } = useColorPicker(); return useCallback(() => { if (!stageRef.current) return; @@ -45,6 +50,11 @@ const useCanvasMouseMove = ( lastCursorPositionRef.current = scaledCursorPosition; + if (tool === 'colorPicker') { + updateColorUnderCursor(); + return; + } + if (!isDrawing || tool === 'move' || isStaging) return; didMouseMoveRef.current = true; @@ -59,6 +69,7 @@ const useCanvasMouseMove = ( lastCursorPositionRef, stageRef, tool, + updateColorUnderCursor, ]); }; diff --git a/frontend/src/features/canvas/hooks/useColorUnderCursor.ts b/frontend/src/features/canvas/hooks/useColorUnderCursor.ts new file mode 100644 index 0000000000..4739070e13 --- /dev/null +++ b/frontend/src/features/canvas/hooks/useColorUnderCursor.ts @@ -0,0 +1,45 @@ +import { useAppDispatch } from 'app/store'; +import Konva from 'konva'; +import _ from 'lodash'; +import { + commitColorPickerColor, + setColorPickerColor, +} from '../store/canvasSlice'; +import { + getCanvasBaseLayer, + getCanvasStage, +} from '../util/konvaInstanceProvider'; + +const useColorPicker = () => { + const dispatch = useAppDispatch(); + const canvasBaseLayer = getCanvasBaseLayer(); + const stage = getCanvasStage(); + + return { + updateColorUnderCursor: () => { + if (!stage || !canvasBaseLayer) return; + + const position = stage.getPointerPosition(); + + if (!position) return; + + const pixelRatio = Konva.pixelRatio; + + const [r, g, b, a] = canvasBaseLayer + .getContext() + .getImageData( + position.x * pixelRatio, + position.y * pixelRatio, + 1, + 1 + ).data; + + dispatch(setColorPickerColor({ r, g, b, a })); + }, + commitColorUnderCursor: () => { + dispatch(commitColorPickerColor()); + }, + }; +}; + +export default useColorPicker; diff --git a/frontend/src/features/canvas/store/canvasSlice.ts b/frontend/src/features/canvas/store/canvasSlice.ts index de7c0b911a..f71083ae17 100644 --- a/frontend/src/features/canvas/store/canvasSlice.ts +++ b/frontend/src/features/canvas/store/canvasSlice.ts @@ -44,6 +44,7 @@ const initialCanvasState: CanvasState = { brushColor: { r: 90, g: 90, b: 255, a: 1 }, brushSize: 50, canvasContainerDimensions: { width: 0, height: 0 }, + colorPickerColor: { r: 90, g: 90, b: 255, a: 1 }, cursorPosition: null, doesCanvasNeedScaling: false, futureLayerStates: [], @@ -345,7 +346,7 @@ export const canvasSlice = createSlice({ addLine: (state, action: PayloadAction) => { const { tool, layer, brushColor, brushSize } = state; - if (tool === 'move') return; + if (tool === 'move' || tool === 'colorPicker') return; const newStrokeWidth = brushSize / 2; @@ -683,6 +684,13 @@ export const canvasSlice = createSlice({ ) => { state.shouldCropToBoundingBoxOnSave = action.payload; }, + setColorPickerColor: (state, action: PayloadAction) => { + state.colorPickerColor = action.payload; + }, + commitColorPickerColor: (state) => { + state.brushColor = state.colorPickerColor; + state.tool = 'brush'; + }, setMergedCanvas: (state, action: PayloadAction) => { state.pastLayerStates.push({ ...state.layerState, @@ -710,6 +718,8 @@ export const { addLine, addPointToCurrentLine, clearMask, + commitColorPickerColor, + setColorPickerColor, commitStagingAreaImage, discardStagedImages, fitBoundingBoxToStage, diff --git a/frontend/src/features/canvas/store/canvasTypes.ts b/frontend/src/features/canvas/store/canvasTypes.ts index ef28101d75..fae2d6f6a8 100644 --- a/frontend/src/features/canvas/store/canvasTypes.ts +++ b/frontend/src/features/canvas/store/canvasTypes.ts @@ -13,7 +13,7 @@ export type CanvasLayer = typeof LAYER_NAMES[number]; export type CanvasDrawingTool = 'brush' | 'eraser'; -export type CanvasTool = CanvasDrawingTool | 'move'; +export type CanvasTool = CanvasDrawingTool | 'move' | 'colorPicker'; export type Dimensions = { width: number; @@ -81,6 +81,7 @@ export interface CanvasState { brushColor: RgbaColor; brushSize: number; canvasContainerDimensions: Dimensions; + colorPickerColor: RgbaColor, cursorPosition: Vector2d | null; doesCanvasNeedScaling: boolean; futureLayerStates: CanvasLayerState[]; diff --git a/frontend/src/features/canvas/util/constants.ts b/frontend/src/features/canvas/util/constants.ts index 10b27c82d1..4f9662e1be 100644 --- a/frontend/src/features/canvas/util/constants.ts +++ b/frontend/src/features/canvas/util/constants.ts @@ -12,3 +12,5 @@ export const MAX_CANVAS_SCALE = 20; // padding given to initial image/bounding box when stage view is reset export const STAGE_PADDING_PERCENTAGE = 0.95; + +export const COLOR_PICKER_SIZE = 60; diff --git a/frontend/src/features/system/components/HotkeysModal/HotkeysModal.tsx b/frontend/src/features/system/components/HotkeysModal/HotkeysModal.tsx index a9858427f6..d500cc06ab 100644 --- a/frontend/src/features/system/components/HotkeysModal/HotkeysModal.tsx +++ b/frontend/src/features/system/components/HotkeysModal/HotkeysModal.tsx @@ -140,22 +140,22 @@ export default function HotkeysModal({ children }: HotkeysModalProps) { const unifiedCanvasHotkeys = [ { title: 'Select Brush', - desc: 'Selects the inpainting brush', + desc: 'Selects the canvas brush', hotkey: 'B', }, { title: 'Select Eraser', - desc: 'Selects the inpainting eraser', + desc: 'Selects the canvas eraser', hotkey: 'E', }, { title: 'Decrease Brush Size', - desc: 'Decreases the size of the inpainting brush/eraser', + desc: 'Decreases the size of the canvas brush/eraser', hotkey: '[', }, { title: 'Increase Brush Size', - desc: 'Increases the size of the inpainting brush/eraser', + desc: 'Increases the size of the canvas brush/eraser', hotkey: ']', }, { @@ -163,6 +163,11 @@ export default function HotkeysModal({ children }: HotkeysModalProps) { desc: 'Allows canvas navigation', hotkey: 'V', }, + { + title: 'Select Color Picker', + desc: 'Selects the canvas color picker', + hotkey: 'C', + }, { title: 'Quick Toggle Move', desc: 'Temporarily toggles Move mode',