diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index c28df6ee383..c7b273c9ba3 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -618,6 +618,10 @@
"title": "Rect Tool",
"desc": "Select the rect tool."
},
+ "selectLassoTool": {
+ "title": "Lasso Tool",
+ "desc": "Select the lasso tool."
+ },
"selectViewTool": {
"title": "View Tool",
"desc": "Select the view tool."
@@ -2570,10 +2574,16 @@
"radial": "Radial",
"clip": "Clip Gradient"
},
+ "lasso": {
+ "freehand": "Freehand",
+ "polygon": "Polygon",
+ "polygonHint": "Click to add points, click the first point to close."
+ },
"tool": {
"brush": "Brush",
"eraser": "Eraser",
"rectangle": "Rectangle",
+ "lasso": "Lasso",
"gradient": "Gradient",
"bbox": "Bbox",
"move": "Move",
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolChooser.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolChooser.tsx
index 44efe12eb95..30d82722072 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolChooser.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolChooser.tsx
@@ -3,6 +3,7 @@ import { ToolBboxButton } from 'features/controlLayers/components/Tool/ToolBboxB
import { ToolBrushButton } from 'features/controlLayers/components/Tool/ToolBrushButton';
import { ToolColorPickerButton } from 'features/controlLayers/components/Tool/ToolColorPickerButton';
import { ToolGradientButton } from 'features/controlLayers/components/Tool/ToolGradientButton';
+import { ToolLassoButton } from 'features/controlLayers/components/Tool/ToolLassoButton';
import { ToolMoveButton } from 'features/controlLayers/components/Tool/ToolMoveButton';
import { ToolRectButton } from 'features/controlLayers/components/Tool/ToolRectButton';
import { ToolTextButton } from 'features/controlLayers/components/Tool/ToolTextButton';
@@ -20,6 +21,7 @@ export const ToolChooser: React.FC = () => {
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolLassoButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolLassoButton.tsx
new file mode 100644
index 00000000000..587e8a7223c
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolLassoButton.tsx
@@ -0,0 +1,34 @@
+import { IconButton, Tooltip } from '@invoke-ai/ui-library';
+import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
+import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiLassoBold } from 'react-icons/pi';
+
+export const ToolLassoButton = memo(() => {
+ const { t } = useTranslation();
+ const isSelected = useToolIsSelected('lasso');
+ const selectLasso = useSelectTool('lasso');
+
+ useRegisteredHotkeys({
+ id: 'selectLassoTool',
+ category: 'canvas',
+ callback: selectLasso,
+ options: { enabled: !isSelected },
+ dependencies: [isSelected, selectLasso],
+ });
+
+ return (
+
+ }
+ colorScheme={isSelected ? 'invokeBlue' : 'base'}
+ variant="solid"
+ onClick={selectLasso}
+ />
+
+ );
+});
+
+ToolLassoButton.displayName = 'ToolLassoButton';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolLassoModeToggle.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolLassoModeToggle.tsx
new file mode 100644
index 00000000000..63aa27c609a
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolLassoModeToggle.tsx
@@ -0,0 +1,47 @@
+import { ButtonGroup, IconButton, Tooltip } from '@invoke-ai/ui-library';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { selectLassoMode, settingsLassoModeChanged } from 'features/controlLayers/store/canvasSettingsSlice';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiPolygonBold, PiScribbleLoopBold } from 'react-icons/pi';
+
+export const ToolLassoModeToggle = memo(() => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const lassoMode = useAppSelector(selectLassoMode);
+
+ const setFreehand = useCallback(() => {
+ dispatch(settingsLassoModeChanged('freehand'));
+ }, [dispatch]);
+
+ const setPolygon = useCallback(() => {
+ dispatch(settingsLassoModeChanged('polygon'));
+ }, [dispatch]);
+
+ return (
+
+
+ }
+ colorScheme={lassoMode === 'freehand' ? 'invokeBlue' : 'base'}
+ variant="solid"
+ onClick={setFreehand}
+ />
+
+
+ }
+ colorScheme={lassoMode === 'polygon' ? 'invokeBlue' : 'base'}
+ variant="solid"
+ onClick={setPolygon}
+ />
+
+
+ );
+});
+
+ToolLassoModeToggle.displayName = 'ToolLassoModeToggle';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx
index bf186ed6300..faea5d98c3f 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx
@@ -5,6 +5,7 @@ import { useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'
import { ToolFillColorPicker } from 'features/controlLayers/components/Tool/ToolFillColorPicker';
import { ToolGradientClipToggle } from 'features/controlLayers/components/Tool/ToolGradientClipToggle';
import { ToolGradientModeToggle } from 'features/controlLayers/components/Tool/ToolGradientModeToggle';
+import { ToolLassoModeToggle } from 'features/controlLayers/components/Tool/ToolLassoModeToggle';
import { ToolOptionsRowContainer } from 'features/controlLayers/components/Tool/ToolOptionsRowContainer';
import { ToolWidthPicker } from 'features/controlLayers/components/Tool/ToolWidthPicker';
import { CanvasToolbarFitBboxToLayersButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarFitBboxToLayersButton';
@@ -31,6 +32,7 @@ export const CanvasToolbar = memo(() => {
const isBrushSelected = useToolIsSelected('brush');
const isEraserSelected = useToolIsSelected('eraser');
const isTextSelected = useToolIsSelected('text');
+ const isLassoSelected = useToolIsSelected('lasso');
const isGradientSelected = useToolIsSelected('gradient');
const showToolWithPicker = useMemo(() => {
return !isTextSelected && (isBrushSelected || isEraserSelected);
@@ -57,6 +59,11 @@ export const CanvasToolbar = memo(() => {
)}
+ {isLassoSelected && (
+
+
+
+ )}
{isTextSelected ? : showToolWithPicker && }
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts
index ef5cee8d897..9941761a2ee 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts
@@ -8,6 +8,7 @@ import { CanvasObjectEraserLine } from 'features/controlLayers/konva/CanvasObjec
import { CanvasObjectEraserLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLineWithPressure';
import { CanvasObjectGradient } from 'features/controlLayers/konva/CanvasObject/CanvasObjectGradient';
import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage';
+import { CanvasObjectLasso } from 'features/controlLayers/konva/CanvasObject/CanvasObjectLasso';
import { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect';
import type { AnyObjectRenderer, AnyObjectState } from 'features/controlLayers/konva/CanvasObject/types';
import { getPrefixedId } from 'features/controlLayers/konva/util';
@@ -152,6 +153,15 @@ export class CanvasEntityBufferObjectRenderer extends CanvasModuleBase {
this.konva.group.add(this.renderer.konva.group);
}
+ didRender = this.renderer.update(this.state, true);
+ } else if (this.state.type === 'lasso') {
+ assert(this.renderer instanceof CanvasObjectLasso || !this.renderer);
+
+ if (!this.renderer) {
+ this.renderer = new CanvasObjectLasso(this.state, this);
+ this.konva.group.add(this.renderer.konva.group);
+ }
+
didRender = this.renderer.update(this.state, true);
} else if (this.state.type === 'gradient') {
assert(this.renderer instanceof CanvasObjectGradient || !this.renderer);
@@ -247,6 +257,9 @@ export class CanvasEntityBufferObjectRenderer extends CanvasModuleBase {
case 'rect':
this.manager.stateApi.addRect({ entityIdentifier, rect: this.state });
break;
+ case 'lasso':
+ this.manager.stateApi.addLasso({ entityIdentifier, lasso: this.state });
+ break;
case 'gradient':
this.manager.stateApi.addGradient({ entityIdentifier, gradient: this.state });
break;
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts
index b33a4aaa87a..1cdde181511 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts
@@ -10,6 +10,7 @@ import { CanvasObjectEraserLine } from 'features/controlLayers/konva/CanvasObjec
import { CanvasObjectEraserLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLineWithPressure';
import { CanvasObjectGradient } from 'features/controlLayers/konva/CanvasObject/CanvasObjectGradient';
import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage';
+import { CanvasObjectLasso } from 'features/controlLayers/konva/CanvasObject/CanvasObjectLasso';
import { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect';
import type { AnyObjectRenderer, AnyObjectState } from 'features/controlLayers/konva/CanvasObject/types';
import { LightnessToAlphaFilter } from 'features/controlLayers/konva/filters';
@@ -401,6 +402,16 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
this.konva.objectGroup.add(renderer.konva.group);
}
+ didRender = renderer.update(objectState, force || isFirstRender);
+ } else if (objectState.type === 'lasso') {
+ assert(renderer instanceof CanvasObjectLasso || !renderer);
+
+ if (!renderer) {
+ renderer = new CanvasObjectLasso(objectState, this);
+ this.renderers.set(renderer.id, renderer);
+ this.konva.objectGroup.add(renderer.konva.group);
+ }
+
didRender = renderer.update(objectState, force || isFirstRender);
} else if (objectState.type === 'gradient') {
assert(renderer instanceof CanvasObjectGradient || !renderer);
@@ -437,17 +448,21 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
* these visually transparent shapes in its calculation:
*
* - Eraser lines, which are normal lines with a globalCompositeOperation of 'destination-out'.
+ * - Subtracting lasso shapes, which use a globalCompositeOperation of 'destination-out'.
* - Clipped portions of any shape.
* - Images, which may have transparent areas.
*/
needsPixelBbox = (): boolean => {
let needsPixelBbox = false;
for (const renderer of this.renderers.values()) {
- const isEraserLine = renderer instanceof CanvasObjectEraserLine;
+ const isEraserLine =
+ renderer instanceof CanvasObjectEraserLine || renderer instanceof CanvasObjectEraserLineWithPressure;
+ const isSubtractingLasso =
+ renderer instanceof CanvasObjectLasso && renderer.state.compositeOperation === 'destination-out';
const isImage = renderer instanceof CanvasObjectImage;
const imageIgnoresTransparency = isImage && renderer.state.usePixelBbox === false;
const hasClip = renderer instanceof CanvasObjectBrushLine && renderer.state.clip;
- if (isEraserLine || hasClip || (isImage && !imageIgnoresTransparency)) {
+ if (isEraserLine || isSubtractingLasso || hasClip || (isImage && !imageIgnoresTransparency)) {
needsPixelBbox = true;
break;
}
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectLasso.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectLasso.ts
new file mode 100644
index 00000000000..ad433a9f678
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectLasso.ts
@@ -0,0 +1,85 @@
+import { deepClone } from 'common/util/deepClone';
+import type { CanvasEntityBufferObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer';
+import type { CanvasEntityObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer';
+import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
+import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
+import type { CanvasLassoState } from 'features/controlLayers/store/types';
+import Konva from 'konva';
+import type { Logger } from 'roarr';
+
+export class CanvasObjectLasso extends CanvasModuleBase {
+ readonly type = 'object_lasso';
+ readonly id: string;
+ readonly path: string[];
+ readonly parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer;
+ readonly manager: CanvasManager;
+ readonly log: Logger;
+
+ state: CanvasLassoState;
+ konva: {
+ group: Konva.Group;
+ line: Konva.Line;
+ };
+
+ constructor(state: CanvasLassoState, parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer) {
+ super();
+ this.id = state.id;
+ this.parent = parent;
+ this.manager = parent.manager;
+ this.path = this.manager.buildPath(this);
+ this.log = this.manager.buildLogger(this);
+
+ this.log.debug({ state }, 'Creating module');
+
+ this.konva = {
+ group: new Konva.Group({
+ name: `${this.type}:group`,
+ listening: false,
+ }),
+ line: new Konva.Line({
+ name: `${this.type}:line`,
+ listening: false,
+ closed: true,
+ fill: 'white',
+ strokeEnabled: false,
+ perfectDrawEnabled: false,
+ }),
+ };
+ this.konva.group.add(this.konva.line);
+ this.state = state;
+ }
+
+ update(state: CanvasLassoState, force = false): boolean {
+ if (force || this.state !== state) {
+ this.log.trace({ state }, 'Updating lasso');
+ this.konva.line.setAttrs({
+ points: state.points,
+ globalCompositeOperation: state.compositeOperation,
+ });
+ this.state = state;
+ return true;
+ }
+
+ return false;
+ }
+
+ setVisibility(isVisible: boolean): void {
+ this.log.trace({ isVisible }, 'Setting lasso visibility');
+ this.konva.group.visible(isVisible);
+ }
+
+ destroy = () => {
+ this.log.debug('Destroying module');
+ this.konva.group.destroy();
+ };
+
+ repr = () => {
+ return {
+ id: this.id,
+ type: this.type,
+ path: this.path,
+ parent: this.parent.id,
+ state: deepClone(this.state),
+ };
+ };
+}
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/types.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/types.ts
index 31a2bfee078..f193c0b391e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/types.ts
@@ -4,6 +4,7 @@ import type { CanvasObjectEraserLine } from 'features/controlLayers/konva/Canvas
import type { CanvasObjectEraserLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLineWithPressure';
import type { CanvasObjectGradient } from 'features/controlLayers/konva/CanvasObject/CanvasObjectGradient';
import type { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage';
+import type { CanvasObjectLasso } from 'features/controlLayers/konva/CanvasObject/CanvasObjectLasso';
import type { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect';
import type {
CanvasBrushLineState,
@@ -12,6 +13,7 @@ import type {
CanvasEraserLineWithPressureState,
CanvasGradientState,
CanvasImageState,
+ CanvasLassoState,
CanvasRectState,
} from 'features/controlLayers/store/types';
@@ -25,6 +27,7 @@ export type AnyObjectRenderer =
| CanvasObjectEraserLine
| CanvasObjectEraserLineWithPressure
| CanvasObjectRect
+ | CanvasObjectLasso
| CanvasObjectImage
| CanvasObjectGradient;
/**
@@ -37,4 +40,5 @@ export type AnyObjectState =
| CanvasEraserLineWithPressureState
| CanvasImageState
| CanvasRectState
+ | CanvasLassoState
| CanvasGradientState;
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts
index e00ade1f8bb..7d4c76b0c06 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts
@@ -21,6 +21,7 @@ import {
entityBrushLineAdded,
entityEraserLineAdded,
entityGradientAdded,
+ entityLassoAdded,
entityMovedBy,
entityMovedTo,
entityRasterized,
@@ -43,6 +44,7 @@ import type {
EntityEraserLineAddedPayload,
EntityGradientAddedPayload,
EntityIdentifierPayload,
+ EntityLassoAddedPayload,
EntityMovedByPayload,
EntityMovedToPayload,
EntityRasterizedPayload,
@@ -175,6 +177,13 @@ export class CanvasStateApiModule extends CanvasModuleBase {
this.store.dispatch(entityRectAdded(arg));
};
+ /**
+ * Adds a lasso object to an entity, pushing state to redux.
+ */
+ addLasso = (arg: EntityLassoAddedPayload) => {
+ this.store.dispatch(entityLassoAdded(arg));
+ };
+
/**
* Adds a gradient to an entity, pushing state to redux.
*/
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasLassoToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasLassoToolModule.ts
new file mode 100644
index 00000000000..757cf5039e0
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasLassoToolModule.ts
@@ -0,0 +1,575 @@
+import { rgbaColorToString } from 'common/util/colorCodeTransformers';
+import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
+import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
+import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasToolModule';
+import { getPrefixedId, isDistanceMoreThanMin, offsetCoord } from 'features/controlLayers/konva/util';
+import type { Coordinate } from 'features/controlLayers/store/types';
+import { simplifyFlatNumbersArray } from 'features/controlLayers/util/simplify';
+import Konva from 'konva';
+import type { KonvaEventObject } from 'konva/lib/Node';
+import type { Logger } from 'roarr';
+
+type CanvasLassoToolModuleConfig = {
+ PREVIEW_STROKE_COLOR: string;
+ PREVIEW_FILL_COLOR: string;
+ PREVIEW_STROKE_WIDTH_PX: number;
+ START_POINT_RADIUS_PX: number;
+ START_POINT_STROKE_WIDTH_PX: number;
+ START_POINT_HOVER_RADIUS_DELTA_PX: number;
+ POLYGON_CLOSE_RADIUS_PX: number;
+ MIN_FREEHAND_POINT_DISTANCE_PX: number;
+ MAX_FREEHAND_SEGMENT_LENGTH_PX: number;
+ FREEHAND_SIMPLIFY_MIN_POINTS: number;
+ FREEHAND_SIMPLIFY_TOLERANCE: number;
+};
+
+const DEFAULT_CONFIG: CanvasLassoToolModuleConfig = {
+ PREVIEW_STROKE_COLOR: rgbaColorToString({ r: 90, g: 175, b: 255, a: 1 }),
+ PREVIEW_FILL_COLOR: rgbaColorToString({ r: 90, g: 175, b: 255, a: 0.2 }),
+ PREVIEW_STROKE_WIDTH_PX: 1.5,
+ START_POINT_RADIUS_PX: 4,
+ START_POINT_STROKE_WIDTH_PX: 2,
+ START_POINT_HOVER_RADIUS_DELTA_PX: 2,
+ POLYGON_CLOSE_RADIUS_PX: 10,
+ MIN_FREEHAND_POINT_DISTANCE_PX: 1,
+ MAX_FREEHAND_SEGMENT_LENGTH_PX: 2,
+ FREEHAND_SIMPLIFY_MIN_POINTS: 200,
+ FREEHAND_SIMPLIFY_TOLERANCE: 0.6,
+};
+
+export class CanvasLassoToolModule extends CanvasModuleBase {
+ readonly type = 'lasso_tool';
+ readonly id: string;
+ readonly path: string[];
+ readonly parent: CanvasToolModule;
+ readonly manager: CanvasManager;
+ readonly log: Logger;
+
+ config: CanvasLassoToolModuleConfig = DEFAULT_CONFIG;
+
+ private freehandPoints: Coordinate[] = [];
+ private polygonPoints: Coordinate[] = [];
+ private polygonPointer: Coordinate | null = null;
+ private isDrawingFreehand = false;
+
+ konva: {
+ group: Konva.Group;
+ fillShape: Konva.Line;
+ strokeShape: Konva.Line;
+ startPointIndicator: Konva.Circle;
+ };
+
+ constructor(parent: CanvasToolModule) {
+ super();
+ this.id = getPrefixedId(this.type);
+ this.parent = parent;
+ this.manager = this.parent.manager;
+ this.path = this.manager.buildPath(this);
+ this.log = this.manager.buildLogger(this);
+ this.log.debug('Creating module');
+
+ this.konva = {
+ group: new Konva.Group({ name: `${this.type}:group`, listening: false }),
+ fillShape: new Konva.Line({
+ name: `${this.type}:fill_shape`,
+ listening: false,
+ closed: true,
+ fill: this.config.PREVIEW_FILL_COLOR,
+ strokeEnabled: false,
+ visible: false,
+ perfectDrawEnabled: false,
+ }),
+ strokeShape: new Konva.Line({
+ name: `${this.type}:stroke_shape`,
+ listening: false,
+ closed: false,
+ stroke: this.config.PREVIEW_STROKE_COLOR,
+ strokeWidth: this.config.PREVIEW_STROKE_WIDTH_PX,
+ lineCap: 'round',
+ lineJoin: 'round',
+ fillEnabled: false,
+ visible: false,
+ perfectDrawEnabled: false,
+ }),
+ startPointIndicator: new Konva.Circle({
+ name: `${this.type}:start_point_indicator`,
+ listening: false,
+ fillEnabled: false,
+ stroke: this.config.PREVIEW_STROKE_COLOR,
+ visible: false,
+ perfectDrawEnabled: false,
+ }),
+ };
+
+ this.konva.group.add(this.konva.fillShape);
+ this.konva.group.add(this.konva.strokeShape);
+ this.konva.group.add(this.konva.startPointIndicator);
+ }
+
+ syncCursorStyle = () => {
+ if (!this.parent.getCanDraw()) {
+ this.manager.stage.setCursor('not-allowed');
+ return;
+ }
+ this.manager.stage.setCursor('crosshair');
+ };
+
+ render = () => {
+ const tool = this.parent.$tool.get();
+ const isTemporaryViewSwitch = tool === 'view' && this.parent.$toolBuffer.get() === 'lasso';
+ if (tool !== 'lasso' && !isTemporaryViewSwitch) {
+ this.hidePreview();
+ return;
+ }
+
+ if (tool === 'lasso') {
+ this.syncCursorStyle();
+ }
+ this.syncPreview();
+ };
+
+ onToolChanged = () => {
+ const tool = this.parent.$tool.get();
+ const isTemporaryViewSwitch = tool === 'view' && this.parent.$toolBuffer.get() === 'lasso';
+ if (tool !== 'lasso' && !isTemporaryViewSwitch) {
+ this.reset();
+ }
+ };
+
+ hasActiveSession = (): boolean => {
+ return this.isDrawingFreehand || this.freehandPoints.length > 0 || this.polygonPoints.length > 0;
+ };
+
+ onStagePointerDown = (e: KonvaEventObject) => {
+ const cursorPos = this.parent.$cursorPos.get();
+ if (!cursorPos) {
+ return;
+ }
+
+ const lassoMode = this.manager.stateApi.getSettings().lassoMode;
+ const point = cursorPos.relative;
+
+ // Keep middle click for pan and right click for context menu.
+ if (e.evt.button !== 0) {
+ return;
+ }
+
+ if (lassoMode === 'freehand') {
+ if (!this.parent.$isPrimaryPointerDown.get()) {
+ return;
+ }
+
+ this.polygonPoints = [];
+ this.polygonPointer = null;
+ this.freehandPoints = [point];
+ this.isDrawingFreehand = true;
+ this.syncPreview();
+ return;
+ }
+
+ this.freehandPoints = [];
+ this.isDrawingFreehand = false;
+
+ if (this.polygonPoints.length === 0) {
+ this.polygonPoints = [point];
+ this.polygonPointer = point;
+ this.syncPreview();
+ return;
+ }
+
+ const startPoint = this.polygonPoints[0];
+ if (!startPoint) {
+ return;
+ }
+
+ if (
+ this.polygonPoints.length >= 3 &&
+ Math.hypot(point.x - startPoint.x, point.y - startPoint.y) <= this.getPolygonCloseRadius()
+ ) {
+ this.commitContour(this.polygonPoints);
+ this.reset();
+ return;
+ }
+
+ const snappedPoint = this.getPolygonPoint(point, e.evt.shiftKey);
+ this.polygonPoints = [...this.polygonPoints, snappedPoint];
+ this.polygonPointer = snappedPoint;
+ this.syncPreview();
+ };
+
+ onStagePointerMove = (_e: KonvaEventObject) => {
+ this.handlePointerMove(_e.evt.shiftKey);
+ };
+
+ onWindowPointerMove = (e: PointerEvent) => {
+ this.handlePointerMove(e.shiftKey);
+ };
+
+ onStagePointerUp = (_e: KonvaEventObject) => {
+ const lassoMode = this.manager.stateApi.getSettings().lassoMode;
+ if (lassoMode !== 'freehand' || !this.isDrawingFreehand) {
+ return;
+ }
+
+ this.commitContour(this.freehandPoints, true);
+ this.reset();
+ };
+
+ onWindowPointerUp = () => {
+ const lassoMode = this.manager.stateApi.getSettings().lassoMode;
+ if (lassoMode !== 'freehand' || !this.isDrawingFreehand) {
+ return;
+ }
+
+ this.commitContour(this.freehandPoints, true);
+ this.reset();
+ };
+
+ reset = () => {
+ this.freehandPoints = [];
+ this.polygonPoints = [];
+ this.polygonPointer = null;
+ this.isDrawingFreehand = false;
+ this.hidePreview();
+ };
+
+ private handlePointerMove = (shouldSnap: boolean) => {
+ const cursorPos = this.parent.$cursorPos.get();
+ if (!cursorPos) {
+ return;
+ }
+
+ const lassoMode = this.manager.stateApi.getSettings().lassoMode;
+ const point = cursorPos.relative;
+
+ if (lassoMode === 'freehand') {
+ if (!this.isDrawingFreehand || !this.parent.$isPrimaryPointerDown.get()) {
+ return;
+ }
+
+ const minDistance = this.manager.stage.unscale(this.config.MIN_FREEHAND_POINT_DISTANCE_PX);
+ const lastPoint = this.freehandPoints.at(-1) ?? null;
+ if (!isDistanceMoreThanMin(point, lastPoint, minDistance)) {
+ return;
+ }
+ this.appendFreehandPoint(point);
+ this.syncPreview();
+ return;
+ }
+
+ if (this.polygonPoints.length > 0) {
+ this.polygonPointer = this.getPolygonPoint(point, shouldSnap);
+ this.syncPreview();
+ }
+ };
+
+ private appendFreehandPoint = (point: Coordinate) => {
+ const lastPoint = this.freehandPoints.at(-1) ?? null;
+ if (!lastPoint) {
+ this.freehandPoints.push(point);
+ return;
+ }
+
+ const maxSegmentLength = this.manager.stage.unscale(this.config.MAX_FREEHAND_SEGMENT_LENGTH_PX);
+ const dx = point.x - lastPoint.x;
+ const dy = point.y - lastPoint.y;
+ const distance = Math.hypot(dx, dy);
+
+ if (distance <= maxSegmentLength) {
+ this.freehandPoints.push(point);
+ return;
+ }
+
+ const steps = Math.ceil(distance / maxSegmentLength);
+ for (let i = 1; i <= steps; i++) {
+ const t = i / steps;
+ this.freehandPoints.push({
+ x: lastPoint.x + dx * t,
+ y: lastPoint.y + dy * t,
+ });
+ }
+ };
+
+ private hidePreview = () => {
+ this.konva.strokeShape.visible(false);
+ this.konva.fillShape.visible(false);
+ this.konva.startPointIndicator.visible(false);
+ };
+
+ private syncPreview = () => {
+ const lassoMode = this.manager.stateApi.getSettings().lassoMode;
+ const stageScale = this.manager.stage.getScale();
+ const strokeWidth = this.config.PREVIEW_STROKE_WIDTH_PX / stageScale;
+
+ let points: Coordinate[] = [];
+ if (lassoMode === 'freehand') {
+ points = this.freehandPoints;
+ } else {
+ points = [...this.polygonPoints];
+ if (this.polygonPointer) {
+ points.push(this.polygonPointer);
+ }
+ }
+
+ if (points.length < 1) {
+ this.hidePreview();
+ return;
+ }
+
+ const flat = points.flatMap((point) => [point.x, point.y]);
+ this.konva.strokeShape.setAttrs({
+ points: flat,
+ strokeWidth,
+ visible: true,
+ });
+
+ if (points.length >= 3) {
+ this.konva.fillShape.setAttrs({
+ points: flat,
+ visible: true,
+ });
+ } else {
+ this.konva.fillShape.visible(false);
+ }
+
+ if (lassoMode === 'polygon' && this.polygonPoints.length > 0) {
+ const startPoint = this.polygonPoints[0];
+ if (startPoint) {
+ const isHoveringStartPoint = this.getIsHoveringStartPoint(startPoint);
+ const baseRadius = this.manager.stage.unscale(this.config.START_POINT_RADIUS_PX);
+ this.konva.startPointIndicator.setAttrs({
+ x: startPoint.x,
+ y: startPoint.y,
+ radius:
+ baseRadius +
+ (isHoveringStartPoint ? this.manager.stage.unscale(this.config.START_POINT_HOVER_RADIUS_DELTA_PX) : 0),
+ strokeWidth: this.manager.stage.unscale(this.config.START_POINT_STROKE_WIDTH_PX),
+ visible: true,
+ });
+ }
+ } else {
+ this.konva.startPointIndicator.visible(false);
+ }
+ };
+
+ private getPolygonCloseRadius = (): number => {
+ return this.manager.stage.unscale(this.config.POLYGON_CLOSE_RADIUS_PX);
+ };
+
+ private getIsHoveringStartPoint = (startPoint: Coordinate): boolean => {
+ if (this.polygonPoints.length < 3) {
+ return false;
+ }
+
+ const pointerPoint = this.parent.$cursorPos.get()?.relative;
+ if (!pointerPoint) {
+ return false;
+ }
+
+ return Math.hypot(pointerPoint.x - startPoint.x, pointerPoint.y - startPoint.y) <= this.getPolygonCloseRadius();
+ };
+
+ private getPolygonPoint = (point: Coordinate, shouldSnap: boolean): Coordinate => {
+ if (!shouldSnap) {
+ return point;
+ }
+
+ const lastPoint = this.polygonPoints.at(-1);
+ if (!lastPoint) {
+ return point;
+ }
+
+ const dx = point.x - lastPoint.x;
+ const dy = point.y - lastPoint.y;
+ const distance = Math.hypot(dx, dy);
+
+ if (distance === 0) {
+ return point;
+ }
+
+ const SNAP_ANGLE = Math.PI / 4;
+ const angle = Math.atan2(dy, dx);
+ const snappedAngle = Math.round(angle / SNAP_ANGLE) * SNAP_ANGLE;
+
+ const snappedPoint = {
+ x: lastPoint.x + Math.cos(snappedAngle) * distance,
+ y: lastPoint.y + Math.sin(snappedAngle) * distance,
+ };
+
+ return this.alignPointToStart(snappedPoint);
+ };
+
+ private alignPointToStart = (point: Coordinate): Coordinate => {
+ if (this.polygonPoints.length < 2) {
+ return point;
+ }
+
+ const startPoint = this.polygonPoints[0];
+ if (!startPoint) {
+ return point;
+ }
+
+ const alignThreshold = this.getPolygonCloseRadius();
+ const deltaX = Math.abs(point.x - startPoint.x);
+ const deltaY = Math.abs(point.y - startPoint.y);
+ const canAlignX = deltaX <= alignThreshold;
+ const canAlignY = deltaY <= alignThreshold;
+
+ if (!canAlignX && !canAlignY) {
+ return point;
+ }
+
+ if (canAlignX && canAlignY) {
+ if (deltaX <= deltaY) {
+ return { x: startPoint.x, y: point.y };
+ }
+ return { x: point.x, y: startPoint.y };
+ }
+
+ if (canAlignX) {
+ return { x: startPoint.x, y: point.y };
+ }
+
+ return { x: point.x, y: startPoint.y };
+ };
+
+ private closeContour = (points: Coordinate[]): Coordinate[] => {
+ if (points.length === 0) {
+ return [];
+ }
+
+ const start = points[0];
+ const end = points.at(-1);
+ if (!start || !end) {
+ return points;
+ }
+
+ if (start.x === end.x && start.y === end.y) {
+ return points;
+ }
+
+ return [...points, start];
+ };
+
+ private commitContour = (points: Coordinate[], simplifyFreehand: boolean = false) => {
+ const canvasState = this.manager.stateApi.getCanvasState();
+ if (canvasState.inpaintMasks.isHidden) {
+ return;
+ }
+
+ const contourPoints = simplifyFreehand ? this.simplifyFreehandContour(points) : points;
+ if (contourPoints.length < 3) {
+ return;
+ }
+
+ const closedPoints = this.closeContour(contourPoints);
+ if (closedPoints.length < 4) {
+ return;
+ }
+
+ let targetMaskId = this.getActiveInpaintMaskId();
+ if (!targetMaskId) {
+ this.manager.stateApi.addInpaintMask({ isSelected: true });
+ targetMaskId = this.getActiveInpaintMaskId();
+ }
+
+ if (!targetMaskId) {
+ return;
+ }
+
+ const targetMaskState = this.manager.stateApi
+ .getInpaintMasksState()
+ .entities.find((entity) => entity.id === targetMaskId);
+ if (!targetMaskState) {
+ return;
+ }
+
+ const normalizedPoints = closedPoints.flatMap((point) => {
+ const normalizedPoint = offsetCoord(point, targetMaskState.position);
+ return [normalizedPoint.x, normalizedPoint.y];
+ });
+
+ this.manager.stateApi.addLasso({
+ entityIdentifier: { type: 'inpaint_mask', id: targetMaskId },
+ lasso: {
+ id: getPrefixedId('lasso'),
+ type: 'lasso',
+ points: normalizedPoints,
+ compositeOperation:
+ this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get()
+ ? 'destination-out'
+ : 'source-over',
+ },
+ });
+ };
+
+ private simplifyFreehandContour = (points: Coordinate[]): Coordinate[] => {
+ if (points.length < this.config.FREEHAND_SIMPLIFY_MIN_POINTS) {
+ return points;
+ }
+
+ const flatPoints = points.flatMap((point) => [point.x, point.y]);
+ const simplifiedFlatPoints = simplifyFlatNumbersArray(flatPoints, {
+ tolerance: this.config.FREEHAND_SIMPLIFY_TOLERANCE,
+ highestQuality: true,
+ });
+ if (simplifiedFlatPoints.length < 6) {
+ return points;
+ }
+
+ const simplifiedPoints = this.flatNumbersToCoords(simplifiedFlatPoints);
+ if (simplifiedPoints.length < 3) {
+ return points;
+ }
+
+ return simplifiedPoints;
+ };
+
+ private flatNumbersToCoords = (points: number[]): Coordinate[] => {
+ const coords: Coordinate[] = [];
+ for (let i = 0; i < points.length; i += 2) {
+ const x = points[i];
+ const y = points[i + 1];
+ if (x === undefined || y === undefined) {
+ continue;
+ }
+ coords.push({ x, y });
+ }
+ return coords;
+ };
+
+ private getActiveInpaintMaskId = (): string | null => {
+ const canvasState = this.manager.stateApi.getCanvasState();
+ if (canvasState.inpaintMasks.isHidden) {
+ return null;
+ }
+
+ const selectedEntityIdentifier = canvasState.selectedEntityIdentifier;
+ if (selectedEntityIdentifier?.type === 'inpaint_mask') {
+ const selectedMask = canvasState.inpaintMasks.entities.find(
+ (entity) => entity.id === selectedEntityIdentifier.id
+ );
+ if (selectedMask?.isEnabled) {
+ return selectedMask.id;
+ }
+ // If the selected mask is disabled, commit to a new mask instead.
+ return null;
+ }
+
+ const inpaintMasks = canvasState.inpaintMasks.entities;
+ const activeMask = [...inpaintMasks].reverse().find((entity) => entity.isEnabled);
+ return activeMask?.id ?? null;
+ };
+
+ repr = () => {
+ return {
+ id: this.id,
+ type: this.type,
+ path: this.path,
+ freehandPoints: this.freehandPoints,
+ polygonPoints: this.polygonPoints,
+ polygonPointer: this.polygonPointer,
+ isDrawingFreehand: this.isDrawingFreehand,
+ };
+ };
+}
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts
index c25a714bad3..ad6837eae1d 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts
@@ -5,6 +5,7 @@ import { CanvasBrushToolModule } from 'features/controlLayers/konva/CanvasTool/C
import { CanvasColorPickerToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasColorPickerToolModule';
import { CanvasEraserToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasEraserToolModule';
import { CanvasGradientToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasGradientToolModule';
+import { CanvasLassoToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasLassoToolModule';
import { CanvasMoveToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasMoveToolModule';
import { CanvasRectToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasRectToolModule';
import { CanvasTextToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasTextToolModule';
@@ -38,6 +39,7 @@ Konva.dragButtons = [0];
const KEY_ESCAPE = 'Escape';
const KEY_SPACE = ' ';
const KEY_ALT = 'Alt';
+const CODE_SPACE = 'Space';
type CanvasToolModuleConfig = {
BRUSH_SPACING_TARGET_SCALE: number;
@@ -62,6 +64,7 @@ export class CanvasToolModule extends CanvasModuleBase {
brush: CanvasBrushToolModule;
eraser: CanvasEraserToolModule;
rect: CanvasRectToolModule;
+ lasso: CanvasLassoToolModule;
gradient: CanvasGradientToolModule;
colorPicker: CanvasColorPickerToolModule;
bbox: CanvasBboxToolModule;
@@ -121,6 +124,7 @@ export class CanvasToolModule extends CanvasModuleBase {
brush: new CanvasBrushToolModule(this),
eraser: new CanvasEraserToolModule(this),
rect: new CanvasRectToolModule(this),
+ lasso: new CanvasLassoToolModule(this),
gradient: new CanvasGradientToolModule(this),
colorPicker: new CanvasColorPickerToolModule(this),
bbox: new CanvasBboxToolModule(this),
@@ -139,15 +143,26 @@ export class CanvasToolModule extends CanvasModuleBase {
this.konva.group.add(this.tools.colorPicker.konva.group);
this.konva.group.add(this.tools.text.konva.group);
this.konva.group.add(this.tools.bbox.konva.group);
+ this.konva.group.add(this.tools.lasso.konva.group);
this.subscriptions.add(this.manager.stage.$stageAttrs.listen(this.render));
this.subscriptions.add(this.manager.$isBusy.listen(this.render));
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectCanvasSettingsSlice, this.render));
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectCanvasSlice, this.render));
this.subscriptions.add(
- this.$tool.listen(() => {
- // On tool switch, reset mouse state
- this.manager.tool.$isPrimaryPointerDown.set(false);
+ this.$tool.listen((tool, previousTool) => {
+ // Preserve pointer state during temporary view switching so lasso sessions can freeze/resume on space.
+ const shouldPreservePointerState =
+ this.$toolBuffer.get() === 'lasso' &&
+ this.tools.lasso.hasActiveSession() &&
+ ((previousTool === 'lasso' && tool === 'view') || (previousTool === 'view' && tool === 'lasso'));
+
+ if (!shouldPreservePointerState) {
+ // On tool switch, reset mouse state
+ this.manager.tool.$isPrimaryPointerDown.set(false);
+ }
+
+ this.tools.lasso.onToolChanged();
void this.tools.text.onToolChanged();
this.render();
})
@@ -189,6 +204,8 @@ export class CanvasToolModule extends CanvasModuleBase {
this.tools.colorPicker.syncCursorStyle();
} else if (tool === 'text') {
this.tools.text.syncCursorStyle();
+ } else if (tool === 'lasso') {
+ this.tools.lasso.syncCursorStyle();
} else if (selectedEntityAdapter) {
if (selectedEntityAdapter.$isDisabled.get()) {
stage.setCursor('not-allowed');
@@ -222,6 +239,7 @@ export class CanvasToolModule extends CanvasModuleBase {
this.tools.colorPicker.render();
this.tools.text.render();
this.tools.bbox.render();
+ this.tools.lasso.render();
};
syncCursorPositions = () => {
@@ -235,6 +253,19 @@ export class CanvasToolModule extends CanvasModuleBase {
this.$cursorPos.set({ relative, absolute });
};
+ syncCursorPositionsFromWindowEvent = (e: PointerEvent): boolean => {
+ this.konva.stage.setPointersPositions(e);
+ const relative = this.konva.stage.getRelativePointerPosition();
+ const absolute = this.konva.stage.getPointerPosition();
+
+ if (!relative || !absolute) {
+ return false;
+ }
+
+ this.$cursorPos.set({ relative, absolute });
+ return true;
+ };
+
getClip = (
entity: CanvasRegionalGuidanceState | CanvasControlLayerState | CanvasRasterLayerState | CanvasInpaintMaskState
) => {
@@ -274,6 +305,7 @@ export class CanvasToolModule extends CanvasModuleBase {
window.addEventListener('keydown', this.onKeyDown);
window.addEventListener('keyup', this.onKeyUp);
+ window.addEventListener('pointermove', this.onWindowPointerMove);
window.addEventListener('pointerup', this.onWindowPointerUp);
window.addEventListener('blur', this.onWindowBlur);
@@ -289,6 +321,7 @@ export class CanvasToolModule extends CanvasModuleBase {
window.removeEventListener('keydown', this.onKeyDown);
window.removeEventListener('keyup', this.onKeyUp);
+ window.removeEventListener('pointermove', this.onWindowPointerMove);
window.removeEventListener('pointerup', this.onWindowPointerUp);
window.removeEventListener('blur', this.onWindowBlur);
};
@@ -316,6 +349,31 @@ export class CanvasToolModule extends CanvasModuleBase {
return true;
}
+ if (tool === 'lasso') {
+ const canvasState = this.manager.stateApi.getCanvasState();
+ const hasVisibleRasterContent =
+ !canvasState.rasterLayers.isHidden &&
+ canvasState.rasterLayers.entities.some((layer) => layer.isEnabled && layer.objects.length > 0);
+
+ if (!hasVisibleRasterContent) {
+ return false;
+ }
+
+ if (canvasState.inpaintMasks.isHidden) {
+ return false;
+ }
+
+ if (this.manager.$isBusy.get()) {
+ return false;
+ }
+
+ if (this.manager.stage.getIsDragging()) {
+ return false;
+ }
+
+ return true;
+ }
+
if (this.manager.stateApi.getRenderedEntityCount() === 0) {
return false;
}
@@ -407,6 +465,8 @@ export class CanvasToolModule extends CanvasModuleBase {
await this.tools.eraser.onStagePointerDown(e);
} else if (tool === 'rect') {
await this.tools.rect.onStagePointerDown(e);
+ } else if (tool === 'lasso') {
+ await this.tools.lasso.onStagePointerDown(e);
} else if (tool === 'gradient') {
await this.tools.gradient.onStagePointerDown(e);
} else if (tool === 'text') {
@@ -441,6 +501,8 @@ export class CanvasToolModule extends CanvasModuleBase {
this.tools.eraser.onStagePointerUp(e);
} else if (tool === 'rect') {
this.tools.rect.onStagePointerUp(e);
+ } else if (tool === 'lasso') {
+ void this.tools.lasso.onStagePointerUp(e);
} else if (tool === 'gradient') {
this.tools.gradient.onStagePointerUp(e);
}
@@ -476,6 +538,8 @@ export class CanvasToolModule extends CanvasModuleBase {
await this.tools.eraser.onStagePointerMove(e);
} else if (tool === 'rect') {
await this.tools.rect.onStagePointerMove(e);
+ } else if (tool === 'lasso') {
+ await this.tools.lasso.onStagePointerMove(e);
} else if (tool === 'gradient') {
await this.tools.gradient.onStagePointerMove(e);
} else if (tool === 'text') {
@@ -560,6 +624,7 @@ export class CanvasToolModule extends CanvasModuleBase {
onWindowPointerUp = (_: PointerEvent) => {
try {
this.$isPrimaryPointerDown.set(false);
+ void this.tools.lasso.onWindowPointerUp();
const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter();
if (selectedEntity && selectedEntity.bufferRenderer.hasBuffer() && !this.manager.$isBusy.get()) {
@@ -570,6 +635,41 @@ export class CanvasToolModule extends CanvasModuleBase {
}
};
+ onWindowPointerMove = (e: PointerEvent) => {
+ const target = e.target;
+ if (target instanceof Node && this.manager.stage.container.contains(target)) {
+ return;
+ }
+
+ if (this.$tool.get() !== 'lasso') {
+ return;
+ }
+
+ if (!this.getCanDraw()) {
+ return;
+ }
+
+ if (!this.$isPrimaryPointerDown.get()) {
+ return;
+ }
+
+ if (!this.tools.lasso.hasActiveSession()) {
+ return;
+ }
+
+ try {
+ this.$lastPointerType.set(e.pointerType);
+
+ if (!this.syncCursorPositionsFromWindowEvent(e)) {
+ return;
+ }
+
+ this.tools.lasso.onWindowPointerMove(e);
+ } finally {
+ this.render();
+ }
+ };
+
/**
* We want to reset any "quick-switch" tool selection on window blur. Fixes an issue where you alt-tab out of the app
* and the color picker tool is still active when you come back.
@@ -579,6 +679,7 @@ export class CanvasToolModule extends CanvasModuleBase {
};
onKeyDown = (e: KeyboardEvent) => {
+ const isSpaceKey = e.key === KEY_SPACE || e.code === CODE_SPACE;
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
@@ -600,6 +701,9 @@ export class CanvasToolModule extends CanvasModuleBase {
if (e.key === KEY_ESCAPE) {
// Cancel shape drawing on escape
e.preventDefault();
+ if (this.$tool.get() === 'lasso') {
+ this.tools.lasso.reset();
+ }
const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter();
if (
selectedEntity &&
@@ -612,19 +716,27 @@ export class CanvasToolModule extends CanvasModuleBase {
return;
}
- if (e.key === KEY_SPACE) {
+ if (isSpaceKey) {
// Select the view tool on space key down
e.preventDefault();
- this.$toolBuffer.set(this.$tool.get());
- this.$tool.set('view');
+ e.stopPropagation();
+ const currentTool = this.$tool.get();
+ this.$toolBuffer.set(currentTool);
this.manager.stateApi.$spaceKey.set(true);
- this.$cursorPos.set(null);
+ this.$tool.set('view');
+ if (currentTool === 'lasso' && this.tools.lasso.hasActiveSession() && this.$isPrimaryPointerDown.get()) {
+ // Start panning immediately if user is already drawing with freehand lasso.
+ this.manager.stage.startDragging();
+ } else {
+ this.$cursorPos.set(null);
+ }
return;
}
if (e.key === KEY_ALT) {
// Select the color picker on alt key down
e.preventDefault();
+ e.stopPropagation();
this.$toolBuffer.set(this.$tool.get());
this.$tool.set('colorPicker');
}
@@ -644,9 +756,10 @@ export class CanvasToolModule extends CanvasModuleBase {
return;
}
- if (e.key === KEY_SPACE) {
+ if (e.key === KEY_SPACE || e.code === CODE_SPACE) {
// Revert the tool to the previous tool on space key up
e.preventDefault();
+ e.stopPropagation();
this.revertToolBuffer();
this.manager.stateApi.$spaceKey.set(false);
return;
@@ -655,6 +768,7 @@ export class CanvasToolModule extends CanvasModuleBase {
if (e.key === KEY_ALT) {
// Revert the tool to the previous tool on alt key up
e.preventDefault();
+ e.stopPropagation();
this.revertToolBuffer();
return;
}
@@ -684,6 +798,7 @@ export class CanvasToolModule extends CanvasModuleBase {
eraser: this.tools.eraser.repr(),
colorPicker: this.tools.colorPicker.repr(),
rect: this.tools.rect.repr(),
+ lasso: this.tools.lasso.repr(),
gradient: this.tools.gradient.repr(),
bbox: this.tools.bbox.repr(),
view: this.tools.view.repr(),
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts
index 91428b45216..202b70e142d 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts
@@ -13,6 +13,7 @@ const zTransformSmoothingMode = z.enum(['bilinear', 'bicubic', 'hamming', 'lancz
export type TransformSmoothingMode = z.infer;
const zGradientType = z.enum(['linear', 'radial']);
+const zLassoMode = z.enum(['freehand', 'polygon']);
const zCanvasSettingsState = z.object({
/**
@@ -118,6 +119,10 @@ const zCanvasSettingsState = z.object({
* Whether the gradient tool clips to the drag gesture.
*/
gradientClipEnabled: z.boolean().default(true),
+ /**
+ * The lasso tool mode.
+ */
+ lassoMode: zLassoMode.default('freehand'),
});
type CanvasSettingsState = z.infer;
@@ -148,6 +153,7 @@ const getInitialState = (): CanvasSettingsState => ({
transformSmoothingMode: 'bicubic',
gradientType: 'linear',
gradientClipEnabled: true,
+ lassoMode: 'freehand',
});
const slice = createSlice({
@@ -245,6 +251,9 @@ const slice = createSlice({
settingsGradientClipToggled: (state) => {
state.gradientClipEnabled = !state.gradientClipEnabled;
},
+ settingsLassoModeChanged: (state, action: PayloadAction) => {
+ state.lassoMode = action.payload;
+ },
},
});
@@ -276,6 +285,7 @@ export const {
settingsFillColorPickerPinnedSet,
settingsGradientTypeChanged,
settingsGradientClipToggled,
+ settingsLassoModeChanged,
} = slice.actions;
export const canvasSettingsSliceConfig: SliceConfig = {
@@ -317,3 +327,4 @@ export const selectTransformSmoothingEnabled = createCanvasSettingsSelector(
export const selectTransformSmoothingMode = createCanvasSettingsSelector((settings) => settings.transformSmoothingMode);
export const selectGradientType = createCanvasSettingsSelector((settings) => settings.gradientType);
export const selectGradientClipEnabled = createCanvasSettingsSelector((settings) => settings.gradientClipEnabled);
+export const selectLassoMode = createCanvasSettingsSelector((settings) => settings.lassoMode);
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
index 79d3963d122..d114568c3c8 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
@@ -66,6 +66,7 @@ import type {
EntityEraserLineAddedPayload,
EntityGradientAddedPayload,
EntityIdentifierPayload,
+ EntityLassoAddedPayload,
EntityMovedToPayload,
EntityRasterizedPayload,
EntityRectAddedPayload,
@@ -99,6 +100,12 @@ import {
makeDefaultRasterLayerAdjustments,
} from './util';
+const resetInpaintMasksHiddenIfEmpty = (state: CanvasState) => {
+ if (state.inpaintMasks.entities.length === 0) {
+ state.inpaintMasks.isHidden = false;
+ }
+};
+
const slice = createSlice({
name: 'canvas',
initialState: getInitialCanvasState(),
@@ -1061,6 +1068,7 @@ const slice = createSlice({
(entity) => !mergedEntitiesToDelete.includes(entity.id)
);
}
+ resetInpaintMasksHiddenIfEmpty(state);
const entityIdentifier = getEntityIdentifier(entityState);
if (isSelected || mergedEntitiesToDelete.length > 0) {
@@ -1132,6 +1140,7 @@ const slice = createSlice({
if (replace) {
// Remove the inpaint mask
state.inpaintMasks.entities = state.inpaintMasks.entities.filter((layer) => layer.id !== entityIdentifier.id);
+ resetInpaintMasksHiddenIfEmpty(state);
}
// Add the new regional guidance
@@ -1548,6 +1557,17 @@ const slice = createSlice({
// re-render it (reference equality check). I don't like this behaviour.
entity.objects.push({ ...rect });
},
+ entityLassoAdded: (state, action: PayloadAction) => {
+ const { entityIdentifier, lasso } = action.payload;
+ const entity = selectEntity(state, entityIdentifier);
+ if (!entity) {
+ return;
+ }
+
+ // TODO(psyche): If we add the object without splatting, the renderer will see it as the same object and not
+ // re-render it (reference equality check). I don't like this behaviour.
+ entity.objects.push({ ...lasso });
+ },
entityGradientAdded: (state, action: PayloadAction) => {
const { entityIdentifier, gradient } = action.payload;
const entity = selectEntity(state, entityIdentifier);
@@ -1590,6 +1610,7 @@ const slice = createSlice({
break;
}
+ resetInpaintMasksHiddenIfEmpty(state);
state.selectedEntityIdentifier = selectedEntityIdentifier;
},
entityArrangedForwardOne: (state, action: PayloadAction) => {
@@ -1678,6 +1699,7 @@ const slice = createSlice({
break;
case 'inpaint_mask':
state.inpaintMasks.isHidden = !state.inpaintMasks.isHidden;
+ resetInpaintMasksHiddenIfEmpty(state);
break;
case 'regional_guidance':
state.regionalGuidance.isHidden = !state.regionalGuidance.isHidden;
@@ -1686,13 +1708,16 @@ const slice = createSlice({
},
allNonRasterLayersIsHiddenToggled: (state) => {
const hasVisibleNonRasterLayers =
- !state.controlLayers.isHidden || !state.inpaintMasks.isHidden || !state.regionalGuidance.isHidden;
+ (state.controlLayers.entities.length > 0 && !state.controlLayers.isHidden) ||
+ (state.inpaintMasks.entities.length > 0 && !state.inpaintMasks.isHidden) ||
+ (state.regionalGuidance.entities.length > 0 && !state.regionalGuidance.isHidden);
const shouldHide = hasVisibleNonRasterLayers;
state.controlLayers.isHidden = shouldHide;
state.inpaintMasks.isHidden = shouldHide;
state.regionalGuidance.isHidden = shouldHide;
+ resetInpaintMasksHiddenIfEmpty(state);
},
allEntitiesDeleted: (state) => {
// Deleting all entities is equivalent to resetting the state for each entity type
@@ -1708,6 +1733,7 @@ const slice = createSlice({
state.inpaintMasks.entities = inpaintMasks;
state.rasterLayers.entities = rasterLayers;
state.regionalGuidance.entities = regionalGuidance;
+ resetInpaintMasksHiddenIfEmpty(state);
return state;
},
canvasUndo: () => {},
@@ -1787,6 +1813,7 @@ export const {
entityBrushLineAdded,
entityEraserLineAdded,
entityRectAdded,
+ entityLassoAdded,
entityGradientAdded,
// Raster layer adjustments
rasterLayerAdjustmentsSet,
@@ -1913,7 +1940,13 @@ export const canvasSliceConfig: SliceConfig = {
},
};
-const doNotGroupMatcher = isAnyOf(entityBrushLineAdded, entityEraserLineAdded, entityRectAdded, entityGradientAdded);
+const doNotGroupMatcher = isAnyOf(
+ entityBrushLineAdded,
+ entityEraserLineAdded,
+ entityRectAdded,
+ entityLassoAdded,
+ entityGradientAdded
+);
// Store rapid actions of the same type at most once every x time.
// See: https://github.com/omnidan/redux-undo/blob/master/examples/throttled-drag/util/undoFilter.js
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts b/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts
index 5c0abfdb892..2e2ae09212f 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts
@@ -361,5 +361,9 @@ export const selectCanvasMetadata = createSelector(
* This is used to determine the state of the toggle button that shows/hides all non-raster layers.
*/
export const selectNonRasterLayersIsHidden = createSelector(selectCanvasSlice, (canvas) => {
- return canvas.controlLayers.isHidden && canvas.inpaintMasks.isHidden && canvas.regionalGuidance.isHidden;
+ const areControlLayersEffectivelyHidden = canvas.controlLayers.entities.length === 0 || canvas.controlLayers.isHidden;
+ const areInpaintMasksEffectivelyHidden = canvas.inpaintMasks.entities.length === 0 || canvas.inpaintMasks.isHidden;
+ const areRegionalGuidanceEffectivelyHidden =
+ canvas.regionalGuidance.entities.length === 0 || canvas.regionalGuidance.isHidden;
+ return areControlLayersEffectivelyHidden && areInpaintMasksEffectivelyHidden && areRegionalGuidanceEffectivelyHidden;
});
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
index 40babc7bc85..6d99fc01815 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -105,7 +105,7 @@ const zIPMethodV2 = z.enum(['full', 'style', 'composition', 'style_strong', 'sty
export type IPMethodV2 = z.infer;
export const isIPMethodV2 = (v: unknown): v is IPMethodV2 => zIPMethodV2.safeParse(v).success;
-const _zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'gradient', 'view', 'bbox', 'colorPicker', 'text']);
+const _zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'lasso', 'gradient', 'view', 'bbox', 'colorPicker', 'text']);
export type Tool = z.infer;
const zPoints = z.array(z.number()).refine((points) => points.length % 2 === 0, {
@@ -260,6 +260,20 @@ const zCanvasRectState = z.object({
});
export type CanvasRectState = z.infer;
+const zCanvasLassoCompositeOperation = z.enum(['source-over', 'destination-out']);
+
+const zCanvasLassoState = z.object({
+ id: zId,
+ type: z.literal('lasso'),
+ /**
+ * Points in the format [x1, y1, x2, y2, ...].
+ * The lasso tool always commits a closed contour.
+ */
+ points: zPoints,
+ compositeOperation: zCanvasLassoCompositeOperation.default('source-over'),
+});
+export type CanvasLassoState = z.infer;
+
// Gradient state includes clip metadata so the tool can optionally clip to drag gesture.
const zCanvasLinearGradientState = z.object({
id: zId,
@@ -309,6 +323,7 @@ const zCanvasObjectState = z.union([
zCanvasBrushLineState,
zCanvasEraserLineState,
zCanvasRectState,
+ zCanvasLassoState,
zCanvasBrushLineWithPressureState,
zCanvasEraserLineWithPressureState,
zCanvasGradientState,
@@ -925,6 +940,7 @@ export type EntityEraserLineAddedPayload = EntityIdentifierPayload<{
eraserLine: CanvasEraserLineState | CanvasEraserLineWithPressureState;
}>;
export type EntityRectAddedPayload = EntityIdentifierPayload<{ rect: CanvasRectState }>;
+export type EntityLassoAddedPayload = EntityIdentifierPayload<{ lasso: CanvasLassoState }>;
export type EntityGradientAddedPayload = EntityIdentifierPayload<{ gradient: CanvasGradientState }>;
export type EntityRasterizedPayload = EntityIdentifierPayload<{
imageObject: CanvasImageState;
diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts
index c6a0bd6705b..dfc9b5d280c 100644
--- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts
+++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts
@@ -118,6 +118,7 @@ export const useHotkeyData = (): HotkeysData => {
addHotkey('canvas', 'selectEraserTool', ['e']);
addHotkey('canvas', 'selectMoveTool', ['v']);
addHotkey('canvas', 'selectRectTool', ['u']);
+ addHotkey('canvas', 'selectLassoTool', ['l']);
addHotkey('canvas', 'selectViewTool', ['h']);
addHotkey('canvas', 'selectColorPickerTool', ['i']);
addHotkey('canvas', 'setFillColorsToDefault', ['d']);