Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -38,6 +39,7 @@ export const RasterLayer = memo(({ id }: Props) => {
<CanvasEntityPreviewImage />
<CanvasEntityEditableTitle />
<Spacer />
<RasterLayerIsTransparencyLockedToggle />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
<RasterLayerAdjustmentsPanel />
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<IconButton
size="sm"
aria-label={t(isTransparencyLocked ? 'controlLayers.transparencyLocked' : 'controlLayers.transparencyUnlocked')}
tooltip={t(isTransparencyLocked ? 'controlLayers.transparencyLocked' : 'controlLayers.transparencyUnlocked')}
variant="link"
alignSelf="stretch"
icon={isTransparencyLocked ? <PiDropHalfFill /> : <PiDropHalfBold />}
onClick={onClick}
isDisabled={isBusy}
/>
);
});

RasterLayerIsTransparencyLockedToggle.displayName = 'RasterLayerIsTransparencyLockedToggle';
Original file line number Diff line number Diff line change
Expand Up @@ -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<NodeConfig['globalCompositeOperation']>;

export class CanvasObjectBrushLine extends CanvasModuleBase {
readonly type = 'object_brush_line';
readonly id: string;
Expand Down Expand Up @@ -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,
}),
};
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NodeConfig['globalCompositeOperation']>;

export class CanvasObjectBrushLineWithPressure extends CanvasModuleBase {
readonly type = 'object_brush_line_with_pressure';
readonly id: string;
Expand Down Expand Up @@ -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,
}),
};
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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
Expand All @@ -230,6 +236,7 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.parent.getClip(selectedEntity.state),
globalCompositeOperation,
});
}
};
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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');
Expand All @@ -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,
});
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,17 @@ const slice = createSlice({
layer.globalCompositeOperation = globalCompositeOperation;
}
},
rasterLayerIsTransparencyLockedToggled: (
state,
action: PayloadAction<EntityIdentifierPayload<void, 'raster_layer'>>
) => {
const { entityIdentifier } = action.payload;
const layer = selectEntity(state, entityIdentifier);
if (!layer) {
return;
}
layer.isTransparencyLocked = !layer.isTransparencyLocked;
},
rasterLayerAdded: {
reducer: (
state,
Expand Down Expand Up @@ -1798,6 +1809,7 @@ export const {
rasterLayerAdjustmentsSimpleUpdated,
rasterLayerAdjustmentsCurvesUpdated,
rasterLayerGlobalCompositeOperationChanged,
rasterLayerIsTransparencyLockedToggled,
entityDeleted,
entityArrangedForwardOne,
entityArrangedToFront,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ const zCanvasBrushLineState = z.object({
points: zPoints,
color: zRgbaColor,
clip: zRect.nullable(),
globalCompositeOperation: z.string().optional(),
});
export type CanvasBrushLineState = z.infer<typeof zCanvasBrushLineState>;

Expand All @@ -225,6 +226,7 @@ const zCanvasBrushLineWithPressureState = z.object({
points: zPointsWithPressure,
color: zRgbaColor,
clip: zRect.nullable(),
globalCompositeOperation: z.string().optional(),
});
export type CanvasBrushLineWithPressureState = z.infer<typeof zCanvasBrushLineWithPressureState>;

Expand Down Expand Up @@ -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<typeof zCanvasRasterLayerState>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export const getRasterLayerState = (
type: 'raster_layer',
isEnabled: true,
isLocked: false,
isTransparencyLocked: false,
objects: [],
opacity: 1,
position: { x: 0, y: 0 },
Expand Down