From d9dea78c21a672665d9d75f7c505942a38a1ab91 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Wed, 29 Oct 2025 00:01:52 -0400 Subject: [PATCH 01/23] feat(canvas): add raster layer blend modes and boolean operations submenu; support per-layer globalCompositeOperation in compositor; UI to toggle and select color blend modes (multiply, screen, darken, lighten, color-dodge, color-burn, hard-light, soft-light, difference, hue, saturation, color, luminosity). --- invokeai/frontend/web/public/locales/en.json | 12 +++ .../components/RasterLayer/RasterLayer.tsx | 2 + .../RasterLayerCompositeOperationSettings.tsx | 73 ++++++++++++++++++ .../RasterLayer/RasterLayerMenuItems.tsx | 4 + .../RasterLayerMenuItemsBooleanSubMenu.tsx | 74 +++++++++++++++++++ ...RasterLayerMenuItemsCompositeOperation.tsx | 37 ++++++++++ .../konva/CanvasBackgroundModule.ts | 7 ++ .../konva/CanvasCompositorModule.ts | 12 ++- .../CanvasEntityAdapterRasterLayer.ts | 35 +++++++++ .../controlLayers/store/canvasSlice.ts | 18 +++++ .../store/compositeOperations.ts | 35 +++++++++ .../src/features/controlLayers/store/types.ts | 3 + 12 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 8a6bd7b337e..7722752e574 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2077,6 +2077,18 @@ "pullBboxIntoReferenceImageError": "Problem Pulling BBox Into ReferenceImage", "addAdjustments": "Add Adjustments", "removeAdjustments": "Remove Adjustments", + "compositeOperation": { + "label": "Blend Mode", + "add": "Add Blend Mode", + "remove": "Remove Blend Mode" + }, + "booleanOps": { + "label": "Boolean Operations", + "intersection": "Intersection", + "cutout": "Cutout", + "cutAway": "Cut Away", + "exclude": "Exclude" + }, "adjustments": { "simple": "Simple", "curves": "Curves", diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx index 13dc30dea20..222397cd602 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -5,6 +5,7 @@ import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/componen import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage'; import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit'; import { RasterLayerAdjustmentsPanel } from 'features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel'; +import { RasterLayerCompositeOperationSettings } from 'features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings'; import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate'; import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext'; import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; @@ -41,6 +42,7 @@ export const RasterLayer = memo(({ id }: Props) => { + { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); + + const layer = useAppSelector((s) => + s.canvas.present.rasterLayers.entities.find((e: CanvasRasterLayerState) => e.id === entityIdentifier.id) + ); + + const showSettings = useMemo(() => { + return layer?.globalCompositeOperation !== undefined; + }, [layer]); + + const currentOperation = useMemo(() => { + return layer?.globalCompositeOperation ?? 'source-over'; + }, [layer]); + + const onChange = useCallback( + (e: ChangeEvent) => { + const value = e.target.value as CompositeOperation; + dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: value })); + }, + [dispatch, entityIdentifier] + ); + + if (!showSettings) { + return null; + } + + // Only expose the requested color blend modes in the UI + const COLOR_BLEND_MODES: CompositeOperation[] = [ + 'multiply', + 'screen', + 'darken', + 'lighten', + 'color-dodge', + 'color-burn', + 'hard-light', + 'soft-light', + 'difference', + 'hue', + 'saturation', + 'color', + 'luminosity', + ]; + + return ( + + + {t('controlLayers.compositeOperation.label')} + + + + ); +}); + +RasterLayerCompositeOperationSettings.displayName = 'RasterLayerCompositeOperationSettings'; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx index 708f7f29cd6..72ed3f65aa9 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx @@ -10,8 +10,10 @@ import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/com import { CanvasEntityMenuItemsSelectObject } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSelectObject'; import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform'; import { RasterLayerMenuItemsAdjustments } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsAdjustments'; +import { RasterLayerMenuItemsCompositeOperation } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation'; import { RasterLayerMenuItemsConvertToSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsConvertToSubMenu'; import { RasterLayerMenuItemsCopyToSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCopyToSubMenu'; +import { RasterLayerMenuItemsBooleanSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu'; import { memo } from 'react'; export const RasterLayerMenuItems = memo(() => { @@ -26,6 +28,8 @@ export const RasterLayerMenuItems = memo(() => { + + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx new file mode 100644 index 00000000000..df4ff921e89 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx @@ -0,0 +1,74 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { useEntityIdentifierBelowThisOne } from 'features/controlLayers/hooks/useNextRenderableEntityIdentifier'; +import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLayers/store/canvasSlice'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import { memo, useCallback } from 'react'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { useTranslation } from 'react-i18next'; + +export const RasterLayerMenuItemsBooleanSubMenu = memo(() => { + const subMenu = useSubMenu(); + const canvasManager = useCanvasManager(); + const isBusy = useCanvasIsBusy(); + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); + const entityIdentifierBelowThisOne = useEntityIdentifierBelowThisOne(entityIdentifier as CanvasEntityIdentifier); + const { t } = useTranslation(); + + const perform = useCallback( + async (op: GlobalCompositeOperation) => { + if (!entityIdentifierBelowThisOne) return; + // Temporarily set composite op on the selected layer to drive the merge algorithm + dispatch( + rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: op }) + ); + try { + await canvasManager.compositor.mergeByEntityIdentifiers( + [entityIdentifierBelowThisOne, entityIdentifier], + true + ); + } finally { + // No need to reset - layers are deleted on success; but in case of failure, clear the op + dispatch( + rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: undefined }) + ); + } + }, + [canvasManager.compositor, dispatch, entityIdentifier, entityIdentifierBelowThisOne] + ); + + const onIntersection = useCallback(() => perform('source-in'), [perform]); + const onCutout = useCallback(() => perform('destination-in'), [perform]); + const onCutAway = useCallback(() => perform('source-out'), [perform]); + const onExclude = useCallback(() => perform('xor'), [perform]); + + return ( + <> + + + + {subMenu.isOpen && ( + <> + + {t('controlLayers.booleanOps.intersection')} + + + {t('controlLayers.booleanOps.cutout')} + + + {t('controlLayers.booleanOps.cutAway')} + + + {t('controlLayers.booleanOps.exclude')} + + + )} + + ); +}); + +RasterLayerMenuItemsBooleanSubMenu.displayName = 'RasterLayerMenuItemsBooleanSubMenu'; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx new file mode 100644 index 00000000000..23a626aaa46 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx @@ -0,0 +1,37 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLayers/store/canvasSlice'; +import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiDropHalfBold } from 'react-icons/pi'; + +export const RasterLayerMenuItemsCompositeOperation = memo(() => { + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); + const { t } = useTranslation(); + const layer = useAppSelector((s) => + s.canvas.present.rasterLayers.entities.find((e: CanvasRasterLayerState) => e.id === entityIdentifier.id) + ); + const hasCompositeOperation = layer?.globalCompositeOperation !== undefined; + + const onToggleCompositeOperationPresence = useCallback(() => { + if (hasCompositeOperation) { + dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: undefined })); + } else { + // default to multiply when enabling blend modes + dispatch( + rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: 'multiply' }) + ); + } + }, [dispatch, entityIdentifier, hasCompositeOperation]); + + return ( + }> + {hasCompositeOperation ? t('controlLayers.compositeOperation.remove') : t('controlLayers.compositeOperation.add')} + + ); +}); + +RasterLayerMenuItemsCompositeOperation.displayName = 'RasterLayerMenuItemsCompositeOperation'; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackgroundModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackgroundModule.ts index b700392c05f..3130e477043 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackgroundModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackgroundModule.ts @@ -96,6 +96,13 @@ export class CanvasBackgroundModule extends CanvasModuleBase { }; this.checkboardPattern.src = 'anonymous'; this.checkboardPattern.src = this.config.CHECKERBOARD_PATTERN_DATAURL; + + // Isolate background to prevent blend modes affecting it + const backgroundCanvas = this.konva.layer.getCanvas()._canvas as HTMLCanvasElement | undefined; + if (backgroundCanvas) { + backgroundCanvas.style.isolation = 'isolate'; + } + this.render(); }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts index 3f419387439..44e0ad70184 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts @@ -226,12 +226,16 @@ export class CanvasCompositorModule extends CanvasModuleBase { ctx.imageSmoothingEnabled = false; - if (compositingOptions?.globalCompositeOperation) { - ctx.globalCompositeOperation = compositingOptions.globalCompositeOperation; - } - for (const adapter of adapters) { this.log.debug({ entityIdentifier: adapter.entityIdentifier }, 'Drawing entity to composite canvas'); + // Set composite operation for this specific layer + // Priority: 1) Per-layer setting, 2) Global compositing option, 3) Default 'source-over' + const layerCompositeOp = + adapter.state.type === 'raster_layer' || adapter.state.type === 'control_layer' + ? (adapter.state as any).globalCompositeOperation + : undefined; + ctx.globalCompositeOperation = layerCompositeOp || compositingOptions?.globalCompositeOperation || 'source-over'; + const adapterCanvas = adapter.getCanvas(rect); ctx.drawImage(adapterCanvas, 0, 0); } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts index 8723664d258..dcc0a063e7f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts @@ -60,6 +60,9 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase< if (!prevState || this.state.opacity !== prevState.opacity) { this.syncOpacity(); } + if (!prevState || this.state.globalCompositeOperation !== prevState.globalCompositeOperation) { + this.syncGlobalCompositeOperation(); + } // Apply per-layer adjustments as a Konva filter if (!prevState || this.haveAdjustmentsChanged(prevState, this.state)) { @@ -67,6 +70,38 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase< } }; + private syncGlobalCompositeOperation = () => { + this.log.trace('Syncing globalCompositeOperation'); + const operation = this.state.globalCompositeOperation ?? 'source-over'; + + // Map globalCompositeOperation to CSS mix-blend-mode for live preview + const mixBlendModeMap: Record = { + 'source-over': 'normal', + multiply: 'multiply', + screen: 'screen', + overlay: 'overlay', + darken: 'darken', + lighten: 'lighten', + 'color-dodge': 'color-dodge', + 'color-burn': 'color-burn', + 'hard-light': 'hard-light', + 'soft-light': 'soft-light', + difference: 'difference', + exclusion: 'exclusion', + hue: 'hue', + saturation: 'saturation', + color: 'color', + luminosity: 'luminosity', + }; + + const mixBlendMode = mixBlendModeMap[operation] || 'normal'; + + const canvasElement = this.konva.layer.getCanvas()._canvas as HTMLCanvasElement | undefined; + if (canvasElement) { + canvasElement.style.mixBlendMode = mixBlendMode; + } + }; + getCanvas = (rect?: Rect): HTMLCanvasElement => { this.log.trace({ rect }, 'Getting canvas'); // The opacity may have been changed in response to user selecting a different entity category, so we must restore diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index f7eef4a6454..c39ba95b10b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -191,6 +191,23 @@ const slice = createSlice({ } layer.adjustments.collapsed = !layer.adjustments.collapsed; }, + rasterLayerGlobalCompositeOperationChanged: ( + state, + action: PayloadAction< + EntityIdentifierPayload<{ globalCompositeOperation?: GlobalCompositeOperation }, 'raster_layer'> + > + ) => { + const { entityIdentifier, globalCompositeOperation } = action.payload; + const layer = selectEntity(state, entityIdentifier); + if (!layer) { + return; + } + if (globalCompositeOperation === undefined) { + delete layer.globalCompositeOperation; + } else { + layer.globalCompositeOperation = globalCompositeOperation; + } + }, rasterLayerAdded: { reducer: ( state, @@ -1719,6 +1736,7 @@ export const { rasterLayerAdjustmentsCollapsedToggled, rasterLayerAdjustmentsSimpleUpdated, rasterLayerAdjustmentsCurvesUpdated, + rasterLayerGlobalCompositeOperationChanged, entityDeleted, entityArrangedForwardOne, entityArrangedToFront, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts b/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts new file mode 100644 index 00000000000..f9662efe3ec --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts @@ -0,0 +1,35 @@ +/** + * Available global composite operations (blend modes) for layers. + * These are the standard Canvas 2D composite operations. + * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation + */ +export const COMPOSITE_OPERATIONS = [ + 'source-over', + 'source-in', + 'source-out', + 'source-atop', + 'destination-over', + 'destination-in', + 'destination-out', + 'destination-atop', + 'lighter', + 'copy', + 'xor', + 'multiply', + 'screen', + 'overlay', + 'darken', + 'lighten', + 'color-dodge', + 'color-burn', + 'hard-light', + 'soft-light', + 'difference', + 'exclusion', + 'hue', + 'saturation', + 'color', + 'luminosity', +] as const; + +export type CompositeOperation = (typeof COMPOSITE_OPERATIONS)[number]; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 87c173d7cca..d094be7daa7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -1,5 +1,6 @@ import { deepClone } from 'common/util/deepClone'; import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEntity/types'; +import { COMPOSITE_OPERATIONS } from 'features/controlLayers/store/compositeOperations'; import { zMainModelBase, zModelIdentifierField } from 'features/nodes/types/common'; import { zParameterCanvasCoherenceMode, @@ -462,6 +463,8 @@ const zCanvasRasterLayerState = zCanvasEntityBase.extend({ objects: z.array(zCanvasObjectState), // Optional per-layer color adjustments (simple + curves). When undefined, no adjustments are applied. adjustments: zRasterLayerAdjustments.optional(), + // Optional per-layer composite operation. When undefined, defaults to 'source-over'. + globalCompositeOperation: z.enum(COMPOSITE_OPERATIONS).optional(), }); export type CanvasRasterLayerState = z.infer; From 7a4c87a5da15ca355dfe41e23e0a17a0eb830c0c Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Wed, 29 Oct 2025 00:08:23 -0400 Subject: [PATCH 02/23] feat(canvas): boolean ops submenu and UI polish --- .../RasterLayerMenuItemsBooleanSubMenu.tsx | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx index df4ff921e89..001a41d730c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx @@ -1,4 +1,4 @@ -import { MenuItem } from '@invoke-ai/ui-library'; +import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; @@ -11,31 +11,25 @@ import { useAppDispatch } from 'app/store/storeHooks'; import { useTranslation } from 'react-i18next'; export const RasterLayerMenuItemsBooleanSubMenu = memo(() => { + const { t } = useTranslation(); const subMenu = useSubMenu(); const canvasManager = useCanvasManager(); const isBusy = useCanvasIsBusy(); const dispatch = useAppDispatch(); const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); const entityIdentifierBelowThisOne = useEntityIdentifierBelowThisOne(entityIdentifier as CanvasEntityIdentifier); - const { t } = useTranslation(); const perform = useCallback( async (op: GlobalCompositeOperation) => { if (!entityIdentifierBelowThisOne) return; - // Temporarily set composite op on the selected layer to drive the merge algorithm - dispatch( - rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: op }) - ); + dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: op })); try { await canvasManager.compositor.mergeByEntityIdentifiers( [entityIdentifierBelowThisOne, entityIdentifier], true ); } finally { - // No need to reset - layers are deleted on success; but in case of failure, clear the op - dispatch( - rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: undefined }) - ); + dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: undefined })); } }, [canvasManager.compositor, dispatch, entityIdentifier, entityIdentifierBelowThisOne] @@ -46,28 +40,30 @@ export const RasterLayerMenuItemsBooleanSubMenu = memo(() => { const onCutAway = useCallback(() => perform('source-out'), [perform]); const onExclude = useCallback(() => perform('xor'), [perform]); + const disabled = isBusy || !entityIdentifierBelowThisOne; + return ( - <> - - - - {subMenu.isOpen && ( - <> - + + + + + + + {t('controlLayers.booleanOps.intersection')} - + {t('controlLayers.booleanOps.cutout')} - + {t('controlLayers.booleanOps.cutAway')} - + {t('controlLayers.booleanOps.exclude')} - - )} - + + + ); }); From de37f87acb8abd554a8e6fbab9d698b5ab2f5258 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Wed, 29 Oct 2025 01:00:36 -0400 Subject: [PATCH 03/23] (chore): prettier lint --- invokeai/frontend/web/public/locales/en.json | 16 ++++++++-------- .../RasterLayerCompositeOperationSettings.tsx | 4 ++-- .../RasterLayer/RasterLayerMenuItems.tsx | 2 +- .../RasterLayerMenuItemsBooleanSubMenu.tsx | 13 ++++++------- .../RasterLayerMenuItemsCompositeOperation.tsx | 6 ++---- .../konva/CanvasCompositorModule.ts | 2 +- .../controlLayers/store/compositeOperations.ts | 2 +- 7 files changed, 21 insertions(+), 24 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 7722752e574..ec8b023964b 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2078,16 +2078,16 @@ "addAdjustments": "Add Adjustments", "removeAdjustments": "Remove Adjustments", "compositeOperation": { - "label": "Blend Mode", - "add": "Add Blend Mode", - "remove": "Remove Blend Mode" + "label": "Blend Mode", + "add": "Add Blend Mode", + "remove": "Remove Blend Mode" }, "booleanOps": { - "label": "Boolean Operations", - "intersection": "Intersection", - "cutout": "Cutout", - "cutAway": "Cut Away", - "exclude": "Exclude" + "label": "Boolean Operations", + "intersection": "Intersection", + "cutout": "Cutout", + "cutAway": "Cut Away", + "exclude": "Exclude" }, "adjustments": { "simple": "Simple", diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx index 3b87c467fad..0661f8ca270 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx @@ -2,7 +2,7 @@ import { Flex, FormControl, FormLabel, Select } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLayers/store/canvasSlice'; -import { COMPOSITE_OPERATIONS, type CompositeOperation } from 'features/controlLayers/store/compositeOperations'; +import type { CompositeOperation } from 'features/controlLayers/store/compositeOperations'; import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; import type { ChangeEvent } from 'react'; import { memo, useCallback, useMemo } from 'react'; @@ -70,4 +70,4 @@ export const RasterLayerCompositeOperationSettings = memo(() => { ); }); -RasterLayerCompositeOperationSettings.displayName = 'RasterLayerCompositeOperationSettings'; \ No newline at end of file +RasterLayerCompositeOperationSettings.displayName = 'RasterLayerCompositeOperationSettings'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx index 72ed3f65aa9..eefb5f9e083 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx @@ -10,10 +10,10 @@ import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/com import { CanvasEntityMenuItemsSelectObject } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSelectObject'; import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform'; import { RasterLayerMenuItemsAdjustments } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsAdjustments'; +import { RasterLayerMenuItemsBooleanSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu'; import { RasterLayerMenuItemsCompositeOperation } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation'; import { RasterLayerMenuItemsConvertToSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsConvertToSubMenu'; import { RasterLayerMenuItemsCopyToSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCopyToSubMenu'; -import { RasterLayerMenuItemsBooleanSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu'; import { memo } from 'react'; export const RasterLayerMenuItems = memo(() => { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx index 001a41d730c..259466efb51 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx @@ -1,4 +1,5 @@ import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; @@ -7,7 +8,6 @@ import { useEntityIdentifierBelowThisOne } from 'features/controlLayers/hooks/us import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLayers/store/canvasSlice'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { memo, useCallback } from 'react'; -import { useAppDispatch } from 'app/store/storeHooks'; import { useTranslation } from 'react-i18next'; export const RasterLayerMenuItemsBooleanSubMenu = memo(() => { @@ -21,13 +21,12 @@ export const RasterLayerMenuItemsBooleanSubMenu = memo(() => { const perform = useCallback( async (op: GlobalCompositeOperation) => { - if (!entityIdentifierBelowThisOne) return; + if (!entityIdentifierBelowThisOne) { + return; + } dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: op })); try { - await canvasManager.compositor.mergeByEntityIdentifiers( - [entityIdentifierBelowThisOne, entityIdentifier], - true - ); + await canvasManager.compositor.mergeByEntityIdentifiers([entityIdentifierBelowThisOne, entityIdentifier], true); } finally { dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: undefined })); } @@ -67,4 +66,4 @@ export const RasterLayerMenuItemsBooleanSubMenu = memo(() => { ); }); -RasterLayerMenuItemsBooleanSubMenu.displayName = 'RasterLayerMenuItemsBooleanSubMenu'; \ No newline at end of file +RasterLayerMenuItemsBooleanSubMenu.displayName = 'RasterLayerMenuItemsBooleanSubMenu'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx index 23a626aaa46..61bb84361fb 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx @@ -21,9 +21,7 @@ export const RasterLayerMenuItemsCompositeOperation = memo(() => { dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: undefined })); } else { // default to multiply when enabling blend modes - dispatch( - rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: 'multiply' }) - ); + dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: 'multiply' })); } }, [dispatch, entityIdentifier, hasCompositeOperation]); @@ -34,4 +32,4 @@ export const RasterLayerMenuItemsCompositeOperation = memo(() => { ); }); -RasterLayerMenuItemsCompositeOperation.displayName = 'RasterLayerMenuItemsCompositeOperation'; \ No newline at end of file +RasterLayerMenuItemsCompositeOperation.displayName = 'RasterLayerMenuItemsCompositeOperation'; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts index 44e0ad70184..6ff426db71b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts @@ -232,7 +232,7 @@ export class CanvasCompositorModule extends CanvasModuleBase { // Priority: 1) Per-layer setting, 2) Global compositing option, 3) Default 'source-over' const layerCompositeOp = adapter.state.type === 'raster_layer' || adapter.state.type === 'control_layer' - ? (adapter.state as any).globalCompositeOperation + ? adapter.state.globalCompositeOperation : undefined; ctx.globalCompositeOperation = layerCompositeOp || compositingOptions?.globalCompositeOperation || 'source-over'; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts b/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts index f9662efe3ec..f94dec218d6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts @@ -32,4 +32,4 @@ export const COMPOSITE_OPERATIONS = [ 'luminosity', ] as const; -export type CompositeOperation = (typeof COMPOSITE_OPERATIONS)[number]; \ No newline at end of file +export type CompositeOperation = (typeof COMPOSITE_OPERATIONS)[number]; From f3d93230892e962ec9598c52f34718342e9da071 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Wed, 29 Oct 2025 20:29:07 -0400 Subject: [PATCH 04/23] add icons to boolean submenu items --- invokeai/frontend/web/public/locales/en.json | 2 +- .../components/RasterLayer/RasterLayerMenuItems.tsx | 2 +- .../RasterLayerMenuItemsBooleanSubMenu.tsx | 11 ++++++----- .../controlLayers/konva/CanvasBackgroundModule.ts | 7 ------- 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index ec8b023964b..e00bf1ea4d1 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2085,7 +2085,7 @@ "booleanOps": { "label": "Boolean Operations", "intersection": "Intersection", - "cutout": "Cutout", + "cutout": "Cut Out", "cutAway": "Cut Away", "exclude": "Exclude" }, diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx index eefb5f9e083..cac242a84a1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx @@ -29,9 +29,9 @@ export const RasterLayerMenuItems = memo(() => { - + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx index 259466efb51..d7ee42136f4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx @@ -9,6 +9,7 @@ import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLaye import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { CgPathBack, CgPathCrop, CgPathExclude, CgPathFront, CgPathIntersect } from 'react-icons/cg'; export const RasterLayerMenuItemsBooleanSubMenu = memo(() => { const { t } = useTranslation(); @@ -42,22 +43,22 @@ export const RasterLayerMenuItemsBooleanSubMenu = memo(() => { const disabled = isBusy || !entityIdentifierBelowThisOne; return ( - + }> - + }> {t('controlLayers.booleanOps.intersection')} - + }> {t('controlLayers.booleanOps.cutout')} - + }> {t('controlLayers.booleanOps.cutAway')} - + }> {t('controlLayers.booleanOps.exclude')} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackgroundModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackgroundModule.ts index 3130e477043..b700392c05f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackgroundModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackgroundModule.ts @@ -96,13 +96,6 @@ export class CanvasBackgroundModule extends CanvasModuleBase { }; this.checkboardPattern.src = 'anonymous'; this.checkboardPattern.src = this.config.CHECKERBOARD_PATTERN_DATAURL; - - // Isolate background to prevent blend modes affecting it - const backgroundCanvas = this.konva.layer.getCanvas()._canvas as HTMLCanvasElement | undefined; - if (backgroundCanvas) { - backgroundCanvas.style.isolation = 'isolate'; - } - this.render(); }; From 4adaa7039db1e4991cde78eb77cce629772af3a3 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Thu, 30 Oct 2025 18:40:55 -0400 Subject: [PATCH 05/23] add delete button for color blend operations --- .../RasterLayerCompositeOperationSettings.tsx | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx index 0661f8ca270..36e1e3505d8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx @@ -1,5 +1,6 @@ import { Flex, FormControl, FormLabel, Select } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { InpaintMaskDeleteModifierButton } from 'features/controlLayers/components/InpaintMask/InpaintMaskDeleteModifierButton'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLayers/store/canvasSlice'; import type { CompositeOperation } from 'features/controlLayers/store/compositeOperations'; @@ -33,11 +34,19 @@ export const RasterLayerCompositeOperationSettings = memo(() => { [dispatch, entityIdentifier] ); + const onDelete = useCallback(() => { + dispatch( + rasterLayerGlobalCompositeOperationChanged({ + entityIdentifier, + globalCompositeOperation: undefined, + }) + ); + }, [dispatch, entityIdentifier]); + if (!showSettings) { return null; } - // Only expose the requested color blend modes in the UI const COLOR_BLEND_MODES: CompositeOperation[] = [ 'multiply', 'screen', @@ -57,14 +66,17 @@ export const RasterLayerCompositeOperationSettings = memo(() => { return ( - {t('controlLayers.compositeOperation.label')} - + {t('controlLayers.compositeOperation.label')} + + + + ); From 0e535ffe7fa8a76f8b487ffa5f7a1b62d88b3249 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sat, 1 Nov 2025 19:57:51 -0400 Subject: [PATCH 06/23] move composite operation type and imports --- .../RasterLayerCompositeOperationSettings.tsx | 18 +----------------- .../RasterLayerMenuItemsBooleanSubMenu.tsx | 3 ++- .../konva/CanvasCompositorModule.ts | 3 ++- .../controlLayers/store/canvasSlice.ts | 3 ++- .../controlLayers/store/compositeOperations.ts | 18 ++++++++++++++++++ .../src/features/controlLayers/store/types.ts | 1 + 6 files changed, 26 insertions(+), 20 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx index 36e1e3505d8..4a98282c120 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx @@ -3,7 +3,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { InpaintMaskDeleteModifierButton } from 'features/controlLayers/components/InpaintMask/InpaintMaskDeleteModifierButton'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLayers/store/canvasSlice'; -import type { CompositeOperation } from 'features/controlLayers/store/compositeOperations'; +import { COLOR_BLEND_MODES, type CompositeOperation } from 'features/controlLayers/store/compositeOperations'; import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; import type { ChangeEvent } from 'react'; import { memo, useCallback, useMemo } from 'react'; @@ -47,22 +47,6 @@ export const RasterLayerCompositeOperationSettings = memo(() => { return null; } - const COLOR_BLEND_MODES: CompositeOperation[] = [ - 'multiply', - 'screen', - 'darken', - 'lighten', - 'color-dodge', - 'color-burn', - 'hard-light', - 'soft-light', - 'difference', - 'hue', - 'saturation', - 'color', - 'luminosity', - ]; - return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx index d7ee42136f4..c22c682e72c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx @@ -6,6 +6,7 @@ import { useEntityIdentifierContext } from 'features/controlLayers/contexts/Enti import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { useEntityIdentifierBelowThisOne } from 'features/controlLayers/hooks/useNextRenderableEntityIdentifier'; import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLayers/store/canvasSlice'; +import type { CompositeOperation } from 'features/controlLayers/store/compositeOperations'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -21,7 +22,7 @@ export const RasterLayerMenuItemsBooleanSubMenu = memo(() => { const entityIdentifierBelowThisOne = useEntityIdentifierBelowThisOne(entityIdentifier as CanvasEntityIdentifier); const perform = useCallback( - async (op: GlobalCompositeOperation) => { + async (op: CompositeOperation) => { if (!entityIdentifierBelowThisOne) { return; } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts index 6ff426db71b..aef59c64d7f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts @@ -29,6 +29,7 @@ import type { } from 'features/controlLayers/store/types'; import { getEntityIdentifier } from 'features/controlLayers/store/types'; import { imageDTOToImageObject } from 'features/controlLayers/store/util'; +import type { CompositeOperation } from 'features/controlLayers/store/compositeOperations'; import { toast } from 'features/toast/toast'; import { t } from 'i18next'; import { atom, computed } from 'nanostores'; @@ -46,7 +47,7 @@ type CompositingOptions = { * The global composite operation to use when compositing each entity. * See: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation */ - globalCompositeOperation?: GlobalCompositeOperation; + globalCompositeOperation?: CompositeOperation; }; /** diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index c39ba95b10b..b837e565263 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -70,6 +70,7 @@ import type { IPMethodV2, T2IAdapterConfig, } from './types'; +import type { CompositeOperation } from 'features/controlLayers/store/compositeOperations'; import { ASPECT_RATIO_MAP, DEFAULT_ASPECT_RATIO_CONFIG, @@ -194,7 +195,7 @@ const slice = createSlice({ rasterLayerGlobalCompositeOperationChanged: ( state, action: PayloadAction< - EntityIdentifierPayload<{ globalCompositeOperation?: GlobalCompositeOperation }, 'raster_layer'> + EntityIdentifierPayload<{ globalCompositeOperation?: CompositeOperation }, 'raster_layer'> > ) => { const { entityIdentifier, globalCompositeOperation } = action.payload; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts b/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts index f94dec218d6..c75fe2af413 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts @@ -33,3 +33,21 @@ export const COMPOSITE_OPERATIONS = [ ] as const; export type CompositeOperation = (typeof COMPOSITE_OPERATIONS)[number]; + +// Subset of color blend modes for UI selection +export const COLOR_BLEND_MODES: CompositeOperation[] = [ + 'multiply', + 'screen', + 'overlay', + 'darken', + 'lighten', + 'color-dodge', + 'color-burn', + 'hard-light', + 'soft-light', + 'difference', + 'hue', + 'saturation', + 'color', + 'luminosity', +]; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index d094be7daa7..2c9265258d5 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -1,6 +1,7 @@ import { deepClone } from 'common/util/deepClone'; import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEntity/types'; import { COMPOSITE_OPERATIONS } from 'features/controlLayers/store/compositeOperations'; +export type { CompositeOperation } from 'features/controlLayers/store/compositeOperations'; import { zMainModelBase, zModelIdentifierField } from 'features/nodes/types/common'; import { zParameterCanvasCoherenceMode, From 47a609294dd520b773b7e93305ca6abb83f8a070 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sat, 1 Nov 2025 20:07:31 -0400 Subject: [PATCH 07/23] chore: pnpm eslint --- .../features/controlLayers/konva/CanvasCompositorModule.ts | 2 +- .../web/src/features/controlLayers/store/canvasSlice.ts | 6 ++---- .../frontend/web/src/features/controlLayers/store/types.ts | 1 - 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts index aef59c64d7f..e1c15f6d525 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts @@ -14,6 +14,7 @@ import { mapId, previewBlob, } from 'features/controlLayers/konva/util'; +import type { CompositeOperation } from 'features/controlLayers/store/compositeOperations'; import { selectActiveControlLayerEntities, selectActiveInpaintMaskEntities, @@ -29,7 +30,6 @@ import type { } from 'features/controlLayers/store/types'; import { getEntityIdentifier } from 'features/controlLayers/store/types'; import { imageDTOToImageObject } from 'features/controlLayers/store/util'; -import type { CompositeOperation } from 'features/controlLayers/store/compositeOperations'; import { toast } from 'features/toast/toast'; import { t } from 'i18next'; import { atom, computed } from 'nanostores'; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index b837e565263..957a6070296 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -7,6 +7,7 @@ import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMul import { merge } from 'es-toolkit/compat'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { canvasReset } from 'features/controlLayers/store/actions'; +import type { CompositeOperation } from 'features/controlLayers/store/compositeOperations'; import { modelChanged } from 'features/controlLayers/store/paramsSlice'; import { selectAllEntities, @@ -70,7 +71,6 @@ import type { IPMethodV2, T2IAdapterConfig, } from './types'; -import type { CompositeOperation } from 'features/controlLayers/store/compositeOperations'; import { ASPECT_RATIO_MAP, DEFAULT_ASPECT_RATIO_CONFIG, @@ -194,9 +194,7 @@ const slice = createSlice({ }, rasterLayerGlobalCompositeOperationChanged: ( state, - action: PayloadAction< - EntityIdentifierPayload<{ globalCompositeOperation?: CompositeOperation }, 'raster_layer'> - > + action: PayloadAction> ) => { const { entityIdentifier, globalCompositeOperation } = action.payload; const layer = selectEntity(state, entityIdentifier); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 2c9265258d5..d094be7daa7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -1,7 +1,6 @@ import { deepClone } from 'common/util/deepClone'; import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEntity/types'; import { COMPOSITE_OPERATIONS } from 'features/controlLayers/store/compositeOperations'; -export type { CompositeOperation } from 'features/controlLayers/store/compositeOperations'; import { zMainModelBase, zModelIdentifierField } from 'features/nodes/types/common'; import { zParameterCanvasCoherenceMode, From 448434630ec375f235984f3b48ae8296a180db47 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sat, 1 Nov 2025 21:57:46 -0400 Subject: [PATCH 08/23] update blend modes order --- .../controlLayers/store/compositeOperations.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts b/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts index c75fe2af413..6460950b8a2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts @@ -36,18 +36,18 @@ export type CompositeOperation = (typeof COMPOSITE_OPERATIONS)[number]; // Subset of color blend modes for UI selection export const COLOR_BLEND_MODES: CompositeOperation[] = [ - 'multiply', - 'screen', + 'color', + 'hue', 'overlay', + 'soft-light', + 'hard-light', + 'screen', + 'color-burn', + 'color-dodge', + 'multiply', 'darken', 'lighten', - 'color-dodge', - 'color-burn', - 'hard-light', - 'soft-light', 'difference', - 'hue', - 'saturation', - 'color', 'luminosity', + 'saturation', ]; From a00661940ef409711a090cbadb3acb1f98ff11d0 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sun, 2 Nov 2025 01:32:56 -0500 Subject: [PATCH 09/23] update default blend mode to 'color' --- .../RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx index 61bb84361fb..65381c9f325 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx @@ -20,8 +20,8 @@ export const RasterLayerMenuItemsCompositeOperation = memo(() => { if (hasCompositeOperation) { dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: undefined })); } else { - // default to multiply when enabling blend modes - dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: 'multiply' })); + // default to color when enabling blend modes + dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: 'color' })); } }, [dispatch, entityIdentifier, hasCompositeOperation]); From b6a89bd53649221eb46469334c984f260d989130 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Mon, 3 Nov 2025 12:40:18 -0500 Subject: [PATCH 10/23] add i18n for blend modes --- invokeai/frontend/web/public/locales/en.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index e00bf1ea4d1..ef245fa3209 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2082,6 +2082,24 @@ "add": "Add Blend Mode", "remove": "Remove Blend Mode" }, + "compositeOperations": { + "blendModes": { + "color": "Color", + "hue": "Hue", + "overlay": "Overlay", + "soft-light": "Soft Light", + "hard-light": "Hard Light", + "screen": "Screen", + "color-burn": "Color Burn", + "color-dodge": "Color Dodge", + "multiply": "Multiply", + "darken": "Darken", + "lighten": "Lighten", + "difference": "Difference", + "luminosity": "Luminosity", + "saturation": "Saturation" + } + }, "booleanOps": { "label": "Boolean Operations", "intersection": "Intersection", From 5cd618328bb9cbf9a430ddcdd57446685dd5d291 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Mon, 3 Nov 2025 14:57:51 -0500 Subject: [PATCH 11/23] actually use translations for blend modes now --- invokeai/frontend/web/public/locales/en.json | 4 +--- .../RasterLayer/RasterLayerCompositeOperationSettings.tsx | 2 +- .../src/features/controlLayers/store/compositeOperations.ts | 3 ++- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index ef245fa3209..0d82fe411b3 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2080,9 +2080,7 @@ "compositeOperation": { "label": "Blend Mode", "add": "Add Blend Mode", - "remove": "Remove Blend Mode" - }, - "compositeOperations": { + "remove": "Remove Blend Mode", "blendModes": { "color": "Color", "hue": "Hue", diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx index 4a98282c120..628187c2443 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx @@ -55,7 +55,7 @@ export const RasterLayerCompositeOperationSettings = memo(() => { diff --git a/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts b/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts index 6460950b8a2..b9e23ef0307 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts @@ -2,6 +2,7 @@ * Available global composite operations (blend modes) for layers. * These are the standard Canvas 2D composite operations. * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation + * NOTE: All of these are supported by canvas layers, but not all are supported by CSS blend modes (live rendering). */ export const COMPOSITE_OPERATIONS = [ 'source-over', @@ -34,7 +35,7 @@ export const COMPOSITE_OPERATIONS = [ export type CompositeOperation = (typeof COMPOSITE_OPERATIONS)[number]; -// Subset of color blend modes for UI selection +// Subset of color blend modes for UI selection. All are supported by both Konva and CSS. export const COLOR_BLEND_MODES: CompositeOperation[] = [ 'color', 'hue', From edcf922692d3da2e861eeb366420924a0d44755f Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Thu, 13 Nov 2025 13:56:48 -0500 Subject: [PATCH 12/23] move composite options into types.ts --- .../RasterLayerCompositeOperationSettings.tsx | 4 +- .../RasterLayerMenuItemsBooleanSubMenu.tsx | 3 +- .../konva/CanvasCompositorModule.ts | 2 +- .../controlLayers/store/canvasSlice.ts | 2 +- .../store/compositeOperations.ts | 54 ------------------ .../src/features/controlLayers/store/types.ts | 56 ++++++++++++++++++- 6 files changed, 60 insertions(+), 61 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx index 628187c2443..6cdd3e0b6ee 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx @@ -3,8 +3,8 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { InpaintMaskDeleteModifierButton } from 'features/controlLayers/components/InpaintMask/InpaintMaskDeleteModifierButton'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLayers/store/canvasSlice'; -import { COLOR_BLEND_MODES, type CompositeOperation } from 'features/controlLayers/store/compositeOperations'; -import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; +import type { CanvasRasterLayerState, CompositeOperation } from 'features/controlLayers/store/types'; +import { COLOR_BLEND_MODES } from 'features/controlLayers/store/types'; import type { ChangeEvent } from 'react'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx index c22c682e72c..a09fe64ccae 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx @@ -6,8 +6,7 @@ import { useEntityIdentifierContext } from 'features/controlLayers/contexts/Enti import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { useEntityIdentifierBelowThisOne } from 'features/controlLayers/hooks/useNextRenderableEntityIdentifier'; import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLayers/store/canvasSlice'; -import type { CompositeOperation } from 'features/controlLayers/store/compositeOperations'; -import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import type { CanvasEntityIdentifier, CompositeOperation } from 'features/controlLayers/store/types'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { CgPathBack, CgPathCrop, CgPathExclude, CgPathFront, CgPathIntersect } from 'react-icons/cg'; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts index e1c15f6d525..a6e5e7fa240 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts @@ -14,7 +14,6 @@ import { mapId, previewBlob, } from 'features/controlLayers/konva/util'; -import type { CompositeOperation } from 'features/controlLayers/store/compositeOperations'; import { selectActiveControlLayerEntities, selectActiveInpaintMaskEntities, @@ -25,6 +24,7 @@ import type { CanvasEntityIdentifier, CanvasEntityState, CanvasEntityType, + CompositeOperation, GenerationMode, Rect, } from 'features/controlLayers/store/types'; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 957a6070296..bad8ae8098b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -7,7 +7,6 @@ import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMul import { merge } from 'es-toolkit/compat'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { canvasReset } from 'features/controlLayers/store/actions'; -import type { CompositeOperation } from 'features/controlLayers/store/compositeOperations'; import { modelChanged } from 'features/controlLayers/store/paramsSlice'; import { selectAllEntities, @@ -22,6 +21,7 @@ import type { CanvasMetadata, ChannelName, ChannelPoints, + CompositeOperation, ControlLoRAConfig, EntityMovedByPayload, FillStyle, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts b/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts deleted file mode 100644 index b9e23ef0307..00000000000 --- a/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Available global composite operations (blend modes) for layers. - * These are the standard Canvas 2D composite operations. - * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation - * NOTE: All of these are supported by canvas layers, but not all are supported by CSS blend modes (live rendering). - */ -export const COMPOSITE_OPERATIONS = [ - 'source-over', - 'source-in', - 'source-out', - 'source-atop', - 'destination-over', - 'destination-in', - 'destination-out', - 'destination-atop', - 'lighter', - 'copy', - 'xor', - 'multiply', - 'screen', - 'overlay', - 'darken', - 'lighten', - 'color-dodge', - 'color-burn', - 'hard-light', - 'soft-light', - 'difference', - 'exclusion', - 'hue', - 'saturation', - 'color', - 'luminosity', -] as const; - -export type CompositeOperation = (typeof COMPOSITE_OPERATIONS)[number]; - -// Subset of color blend modes for UI selection. All are supported by both Konva and CSS. -export const COLOR_BLEND_MODES: CompositeOperation[] = [ - 'color', - 'hue', - 'overlay', - 'soft-light', - 'hard-light', - 'screen', - 'color-burn', - 'color-dodge', - 'multiply', - 'darken', - 'lighten', - 'difference', - 'luminosity', - 'saturation', -]; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index d094be7daa7..ea7730424e6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -1,6 +1,5 @@ import { deepClone } from 'common/util/deepClone'; import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEntity/types'; -import { COMPOSITE_OPERATIONS } from 'features/controlLayers/store/compositeOperations'; import { zMainModelBase, zModelIdentifierField } from 'features/nodes/types/common'; import { zParameterCanvasCoherenceMode, @@ -456,6 +455,61 @@ const zRasterLayerAdjustments = z.object({ }); export type RasterLayerAdjustments = z.infer; +/** + * Available global composite operations (blend modes) for layers. + * These are the standard Canvas 2D composite operations. + * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation + * NOTE: All of these are supported by canvas layers, but not all are supported by CSS blend modes (live rendering). + */ +const COMPOSITE_OPERATIONS = [ + 'source-over', + 'source-in', + 'source-out', + 'source-atop', + 'destination-over', + 'destination-in', + 'destination-out', + 'destination-atop', + 'lighter', + 'copy', + 'xor', + 'multiply', + 'screen', + 'overlay', + 'darken', + 'lighten', + 'color-dodge', + 'color-burn', + 'hard-light', + 'soft-light', + 'difference', + 'exclusion', + 'hue', + 'saturation', + 'color', + 'luminosity', +] as const; + +export type CompositeOperation = (typeof COMPOSITE_OPERATIONS)[number]; + +// Subset of color blend modes for UI selection. All are supported by both Konva and CSS. +export const COLOR_BLEND_MODES: CompositeOperation[] = [ + 'color', + 'hue', + 'overlay', + 'soft-light', + 'hard-light', + 'screen', + 'color-burn', + 'color-dodge', + 'multiply', + 'darken', + 'lighten', + 'difference', + 'luminosity', + 'saturation', +]; + const zCanvasRasterLayerState = zCanvasEntityBase.extend({ type: z.literal('raster_layer'), position: zCoordinate, From 8a0069015a1513ee7da85005066626c99ff501d3 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Fri, 14 Nov 2025 18:02:57 -0500 Subject: [PATCH 13/23] cleanup and comments --- .../konva/CanvasCompositorModule.ts | 1 + .../CanvasEntityAdapterRasterLayer.ts | 66 +++++++++---------- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts index a6e5e7fa240..337151581c0 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts @@ -46,6 +46,7 @@ type CompositingOptions = { /** * The global composite operation to use when compositing each entity. * See: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation + * Invoke supports a subset of these modes for raster and control layer combinations. */ globalCompositeOperation?: CompositeOperation; }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts index dcc0a063e7f..a900f71e27a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts @@ -70,38 +70,6 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase< } }; - private syncGlobalCompositeOperation = () => { - this.log.trace('Syncing globalCompositeOperation'); - const operation = this.state.globalCompositeOperation ?? 'source-over'; - - // Map globalCompositeOperation to CSS mix-blend-mode for live preview - const mixBlendModeMap: Record = { - 'source-over': 'normal', - multiply: 'multiply', - screen: 'screen', - overlay: 'overlay', - darken: 'darken', - lighten: 'lighten', - 'color-dodge': 'color-dodge', - 'color-burn': 'color-burn', - 'hard-light': 'hard-light', - 'soft-light': 'soft-light', - difference: 'difference', - exclusion: 'exclusion', - hue: 'hue', - saturation: 'saturation', - color: 'color', - luminosity: 'luminosity', - }; - - const mixBlendMode = mixBlendModeMap[operation] || 'normal'; - - const canvasElement = this.konva.layer.getCanvas()._canvas as HTMLCanvasElement | undefined; - if (canvasElement) { - canvasElement.style.mixBlendMode = mixBlendMode; - } - }; - getCanvas = (rect?: Rect): HTMLCanvasElement => { this.log.trace({ rect }, 'Getting canvas'); // The opacity may have been changed in response to user selecting a different entity category, so we must restore @@ -182,7 +150,7 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase< ) { return true; } - // curves reference (UI not implemented yet) - if arrays differ by ref, consider changed + // curves params const pc = pa.curves; const cc = ca.curves; if (pc !== cc) { @@ -190,4 +158,36 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase< } return false; }; + + private syncGlobalCompositeOperation = () => { + this.log.trace('Syncing globalCompositeOperation'); + const operation = this.state.globalCompositeOperation ?? 'source-over'; + + // Map globalCompositeOperation to CSS mix-blend-mode for live preview + const mixBlendModeMap: Record = { + 'source-over': 'normal', // this one is why we need the map + multiply: 'multiply', + screen: 'screen', + overlay: 'overlay', + darken: 'darken', + lighten: 'lighten', + 'color-dodge': 'color-dodge', + 'color-burn': 'color-burn', + 'hard-light': 'hard-light', + 'soft-light': 'soft-light', + difference: 'difference', + exclusion: 'exclusion', + hue: 'hue', + saturation: 'saturation', + color: 'color', + luminosity: 'luminosity', + }; + + const mixBlendMode = mixBlendModeMap[operation] || 'normal'; + + const canvasElement = this.konva.layer.getCanvas()._canvas as HTMLCanvasElement | undefined; + if (canvasElement) { + canvasElement.style.mixBlendMode = mixBlendMode; + } + }; } From 03f762761e7ef081c183a7b089f8150dbbcdf990 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Fri, 14 Nov 2025 18:36:04 -0500 Subject: [PATCH 14/23] update names --- invokeai/frontend/web/public/locales/en.json | 4 ++-- .../RasterLayerMenuItemsBooleanSubMenu.tsx | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 0d82fe411b3..cbdd15f989a 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2100,9 +2100,9 @@ }, "booleanOps": { "label": "Boolean Operations", - "intersection": "Intersection", + "intersect": "Intersect", "cutout": "Cut Out", - "cutAway": "Cut Away", + "cutaway": "Cut Away", "exclude": "Exclude" }, "adjustments": { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx index a09fe64ccae..8c824a1e302 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx @@ -35,8 +35,8 @@ export const RasterLayerMenuItemsBooleanSubMenu = memo(() => { [canvasManager.compositor, dispatch, entityIdentifier, entityIdentifierBelowThisOne] ); - const onIntersection = useCallback(() => perform('source-in'), [perform]); - const onCutout = useCallback(() => perform('destination-in'), [perform]); + const onIntersect = useCallback(() => perform('source-in'), [perform]); + const onCutOut = useCallback(() => perform('destination-in'), [perform]); const onCutAway = useCallback(() => perform('source-out'), [perform]); const onExclude = useCallback(() => perform('xor'), [perform]); @@ -49,14 +49,14 @@ export const RasterLayerMenuItemsBooleanSubMenu = memo(() => { - }> - {t('controlLayers.booleanOps.intersection')} + }> + {t('controlLayers.booleanOps.intersect')} - }> + }> {t('controlLayers.booleanOps.cutout')} }> - {t('controlLayers.booleanOps.cutAway')} + {t('controlLayers.booleanOps.cutaway')} }> {t('controlLayers.booleanOps.exclude')} From 7c4737f0631ca7609372f6740112e6aa1ed07e19 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Fri, 14 Nov 2025 19:06:29 -0500 Subject: [PATCH 15/23] move constant mapping out of function --- .../CanvasEntityAdapterRasterLayer.ts | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts index a900f71e27a..f1b50cabca7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts @@ -11,6 +11,26 @@ import type { CanvasEntityIdentifier, CanvasRasterLayerState, Rect } from 'featu import type { GroupConfig } from 'konva/lib/Group'; import type { JsonObject } from 'type-fest'; +// Map globalCompositeOperation to CSS mix-blend-mode for live preview +const mixBlendModeMap: Record = { + 'source-over': 'normal', // this one is why we need the map + multiply: 'multiply', + screen: 'screen', + overlay: 'overlay', + darken: 'darken', + lighten: 'lighten', + 'color-dodge': 'color-dodge', + 'color-burn': 'color-burn', + 'hard-light': 'hard-light', + 'soft-light': 'soft-light', + difference: 'difference', + exclusion: 'exclusion', + hue: 'hue', + saturation: 'saturation', + color: 'color', + luminosity: 'luminosity', +}; + export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase< CanvasRasterLayerState, 'raster_layer_adapter' @@ -162,29 +182,7 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase< private syncGlobalCompositeOperation = () => { this.log.trace('Syncing globalCompositeOperation'); const operation = this.state.globalCompositeOperation ?? 'source-over'; - - // Map globalCompositeOperation to CSS mix-blend-mode for live preview - const mixBlendModeMap: Record = { - 'source-over': 'normal', // this one is why we need the map - multiply: 'multiply', - screen: 'screen', - overlay: 'overlay', - darken: 'darken', - lighten: 'lighten', - 'color-dodge': 'color-dodge', - 'color-burn': 'color-burn', - 'hard-light': 'hard-light', - 'soft-light': 'soft-light', - difference: 'difference', - exclusion: 'exclusion', - hue: 'hue', - saturation: 'saturation', - color: 'color', - luminosity: 'luminosity', - }; - const mixBlendMode = mixBlendModeMap[operation] || 'normal'; - const canvasElement = this.konva.layer.getCanvas()._canvas as HTMLCanvasElement | undefined; if (canvasElement) { canvasElement.style.mixBlendMode = mixBlendMode; From 15b589a176b0a1915d24bd3cf9cb75b94e27cddf Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Sat, 31 Jan 2026 09:30:17 +0530 Subject: [PATCH 16/23] feat(ui): Refactor Blend Mode Implementation - Blend Modes are not right click menu options anymore. Instead they rest above the layer panel as they do in other art programs readily available for each layer. - Blend Modes have been resorted to match the listings of other art programs so users can avail their muscle memory. - Blend Mode now defaults to `Normal` for each layer as it should. - The extra layer operations have now been moved down to the `Operations Bar` at the bottom of the layer stack. This is to increase familiarity again with other art programs and also to make space for us in the top action bar. - The Operations Bars operations have been resorted in order of usage that makes sense. --- invokeai/frontend/web/public/locales/en.json | 1 + .../EntityListSelectedEntityActionBar.tsx | 29 ++------ ...ectedEntityActionBarCompositeOperation.tsx | 74 +++++++++++++++++++ .../EntityListSelectedEntityOperationsBar.tsx | 28 +++++++ .../components/CanvasLayersPanelContent.tsx | 7 +- .../components/RasterLayer/RasterLayer.tsx | 2 - .../RasterLayer/RasterLayerMenuItems.tsx | 2 - ...RasterLayerMenuItemsCompositeOperation.tsx | 35 --------- .../src/features/controlLayers/store/types.ts | 52 ++++++------- 9 files changed, 142 insertions(+), 88 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarCompositeOperation.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityOperationsBar.tsx delete mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 68170514cf5..1f1bd2be7bb 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2207,6 +2207,7 @@ "add": "Add Blend Mode", "remove": "Remove Blend Mode", "blendModes": { + "normal": "Normal", "color": "Color", "hue": "Hue", "overlay": "Overlay", diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx index 2eb664e0287..afc663122fd 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx @@ -1,32 +1,17 @@ -import { Flex, Spacer } from '@invoke-ai/ui-library'; -import { EntityListGlobalActionBarAddLayerMenu } from 'features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu'; -import { EntityListSelectedEntityActionBarDuplicateButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarDuplicateButton'; +import { Flex } from '@invoke-ai/ui-library'; import { EntityListSelectedEntityActionBarFill } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFill'; -import { EntityListSelectedEntityActionBarFilterButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFilterButton'; -import { EntityListSelectedEntityActionBarInvertMaskButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarInvertMaskButton'; import { EntityListSelectedEntityActionBarOpacity } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity'; -import { EntityListSelectedEntityActionBarSelectObjectButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarSelectObjectButton'; -import { EntityListSelectedEntityActionBarTransformButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarTransformButton'; -import { EntityListNonRasterLayerToggle } from 'features/controlLayers/components/common/CanvasNonRasterLayersIsHiddenToggle'; import { memo } from 'react'; -import { EntityListSelectedEntityActionBarSaveToAssetsButton } from './EntityListSelectedEntityActionBarSaveToAssetsButton'; +import { EntityListSelectedEntityActionBarCompositeOperation } from './EntityListSelectedEntityActionBarCompositeOperation'; export const EntityListSelectedEntityActionBar = memo(() => { return ( - - - - - - - - - - - - - + + + + + ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarCompositeOperation.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarCompositeOperation.tsx new file mode 100644 index 00000000000..4034f0093fc --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarCompositeOperation.tsx @@ -0,0 +1,74 @@ +import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLayers/store/canvasSlice'; +import { + selectCanvasSlice, + selectEntity, + selectSelectedEntityIdentifier, +} from 'features/controlLayers/store/selectors'; +import type { + CanvasEntityIdentifier, + CanvasRasterLayerState, + CompositeOperation, +} from 'features/controlLayers/store/types'; +import { COLOR_BLEND_MODES } from 'features/controlLayers/store/types'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +const selectCompositeOperation = createSelector(selectCanvasSlice, (canvas) => { + const { selectedEntityIdentifier } = canvas; + + if (selectedEntityIdentifier?.type !== 'raster_layer') { + return 'source-over'; + } + + const entity = selectEntity(canvas, selectedEntityIdentifier); + + return (entity as CanvasRasterLayerState)?.globalCompositeOperation ?? 'source-over'; +}); + +export const EntityListSelectedEntityActionBarCompositeOperation = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); + const currentOperation = useAppSelector(selectCompositeOperation); + + const onChange = useCallback( + (e: ChangeEvent) => { + if (selectedEntityIdentifier?.type === 'raster_layer') { + const value = e.target.value as CompositeOperation; + + dispatch( + rasterLayerGlobalCompositeOperationChanged({ + entityIdentifier: selectedEntityIdentifier as CanvasEntityIdentifier<'raster_layer'>, + globalCompositeOperation: value, + }) + ); + } + }, + [dispatch, selectedEntityIdentifier] + ); + + if (selectedEntityIdentifier?.type !== 'raster_layer') { + return null; + } + + return ( + + + {t('controlLayers.compositeOperation.label')} + + + + ); +}); + +EntityListSelectedEntityActionBarCompositeOperation.displayName = 'EntityListSelectedEntityActionBarCompositeOperation'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityOperationsBar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityOperationsBar.tsx new file mode 100644 index 00000000000..da94b0a6971 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityOperationsBar.tsx @@ -0,0 +1,28 @@ +import { Flex } from '@invoke-ai/ui-library'; +import { EntityListGlobalActionBarAddLayerMenu } from 'features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu'; +import { EntityListSelectedEntityActionBarDuplicateButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarDuplicateButton'; +import { EntityListSelectedEntityActionBarFilterButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFilterButton'; +import { EntityListSelectedEntityActionBarInvertMaskButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarInvertMaskButton'; +import { EntityListSelectedEntityActionBarSelectObjectButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarSelectObjectButton'; +import { EntityListSelectedEntityActionBarTransformButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarTransformButton'; +import { EntityListNonRasterLayerToggle } from 'features/controlLayers/components/common/CanvasNonRasterLayersIsHiddenToggle'; +import { memo } from 'react'; + +import { EntityListSelectedEntityActionBarSaveToAssetsButton } from './EntityListSelectedEntityActionBarSaveToAssetsButton'; + +export const EntityListSelectedEntityOperationsBar = memo(() => { + return ( + + + + + + + + + + + ); +}); + +EntityListSelectedEntityOperationsBar.displayName = 'EntityListSelectedEntityOperationsBar'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx index 3b28be2799a..4dd08edcd58 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx @@ -7,6 +7,7 @@ import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/Canva import { selectHasEntities } from 'features/controlLayers/store/selectors'; import { memo } from 'react'; +import { EntityListSelectedEntityOperationsBar } from './CanvasEntityList/EntityListSelectedEntityOperationsBar'; import { ParamDenoisingStrength } from './ParamDenoisingStrength'; export const CanvasLayersPanel = memo(() => { @@ -15,12 +16,14 @@ export const CanvasLayersPanel = memo(() => { return ( - - + + {!hasEntities && } {hasEntities && } + + ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx index 222397cd602..13dc30dea20 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -5,7 +5,6 @@ import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/componen import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage'; import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit'; import { RasterLayerAdjustmentsPanel } from 'features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel'; -import { RasterLayerCompositeOperationSettings } from 'features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings'; import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate'; import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext'; import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; @@ -42,7 +41,6 @@ export const RasterLayer = memo(({ id }: Props) => { - { - diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx deleted file mode 100644 index 65381c9f325..00000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { MenuItem } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; -import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLayers/store/canvasSlice'; -import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; -import { memo, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiDropHalfBold } from 'react-icons/pi'; - -export const RasterLayerMenuItemsCompositeOperation = memo(() => { - const dispatch = useAppDispatch(); - const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); - const { t } = useTranslation(); - const layer = useAppSelector((s) => - s.canvas.present.rasterLayers.entities.find((e: CanvasRasterLayerState) => e.id === entityIdentifier.id) - ); - const hasCompositeOperation = layer?.globalCompositeOperation !== undefined; - - const onToggleCompositeOperationPresence = useCallback(() => { - if (hasCompositeOperation) { - dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: undefined })); - } else { - // default to color when enabling blend modes - dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: 'color' })); - } - }, [dispatch, entityIdentifier, hasCompositeOperation]); - - return ( - }> - {hasCompositeOperation ? t('controlLayers.compositeOperation.remove') : t('controlLayers.compositeOperation.add')} - - ); -}); - -RasterLayerMenuItemsCompositeOperation.displayName = 'RasterLayerMenuItemsCompositeOperation'; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index b87b9f97429..e7a701b8801 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -491,52 +491,54 @@ export type RasterLayerAdjustments = z.infer; * NOTE: All of these are supported by canvas layers, but not all are supported by CSS blend modes (live rendering). */ const COMPOSITE_OPERATIONS = [ - 'source-over', - 'source-in', - 'source-out', - 'source-atop', - 'destination-over', - 'destination-in', - 'destination-out', - 'destination-atop', - 'lighter', - 'copy', - 'xor', - 'multiply', - 'screen', - 'overlay', + 'normal', 'darken', + 'multiply', + 'color-burn', 'lighten', + 'screen', 'color-dodge', - 'color-burn', - 'hard-light', + 'lighter', + 'overlay', 'soft-light', + 'hard-light', 'difference', 'exclusion', + 'xor', 'hue', 'saturation', 'color', 'luminosity', + 'source-over', + 'source-in', + 'source-out', + 'source-atop', + 'destination-over', + 'destination-in', + 'destination-out', + 'destination-atop', + 'copy', ] as const; export type CompositeOperation = (typeof COMPOSITE_OPERATIONS)[number]; // Subset of color blend modes for UI selection. All are supported by both Konva and CSS. export const COLOR_BLEND_MODES: CompositeOperation[] = [ - 'color', - 'hue', + 'normal', + 'darken', + 'multiply', + 'color-burn', + 'lighten', + 'screen', + 'color-dodge', 'overlay', 'soft-light', 'hard-light', - 'screen', - 'color-burn', - 'color-dodge', - 'multiply', - 'darken', - 'lighten', 'difference', - 'luminosity', + 'hue', 'saturation', + 'color', + 'luminosity', ]; const zCanvasRasterLayerState = zCanvasEntityBase.extend({ From 9b9a52d83dfaa189dcade22dc4278856778c3dd4 Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Sat, 31 Jan 2026 09:38:40 +0530 Subject: [PATCH 17/23] fix: use source-over instead of normal --- invokeai/frontend/web/public/locales/en.json | 2 +- .../frontend/web/src/features/controlLayers/store/types.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 1f1bd2be7bb..398d0bb9dbe 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2207,7 +2207,7 @@ "add": "Add Blend Mode", "remove": "Remove Blend Mode", "blendModes": { - "normal": "Normal", + "source-over": "Normal", "color": "Color", "hue": "Hue", "overlay": "Overlay", diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index e7a701b8801..211e3276130 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -491,7 +491,6 @@ export type RasterLayerAdjustments = z.infer; * NOTE: All of these are supported by canvas layers, but not all are supported by CSS blend modes (live rendering). */ const COMPOSITE_OPERATIONS = [ - 'normal', 'darken', 'multiply', 'color-burn', @@ -524,7 +523,7 @@ export type CompositeOperation = (typeof COMPOSITE_OPERATIONS)[number]; // Subset of color blend modes for UI selection. All are supported by both Konva and CSS. export const COLOR_BLEND_MODES: CompositeOperation[] = [ - 'normal', + 'source-over', 'darken', 'multiply', 'color-burn', From 0e5fe3a791b792783a69c5b97c7846462b29ad41 Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Sat, 31 Jan 2026 09:45:00 +0530 Subject: [PATCH 18/23] fix: pixel fix for slightly offset action bar labels. --- ...ectedEntityActionBarCompositeOperation.tsx | 2 +- ...tityListSelectedEntityActionBarOpacity.tsx | 4 +- .../RasterLayerCompositeOperationSettings.tsx | 69 ------------------- 3 files changed, 4 insertions(+), 71 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarCompositeOperation.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarCompositeOperation.tsx index 4034f0093fc..f82dd530c62 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarCompositeOperation.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarCompositeOperation.tsx @@ -57,7 +57,7 @@ export const EntityListSelectedEntityActionBarCompositeOperation = memo(() => { return ( - + {t('controlLayers.compositeOperation.label')} - {COLOR_BLEND_MODES.map((op) => ( - - ))} - - - - - - ); -}); - -RasterLayerCompositeOperationSettings.displayName = 'RasterLayerCompositeOperationSettings'; From 58dc597fbb1960799b2043776683045784632e7a Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sun, 1 Feb 2026 19:46:38 -0500 Subject: [PATCH 19/23] feat(canvas): boolean raster merge creates new layer and disables sources --- .../RasterLayerMenuItemsBooleanSubMenu.tsx | 7 ++- .../konva/CanvasCompositorModule.ts | 53 +++++++++++++++++++ .../controlLayers/store/canvasSlice.ts | 29 +++++++++- 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx index 8c824a1e302..c321317a34e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx @@ -27,7 +27,12 @@ export const RasterLayerMenuItemsBooleanSubMenu = memo(() => { } dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: op })); try { - await canvasManager.compositor.mergeByEntityIdentifiers([entityIdentifierBelowThisOne, entityIdentifier], true); + // Use boolean-specific merge which disables the source raster layers instead of deleting them + await canvasManager.compositor.mergeBooleanRasterLayers( + entityIdentifierBelowThisOne as CanvasEntityIdentifier<'raster_layer'>, + entityIdentifier as CanvasEntityIdentifier<'raster_layer'>, + true + ); } finally { dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: undefined })); } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts index 337151581c0..924a93603a4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts @@ -403,6 +403,59 @@ export class CanvasCompositorModule extends CanvasModuleBase { return result.value; }; + /** + * Performs a boolean merge specifically for raster layers. + * Creates a new raster layer with the composite result and disables the source layers instead of deleting them. + */ + mergeBooleanRasterLayers = async ( + below: CanvasEntityIdentifier<'raster_layer'>, + top: CanvasEntityIdentifier<'raster_layer'>, + disableSources = true + ): Promise => { + toast({ id: 'MERGE_LAYERS_TOAST', title: t('controlLayers.mergingLayers'), withCount: false }); + + const adapters = this.manager.getAdapters([below, top]); + assert(adapters.length === 2, 'Failed to get adapters for boolean merge'); + + const rect = this.getRectOfAdapters(adapters); + + const compositingOptions: CompositingOptions = { + globalCompositeOperation: 'source-over', + }; + + const result = await withResultAsync(() => + this.getCompositeImageDTO(adapters, rect, { is_intermediate: true }, compositingOptions) + ); + + if (result.isErr()) { + this.log.error({ error: serializeError(result.error) }, 'Failed to boolean merge raster layers'); + toast({ + id: 'MERGE_LAYERS_TOAST', + title: t('controlLayers.mergeVisibleError'), + status: 'error', + withCount: false, + }); + return null; + } + + // Add new raster layer while disabling the merged sources + const addEntityArg = { + isSelected: true, + overrides: { + objects: [imageDTOToImageObject(result.value)], + position: { x: Math.floor(rect.x), y: Math.floor(rect.y) }, + }, + mergedEntitiesToDisable: disableSources ? [below.id, top.id] : [], + addAfter: top.id, + } as const; + + this.manager.stateApi.addRasterLayer(addEntityArg as any); + + toast({ id: 'MERGE_LAYERS_TOAST', title: t('controlLayers.mergeVisibleOk'), status: 'success', withCount: false }); + + return result.value; + }; + /** * Merges all visible entities of the given type. This is used for "merge visible" functionality. * diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 2561d27f46e..b2b4ed81ab8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -218,10 +218,19 @@ const slice = createSlice({ isSelected?: boolean; isBookmarked?: boolean; mergedEntitiesToDelete?: string[]; + mergedEntitiesToDisable?: string[]; addAfter?: string; }> ) => { - const { id, overrides, isSelected, isBookmarked, mergedEntitiesToDelete = [], addAfter } = action.payload; + const { + id, + overrides, + isSelected, + isBookmarked, + mergedEntitiesToDelete = [], + mergedEntitiesToDisable = [], + addAfter, + } = action.payload; const entityState = getRasterLayerState(id, overrides); const index = addAfter @@ -229,6 +238,16 @@ const slice = createSlice({ : state.rasterLayers.entities.length; state.rasterLayers.entities.splice(index, 0, entityState); + // For boolean operations we may want to disable the source layers instead of deleting them + if (mergedEntitiesToDisable.length > 0) { + for (const idToDisable of mergedEntitiesToDisable) { + const entity = state.rasterLayers.entities.find((e) => e.id === idToDisable); + if (entity) { + entity.isEnabled = false; + } + } + } + if (mergedEntitiesToDelete.length > 0) { state.rasterLayers.entities = state.rasterLayers.entities.filter( (entity) => !mergedEntitiesToDelete.includes(entity.id) @@ -237,7 +256,12 @@ const slice = createSlice({ const entityIdentifier = getEntityIdentifier(entityState); - if (isSelected || mergedEntitiesToDelete.length > 0) { + // When sources were either deleted OR disabled, select the new merged layer + if ( + isSelected || + mergedEntitiesToDelete.length > 0 || + mergedEntitiesToDisable.length > 0 + ) { state.selectedEntityIdentifier = entityIdentifier; } @@ -250,6 +274,7 @@ const slice = createSlice({ isSelected?: boolean; isBookmarked?: boolean; mergedEntitiesToDelete?: string[]; + mergedEntitiesToDisable?: string[]; addAfter?: string; }) => ({ payload: { ...payload, id: getPrefixedId('raster_layer') }, From 7db19b2868268afebbb6c6dd7d0c1375cc20f9f7 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Mon, 2 Feb 2026 00:57:01 -0500 Subject: [PATCH 20/23] (fix) lint errors --- .../features/controlLayers/konva/CanvasCompositorModule.ts | 7 ++++--- .../web/src/features/controlLayers/store/canvasSlice.ts | 6 +----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts index 924a93603a4..c9ff2531f00 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts @@ -14,6 +14,7 @@ import { mapId, previewBlob, } from 'features/controlLayers/konva/util'; +import type { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice'; import { selectActiveControlLayerEntities, selectActiveInpaintMaskEntities, @@ -38,7 +39,7 @@ import { serializeError } from 'serialize-error'; import { getImageDTOSafe, uploadImage } from 'services/api/endpoints/images'; import type { ImageDTO, UploadImageArg } from 'services/api/types'; import stableHash from 'stable-hash'; -import type { Equals } from 'tsafe'; +import type { Equals, Param0 } from 'tsafe'; import { assert } from 'tsafe'; import type { JsonObject, SetOptional } from 'type-fest'; @@ -447,9 +448,9 @@ export class CanvasCompositorModule extends CanvasModuleBase { }, mergedEntitiesToDisable: disableSources ? [below.id, top.id] : [], addAfter: top.id, - } as const; + }; - this.manager.stateApi.addRasterLayer(addEntityArg as any); + this.manager.stateApi.addRasterLayer(addEntityArg as Param0); toast({ id: 'MERGE_LAYERS_TOAST', title: t('controlLayers.mergeVisibleOk'), status: 'success', withCount: false }); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index b2b4ed81ab8..d0085611097 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -257,11 +257,7 @@ const slice = createSlice({ const entityIdentifier = getEntityIdentifier(entityState); // When sources were either deleted OR disabled, select the new merged layer - if ( - isSelected || - mergedEntitiesToDelete.length > 0 || - mergedEntitiesToDisable.length > 0 - ) { + if (isSelected || mergedEntitiesToDelete.length > 0 || mergedEntitiesToDisable.length > 0) { state.selectedEntityIdentifier = entityIdentifier; } From 5a3020cbc19b9419d6b54f3894e616171cbe391b Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Mon, 2 Feb 2026 01:28:16 -0500 Subject: [PATCH 21/23] remove extra typecast --- .../features/controlLayers/konva/CanvasCompositorModule.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts index c9ff2531f00..9638c771a1c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts @@ -14,7 +14,6 @@ import { mapId, previewBlob, } from 'features/controlLayers/konva/util'; -import type { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice'; import { selectActiveControlLayerEntities, selectActiveInpaintMaskEntities, @@ -39,7 +38,7 @@ import { serializeError } from 'serialize-error'; import { getImageDTOSafe, uploadImage } from 'services/api/endpoints/images'; import type { ImageDTO, UploadImageArg } from 'services/api/types'; import stableHash from 'stable-hash'; -import type { Equals, Param0 } from 'tsafe'; +import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; import type { JsonObject, SetOptional } from 'type-fest'; @@ -450,7 +449,7 @@ export class CanvasCompositorModule extends CanvasModuleBase { addAfter: top.id, }; - this.manager.stateApi.addRasterLayer(addEntityArg as Param0); + this.manager.stateApi.addRasterLayer(addEntityArg); toast({ id: 'MERGE_LAYERS_TOAST', title: t('controlLayers.mergeVisibleOk'), status: 'success', withCount: false }); From d5c59ee64e2e2bfacf9c1ccca61cd2fe8ba12f40 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Tue, 3 Feb 2026 21:07:11 +0100 Subject: [PATCH 22/23] ui: translations update from weblate (#8834) * translationBot(ui): update translation (Italian) Currently translated at 98.0% (2205 of 2250 strings) Co-authored-by: Riccardo Giovanetti Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ Translation: InvokeAI/Web UI * translationBot(ui): update translation files Updated by "Remove blank strings" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/ Translation: InvokeAI/Web UI --------- Co-authored-by: Riccardo Giovanetti --- invokeai/frontend/web/public/locales/it.json | 22 +++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/public/locales/it.json b/invokeai/frontend/web/public/locales/it.json index c80117bf101..e5bccc1f584 100644 --- a/invokeai/frontend/web/public/locales/it.json +++ b/invokeai/frontend/web/public/locales/it.json @@ -202,7 +202,8 @@ "unableToLoad": "Impossibile caricare la Galleria", "selectAnImageToCompare": "Seleziona un'immagine da confrontare", "openViewer": "Apri Visualizzatore", - "closeViewer": "Chiudi Visualizzatore" + "closeViewer": "Chiudi Visualizzatore", + "usePagedGalleryView": "Utilizza la visualizzazione Galleria a pagine" }, "hotkeys": { "searchHotkeys": "Cerca tasti di scelta rapida", @@ -804,7 +805,9 @@ "updatePathTooltip": "Aggiorna il percorso del file per questo modello se hai spostato i file del modello in una nuova posizione.", "updatePath": "Aggiorna percorso", "actions": "Azioni in blocco", - "zImageVae": "VAE (opzionale)" + "zImageVae": "VAE (opzionale)", + "missingFiles": "File mancanti", + "missingFilesTooltip": "I file del modello sono mancanti dal disco" }, "parameters": { "images": "Immagini", @@ -1949,6 +1952,20 @@ "Off: generazione standard. Auto: abilita automaticamente per immagini > 1536px. 4K: impostazioni ottimizzate per output con risoluzione 4K." ], "heading": "DyPE (alta risoluzione)" + }, + "fluxDypeScale": { + "paragraphs": [ + "Controlla l'entità della modulazione DyPE. Valori più alti = estrapolazione più forte.", + "Predefinito: 2.0. Intervallo: 0.0-8.0." + ] + }, + "fluxDypeExponent": { + "paragraphs": [ + "Controlla l'intensità dell'effetto dinamico nel tempo.", + "2.0: Consigliato per risoluzioni 4K+. Programmazione aggressiva con transizioni rapide per la pulizia degli artefatti.", + "1.0: Buon punto di partenza per risoluzioni ~2K-3K.", + "0.5: Programma più delicato per risoluzioni appena superiori a quelle native (1024px)." + ] } }, "sdxl": { @@ -2001,7 +2018,6 @@ "vae": "VAE", "parsingFailed": "Analisi non riuscita", "recallParameter": "Richiama {{label}}", - "dypePreset": "Preimpostazione DyPE", "seedVarianceRandomizePercent": "Casualità della varianza del seme %", "seedVarianceEnabled": "Varianza seme abilitata", "seedVarianceStrength": "Intensità della varianza del seme" From b7d7cd07489377cb80d198f5af7a0f53d2dca212 Mon Sep 17 00:00:00 2001 From: DustyShoe <38873282+DustyShoe@users.noreply.github.com> Date: Tue, 3 Feb 2026 22:20:59 +0200 Subject: [PATCH 23/23] Feat(UI): Add linear and radial gradient tools to canvas (#8774) * Adding gradient tool to canvas. Lineara and radial. * Formatting again... * Formatting again 2... * Minor bug fix * Some button design tweaking * Fixed icorrect wording where Circular was used instead of Radial. * Update invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectGradient.ts Co-authored-by: dunkeroni * Update invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientButton.tsx Co-authored-by: dunkeroni * Update invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientButton.tsx Co-authored-by: dunkeroni * Update invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientButton.tsx Co-authored-by: dunkeroni * Autocommit fix on mouse leaving canvas area * feature(canvas): move gradient mode controls to top toolbar; remove popover mode buttons and group clip+mode cluster * (chore) prettier * remove fixed icon size --------- Co-authored-by: dunkeroni Co-authored-by: Lincoln Stein --- invokeai/frontend/web/public/locales/en.json | 6 + .../components/Tool/GradientIcons.tsx | 81 +++++++ .../components/Tool/ToolChooser.tsx | 2 + .../components/Tool/ToolGradientButton.tsx | 30 +++ .../Tool/ToolGradientClipToggle.tsx | 36 +++ .../Tool/ToolGradientModeToggle.tsx | 40 ++++ .../components/Toolbar/CanvasToolbar.tsx | 11 +- .../CanvasEntityBufferObjectRenderer.ts | 13 ++ .../CanvasEntityObjectRenderer.ts | 11 + .../CanvasObject/CanvasObjectGradient.ts | 149 ++++++++++++ .../controlLayers/konva/CanvasObject/types.ts | 9 +- .../konva/CanvasStateApiModule.ts | 9 + .../CanvasTool/CanvasGradientToolModule.ts | 219 ++++++++++++++++++ .../konva/CanvasTool/CanvasToolModule.ts | 19 +- .../store/canvasSettingsSlice.ts | 22 ++ .../controlLayers/store/canvasSlice.ts | 15 +- .../src/features/controlLayers/store/types.ts | 40 +++- 17 files changed, 706 insertions(+), 6 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/Tool/GradientIcons.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientButton.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientClipToggle.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientModeToggle.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectGradient.ts create mode 100644 invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasGradientToolModule.ts diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index b1ab43456e0..d2ec5b73526 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -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", diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/GradientIcons.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/GradientIcons.tsx new file mode 100644 index 00000000000..b09e46d7320 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/GradientIcons.tsx @@ -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 ( + + + + + + + + + + ); +}); +GradientToolIcon.displayName = 'GradientToolIcon'; + +export const GradientLinearIcon = memo(() => { + const id = useId(); + const gradientId = `${id}-gradient-linear-diagonal`; + return ( + + + + + + + + + + ); +}); +GradientLinearIcon.displayName = 'GradientLinearIcon'; + +export const GradientRadialIcon = memo(() => { + const id = useId(); + const gradientId = `${id}-gradient-radial`; + return ( + + + + + + + + + + ); +}); +GradientRadialIcon.displayName = 'GradientRadialIcon'; 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 d2b35ced8ac..b0652210007 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolChooser.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolChooser.tsx @@ -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'; @@ -16,6 +17,7 @@ export const ToolChooser: React.FC = () => { + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientButton.tsx new file mode 100644 index 00000000000..32275399180 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientButton.tsx @@ -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 ( + + } + isActive={isSelected} + colorScheme={isSelected ? 'invokeBlue' : 'base'} + variant="solid" + onClick={handleClick} + /> + + ); +}); + +ToolGradientButton.displayName = 'ToolGradientButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientClipToggle.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientClipToggle.tsx new file mode 100644 index 00000000000..a89ef6a470b --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientClipToggle.tsx @@ -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 ( + + } + size="sm" + variant="solid" + colorScheme={isEnabled ? 'invokeBlue' : 'base'} + onClick={onClick} + /> + + ); +}); + +ToolGradientClipToggle.displayName = 'ToolGradientClipToggle'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientModeToggle.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientModeToggle.tsx new file mode 100644 index 00000000000..03b18c466ee --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientModeToggle.tsx @@ -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 ( + + + } + colorScheme={gradientType === 'linear' ? 'invokeBlue' : 'base'} + variant="solid" + onClick={onLinearClick} + /> + + + } + colorScheme={gradientType === 'radial' ? 'invokeBlue' : 'base'} + variant="solid" + onClick={onRadialClick} + /> + + + ); +}); + +ToolGradientModeToggle.displayName = 'ToolGradientModeToggle'; 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 979eea95d76..91976cb55d6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx @@ -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'; @@ -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]); @@ -45,6 +48,12 @@ export const CanvasToolbar = memo(() => { + {isGradientSelected && ( + + + + + )} {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 2dc5bdab26a..ef5cee8d897 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts @@ -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'; @@ -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); @@ -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; } } 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 114542cdb9b..b33a4aaa87a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts @@ -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'; @@ -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); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectGradient.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectGradient.ts new file mode 100644 index 00000000000..3dfb281c664 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectGradient.ts @@ -0,0 +1,149 @@ +import { rgbaColorToString } from 'common/util/colorCodeTransformers'; +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 { CanvasGradientState } from 'features/controlLayers/store/types'; +import Konva from 'konva'; +import type { Logger } from 'roarr'; + +export class CanvasObjectGradient extends CanvasModuleBase { + readonly type = 'object_gradient'; + readonly id: string; + readonly path: string[]; + readonly parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer; + readonly manager: CanvasManager; + readonly log: Logger; + + state: CanvasGradientState; + konva: { + group: Konva.Group; + rect: Konva.Rect; + }; + isFirstRender: boolean = false; + + constructor(state: CanvasGradientState, 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 }), + rect: new Konva.Rect({ name: `${this.type}:rect`, listening: false, perfectDrawEnabled: false }), + }; + this.konva.group.add(this.konva.rect); + this.state = state; + } + + update(state: CanvasGradientState, force = false): boolean { + if (force || this.state !== state) { + this.isFirstRender = false; + + const { rect, fgColor, bgColor } = state; + const fg = rgbaColorToString(fgColor); + const bg = rgbaColorToString(bgColor); + + this.log.trace({ state }, 'Updating gradient'); + this.konva.rect.setAttrs({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }); + + this.konva.group.clipFunc((ctx) => { + ctx.beginPath(); + ctx.rect(state.bboxRect.x, state.bboxRect.y, state.bboxRect.width, state.bboxRect.height); + ctx.clip(); + if (state.clipEnabled) { + ctx.beginPath(); + if (state.gradientType === 'linear') { + const startX = state.rect.x + state.start.x; + const startY = state.rect.y + state.start.y; + const endX = state.rect.x + state.end.x; + const endY = state.rect.y + state.end.y; + const dirX = Math.cos(state.clipAngle); + const dirY = Math.sin(state.clipAngle); + const perpX = -dirY; + const perpY = dirX; + const clipExtent = Math.max(Math.hypot(state.bboxRect.width, state.bboxRect.height) * 2, 1); + const drawHalfPlane = (originX: number, originY: number, dirSign: number) => { + const dX = dirX * dirSign; + const dY = dirY * dirSign; + const p1x = originX + perpX * clipExtent; + const p1y = originY + perpY * clipExtent; + const p2x = originX - perpX * clipExtent; + const p2y = originY - perpY * clipExtent; + const p3x = p2x + dX * clipExtent; + const p3y = p2y + dY * clipExtent; + const p4x = p1x + dX * clipExtent; + const p4y = p1y + dY * clipExtent; + ctx.moveTo(p1x, p1y); + ctx.lineTo(p2x, p2y); + ctx.lineTo(p3x, p3y); + ctx.lineTo(p4x, p4y); + ctx.closePath(); + }; + drawHalfPlane(startX, startY, 1); + ctx.clip(); + ctx.beginPath(); + drawHalfPlane(endX, endY, -1); + } else { + ctx.arc(state.clipCenter.x, state.clipCenter.y, state.clipRadius, 0, Math.PI * 2); + } + ctx.clip(); + } + }); + + if (state.gradientType === 'linear') { + this.konva.rect.setAttrs({ + fillPriority: 'linear-gradient', + fillLinearGradientStartPoint: { x: state.start.x, y: state.start.y }, + fillLinearGradientEndPoint: { x: state.end.x, y: state.end.y }, + fillLinearGradientColorStops: [0, fg, 1, bg], + }); + } else { + this.konva.rect.setAttrs({ + fillPriority: 'radial-gradient', + fillRadialGradientStartPoint: { x: state.center.x, y: state.center.y }, + fillRadialGradientEndPoint: { x: state.center.x, y: state.center.y }, + fillRadialGradientStartRadius: 0, + fillRadialGradientEndRadius: state.radius, + fillRadialGradientColorStops: [0, fg, 1, bg], + }); + } + + this.state = state; + return true; + } + + return false; + } + + setVisibility(isVisible: boolean): void { + this.log.trace({ isVisible }, 'Setting gradient 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, + isFirstRender: this.isFirstRender, + 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 eb17bf7e136..31a2bfee078 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/types.ts @@ -2,6 +2,7 @@ import type { CanvasObjectBrushLine } from 'features/controlLayers/konva/CanvasO import type { CanvasObjectBrushLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure'; import type { CanvasObjectEraserLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLine'; 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 { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect'; import type { @@ -9,6 +10,7 @@ import type { CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, + CanvasGradientState, CanvasImageState, CanvasRectState, } from 'features/controlLayers/store/types'; @@ -23,7 +25,9 @@ export type AnyObjectRenderer = | CanvasObjectEraserLine | CanvasObjectEraserLineWithPressure | CanvasObjectRect - | CanvasObjectImage; /** + | CanvasObjectImage + | CanvasObjectGradient; +/** * Union of all object states. */ export type AnyObjectState = @@ -32,4 +36,5 @@ export type AnyObjectState = | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasImageState - | CanvasRectState; + | CanvasRectState + | CanvasGradientState; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts index dc2c0b15043..e00ade1f8bb 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts @@ -20,6 +20,7 @@ import { controlLayerAdded, entityBrushLineAdded, entityEraserLineAdded, + entityGradientAdded, entityMovedBy, entityMovedTo, entityRasterized, @@ -40,6 +41,7 @@ import type { CanvasState, EntityBrushLineAddedPayload, EntityEraserLineAddedPayload, + EntityGradientAddedPayload, EntityIdentifierPayload, EntityMovedByPayload, EntityMovedToPayload, @@ -173,6 +175,13 @@ export class CanvasStateApiModule extends CanvasModuleBase { this.store.dispatch(entityRectAdded(arg)); }; + /** + * Adds a gradient to an entity, pushing state to redux. + */ + addGradient = (arg: EntityGradientAddedPayload) => { + this.store.dispatch(entityGradientAdded(arg)); + }; + /** * Adds a raster layer to the canvas, pushing state to redux. */ diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasGradientToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasGradientToolModule.ts new file mode 100644 index 00000000000..745a5417355 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasGradientToolModule.ts @@ -0,0 +1,219 @@ +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, offsetCoord } from 'features/controlLayers/konva/util'; +import type { KonvaEventObject } from 'konva/lib/Node'; +import type { Logger } from 'roarr'; + +export class CanvasGradientToolModule extends CanvasModuleBase { + readonly type = 'gradient_tool'; + readonly id: string; + readonly path: string[]; + readonly parent: CanvasToolModule; + readonly manager: CanvasManager; + readonly log: Logger; + + private startPoint: { x: number; y: number } | null = null; + private lastPoint: { x: number; y: number } | null = null; + private gradientId: string | null = null; + + 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'); + } + + syncCursorStyle = () => { + const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); + if (!selectedEntity || selectedEntity.state.type !== 'raster_layer') { + this.manager.stage.setCursor('not-allowed'); + return; + } + this.manager.stage.setCursor('crosshair'); + }; + + onStagePointerDown = async (_e: KonvaEventObject) => { + const cursorPos = this.parent.$cursorPos.get(); + const isPrimaryPointerDown = this.parent.$isPrimaryPointerDown.get(); + const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); + + if (!cursorPos || !isPrimaryPointerDown || !selectedEntity || selectedEntity.state.type !== 'raster_layer') { + return; + } + + if (selectedEntity.bufferRenderer.hasBuffer()) { + selectedEntity.bufferRenderer.commitBuffer(); + } + + const normalizedPoint = offsetCoord(cursorPos.relative, selectedEntity.state.position); + this.startPoint = normalizedPoint; + this.lastPoint = normalizedPoint; + this.gradientId = getPrefixedId('gradient'); + + await this.updateGradientBuffer(normalizedPoint, normalizedPoint); + }; + + onStagePointerMove = async (_e: KonvaEventObject) => { + const cursorPos = this.parent.$cursorPos.get(); + + if (!cursorPos || !this.parent.$isPrimaryPointerDown.get()) { + return; + } + + const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); + + if (!selectedEntity || selectedEntity.state.type !== 'raster_layer') { + return; + } + + const bufferState = selectedEntity.bufferRenderer.state; + if (!bufferState || bufferState.type !== 'gradient' || !this.startPoint) { + return; + } + + const normalizedPoint = offsetCoord(cursorPos.relative, selectedEntity.state.position); + this.lastPoint = normalizedPoint; + await this.updateGradientBuffer(this.startPoint, normalizedPoint); + }; + + onStagePointerUp = (_e: KonvaEventObject) => { + const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); + if (!selectedEntity) { + this.resetState(); + return; + } + + const shouldCommit = this.startPoint && this.lastPoint && this.getRadius(this.startPoint, this.lastPoint) > 1; + if (selectedEntity.bufferRenderer.state?.type === 'gradient' && selectedEntity.bufferRenderer.hasBuffer()) { + if (shouldCommit) { + selectedEntity.bufferRenderer.commitBuffer(); + } else { + selectedEntity.bufferRenderer.clearBuffer(); + } + } + + this.resetState(); + }; + + private resetState = () => { + this.startPoint = null; + this.lastPoint = null; + this.gradientId = null; + }; + + private getRadius = (start: { x: number; y: number }, end: { x: number; y: number }) => { + return Math.hypot(end.x - start.x, end.y - start.y); + }; + + private updateGradientBuffer = async (start: { x: number; y: number }, end: { x: number; y: number }) => { + const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); + if (!selectedEntity || selectedEntity.state.type !== 'raster_layer') { + return; + } + + const settings = this.manager.stateApi.getSettings(); + const { rect: bboxRect } = this.manager.stateApi.getBbox(); + const bboxInLayer = { + x: bboxRect.x - selectedEntity.state.position.x, + y: bboxRect.y - selectedEntity.state.position.y, + width: bboxRect.width, + height: bboxRect.height, + }; + const radius = this.getRadius(start, end); + const angle = Math.atan2(end.y - start.y, end.x - start.x); + let rect = { + x: start.x - radius, + y: start.y - radius, + width: radius * 2, + height: radius * 2, + }; + + if (settings.gradientType === 'linear') { + const bboxCenter = { + x: bboxInLayer.x + bboxInLayer.width / 2, + y: bboxInLayer.y + bboxInLayer.height / 2, + }; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const halfWidth = (Math.abs(bboxInLayer.width * cos) + Math.abs(bboxInLayer.height * sin)) / 2; + const halfHeight = (Math.abs(bboxInLayer.width * sin) + Math.abs(bboxInLayer.height * cos)) / 2; + rect = { + x: bboxCenter.x - halfWidth, + y: bboxCenter.y - halfHeight, + width: Math.max(halfWidth * 2, 1), + height: Math.max(halfHeight * 2, 1), + }; + } + + const startInRect = offsetCoord(start, { x: rect.x, y: rect.y }); + const endInRect = offsetCoord(end, { x: rect.x, y: rect.y }); + + const id = this.gradientId ?? getPrefixedId('gradient'); + if (!this.gradientId) { + this.gradientId = id; + } + + const activeColor = settings.activeColor === 'bgColor' ? settings.bgColor : settings.fgColor; + const inactiveColor = settings.activeColor === 'bgColor' ? settings.fgColor : settings.bgColor; + const clipEnabled = settings.gradientClipEnabled; + + if (settings.gradientType === 'radial') { + let radialRect = rect; + let radialCenter = startInRect; + let radialRadius = Math.max(1, radius); + if (!clipEnabled) { + radialRect = bboxInLayer; + radialCenter = offsetCoord(start, { x: radialRect.x, y: radialRect.y }); + } + await selectedEntity.bufferRenderer.setBuffer({ + id, + type: 'gradient', + gradientType: 'radial', + rect: radialRect, + center: radialCenter, + radius: radialRadius, + clipCenter: start, + clipRadius: Math.max(1, radius), + clipEnabled, + bboxRect: bboxInLayer, + fgColor: activeColor, + bgColor: inactiveColor, + }); + } else { + const endPoint = { + x: endInRect.x === startInRect.x && endInRect.y === startInRect.y ? endInRect.x + 1 : endInRect.x, + y: endInRect.x === startInRect.x && endInRect.y === startInRect.y ? endInRect.y : endInRect.y, + }; + await selectedEntity.bufferRenderer.setBuffer({ + id, + type: 'gradient', + gradientType: 'linear', + rect, + start: startInRect, + end: endPoint, + clipCenter: start, + clipRadius: Math.max(1, radius), + clipAngle: angle, + clipEnabled, + bboxRect: bboxInLayer, + fgColor: activeColor, + bgColor: inactiveColor, + }); + } + }; + + repr = () => { + return { + id: this.id, + type: this.type, + path: this.path, + startPoint: this.startPoint, + gradientId: this.gradientId, + }; + }; +} 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 b9b8adae9b8..1477f1b097c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts @@ -4,6 +4,7 @@ import { CanvasBboxToolModule } from 'features/controlLayers/konva/CanvasTool/Ca import { CanvasBrushToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasBrushToolModule'; 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 { CanvasMoveToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasMoveToolModule'; import { CanvasRectToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasRectToolModule'; import { CanvasViewToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasViewToolModule'; @@ -60,6 +61,7 @@ export class CanvasToolModule extends CanvasModuleBase { brush: CanvasBrushToolModule; eraser: CanvasEraserToolModule; rect: CanvasRectToolModule; + gradient: CanvasGradientToolModule; colorPicker: CanvasColorPickerToolModule; bbox: CanvasBboxToolModule; view: CanvasViewToolModule; @@ -117,6 +119,7 @@ export class CanvasToolModule extends CanvasModuleBase { brush: new CanvasBrushToolModule(this), eraser: new CanvasEraserToolModule(this), rect: new CanvasRectToolModule(this), + gradient: new CanvasGradientToolModule(this), colorPicker: new CanvasColorPickerToolModule(this), bbox: new CanvasBboxToolModule(this), view: new CanvasViewToolModule(this), @@ -194,6 +197,8 @@ export class CanvasToolModule extends CanvasModuleBase { this.tools.move.syncCursorStyle(); } else if (tool === 'rect') { this.tools.rect.syncCursorStyle(); + } else if (tool === 'gradient') { + this.tools.gradient.syncCursorStyle(); } } else if (this.manager.stateApi.getRenderedEntityCount() === 0) { stage.setCursor('not-allowed'); @@ -336,7 +341,11 @@ export class CanvasToolModule extends CanvasModuleBase { const tool = this.$tool.get(); const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); - if (selectedEntity?.bufferRenderer.state?.type !== 'rect' && selectedEntity?.bufferRenderer.hasBuffer()) { + if ( + selectedEntity?.bufferRenderer.state?.type !== 'rect' && + selectedEntity?.bufferRenderer.state?.type !== 'gradient' && + selectedEntity?.bufferRenderer.hasBuffer() + ) { selectedEntity.bufferRenderer.commitBuffer(); return; } @@ -375,6 +384,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 === 'gradient') { + await this.tools.gradient.onStagePointerDown(e); } } finally { this.render(); @@ -405,6 +416,8 @@ export class CanvasToolModule extends CanvasModuleBase { this.tools.eraser.onStagePointerUp(e); } else if (tool === 'rect') { this.tools.rect.onStagePointerUp(e); + } else if (tool === 'gradient') { + this.tools.gradient.onStagePointerUp(e); } } finally { this.render(); @@ -436,6 +449,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 === 'gradient') { + await this.tools.gradient.onStagePointerMove(e); } else { this.manager.stateApi.getSelectedEntityAdapter()?.bufferRenderer.clearBuffer(); } @@ -462,6 +477,7 @@ export class CanvasToolModule extends CanvasModuleBase { if ( selectedEntity && selectedEntity.bufferRenderer.state?.type !== 'rect' && + selectedEntity.bufferRenderer.state?.type !== 'gradient' && selectedEntity.bufferRenderer.hasBuffer() ) { selectedEntity.bufferRenderer.commitBuffer(); @@ -629,6 +645,7 @@ export class CanvasToolModule extends CanvasModuleBase { eraser: this.tools.eraser.repr(), colorPicker: this.tools.colorPicker.repr(), rect: this.tools.rect.repr(), + gradient: this.tools.gradient.repr(), bbox: this.tools.bbox.repr(), view: this.tools.view.repr(), move: this.tools.move.repr(), diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts index f76654d4c80..91428b45216 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts @@ -12,6 +12,8 @@ export type AutoSwitchMode = z.infer; const zTransformSmoothingMode = z.enum(['bilinear', 'bicubic', 'hamming', 'lanczos']); export type TransformSmoothingMode = z.infer; +const zGradientType = z.enum(['linear', 'radial']); + const zCanvasSettingsState = z.object({ /** * Whether to show HUD (Heads-Up Display) on the canvas. @@ -108,6 +110,14 @@ const zCanvasSettingsState = z.object({ * Whether the fill color picker UI is pinned (persistently shown in the canvas overlay). */ fillColorPickerPinned: z.boolean(), + /** + * The gradient tool type. + */ + gradientType: zGradientType.default('linear'), + /** + * Whether the gradient tool clips to the drag gesture. + */ + gradientClipEnabled: z.boolean().default(true), }); type CanvasSettingsState = z.infer; @@ -136,6 +146,8 @@ const getInitialState = (): CanvasSettingsState => ({ fillColorPickerPinned: false, transformSmoothingEnabled: false, transformSmoothingMode: 'bicubic', + gradientType: 'linear', + gradientClipEnabled: true, }); const slice = createSlice({ @@ -227,6 +239,12 @@ const slice = createSlice({ settingsFillColorPickerPinnedSet: (state, action: PayloadAction) => { state.fillColorPickerPinned = action.payload; }, + settingsGradientTypeChanged: (state, action: PayloadAction) => { + state.gradientType = action.payload; + }, + settingsGradientClipToggled: (state) => { + state.gradientClipEnabled = !state.gradientClipEnabled; + }, }, }); @@ -256,6 +274,8 @@ export const { settingsTransformSmoothingModeChanged, settingsStagingAreaAutoSwitchChanged, settingsFillColorPickerPinnedSet, + settingsGradientTypeChanged, + settingsGradientClipToggled, } = slice.actions; export const canvasSettingsSliceConfig: SliceConfig = { @@ -295,3 +315,5 @@ export const selectTransformSmoothingEnabled = createCanvasSettingsSelector( (settings) => settings.transformSmoothingEnabled ); export const selectTransformSmoothingMode = createCanvasSettingsSelector((settings) => settings.transformSmoothingMode); +export const selectGradientType = createCanvasSettingsSelector((settings) => settings.gradientType); +export const selectGradientClipEnabled = createCanvasSettingsSelector((settings) => settings.gradientClipEnabled); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 868e2aa2b78..126b61bcc2a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -63,6 +63,7 @@ import type { ControlNetConfig, EntityBrushLineAddedPayload, EntityEraserLineAddedPayload, + EntityGradientAddedPayload, EntityIdentifierPayload, EntityMovedToPayload, EntityRasterizedPayload, @@ -1510,6 +1511,17 @@ const slice = createSlice({ // re-render it (reference equality check). I don't like this behaviour. entity.objects.push({ ...rect }); }, + entityGradientAdded: (state, action: PayloadAction) => { + const { entityIdentifier, gradient } = 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({ ...gradient }); + }, entityDeleted: (state, action: PayloadAction) => { const { entityIdentifier } = action.payload; @@ -1738,6 +1750,7 @@ export const { entityBrushLineAdded, entityEraserLineAdded, entityRectAdded, + entityGradientAdded, // Raster layer adjustments rasterLayerAdjustmentsSet, rasterLayerAdjustmentsCancel, @@ -1862,7 +1875,7 @@ export const canvasSliceConfig: SliceConfig = { }, }; -const doNotGroupMatcher = isAnyOf(entityBrushLineAdded, entityEraserLineAdded, entityRectAdded); +const doNotGroupMatcher = isAnyOf(entityBrushLineAdded, entityEraserLineAdded, entityRectAdded, 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/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 59d6868bb1a..ff0e13a6e38 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', 'view', 'bbox', 'colorPicker']); +const _zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'gradient', 'view', 'bbox', 'colorPicker']); export type Tool = z.infer; const zPoints = z.array(z.number()).refine((points) => points.length % 2 === 0, { @@ -260,6 +260,42 @@ const zCanvasRectState = z.object({ }); export type CanvasRectState = z.infer; +// Gradient state includes clip metadata so the tool can optionally clip to drag gesture. +const zCanvasLinearGradientState = z.object({ + id: zId, + type: z.literal('gradient'), + gradientType: z.literal('linear'), + rect: zRect, + start: zCoordinate, + end: zCoordinate, + clipCenter: zCoordinate, + clipRadius: z.number().min(0), + clipAngle: z.number(), + clipEnabled: z.boolean().default(true), + bboxRect: zRect, + fgColor: zRgbaColor, + bgColor: zRgbaColor, +}); +const zCanvasRadialGradientState = z.object({ + id: zId, + type: z.literal('gradient'), + gradientType: z.literal('radial'), + rect: zRect, + center: zCoordinate, + radius: z.number().min(0), + clipCenter: zCoordinate, + clipRadius: z.number().min(0), + clipEnabled: z.boolean().default(true), + bboxRect: zRect, + fgColor: zRgbaColor, + bgColor: zRgbaColor, +}); +const zCanvasGradientState = z.discriminatedUnion('gradientType', [ + zCanvasLinearGradientState, + zCanvasRadialGradientState, +]); +export type CanvasGradientState = z.infer; + const zCanvasImageState = z.object({ id: zId, type: z.literal('image'), @@ -275,6 +311,7 @@ const zCanvasObjectState = z.union([ zCanvasRectState, zCanvasBrushLineWithPressureState, zCanvasEraserLineWithPressureState, + zCanvasGradientState, ]); export type CanvasObjectState = z.infer; @@ -830,6 +867,7 @@ export type EntityEraserLineAddedPayload = EntityIdentifierPayload<{ eraserLine: CanvasEraserLineState | CanvasEraserLineWithPressureState; }>; export type EntityRectAddedPayload = EntityIdentifierPayload<{ rect: CanvasRectState }>; +export type EntityGradientAddedPayload = EntityIdentifierPayload<{ gradient: CanvasGradientState }>; export type EntityRasterizedPayload = EntityIdentifierPayload<{ imageObject: CanvasImageState; position: Coordinate;