feat(ui): rough out eyedropper tool

It's a bit slow bc we are converting the stage to canvas on every mouse move. Also need to improve the visual but it works.
This commit is contained in:
psychedelicious 2024-08-15 20:41:57 +10:00
parent 11010236b3
commit 31ace5fb0c
7 changed files with 125 additions and 10 deletions

View File

@ -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<HTMLInputElement>) => {
if (!canvasManager) {

View File

@ -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 = () => {
<MoveToolButton />
<ViewToolButton />
<BboxToolButton />
<ToolEyeDropperButton />
</ButtonGroup>
<TransformToolButton />
</>

View File

@ -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 (
<IconButton
aria-label={`${t('controlLayers.eyeDropper')} (I)`}
tooltip={`${t('controlLayers.eyeDropper')} (I)`}
icon={<PiEyedropperBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="outline"
onClick={onClick}
isDisabled={isDisabled}
/>
);
});
ToolEyeDropperButton.displayName = 'ToolEyeDropperButton';

View File

@ -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<RgbaColor> = atom();
$selectedEntity: WritableAtom<EntityStateAndAdapter | null> = atom();
$selectedEntityIdentifier: WritableAtom<CanvasEntityIdentifier | null> = atom();
$colorUnderCursor: WritableAtom<RgbaColor | null> = atom();
// Read-write state, ephemeral interaction state
$isDrawing = $isDrawing;

View File

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

View File

@ -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 &&

View File

@ -469,7 +469,7 @@ export const IMAGE_FILTERS: { [key in FilterConfig['type']]: ImageFilterData<key
},
} as const;
const zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'view', 'bbox']);
const zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'view', 'bbox', 'eyeDropper']);
export type Tool = z.infer<typeof zTool>;
export function isDrawingTool(tool: Tool): tool is 'brush' | 'eraser' | 'rect' {
return tool === 'brush' || tool === 'eraser' || tool === 'rect';