diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx index c354fd9a19..dd98df8dab 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx @@ -20,7 +20,6 @@ import { memo, useCallback } from 'react'; export const ControlLayersToolbar = memo(() => { const tool = useAppSelector((s) => s.canvasV2.tool.selected); const canvasManager = useStore($canvasManager); - const onChangeDebugging = useCallback( (e: ChangeEvent) => { if (!canvasManager) { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ToolChooser.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ToolChooser.tsx index 860bf3c601..0a0c2f98df 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ToolChooser.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ToolChooser.tsx @@ -5,6 +5,7 @@ import { BrushToolButton } from 'features/controlLayers/components/BrushToolButt import { EraserToolButton } from 'features/controlLayers/components/EraserToolButton'; import { MoveToolButton } from 'features/controlLayers/components/MoveToolButton'; import { RectToolButton } from 'features/controlLayers/components/RectToolButton'; +import { ToolEyeDropperButton } from 'features/controlLayers/components/ToolEyeDropperButton'; import { TransformToolButton } from 'features/controlLayers/components/TransformToolButton'; import { ViewToolButton } from 'features/controlLayers/components/ViewToolButton'; import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanvasDeleteLayerHotkey'; @@ -24,6 +25,7 @@ export const ToolChooser: React.FC = () => { + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ToolEyeDropperButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ToolEyeDropperButton.tsx new file mode 100644 index 0000000000..7bf746ca60 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ToolEyeDropperButton.tsx @@ -0,0 +1,34 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { toolChanged } from 'features/controlLayers/store/canvasV2Slice'; +import { memo, useCallback } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { useTranslation } from 'react-i18next'; +import { PiEyedropperBold } from 'react-icons/pi'; + +export const ToolEyeDropperButton = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const isDisabled = useAppSelector((s) => s.canvasV2.session.isStaging || s.canvasV2.tool.isTransforming); + const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'eyeDropper'); + + const onClick = useCallback(() => { + dispatch(toolChanged('eyeDropper')); + }, [dispatch]); + + useHotkeys('i', onClick, { enabled: !isDisabled }, [onClick, isDisabled]); + + return ( + } + colorScheme={isSelected ? 'invokeBlue' : 'base'} + variant="outline" + onClick={onClick} + isDisabled={isDisabled} + /> + ); +}); + +ToolEyeDropperButton.displayName = 'ToolEyeDropperButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApi.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApi.ts index b6bb0e8fe1..ed67ebda22 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApi.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApi.ts @@ -26,6 +26,7 @@ import { entityReset, entitySelected, eraserWidthChanged, + fillChanged, rasterLayerCompositeRasterized, toolBufferChanged, toolChanged, @@ -144,6 +145,9 @@ export class CanvasStateApi { log.trace({ toolBuffer }, 'Setting tool buffer'); this._store.dispatch(toolBufferChanged(toolBuffer)); }; + setFill = (fill: RgbaColor) => { + return this._store.dispatch(fillChanged(fill)); + }; getBbox = () => { return this.getState().bbox; @@ -257,6 +261,7 @@ export class CanvasStateApi { $currentFill: WritableAtom = atom(); $selectedEntity: WritableAtom = atom(); $selectedEntityIdentifier: WritableAtom = atom(); + $colorUnderCursor: WritableAtom = atom(); // Read-write state, ephemeral interaction state $isDrawing = $isDrawing; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool.ts index 9e4183aa2a..f112fec3a7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool.ts @@ -35,6 +35,11 @@ export class CanvasTool { innerBorderCircle: Konva.Circle; outerBorderCircle: Konva.Circle; }; + eyeDropper: { + group: Konva.Group; + fillCircle: Konva.Circle; + transparentCenterCircle: Konva.Circle; + }; }; /** @@ -96,6 +101,26 @@ export class CanvasTool { strokeEnabled: true, }), }, + eyeDropper: { + group: new Konva.Group({ name: `${this.type}:eyeDropper_group` }), + fillCircle: new Konva.Circle({ + name: `${this.type}:eyeDropper_fill_circle`, + listening: false, + fill: '', + radius: 20, + strokeWidth: 1, + stroke: 'black', + strokeScaleEnabled: false, + }), + transparentCenterCircle: new Konva.Circle({ + name: `${this.type}:eyeDropper_fill_circle`, + listening: false, + strokeEnabled: false, + fill: 'white', + radius: 5, + globalCompositeOperation: 'destination-out', + }), + }, }; this.konva.brush.group.add(this.konva.brush.fillCircle); this.konva.brush.group.add(this.konva.brush.innerBorderCircle); @@ -107,6 +132,10 @@ export class CanvasTool { this.konva.eraser.group.add(this.konva.eraser.outerBorderCircle); this.konva.group.add(this.konva.eraser.group); + this.konva.eyeDropper.group.add(this.konva.eyeDropper.fillCircle); + this.konva.eyeDropper.group.add(this.konva.eyeDropper.transparentCenterCircle); + this.konva.group.add(this.konva.eyeDropper.group); + this.subscriptions.add( this.manager.stateApi.$stageAttrs.listen(() => { this.render(); @@ -146,6 +175,12 @@ export class CanvasTool { }); }; + setToolVisibility = (tool: 'brush' | 'eraser' | 'eyeDropper' | 'none') => { + this.konva.brush.group.visible(tool === 'brush'); + this.konva.eraser.group.visible(tool === 'eraser'); + this.konva.eyeDropper.group.visible(tool === 'eyeDropper'); + }; + render() { const stage = this.manager.stage; const renderedEntityCount: number = 1; // TODO(psyche): this.manager should be renderable entity count @@ -154,6 +189,7 @@ export class CanvasTool { const cursorPos = this.manager.stateApi.$lastCursorPos.get(); const isDrawing = this.manager.stateApi.$isDrawing.get(); const isMouseDown = this.manager.stateApi.$isMouseDown.get(); + const colorUnderCursor = this.manager.stateApi.$colorUnderCursor.get(); const tool = toolState.selected; @@ -180,6 +216,8 @@ export class CanvasTool { stage.container().style.cursor = 'none'; } else if (tool === 'bbox') { stage.container().style.cursor = 'default'; + } else if (tool === 'eyeDropper') { + stage.container().style.cursor = 'none'; } stage.draggable(tool === 'view'); @@ -216,9 +254,7 @@ export class CanvasTool { }); this.scaleTool(); - - this.konva.brush.group.visible(true); - this.konva.eraser.group.visible(false); + this.setToolVisibility('brush'); } else if (cursorPos && tool === 'eraser') { const alignedCursorPos = alignCoordForTool(cursorPos, toolState.eraser.width); @@ -243,12 +279,20 @@ export class CanvasTool { }); this.scaleTool(); - - this.konva.brush.group.visible(false); - this.konva.eraser.group.visible(true); + this.setToolVisibility('eraser'); + } else if (cursorPos && colorUnderCursor) { + this.konva.eyeDropper.fillCircle.setAttrs({ + x: cursorPos.x, + y: cursorPos.y, + fill: rgbaColorToString(colorUnderCursor), + }); + this.konva.eyeDropper.transparentCenterCircle.setAttrs({ + x: cursorPos.x, + y: cursorPos.y, + }); + this.setToolVisibility('eyeDropper'); } else { - this.konva.brush.group.visible(false); - this.konva.eraser.group.visible(false); + this.setToolVisibility('none'); } } } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts index 96465480c1..96f5ece261 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts @@ -12,6 +12,7 @@ import type { CanvasRegionalGuidanceState, CanvasV2State, Coordinate, + RgbaColor, Tool, } from 'features/controlLayers/store/types'; import { isDrawableEntity, isDrawableEntityAdapter } from 'features/controlLayers/store/types'; @@ -115,6 +116,23 @@ const getLastPointOfLastLineOfEntity = ( return { x, y }; }; +const getColorUnderCursor = (stage: Konva.Stage): RgbaColor | null => { + const pos = stage.getPointerPosition(); + if (!pos) { + return null; + } + const ctx = stage.toCanvas({ x: pos.x, y: pos.y, width: 1, height: 1 }).getContext('2d'); + if (!ctx) { + return null; + } + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data; + if (r === undefined || g === undefined || b === undefined || a === undefined) { + return null; + } + + return { r, g, b, a }; +}; + export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { const { stage, stateApi } = manager; const { @@ -174,6 +192,14 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { const pos = updateLastCursorPos(stage, $lastCursorPos.set); const selectedEntity = getSelectedEntity(); + if (toolState.selected === 'eyeDropper') { + const color = getColorUnderCursor(stage); + manager.stateApi.$colorUnderCursor.set(color); + if (color) { + manager.stateApi.setFill(color); + } + } + if ( pos && selectedEntity && @@ -322,6 +348,11 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => { const pos = updateLastCursorPos(stage, $lastCursorPos.set); const selectedEntity = getSelectedEntity(); + if (toolState.selected === 'eyeDropper') { + const color = getColorUnderCursor(stage); + manager.stateApi.$colorUnderCursor.set(color); + } + if ( pos && selectedEntity && diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index c7e54b2a83..a6d9115386 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -469,7 +469,7 @@ export const IMAGE_FILTERS: { [key in FilterConfig['type']]: ImageFilterData; export function isDrawingTool(tool: Tool): tool is 'brush' | 'eraser' | 'rect' { return tool === 'brush' || tool === 'eraser' || tool === 'rect';