Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -2394,10 +2394,16 @@
"horizontal": "Horizontal",
"diagonal": "Diagonal"
},
"gradient": {
"linear": "Linear",
"circular": "Circular",
"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
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,191 @@
import {
Box,
ButtonGroup,
IconButton,
Popover,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
Portal,
Tooltip,
useDisclosure,
} from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
import { selectGradientType, settingsGradientTypeChanged } from 'features/controlLayers/store/canvasSettingsSlice';
import { memo, useCallback, useEffect, useId } from 'react';
import { useTranslation } from 'react-i18next';

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.25" />
<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';

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.25" />
<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';

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.25" />
<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';

export const ToolGradientButton = memo(() => {
const { t } = useTranslation();
const isSelected = useToolIsSelected('gradient');
const selectGradient = useSelectTool('gradient');
const gradientType = useAppSelector(selectGradientType);
const dispatch = useAppDispatch();
const disclosure = useDisclosure();

useEffect(() => {
if (!isSelected) {
disclosure.onClose();
}
}, [disclosure, isSelected]);

const handleClick = useCallback(() => {
selectGradient();
if (disclosure.isOpen) {
disclosure.onClose();
} else {
disclosure.onOpen();
}
}, [disclosure, selectGradient]);

const setLinear = useCallback(() => {
dispatch(settingsGradientTypeChanged('linear'));
}, [dispatch]);

const setRadial = useCallback(() => {
dispatch(settingsGradientTypeChanged('radial'));
}, [dispatch]);

const gradientLabel = t('controlLayers.tool.gradient', { defaultValue: 'Gradient' });
const linearLabel = t('controlLayers.gradient.linear', { defaultValue: t('common.linear', 'Linear') });
const circularLabel = t('controlLayers.gradient.circular', { defaultValue: 'Circular' });

return (
<Popover
isLazy
isOpen={disclosure.isOpen}
onClose={disclosure.onClose}
closeOnBlur={true}
closeOnEsc={true}
placement="right-start"
gutter={0}
>
<PopoverTrigger>
<Tooltip label={gradientLabel} placement="end">
<IconButton
aria-label={gradientLabel}
icon={<GradientToolIcon />}
isActive={isSelected}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={handleClick}
/>
</Tooltip>
</PopoverTrigger>
<Portal>
<PopoverContent width="auto" minW={0} ms={-20} mt={-7} bg="transparent" border="none" boxShadow="none">
<PopoverArrow display="none" />
<PopoverBody p={1}>
<ButtonGroup isAttached size="sm" orientation="vertical">
<Tooltip label={linearLabel}>
<IconButton
aria-label={linearLabel}
icon={<GradientLinearIcon />}
colorScheme={gradientType === 'linear' ? 'invokeBlue' : 'base'}
variant="solid"
w="30px"
h="30px"
onClick={setLinear}
/>
</Tooltip>
<Tooltip label={circularLabel}>
<IconButton
aria-label={circularLabel}
icon={<GradientRadialIcon />}
colorScheme={gradientType === 'radial' ? 'invokeBlue' : 'base'}
variant="solid"
w="30px"
h="30px"
onClick={setRadial}
/>
</Tooltip>
</ButtonGroup>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
});

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
@@ -1,7 +1,8 @@
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 { 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 +27,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 +47,11 @@ 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">
<ToolGradientClipToggle />
</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