diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 2db971d06a6..d042c40d014 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2488,6 +2488,8 @@ "logDebugInfo": "Log Debug Info", "locked": "Locked", "unlocked": "Unlocked", + "transparencyLocked": "Transparency Locked", + "transparencyUnlocked": "Transparency Unlocked", "deleteSelected": "Delete Selected", "stagingOnCanvas": "Staging images on", "replaceLayer": "Replace Layer", 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..1f3e766156e 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 { RasterLayerIsTransparencyLockedToggle } from 'features/controlLayers/components/RasterLayer/RasterLayerIsTransparencyLockedToggle'; import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate'; import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext'; import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; @@ -38,6 +39,7 @@ export const RasterLayer = memo(({ id }: Props) => { + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerIsTransparencyLockedToggle.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerIsTransparencyLockedToggle.tsx new file mode 100644 index 00000000000..94ea540f6d3 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerIsTransparencyLockedToggle.tsx @@ -0,0 +1,50 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { rasterLayerIsTransparencyLockedToggled } from 'features/controlLayers/store/canvasSlice'; +import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiDropHalfBold, PiDropHalfFill } from 'react-icons/pi'; + +export const RasterLayerIsTransparencyLockedToggle = memo(() => { + const { t } = useTranslation(); + const entityIdentifier = useEntityIdentifierContext('raster_layer'); + const isBusy = useCanvasIsBusy(); + const dispatch = useAppDispatch(); + + const selectIsTransparencyLocked = useMemo( + () => + createSelector(selectCanvasSlice, (canvas) => { + const entity = selectEntity(canvas, entityIdentifier); + if (!entity) { + return false; + } + return entity.isTransparencyLocked ?? false; + }), + [entityIdentifier] + ); + + const isTransparencyLocked = useAppSelector(selectIsTransparencyLocked); + + const onClick = useCallback(() => { + dispatch(rasterLayerIsTransparencyLockedToggled({ entityIdentifier })); + }, [dispatch, entityIdentifier]); + + return ( + : } + onClick={onClick} + isDisabled={isBusy} + /> + ); +}); + +RasterLayerIsTransparencyLockedToggle.displayName = 'RasterLayerIsTransparencyLockedToggle'; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLine.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLine.ts index 8ce49b00b4e..38321db1a20 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLine.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLine.ts @@ -6,8 +6,11 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; import type { CanvasBrushLineState } from 'features/controlLayers/store/types'; import Konva from 'konva'; +import type { NodeConfig } from 'konva/lib/Node'; import type { Logger } from 'roarr'; +type GlobalCompositeOperation = NonNullable; + export class CanvasObjectBrushLine extends CanvasModuleBase { readonly type = 'object_brush_line'; readonly id: string; @@ -46,7 +49,7 @@ export class CanvasObjectBrushLine extends CanvasModuleBase { tension: 0.3, lineCap: 'round', lineJoin: 'round', - globalCompositeOperation: 'source-over', + globalCompositeOperation: (state.globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation, perfectDrawEnabled: false, }), }; @@ -57,12 +60,13 @@ export class CanvasObjectBrushLine extends CanvasModuleBase { update(state: CanvasBrushLineState, force = false): boolean { if (force || this.state !== state) { this.log.trace({ state }, 'Updating brush line'); - const { points, color, strokeWidth } = state; + const { points, color, strokeWidth, globalCompositeOperation } = state; this.konva.line.setAttrs({ // A line with only one point will not be rendered, so we duplicate the points to make it visible points: points.length === 2 ? [...points, ...points] : points, stroke: rgbaColorToString(color), strokeWidth, + globalCompositeOperation: (globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation, }); this.state = state; return true; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure.ts index 41a9a6d0c81..1dba23718b8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure.ts @@ -7,8 +7,11 @@ import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase' import { getSVGPathDataFromPoints } from 'features/controlLayers/konva/util'; import type { CanvasBrushLineWithPressureState } from 'features/controlLayers/store/types'; import Konva from 'konva'; +import type { NodeConfig } from 'konva/lib/Node'; import type { Logger } from 'roarr'; +type GlobalCompositeOperation = NonNullable; + export class CanvasObjectBrushLineWithPressure extends CanvasModuleBase { readonly type = 'object_brush_line_with_pressure'; readonly id: string; @@ -47,7 +50,7 @@ export class CanvasObjectBrushLineWithPressure extends CanvasModuleBase { name: `${this.type}:path`, listening: false, shadowForStrokeEnabled: false, - globalCompositeOperation: 'source-over', + globalCompositeOperation: (state.globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation, perfectDrawEnabled: false, }), }; @@ -58,8 +61,9 @@ export class CanvasObjectBrushLineWithPressure extends CanvasModuleBase { update(state: CanvasBrushLineWithPressureState, force = false): boolean { if (force || this.state !== state) { this.log.trace({ state }, 'Updating brush line with pressure'); - const { points, color, strokeWidth } = state; + const { points, color, strokeWidth, globalCompositeOperation } = state; this.konva.line.setAttrs({ + globalCompositeOperation: (globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation, data: getSVGPathDataFromPoints(points, { size: strokeWidth / 2, simulatePressure: false, diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBrushToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBrushToolModule.ts index 5404622cc4a..e02a3666501 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBrushToolModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBrushToolModule.ts @@ -211,6 +211,11 @@ export class CanvasBrushToolModule extends CanvasModuleBase { const normalizedPoint = offsetCoord(cursorPos.relative, selectedEntity.state.position); const alignedPoint = alignCoordForTool(normalizedPoint, settings.brushWidth); + // When transparency is locked on a raster layer, use 'source-atop' to only paint on existing opaque pixels + const isTransparencyLocked = + selectedEntity.state.type === 'raster_layer' && selectedEntity.state.isTransparencyLocked; + const globalCompositeOperation = isTransparencyLocked ? 'source-atop' : undefined; + if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) { // If the pen is down and pressure sensitivity is enabled, add the point with pressure await selectedEntity.bufferRenderer.setBuffer({ @@ -220,6 +225,7 @@ export class CanvasBrushToolModule extends CanvasModuleBase { strokeWidth: settings.brushWidth, color: this.manager.stateApi.getCurrentColor(), clip: this.parent.getClip(selectedEntity.state), + globalCompositeOperation, }); } else { // Else, add the point without pressure @@ -230,6 +236,7 @@ export class CanvasBrushToolModule extends CanvasModuleBase { strokeWidth: settings.brushWidth, color: this.manager.stateApi.getCurrentColor(), clip: this.parent.getClip(selectedEntity.state), + globalCompositeOperation, }); } }; @@ -268,6 +275,11 @@ export class CanvasBrushToolModule extends CanvasModuleBase { const normalizedPoint = offsetCoord(cursorPos.relative, selectedEntity.state.position); const alignedPoint = alignCoordForTool(normalizedPoint, settings.brushWidth); + // When transparency is locked on a raster layer, use 'source-atop' to only paint on existing opaque pixels + const isTransparencyLocked = + selectedEntity.state.type === 'raster_layer' && selectedEntity.state.isTransparencyLocked; + const globalCompositeOperation = isTransparencyLocked ? 'source-atop' : undefined; + if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) { // We need to get the last point of the last line to create a straight line if shift is held const lastLinePoint = getLastPointOfLastLineWithPressure( @@ -304,6 +316,7 @@ export class CanvasBrushToolModule extends CanvasModuleBase { // When shift is held, the line may extend beyond the clip region. Clip only if we are clipping to bbox. If we // are clipping to stage, we don't need to clip at all. clip: isShiftDraw && !settings.clipToBbox ? null : this.parent.getClip(selectedEntity.state), + globalCompositeOperation, }); } else { const lastLinePoint = getLastPointOfLastLine(selectedEntity.state.objects, 'brush_line'); @@ -329,6 +342,7 @@ export class CanvasBrushToolModule extends CanvasModuleBase { // When shift is held, the line may extend beyond the clip region. Clip only if we are clipping to bbox. If we // are clipping to stage, we don't need to clip at all. clip: isShiftDraw && !settings.clipToBbox ? null : this.parent.getClip(selectedEntity.state), + globalCompositeOperation, }); } }; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 79d3963d122..0fff90a5591 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -210,6 +210,17 @@ const slice = createSlice({ layer.globalCompositeOperation = globalCompositeOperation; } }, + rasterLayerIsTransparencyLockedToggled: ( + state, + action: PayloadAction> + ) => { + const { entityIdentifier } = action.payload; + const layer = selectEntity(state, entityIdentifier); + if (!layer) { + return; + } + layer.isTransparencyLocked = !layer.isTransparencyLocked; + }, rasterLayerAdded: { reducer: ( state, @@ -1798,6 +1809,7 @@ export const { rasterLayerAdjustmentsSimpleUpdated, rasterLayerAdjustmentsCurvesUpdated, rasterLayerGlobalCompositeOperationChanged, + rasterLayerIsTransparencyLockedToggled, entityDeleted, entityArrangedForwardOne, entityArrangedToFront, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 40babc7bc85..abd521ba2bf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -212,6 +212,7 @@ const zCanvasBrushLineState = z.object({ points: zPoints, color: zRgbaColor, clip: zRect.nullable(), + globalCompositeOperation: z.string().optional(), }); export type CanvasBrushLineState = z.infer; @@ -225,6 +226,7 @@ const zCanvasBrushLineWithPressureState = z.object({ points: zPointsWithPressure, color: zRgbaColor, clip: zRect.nullable(), + globalCompositeOperation: z.string().optional(), }); export type CanvasBrushLineWithPressureState = z.infer; @@ -586,6 +588,8 @@ const zCanvasRasterLayerState = zCanvasEntityBase.extend({ adjustments: zRasterLayerAdjustments.optional(), // Optional per-layer composite operation. When undefined, defaults to 'source-over'. globalCompositeOperation: z.enum(COMPOSITE_OPERATIONS).optional(), + // When true, brush strokes only paint where existing pixels are non-transparent (preserve alpha). + isTransparencyLocked: z.boolean().optional(), }); export type CanvasRasterLayerState = z.infer; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/util.ts b/invokeai/frontend/web/src/features/controlLayers/store/util.ts index f14af4feee7..fcc032058f1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/util.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/util.ts @@ -234,6 +234,7 @@ export const getRasterLayerState = ( type: 'raster_layer', isEnabled: true, isLocked: false, + isTransparencyLocked: false, objects: [], opacity: 1, position: { x: 0, y: 0 },