Skip to content
Merged
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
878cb33
Adding gradient tool to canvas. Lineara and radial.
DustyShoe Jan 18, 2026
40a2cc8
Formatting again...
DustyShoe Jan 18, 2026
b66cf61
Formatting again 2...
DustyShoe Jan 18, 2026
61f0381
Minor bug fix
DustyShoe Jan 18, 2026
11caa0d
Some button design tweaking
DustyShoe Jan 19, 2026
6c3251b
Merge branch 'main' into Feat(UI)/Canvas/Add_gradient_tool
DustyShoe Jan 21, 2026
2bf0fe9
Fixed icorrect wording where Circular was used instead of Radial.
DustyShoe Jan 23, 2026
236cd12
Update invokeai/frontend/web/src/features/controlLayers/konva/CanvasO…
DustyShoe Jan 26, 2026
ebba9b6
Update invokeai/frontend/web/src/features/controlLayers/components/To…
DustyShoe Jan 26, 2026
95def55
Update invokeai/frontend/web/src/features/controlLayers/components/To…
DustyShoe Jan 26, 2026
63f3218
Update invokeai/frontend/web/src/features/controlLayers/components/To…
DustyShoe Jan 26, 2026
6fcbd25
Autocommit fix on mouse leaving canvas area
DustyShoe Jan 26, 2026
e035155
Merge branch 'main' into Feat(UI)/Canvas/Add_gradient_tool
DustyShoe Jan 26, 2026
7cdde45
Merge branch 'main' into Feat(UI)/Canvas/Add_gradient_tool
DustyShoe Jan 27, 2026
2d6dc80
Merge branch 'main' into Feat(UI)/Canvas/Add_gradient_tool
dunkeroni Feb 2, 2026
829c294
feature(canvas): move gradient mode controls to top toolbar; remove p…
dunkeroni Feb 2, 2026
4df2f92
(chore) prettier
dunkeroni Feb 2, 2026
46481f1
remove fixed icon size
dunkeroni Feb 2, 2026
fc8a3ef
Merge branch 'main' into Feat(UI)/Canvas/Add_gradient_tool
lstein Feb 3, 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
6 changes: 6 additions & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2433,10 +2433,16 @@
"horizontal": "Horizontal",
"diagonal": "Diagonal"
},
"gradient": {
"linear": "Linear",
"radial": "Radial",
"clip": "Clip Gradient"
},
"tool": {
"brush": "Brush",
"eraser": "Eraser",
"rectangle": "Rectangle",
"gradient": "Gradient",
"bbox": "Bbox",
"move": "Move",
"view": "View",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Box } from '@invoke-ai/ui-library';
import { memo, useId } from 'react';

export const GradientToolIcon = memo(() => {
const id = useId();
const gradientId = `${id}-gradient-tool-horizontal`;
return (
<Box as="svg" viewBox="0 0 24 24" boxSize={6} aria-hidden focusable={false} display="block">
<defs>
<linearGradient id={gradientId} x1="0" y1="0.5" x2="1" y2="0.5">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.0" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.85" />
</linearGradient>
</defs>
<rect
x="3"
y="6"
width="18"
height="12"
rx="2"
fill={`url(#${gradientId})`}
stroke="currentColor"
strokeOpacity="0.9"
strokeWidth="1"
/>
</Box>
);
});
GradientToolIcon.displayName = 'GradientToolIcon';

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">
<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"
width="16"
height="16"
rx="2"
fill={`url(#${gradientId})`}
stroke="currentColor"
strokeOpacity="0.9"
strokeWidth="1"
/>
</Box>
);
});
GradientLinearIcon.displayName = 'GradientLinearIcon';

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">
<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"
r="8"
fill={`url(#${gradientId})`}
stroke="currentColor"
strokeOpacity="0.9"
strokeWidth="1"
/>
</Box>
);
});
GradientRadialIcon.displayName = 'GradientRadialIcon';
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ButtonGroup } from '@invoke-ai/ui-library';
import { ToolBboxButton } from 'features/controlLayers/components/Tool/ToolBboxButton';
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 { ToolMoveButton } from 'features/controlLayers/components/Tool/ToolMoveButton';
import { ToolRectButton } from 'features/controlLayers/components/Tool/ToolRectButton';
import React from 'react';
Expand All @@ -16,6 +17,7 @@ export const ToolChooser: React.FC = () => {
<ToolBrushButton />
<ToolEraserButton />
<ToolRectButton />
<ToolGradientButton />
<ToolMoveButton />
<ToolViewButton />
<ToolBboxButton />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { GradientToolIcon } from 'features/controlLayers/components/Tool/GradientIcons';
import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';

export const ToolGradientButton = memo(() => {
const { t } = useTranslation();
const isSelected = useToolIsSelected('gradient');
const selectGradient = useSelectTool('gradient');
// clicking selects the gradient tool; mode switching is handled in the top toolbar
const handleClick = useCallback(() => selectGradient(), [selectGradient]);

const gradientLabel = t('controlLayers.tool.gradient', { defaultValue: 'Gradient' });

return (
<Tooltip label={gradientLabel} placement="end">
<IconButton
aria-label={gradientLabel}
icon={<GradientToolIcon />}
isActive={isSelected}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={handleClick}
/>
</Tooltip>
);
});

ToolGradientButton.displayName = 'ToolGradientButton';
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
selectGradientClipEnabled,
settingsGradientClipToggled,
} from 'features/controlLayers/store/canvasSettingsSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCropBold } from 'react-icons/pi';

