Skip to content
Closed
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
16 changes: 14 additions & 2 deletions docs/src/content/docs/features/Canvas/gradient-tool.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ The Gradient tool uses the current **FG/BG color pair**:
</Card>
</CardGrid>

## Transparency Lock

When **Transparency Lock** is enabled on the active raster layer, the Gradient tool respects it:

- The gradient only affects pixels that already have opacity on the layer.
- Fully transparent areas stay transparent.
- This applies to both **Linear** and **Radial** gradients.

Use this when you want to recolor or shade existing painted content without spilling into empty transparent regions of
the layer.

## Clip Gradient

The toolbar includes a **Clip Gradient** toggle:
Expand All @@ -65,11 +76,12 @@ In practice:

- Use **Linear** for sky fades, shadow ramps, and broad directional lighting.
- Use **Radial** for vignettes, glows, spotlights, and soft falloff around a focal point.
- Enable **Transparency Lock** when you want the gradient to stay inside existing painted pixels.
- Disable **Clip Gradient** when you want a full-bbox color transition.
- Keep **Clip Gradient** enabled when you only want to affect a localized area.

## Summary

The Gradient tool is a raster-only canvas tool for painting linear and radial color transitions. Use it when you want
soft blends between your FG and BG colors, and use **Clip Gradient** to decide whether the effect stays local or fills
the full bbox.
soft blends between your FG and BG colors, use **Transparency Lock** to keep the effect inside existing opaque pixels,
and use **Clip Gradient** to decide whether the effect stays local or fills the full bbox.
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 { CanvasGradientState } 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 CanvasObjectGradient extends CanvasModuleBase {
readonly type = 'object_gradient';
readonly id: string;
Expand Down Expand Up @@ -35,7 +38,12 @@ export class CanvasObjectGradient extends CanvasModuleBase {

this.konva = {
group: new Konva.Group({ name: `${this.type}:group`, listening: false }),
rect: new Konva.Rect({ name: `${this.type}:rect`, listening: false, perfectDrawEnabled: false }),
rect: new Konva.Rect({
name: `${this.type}:rect`,
listening: false,
globalCompositeOperation: (state.globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation,
perfectDrawEnabled: false,
}),
};
this.konva.group.add(this.konva.rect);
this.state = state;
Expand All @@ -55,6 +63,7 @@ export class CanvasObjectGradient extends CanvasModuleBase {
y: rect.y,
width: rect.width,
height: rect.height,
globalCompositeOperation: (state.globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation,
});

this.konva.group.clipFunc((ctx) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { rgbaColorToString } from 'common/util/colorCodeTransformers';
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 { getTransparencyLockedCompositeOperation } from 'features/controlLayers/konva/CanvasTool/transparencyLocking';
import {
alignCoordForTool,
getLastPointOfLastLine,
Expand Down Expand Up @@ -211,10 +212,7 @@ 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;
const globalCompositeOperation = getTransparencyLockedCompositeOperation(selectedEntity.state);

if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
// If the pen is down and pressure sensitivity is enabled, add the point with pressure
Expand Down Expand Up @@ -275,10 +273,7 @@ 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;
const globalCompositeOperation = getTransparencyLockedCompositeOperation(selectedEntity.state);

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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';

import { buildGradientBufferState } from './gradientBufferState';

describe('CanvasGradientToolModule', () => {
it('preserves source-atop for locked linear gradients', () => {
const gradient = buildGradientBufferState({
id: 'gradient:test',
gradientType: 'linear',
rect: { x: 0, y: 0, width: 128, height: 64 },
start: { x: 10, y: 20 },
end: { x: 10, y: 20 },
clipCenter: { x: 10, y: 20 },
clipRadius: 0,
clipAngle: 0,
clipEnabled: true,
bboxRect: { x: 0, y: 0, width: 128, height: 64 },
fgColor: { r: 255, g: 0, b: 0, a: 1 },
bgColor: { r: 0, g: 0, b: 255, a: 0 },
globalCompositeOperation: 'source-atop',
});

expect(gradient.globalCompositeOperation).toBe('source-atop');
if (gradient.gradientType !== 'linear') {
throw new Error('Expected a linear gradient');
}
expect(gradient.end).toEqual({ x: 11, y: 20 });
expect(gradient.clipRadius).toBe(1);
});

it('preserves source-atop for locked radial gradients', () => {
const gradient = buildGradientBufferState({
id: 'gradient:test',
gradientType: 'radial',
rect: { x: 0, y: 0, width: 128, height: 64 },
center: { x: 32, y: 24 },
radius: 0,
clipCenter: { x: 32, y: 24 },
clipRadius: 0,
clipEnabled: false,
bboxRect: { x: 0, y: 0, width: 128, height: 64 },
fgColor: { r: 255, g: 0, b: 0, a: 1 },
bgColor: { r: 0, g: 0, b: 255, a: 0 },
globalCompositeOperation: 'source-atop',
});

expect(gradient.globalCompositeOperation).toBe('source-atop');
if (gradient.gradientType !== 'radial') {
throw new Error('Expected a radial gradient');
}
expect(gradient.radius).toBe(1);
expect(gradient.clipRadius).toBe(1);
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
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 { buildGradientBufferState } from 'features/controlLayers/konva/CanvasTool/gradientBufferState';
import { getTransparencyLockedCompositeOperation } from 'features/controlLayers/konva/CanvasTool/transparencyLocking';
import { getPrefixedId, offsetCoord } from 'features/controlLayers/konva/util';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Logger } from 'roarr';
Expand Down Expand Up @@ -156,6 +158,7 @@ export class CanvasGradientToolModule extends CanvasModuleBase {
const activeColor = settings.activeColor === 'bgColor' ? settings.bgColor : settings.fgColor;
const inactiveColor = settings.activeColor === 'bgColor' ? settings.fgColor : settings.bgColor;
const clipEnabled = settings.gradientClipEnabled;
const globalCompositeOperation = getTransparencyLockedCompositeOperation(selectedEntity.state);

if (settings.gradientType === 'radial') {
let radialRect = rect;
Expand All @@ -165,40 +168,40 @@ export class CanvasGradientToolModule extends CanvasModuleBase {
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,
});
await selectedEntity.bufferRenderer.setBuffer(
buildGradientBufferState({
id,
gradientType: 'radial',
rect: radialRect,
center: radialCenter,
radius: radialRadius,
clipCenter: start,
clipRadius: radius,
clipEnabled,
bboxRect: bboxInLayer,
fgColor: activeColor,
bgColor: inactiveColor,
globalCompositeOperation,
})
);
} 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,
});
await selectedEntity.bufferRenderer.setBuffer(
buildGradientBufferState({
id,
gradientType: 'linear',
rect,
start: startInRect,
end: endInRect,
clipCenter: start,
clipRadius: radius,
clipAngle: angle,
clipEnabled,
bboxRect: bboxInLayer,
fgColor: activeColor,
bgColor: inactiveColor,
globalCompositeOperation,
})
);
}
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type {
CanvasGradientState,
CompositeOperation,
Coordinate,
Rect,
RgbaColor,
} from 'features/controlLayers/store/types';

type BuildRadialGradientBufferStateArg = {
id: string;
gradientType: 'radial';
rect: Rect;
center: Coordinate;
radius: number;
clipCenter: Coordinate;
clipRadius: number;
clipEnabled: boolean;
bboxRect: Rect;
fgColor: RgbaColor;
bgColor: RgbaColor;
globalCompositeOperation?: CompositeOperation;
};

type BuildLinearGradientBufferStateArg = {
id: string;
gradientType: 'linear';
rect: Rect;
start: Coordinate;
end: Coordinate;
clipCenter: Coordinate;
clipRadius: number;
clipAngle: number;
clipEnabled: boolean;
bboxRect: Rect;
fgColor: RgbaColor;
bgColor: RgbaColor;
globalCompositeOperation?: CompositeOperation;
};

type BuildGradientBufferStateArg = BuildRadialGradientBufferStateArg | BuildLinearGradientBufferStateArg;

export const buildGradientBufferState = (arg: BuildGradientBufferStateArg): CanvasGradientState => {
if (arg.gradientType === 'radial') {
return {
id: arg.id,
type: 'gradient',
gradientType: 'radial',
rect: arg.rect,
center: arg.center,
radius: Math.max(1, arg.radius),
clipCenter: arg.clipCenter,
clipRadius: Math.max(1, arg.clipRadius),
clipEnabled: arg.clipEnabled,
bboxRect: arg.bboxRect,
fgColor: arg.fgColor,
bgColor: arg.bgColor,
globalCompositeOperation: arg.globalCompositeOperation,
};
}

const end = arg.end.x === arg.start.x && arg.end.y === arg.start.y ? { x: arg.end.x + 1, y: arg.end.y } : arg.end;

return {
id: arg.id,
type: 'gradient',
gradientType: 'linear',
rect: arg.rect,
start: arg.start,
end,
clipCenter: arg.clipCenter,
clipRadius: Math.max(1, arg.clipRadius),
clipAngle: arg.clipAngle,
clipEnabled: arg.clipEnabled,
bboxRect: arg.bboxRect,
fgColor: arg.fgColor,
bgColor: arg.bgColor,
globalCompositeOperation: arg.globalCompositeOperation,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { CanvasEntityState } from 'features/controlLayers/store/types';
import { describe, expect, it } from 'vitest';

import { getTransparencyLockedCompositeOperation } from './transparencyLocking';

describe('transparency locking', () => {
it('uses source-atop for locked raster layers', () => {
const entity = {
type: 'raster_layer',
isTransparencyLocked: true,
} as CanvasEntityState;

expect(getTransparencyLockedCompositeOperation(entity)).toBe('source-atop');
});

it('does not change compositing for unlocked or non-raster entities', () => {
const unlockedRasterLayer = {
type: 'raster_layer',
isTransparencyLocked: false,
} as CanvasEntityState;
const controlLayer = {
type: 'control_layer',
isTransparencyLocked: true,
} as CanvasEntityState;

expect(getTransparencyLockedCompositeOperation(unlockedRasterLayer)).toBeUndefined();
expect(getTransparencyLockedCompositeOperation(controlLayer)).toBeUndefined();
expect(getTransparencyLockedCompositeOperation(null)).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { CanvasEntityState, CompositeOperation } from 'features/controlLayers/store/types';

export const getTransparencyLockedCompositeOperation = (
entity: CanvasEntityState | null | undefined
): CompositeOperation | undefined => {
if (entity?.type === 'raster_layer' && entity.isTransparencyLocked) {
return 'source-atop';
}

return undefined;
};
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ const zCanvasLinearGradientState = z.object({
bboxRect: zRect,
fgColor: zRgbaColor,
bgColor: zRgbaColor,
globalCompositeOperation: z.string().optional(),
});
const zCanvasRadialGradientState = z.object({
id: zId,
Expand All @@ -329,6 +330,7 @@ const zCanvasRadialGradientState = z.object({
bboxRect: zRect,
fgColor: zRgbaColor,
bgColor: zRgbaColor,
globalCompositeOperation: z.string().optional(),
});
const zCanvasGradientState = z.discriminatedUnion('gradientType', [
zCanvasLinearGradientState,
Expand Down
Loading