Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c49fee9
Feat(Canvas): Replace Rectangle tool with multifunctional Shapes tool.
DustyShoe Mar 8, 2026
bc68c59
Fix: Tweaked icon size on top bar
DustyShoe Mar 15, 2026
708ba51
Fix: also tweaked SVGs for Gradient tool to align with unified 16px i…
DustyShoe Mar 15, 2026
890d151
Feat: added freehand shape
DustyShoe Mar 25, 2026
58bdb0d
fix(canvas): remove duplicate lasso payload export after rebase
DustyShoe Apr 14, 2026
9ad8225
Merge branch 'invoke-ai:main' into Feat(Canvas)/Replace-Rectangle-too…
DustyShoe Apr 14, 2026
dd0dd20
Merge branch 'invoke-ai:main' into Feat(Canvas)/Replace-Rectangle-too…
DustyShoe Apr 14, 2026
d282020
Merge branch 'invoke-ai:main' into Feat(Canvas)/Replace-Rectangle-too…
DustyShoe Apr 14, 2026
9a696b1
Merge branch 'invoke-ai:main' into Feat(Canvas)/Replace-Rectangle-too…
DustyShoe Apr 16, 2026
9102fea
Merge branch 'invoke-ai:main' into Feat(Canvas)/Replace-Rectangle-too…
DustyShoe Apr 20, 2026
4c25e9b
`fix(canvas): clear polygon preview stroke on commit`
DustyShoe Apr 21, 2026
d787f15
Merge branch 'invoke-ai:main' into Feat(Canvas)/Replace-Rectangle-too…
DustyShoe Apr 21, 2026
4e18f1a
chore: remove temporary codex artifact
DustyShoe Apr 21, 2026
2b6f11e
chore: format with prettier
DustyShoe Apr 21, 2026
15c9252
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
DustyShoe Apr 22, 2026
4bd2593
fix(canvas): preserve shapes sessions across view switch
DustyShoe Apr 22, 2026
29ae589
chore: format with prettier
DustyShoe Apr 22, 2026
6d454d6
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
DustyShoe Apr 23, 2026
b1bc708
add: constrain rectangles to squares with shift
DustyShoe Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -714,8 +714,8 @@
"desc": "Select the move tool."
},
"selectRectTool": {
"title": "Rect Tool",
"desc": "Select the rect tool."
"title": "Shapes Tool",
"desc": "Select the shapes tool."
},
"selectLassoTool": {
"title": "Lasso Tool",
Expand Down Expand Up @@ -2835,9 +2835,16 @@
"polygon": "Polygon",
"polygonHint": "Click to add points, click the first point to close."
},
"shape": {
"rect": "Rect",
"oval": "Oval",
"polygon": "Polygon",
"freehand": "Freehand"
},
"tool": {
"brush": "Brush",
"eraser": "Eraser",
"shapes": "Shapes",
"rectangle": "Rectangle",
"lasso": "Lasso",
"gradient": "Gradient",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,23 @@ export const GradientLinearIcon = memo(() => {
const id = useId();
const gradientId = `${id}-gradient-linear-diagonal`;
return (
<Box as="svg" viewBox="0 0 24 24" boxSize="22px" aria-hidden focusable={false} display="block">
<Box as="svg" viewBox="0 0 20 20" aria-hidden focusable={false} display="block">
<defs>
<linearGradient id={gradientId} x1="0" y1="1" x2="1" y2="0">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.0" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.85" />
</linearGradient>
</defs>
<rect
x="4"
y="4"
x="2"
y="2"
width="16"
height="16"
rx="2"
fill={`url(#${gradientId})`}
stroke="currentColor"
strokeOpacity="0.9"
strokeWidth="1"
strokeWidth="1.2"
/>
</Box>
);
Expand All @@ -59,21 +59,21 @@ export const GradientRadialIcon = memo(() => {
const id = useId();
const gradientId = `${id}-gradient-radial`;
return (
<Box as="svg" viewBox="0 0 24 24" boxSize="22px" aria-hidden focusable={false} display="block">
<Box as="svg" viewBox="0 0 20 20" aria-hidden focusable={false} display="block">
<defs>
<radialGradient id={gradientId} cx="0.5" cy="0.5" r="0.5">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.0" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.85" />
</radialGradient>
</defs>
<circle
cx="12"
cy="12"
cx="10"
cy="10"
r="8"
fill={`url(#${gradientId})`}
stroke="currentColor"
strokeOpacity="0.9"
strokeWidth="1"
strokeWidth="1.2"
/>
</Box>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ToolColorPickerButton } from 'features/controlLayers/components/Tool/To
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 { ToolShapesButton } from 'features/controlLayers/components/Tool/ToolShapesButton';
import { ToolTextButton } from 'features/controlLayers/components/Tool/ToolTextButton';
import React from 'react';

Expand All @@ -18,7 +18,7 @@ export const ToolChooser: React.FC = () => {
<ButtonGroup isAttached orientation="vertical">
<ToolBrushButton />
<ToolEraserButton />
<ToolRectButton />
<ToolShapesButton />
<ToolGradientButton />
<ToolTextButton />
<ToolLassoButton />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { ButtonGroup, IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectShapeType, settingsShapeTypeChanged } from 'features/controlLayers/store/canvasSettingsSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCircleBold, PiPolygonBold, PiRectangleBold, PiScribbleLoopBold } from 'react-icons/pi';

export const ToolShapeTypeToggle = memo(() => {
const { t } = useTranslation();
const shapeType = useAppSelector(selectShapeType);
const dispatch = useAppDispatch();

const onRectClick = useCallback(() => dispatch(settingsShapeTypeChanged('rect')), [dispatch]);
const onOvalClick = useCallback(() => dispatch(settingsShapeTypeChanged('oval')), [dispatch]);
const onPolygonClick = useCallback(() => dispatch(settingsShapeTypeChanged('polygon')), [dispatch]);
const onFreehandClick = useCallback(() => dispatch(settingsShapeTypeChanged('freehand')), [dispatch]);

const rectLabel = t('controlLayers.shape.rect', { defaultValue: 'Rect' });
const ovalLabel = t('controlLayers.shape.oval', { defaultValue: 'Oval' });
const polygonLabel = t('controlLayers.shape.polygon', { defaultValue: 'Polygon' });
const freehandLabel = t('controlLayers.shape.freehand', { defaultValue: 'Freehand' });

return (
<ButtonGroup isAttached size="sm">
<Tooltip label={rectLabel}>
<IconButton
aria-label={rectLabel}
icon={<PiRectangleBold />}
colorScheme={shapeType === 'rect' ? 'invokeBlue' : 'base'}
variant="solid"
onClick={onRectClick}
/>
</Tooltip>
<Tooltip label={ovalLabel}>
<IconButton
aria-label={ovalLabel}
icon={<PiCircleBold />}
colorScheme={shapeType === 'oval' ? 'invokeBlue' : 'base'}
variant="solid"
onClick={onOvalClick}
/>
</Tooltip>
<Tooltip label={polygonLabel}>
<IconButton
aria-label={polygonLabel}
icon={<PiPolygonBold />}
colorScheme={shapeType === 'polygon' ? 'invokeBlue' : 'base'}
variant="solid"
onClick={onPolygonClick}
/>
</Tooltip>
<Tooltip label={freehandLabel}>
<IconButton
aria-label={freehandLabel}
icon={<PiScribbleLoopBold />}
colorScheme={shapeType === 'freehand' ? 'invokeBlue' : 'base'}
variant="solid"
onClick={onFreehandClick}
/>
</Tooltip>
</ButtonGroup>
);
});

ToolShapeTypeToggle.displayName = 'ToolShapeTypeToggle';
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,33 @@ import { useSelectTool, useToolIsSelected } from 'features/controlLayers/compone
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiRectangleBold } from 'react-icons/pi';
import { PiShapesBold } from 'react-icons/pi';

export const ToolRectButton = memo(() => {
export const ToolShapesButton = memo(() => {
const { t } = useTranslation();
const isSelected = useToolIsSelected('rect');
const selectRect = useSelectTool('rect');
const selectShapes = useSelectTool('rect');
const label = t('controlLayers.tool.shapes', { defaultValue: 'Shapes' });

useRegisteredHotkeys({
id: 'selectRectTool',
category: 'canvas',
callback: selectRect,
callback: selectShapes,
options: { enabled: !isSelected },
dependencies: [isSelected, selectRect],
dependencies: [isSelected, selectShapes],
});

return (
<Tooltip label={`${t('controlLayers.tool.rectangle')} (U)`} placement="end">
<Tooltip label={`${label} (U)`} placement="end">
<IconButton
aria-label={`${t('controlLayers.tool.rectangle')} (U)`}
icon={<PiRectangleBold />}
aria-label={`${label} (U)`}
icon={<PiShapesBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectRect}
onClick={selectShapes}
/>
</Tooltip>
);
});

ToolRectButton.displayName = 'ToolRectButton';
ToolShapesButton.displayName = 'ToolShapesButton';
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ToolGradientClipToggle } from 'features/controlLayers/components/Tool/T
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 { ToolShapeTypeToggle } from 'features/controlLayers/components/Tool/ToolShapeTypeToggle';
import { ToolWidthPicker } from 'features/controlLayers/components/Tool/ToolWidthPicker';
import { CanvasToolbarFitBboxToLayersButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarFitBboxToLayersButton';
import { CanvasToolbarFitBboxToMasksButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarFitBboxToMasksButton';
Expand All @@ -32,6 +33,7 @@ import { memo, useMemo } from 'react';
export const CanvasToolbar = memo(() => {
const isBrushSelected = useToolIsSelected('brush');
const isEraserSelected = useToolIsSelected('eraser');
const isShapeSelected = useToolIsSelected('rect');
const isTextSelected = useToolIsSelected('text');
const isLassoSelected = useToolIsSelected('lasso');
const isGradientSelected = useToolIsSelected('gradient');
Expand All @@ -51,9 +53,25 @@ export const CanvasToolbar = memo(() => {
useCanvasToggleBboxHotkey();

return (
<Flex w="full" gap={2} alignItems="center" px={2}>
<Flex
w="full"
gap={2}
alignItems="center"
px={2}
sx={{
'& svg': {
width: '16px',
height: '16px',
},
}}
>
<ToolOptionsRowContainer gap={4} alignItems="center" h="full">
<ToolFillColorPicker />
{isShapeSelected && (
<Box ms={2} mt="-2px" display="flex" alignItems="center" gap={2}>
<ToolShapeTypeToggle />
</Box>
)}
{isGradientSelected && (
<Box ms={2} mt="-2px" display="flex" alignItems="center" gap={2}>
<ToolGradientClipToggle />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { CanvasObjectEraserLineWithPressure } from 'features/controlLayers/konva
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 { CanvasObjectOval } from 'features/controlLayers/konva/CanvasObject/CanvasObjectOval';
import { CanvasObjectPolygon } from 'features/controlLayers/konva/CanvasObject/CanvasObjectPolygon';
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 @@ -83,6 +85,20 @@ export class CanvasEntityBufferObjectRenderer extends CanvasModuleBase {
this.subscriptions.add(
this.manager.tool.$tool.listen(() => {
if (this.hasBuffer() && !this.manager.$isBusy.get()) {
const isTemporaryShapesViewSwitch =
this.manager.tool.tools.rect.hasSuspendableSession() &&
((this.manager.tool.$tool.get() === 'view' && this.manager.tool.$toolBuffer.get() === 'rect') ||
this.manager.tool.$tool.get() === 'rect');

if (isTemporaryShapesViewSwitch) {
return;
}

if (this.state?.type === 'polygon' && this.state.previewPoint) {
this.clearBuffer();
return;
}

this.commitBuffer();
}
})
Expand Down Expand Up @@ -153,6 +169,24 @@ 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 === 'oval') {
assert(this.renderer instanceof CanvasObjectOval || !this.renderer);

if (!this.renderer) {
this.renderer = new CanvasObjectOval(this.state, this);
this.konva.group.add(this.renderer.konva.group);
}

didRender = this.renderer.update(this.state, true);
} else if (this.state.type === 'polygon') {
assert(this.renderer instanceof CanvasObjectPolygon || !this.renderer);

if (!this.renderer) {
this.renderer = new CanvasObjectPolygon(this.state, this);
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);
Expand Down Expand Up @@ -240,28 +274,40 @@ export class CanvasEntityBufferObjectRenderer extends CanvasModuleBase {

this.log.trace({ buffer: this.renderer.repr() }, 'Committing buffer');

let committedState = this.state;

// Polygon previews render an outline while they are still live in the buffer.
// Clear that preview state before adopting the renderer into the persistent object group.
if (committedState.type === 'polygon' && this.renderer instanceof CanvasObjectPolygon) {
committedState = { ...committedState, previewPoint: undefined };
this.state = null;
this.renderer.update(committedState, true);
}

// Move the buffer to the persistent objects group/renderers
this.parent.renderer.adoptObjectRenderer(this.renderer);

if (pushToState) {
const entityIdentifier = this.parent.entityIdentifier;
switch (this.state.type) {
switch (committedState.type) {
case 'brush_line':
case 'brush_line_with_pressure':
this.manager.stateApi.addBrushLine({ entityIdentifier, brushLine: this.state });
this.manager.stateApi.addBrushLine({ entityIdentifier, brushLine: committedState });
break;
case 'eraser_line':
case 'eraser_line_with_pressure':
this.manager.stateApi.addEraserLine({ entityIdentifier, eraserLine: this.state });
this.manager.stateApi.addEraserLine({ entityIdentifier, eraserLine: committedState });
break;
case 'rect':
this.manager.stateApi.addRect({ entityIdentifier, rect: this.state });
case 'oval':
case 'polygon':
this.manager.stateApi.addShape({ entityIdentifier, shape: committedState });
break;
case 'lasso':
this.manager.stateApi.addLasso({ entityIdentifier, lasso: this.state });
this.manager.stateApi.addLasso({ entityIdentifier, lasso: committedState });
break;
case 'gradient':
this.manager.stateApi.addGradient({ entityIdentifier, gradient: this.state });
this.manager.stateApi.addGradient({ entityIdentifier, gradient: committedState });
break;
}
}
Expand Down
Loading
Loading