export const ToolGradientClipToggle = memo(() => {
const { t } = useTranslation();
const isEnabled = useAppSelector(selectGradientClipEnabled);
const dispatch = useAppDispatch();

const onClick = useCallback(() => {
dispatch(settingsGradientClipToggled());
}, [dispatch]);

const label = t('controlLayers.gradient.clip', { defaultValue: 'Clip Gradient' });

return (
<Tooltip label={label}>
<IconButton
aria-label={label}
icon={<PiCropBold size={16} />}
size="sm"
variant="solid"
colorScheme={isEnabled ? 'invokeBlue' : 'base'}
onClick={onClick}
/>
</Tooltip>
);
});

ToolGradientClipToggle.displayName = 'ToolGradientClipToggle';
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { ButtonGroup, IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { GradientLinearIcon, GradientRadialIcon } from 'features/controlLayers/components/Tool/GradientIcons';
import { selectGradientType, settingsGradientTypeChanged } from 'features/controlLayers/store/canvasSettingsSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';

export const ToolGradientModeToggle = memo(() => {
const { t } = useTranslation();
const gradientType = useAppSelector(selectGradientType);
const dispatch = useAppDispatch();

const onLinearClick = useCallback(() => dispatch(settingsGradientTypeChanged('linear')), [dispatch]);
const onRadialClick = useCallback(() => dispatch(settingsGradientTypeChanged('radial')), [dispatch]);

return (
<ButtonGroup isAttached size="sm">
<Tooltip label={t('controlLayers.gradient.linear', { defaultValue: 'Linear' })}>
<IconButton
aria-label={t('controlLayers.gradient.linear', { defaultValue: 'Linear' })}
icon={<GradientLinearIcon />}
colorScheme={gradientType === 'linear' ? 'invokeBlue' : 'base'}
variant="solid"
onClick={onLinearClick}
/>
</Tooltip>
<Tooltip label={t('controlLayers.gradient.radial', { defaultValue: 'Radial' })}>
<IconButton
aria-label={t('controlLayers.gradient.radial', { defaultValue: 'Radial' })}
icon={<GradientRadialIcon />}
colorScheme={gradientType === 'radial' ? 'invokeBlue' : 'base'}
variant="solid"
onClick={onRadialClick}
/>
</Tooltip>
</ButtonGroup>
);
});

ToolGradientModeToggle.displayName = 'ToolGradientModeToggle';
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Divider, Flex } from '@invoke-ai/ui-library';
import { Box, Divider, Flex } from '@invoke-ai/ui-library';
import { CanvasSettingsPopover } from 'features/controlLayers/components/Settings/CanvasSettingsPopover';
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 { 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 @@ -26,6 +28,7 @@ import { memo, useMemo } from 'react';
export const CanvasToolbar = memo(() => {
const isBrushSelected = useToolIsSelected('brush');
const isEraserSelected = useToolIsSelected('eraser');
const isGradientSelected = useToolIsSelected('gradient');
const showToolWithPicker = useMemo(() => {
return isBrushSelected || isEraserSelected;
}, [isBrushSelected, isEraserSelected]);
Expand All @@ -45,6 +48,12 @@ export const CanvasToolbar = memo(() => {
<Flex w="full" gap={2} alignItems="center" px={2}>
<Flex alignItems="center" h="full" flexGrow={1}>
<ToolFillColorPicker />
{isGradientSelected && (
<Box ms={2} mt="-2px" display="flex" alignItems="center" gap={2}>
<ToolGradientClipToggle />
<ToolGradientModeToggle />
</Box>
)}
{showToolWithPicker && <ToolWidthPicker />}
</Flex>
<Flex alignItems="center" h="full">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CanvasObjectBrushLine } from 'features/controlLayers/konva/CanvasObject
import { CanvasObjectBrushLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure';
import { CanvasObjectEraserLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLine';
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 { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect';
import type { AnyObjectRenderer, AnyObjectState } from 'features/controlLayers/konva/CanvasObject/types';
Expand Down Expand Up @@ -151,6 +152,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 === 'gradient') {
assert(this.renderer instanceof CanvasObjectGradient || !this.renderer);

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

didRender = this.renderer.update(this.state, true);
} else if (this.state.type === 'image') {
assert(this.renderer instanceof CanvasObjectImage || !this.renderer);
Expand Down Expand Up @@ -237,6 +247,9 @@ export class CanvasEntityBufferObjectRenderer extends CanvasModuleBase {
case 'rect':
this.manager.stateApi.addRect({ entityIdentifier, rect: 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 @@ -8,6 +8,7 @@ import { CanvasObjectBrushLine } from 'features/controlLayers/konva/CanvasObject
import { CanvasObjectBrushLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure';
import { CanvasObjectEraserLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLine';
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 { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect';
import type { AnyObjectRenderer, AnyObjectState } from 'features/controlLayers/konva/CanvasObject/types';
Expand Down Expand Up @@ -400,6 +401,16 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
this.konva.objectGroup.add(renderer.konva.group);
}

didRender = renderer.update(objectState, force || isFirstRender);
} else if (objectState.type === 'gradient') {
assert(renderer instanceof CanvasObjectGradient || !renderer);

if (!renderer) {
renderer = new CanvasObjectGradient(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 === 'image') {
assert(renderer instanceof CanvasObjectImage || !renderer);
Expand Down
Loading