Skip to content
Open
10 changes: 10 additions & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,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."
Expand Down Expand Up @@ -2641,10 +2645,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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,6 +21,7 @@ export const ToolChooser: React.FC = () => {
<ToolRectButton />
<ToolGradientButton />
<ToolTextButton />
<ToolLassoButton />
<ToolMoveButton />
<ToolViewButton />
<ToolBboxButton />
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip label={`${t('controlLayers.tool.lasso', { defaultValue: 'Lasso' })} (L)`} placement="end">
<IconButton
aria-label={`${t('controlLayers.tool.lasso', { defaultValue: 'Lasso' })} (L)`}
icon={<PiLassoBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectLasso}
/>
</Tooltip>
);
});

ToolLassoButton.displayName = 'ToolLassoButton';
Original file line number Diff line number Diff line change
@@ -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 (
<ButtonGroup isAttached size="sm">
<Tooltip label={t('controlLayers.lasso.freehand', { defaultValue: 'Freehand' })}>
<IconButton
aria-label={t('controlLayers.lasso.freehand', { defaultValue: 'Freehand' })}
icon={<PiScribbleLoopBold size={16} />}
colorScheme={lassoMode === 'freehand' ? 'invokeBlue' : 'base'}
variant="solid"
onClick={setFreehand}
/>
</Tooltip>
<Tooltip label={t('controlLayers.lasso.polygon', { defaultValue: 'Polygon' })}>
<IconButton
aria-label={t('controlLayers.lasso.polygonHint', {
defaultValue: 'Click to add points, click the first point to close.',
})}
icon={<PiPolygonBold size={16} />}
colorScheme={lassoMode === 'polygon' ? 'invokeBlue' : 'base'}
variant="solid"
onClick={setPolygon}
/>
</Tooltip>
</ButtonGroup>
);
});

ToolLassoModeToggle.displayName = 'ToolLassoModeToggle';
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand All @@ -57,6 +59,11 @@ export const CanvasToolbar = memo(() => {
<ToolGradientModeToggle />
</Box>
)}
{isLassoSelected && (
<Box ms={2} mt="-2px" display="flex" alignItems="center" gap={2}>
<ToolLassoModeToggle />
</Box>
)}
{isTextSelected ? <TextToolOptions /> : showToolWithPicker && <ToolWidthPicker />}
</ToolOptionsRowContainer>
<Flex alignItems="center" h="full">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
};
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -12,6 +13,7 @@ import type {
CanvasEraserLineWithPressureState,
CanvasGradientState,
CanvasImageState,
CanvasLassoState,
CanvasRectState,
} from 'features/controlLayers/store/types';

Expand All @@ -25,6 +27,7 @@ export type AnyObjectRenderer =
| CanvasObjectEraserLine
| CanvasObjectEraserLineWithPressure
| CanvasObjectRect
| CanvasObjectLasso
| CanvasObjectImage
| CanvasObjectGradient;
/**
Expand All @@ -37,4 +40,5 @@ export type AnyObjectState =
| CanvasEraserLineWithPressureState
| CanvasImageState
| CanvasRectState
| CanvasLassoState
| CanvasGradientState;
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
entityBrushLineAdded,
entityEraserLineAdded,
entityGradientAdded,
entityLassoAdded,
entityMovedBy,
entityMovedTo,
entityRasterized,
Expand All @@ -43,6 +44,7 @@ import type {
EntityEraserLineAddedPayload,
EntityGradientAddedPayload,
EntityIdentifierPayload,
EntityLassoAddedPayload,
EntityMovedByPayload,
EntityMovedToPayload,
EntityRasterizedPayload,
Expand Down Expand Up @@ -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.
*/
Expand Down
Loading
Loading