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']);