feat(ui): initial pressure sensitivity implementation

This commit is contained in:
psychedelicious
2024-10-01 22:51:01 +10:00
parent 84e2c0d73c
commit 904d64e2a0
12 changed files with 576 additions and 108 deletions

View File

@@ -166,8 +166,8 @@ export const createStore = (uniqueStoreKey?: string, persist = true) =>
reducer: rememberedRootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: import.meta.env.MODE === 'development',
immutableCheck: import.meta.env.MODE === 'development',
serializableCheck: false, // import.meta.env.MODE === 'development',
immutableCheck: false, // import.meta.env.MODE === 'development',
})
.concat(api.middleware)
.concat(dynamicMiddlewares)

View File

@@ -3,7 +3,9 @@ import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEnt
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasObjectBrushLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLine';
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 { 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';
@@ -113,6 +115,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 === 'brush_line_with_pressure') {
assert(this.renderer instanceof CanvasObjectBrushLineWithPressure || !this.renderer);
if (!this.renderer) {
this.renderer = new CanvasObjectBrushLineWithPressure(this.state, this);
this.konva.group.add(this.renderer.konva.group);
}
didRender = this.renderer.update(this.state, true);
} else if (this.state.type === 'eraser_line') {
assert(this.renderer instanceof CanvasObjectEraserLine || !this.renderer);
@@ -122,6 +133,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 === 'eraser_line_with_pressure') {
assert(this.renderer instanceof CanvasObjectEraserLineWithPressure || !this.renderer);
if (!this.renderer) {
this.renderer = new CanvasObjectEraserLineWithPressure(this.state, this);
this.konva.group.add(this.renderer.konva.group);
}
didRender = this.renderer.update(this.state, true);
} else if (this.state.type === 'rect') {
assert(this.renderer instanceof CanvasObjectRect || !this.renderer);
@@ -205,14 +225,18 @@ export class CanvasEntityBufferObjectRenderer extends CanvasModuleBase {
if (pushToState) {
const entityIdentifier = this.parent.entityIdentifier;
if (this.state.type === 'brush_line') {
this.manager.stateApi.addBrushLine({ entityIdentifier, brushLine: this.state });
} else if (this.state.type === 'eraser_line') {
this.manager.stateApi.addEraserLine({ entityIdentifier, eraserLine: this.state });
} else if (this.state.type === 'rect') {
this.manager.stateApi.addRect({ entityIdentifier, rect: this.state });
} else {
this.log.warn({ buffer: this.state }, 'Invalid buffer object type');
switch (this.state.type) {
case 'brush_line':
case 'brush_line_with_pressure':
this.manager.stateApi.addBrushLine({ entityIdentifier, brushLine: this.state });
break;
case 'eraser_line':
case 'eraser_line_with_pressure':
this.manager.stateApi.addEraserLine({ entityIdentifier, eraserLine: this.state });
break;
case 'rect':
this.manager.stateApi.addRect({ entityIdentifier, rect: this.state });
break;
}
}

View File

@@ -6,7 +6,9 @@ import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEnt
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasObjectBrushLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLine';
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 { 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';
@@ -285,6 +287,16 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
this.konva.objectGroup.add(renderer.konva.group);
}
didRender = renderer.update(objectState, force || isFirstRender);
} else if (objectState.type === 'brush_line_with_pressure') {
assert(renderer instanceof CanvasObjectBrushLineWithPressure || !renderer);
if (!renderer) {
renderer = new CanvasObjectBrushLineWithPressure(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 === 'eraser_line') {
assert(renderer instanceof CanvasObjectEraserLine || !renderer);
@@ -295,6 +307,16 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
this.konva.objectGroup.add(renderer.konva.group);
}
didRender = renderer.update(objectState, force || isFirstRender);
} else if (objectState.type === 'eraser_line_with_pressure') {
assert(renderer instanceof CanvasObjectEraserLineWithPressure || !renderer);
if (!renderer) {
renderer = new CanvasObjectEraserLineWithPressure(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 === 'rect') {
assert(renderer instanceof CanvasObjectRect || !renderer);

View File

@@ -0,0 +1,96 @@
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 { getSVGPathDataFromPoints } from 'features/controlLayers/konva/util';
import type { CanvasBrushLineWithPressureState } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { Logger } from 'roarr';
export class CanvasObjectBrushLineWithPressure extends CanvasModuleBase {
readonly type = 'object_brush_line_with_pressure';
readonly id: string;
readonly path: string[];
readonly parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer;
readonly manager: CanvasManager;
readonly log: Logger;
state: CanvasBrushLineWithPressureState;
konva: {
group: Konva.Group;
line: Konva.Path;
};
constructor(
state: CanvasBrushLineWithPressureState,
parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer
) {
super();
const { id, clip } = state;
this.id = 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`,
clip,
listening: false,
}),
line: new Konva.Path({
name: `${this.type}:path`,
listening: false,
shadowForStrokeEnabled: false,
globalCompositeOperation: 'source-over',
}),
};
this.konva.group.add(this.konva.line);
this.state = state;
}
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;
this.konva.line.setAttrs({
data: getSVGPathDataFromPoints(points, {
size: strokeWidth / 2,
simulatePressure: false,
last: true,
thinning: 1,
}),
fill: rgbaColorToString(color),
});
this.state = state;
return true;
}
return false;
}
setVisibility(isVisible: boolean): void {
this.log.trace({ isVisible }, 'Setting brush line 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,
state: deepClone(this.state),
};
};
}

View File

@@ -0,0 +1,95 @@
import { deepClone } from 'common/util/deepClone';
import type { CanvasEntityBufferObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer';
import { CanvasEntityObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getSVGPathDataFromPoints } from 'features/controlLayers/konva/util';
import type { CanvasEraserLineWithPressureState } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { Logger } from 'roarr';
export class CanvasObjectEraserLineWithPressure extends CanvasModuleBase {
readonly type = 'object_eraser_line_with_pressure';
readonly id: string;
readonly path: string[];
readonly parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer;
readonly manager: CanvasManager;
readonly log: Logger;
state: CanvasEraserLineWithPressureState;
konva: {
group: Konva.Group;
line: Konva.Path;
};
constructor(
state: CanvasEraserLineWithPressureState,
parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer
) {
super();
const { id, clip } = state;
this.id = 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`,
clip,
listening: false,
}),
line: new Konva.Path({
name: `${this.type}:path`,
listening: false,
fill: 'red', // Eraser lines use compositing, does not matter what color they have
shadowForStrokeEnabled: false,
globalCompositeOperation: 'destination-out',
}),
};
this.konva.group.add(this.konva.line);
this.state = state;
}
update(state: CanvasEraserLineWithPressureState, force = false): boolean {
if (force || this.state !== state) {
this.log.trace({ state }, 'Updating eraser line with pressure');
const { points, strokeWidth } = state;
this.konva.line.setAttrs({
data: getSVGPathDataFromPoints(points, {
size: strokeWidth / 2,
simulatePressure: false,
last: this.parent instanceof CanvasEntityObjectRenderer,
thinning: 1,
}),
});
this.state = state;
return true;
}
return false;
}
setVisibility(isVisible: boolean): void {
this.log.trace({ isVisible }, 'Setting eraser line 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,
state: deepClone(this.state),
};
};
}

View File

@@ -1,10 +1,14 @@
import type { CanvasObjectBrushLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLine';
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 { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage';
import type { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect';
import type {
CanvasBrushLineState,
CanvasBrushLineWithPressureState,
CanvasEraserLineState,
CanvasEraserLineWithPressureState,
CanvasImageState,
CanvasRectState,
} from 'features/controlLayers/store/types';
@@ -15,9 +19,17 @@ import type {
export type AnyObjectRenderer =
| CanvasObjectBrushLine
| CanvasObjectBrushLineWithPressure
| CanvasObjectEraserLine
| CanvasObjectEraserLineWithPressure
| CanvasObjectRect
| CanvasObjectImage; /**
* Union of all object states.
*/
export type AnyObjectState = CanvasBrushLineState | CanvasEraserLineState | CanvasImageState | CanvasRectState;
export type AnyObjectState =
| CanvasBrushLineState
| CanvasBrushLineWithPressureState
| CanvasEraserLineState
| CanvasEraserLineWithPressureState
| CanvasImageState
| CanvasRectState;

View File

@@ -105,6 +105,7 @@ export class CanvasToolBrush extends CanvasModuleBase {
y: alignedCursorPos.y,
radius,
fill: rgbaColorToString(brushPreviewFill),
visible: !this.manager.tool.$isMouseDown.get(),
});
// But the borders are in screen-pixels

View File

@@ -9,6 +9,7 @@ import {
floorCoord,
getIsPrimaryMouseDown,
getLastPointOfLastLine,
getLastPointOfLastLineWithPressure,
getLastPointOfLine,
getPrefixedId,
getScaledCursorPosition,
@@ -302,14 +303,12 @@ export class CanvasToolModule extends CanvasModuleBase {
};
onStagePointerEnter = async (e: KonvaEventObject<PointerEvent>) => {
e.evt.preventDefault();
if (!this.getCanDraw()) {
return;
}
const cursorPos = this.syncLastCursorPos();
try {
if (!this.getCanDraw()) {
return;
}
const cursorPos = this.syncLastCursorPos();
const isMouseDown = this.$isMouseDown.get();
const settings = this.manager.stateApi.getSettings();
const tool = this.$tool.get();
@@ -327,14 +326,25 @@ export class CanvasToolModule extends CanvasModuleBase {
if (tool === 'brush') {
const normalizedPoint = offsetCoord(cursorPos, selectedEntity.state.position);
const alignedPoint = alignCoordForTool(normalizedPoint, settings.brushWidth);
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('brush_line'),
type: 'brush_line',
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.getClip(selectedEntity.state),
});
if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('brush_line_with_pressure'),
type: 'brush_line_with_pressure',
points: [alignedPoint.x, alignedPoint.y, e.evt.pressure],
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.getClip(selectedEntity.state),
});
} else {
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('brush_line'),
type: 'brush_line',
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.getClip(selectedEntity.state),
});
}
return;
}
@@ -344,13 +354,23 @@ export class CanvasToolModule extends CanvasModuleBase {
if (selectedEntity.bufferRenderer.state && selectedEntity.bufferRenderer.hasBuffer()) {
selectedEntity.bufferRenderer.commitBuffer();
}
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('eraser_line'),
type: 'eraser_line',
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: settings.eraserWidth,
clip: this.getClip(selectedEntity.state),
});
if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('eraser_line_with_pressure'),
type: 'eraser_line_with_pressure',
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: settings.eraserWidth,
clip: this.getClip(selectedEntity.state),
});
} else {
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('eraser_line'),
type: 'eraser_line',
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: settings.eraserWidth,
clip: this.getClip(selectedEntity.state),
});
}
return;
}
} finally {
@@ -359,16 +379,13 @@ export class CanvasToolModule extends CanvasModuleBase {
};
onStagePointerDown = async (e: KonvaEventObject<PointerEvent>) => {
e.evt.preventDefault();
if (!this.getCanDraw()) {
return;
}
this.$isMouseDown.set(getIsPrimaryMouseDown(e));
const cursorPos = this.syncLastCursorPos();
try {
if (!this.getCanDraw()) {
return;
}
this.$isMouseDown.set(getIsPrimaryMouseDown(e));
const cursorPos = this.syncLastCursorPos();
const tool = this.$tool.get();
const settings = this.manager.stateApi.getSettings();
@@ -390,36 +407,57 @@ export class CanvasToolModule extends CanvasModuleBase {
const normalizedPoint = offsetCoord(cursorPos, selectedEntity.state.position);
if (tool === 'brush') {
const lastLinePoint = getLastPointOfLastLine(selectedEntity.state.objects, 'brush_line');
const alignedPoint = alignCoordForTool(normalizedPoint, settings.brushWidth);
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
const lastLinePoint = getLastPointOfLastLineWithPressure(
selectedEntity.state.objects,
'brush_line_with_pressure'
);
const alignedPoint = alignCoordForTool(normalizedPoint, settings.brushWidth);
if (selectedEntity.bufferRenderer.hasBuffer()) {
selectedEntity.bufferRenderer.commitBuffer();
}
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('brush_line'),
type: 'brush_line',
points: [
// The last point of the last line is already normalized to the entity's coordinates
let points: number[];
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
points = [
lastLinePoint.x,
lastLinePoint.y,
lastLinePoint.pressure,
alignedPoint.x,
alignedPoint.y,
],
e.evt.pressure,
];
} else {
points = [alignedPoint.x, alignedPoint.y, e.evt.pressure];
}
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('brush_line_with_pressure'),
type: 'brush_line_with_pressure',
points,
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.getClip(selectedEntity.state),
});
} else {
const lastLinePoint = getLastPointOfLastLine(selectedEntity.state.objects, 'brush_line');
const alignedPoint = alignCoordForTool(normalizedPoint, settings.brushWidth);
if (selectedEntity.bufferRenderer.hasBuffer()) {
selectedEntity.bufferRenderer.commitBuffer();
}
let points: number[];
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
points = [lastLinePoint.x, lastLinePoint.y, alignedPoint.x, alignedPoint.y];
} else {
points = [alignedPoint.x, alignedPoint.y];
}
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('brush_line'),
type: 'brush_line',
points: [alignedPoint.x, alignedPoint.y],
points,
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.getClip(selectedEntity.state),
@@ -428,34 +466,56 @@ export class CanvasToolModule extends CanvasModuleBase {
}
if (tool === 'eraser') {
const lastLinePoint = getLastPointOfLastLine(selectedEntity.state.objects, 'eraser_line');
const alignedPoint = alignCoordForTool(normalizedPoint, settings.eraserWidth);
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
const lastLinePoint = getLastPointOfLastLineWithPressure(
selectedEntity.state.objects,
'eraser_line_with_pressure'
);
const alignedPoint = alignCoordForTool(normalizedPoint, settings.eraserWidth);
if (selectedEntity.bufferRenderer.hasBuffer()) {
selectedEntity.bufferRenderer.commitBuffer();
}
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('eraser_line'),
type: 'eraser_line',
points: [
// The last point of the last line is already normalized to the entity's coordinates
let points: number[];
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
points = [
lastLinePoint.x,
lastLinePoint.y,
lastLinePoint.pressure,
alignedPoint.x,
alignedPoint.y,
],
e.evt.pressure,
];
} else {
points = [alignedPoint.x, alignedPoint.y, e.evt.pressure];
}
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('eraser_line_with_pressure'),
type: 'eraser_line_with_pressure',
points,
strokeWidth: settings.eraserWidth,
clip: this.getClip(selectedEntity.state),
});
} else {
const lastLinePoint = getLastPointOfLastLine(selectedEntity.state.objects, 'eraser_line');
const alignedPoint = alignCoordForTool(normalizedPoint, settings.eraserWidth);
if (selectedEntity.bufferRenderer.hasBuffer()) {
selectedEntity.bufferRenderer.commitBuffer();
}
let points: number[];
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
points = [lastLinePoint.x, lastLinePoint.y, alignedPoint.x, alignedPoint.y];
} else {
points = [alignedPoint.x, alignedPoint.y];
}
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('eraser_line'),
type: 'eraser_line',
points: [alignedPoint.x, alignedPoint.y],
points,
strokeWidth: settings.eraserWidth,
clip: this.getClip(selectedEntity.state),
});
@@ -478,14 +538,12 @@ export class CanvasToolModule extends CanvasModuleBase {
}
};
onStagePointerUp = (e: KonvaEventObject<PointerEvent>) => {
e.evt.preventDefault();
if (!this.getCanDraw()) {
return;
}
onStagePointerUp = (_: KonvaEventObject<PointerEvent>) => {
try {
if (!this.getCanDraw()) {
return;
}
this.$isMouseDown.set(false);
const cursorPos = this.syncLastCursorPos();
if (!cursorPos) {
@@ -499,7 +557,11 @@ export class CanvasToolModule extends CanvasModuleBase {
const tool = this.$tool.get();
if (tool === 'brush') {
if (selectedEntity.bufferRenderer.state?.type === 'brush_line' && selectedEntity.bufferRenderer.hasBuffer()) {
if (
(selectedEntity.bufferRenderer.state?.type === 'brush_line' ||
selectedEntity.bufferRenderer.state?.type === 'brush_line_with_pressure') &&
selectedEntity.bufferRenderer.hasBuffer()
) {
selectedEntity.bufferRenderer.commitBuffer();
} else {
selectedEntity.bufferRenderer.clearBuffer();
@@ -507,7 +569,11 @@ export class CanvasToolModule extends CanvasModuleBase {
}
if (tool === 'eraser') {
if (selectedEntity.bufferRenderer.state?.type === 'eraser_line' && selectedEntity.bufferRenderer.hasBuffer()) {
if (
(selectedEntity.bufferRenderer.state?.type === 'eraser_line' ||
selectedEntity.bufferRenderer.state?.type === 'eraser_line_with_pressure') &&
selectedEntity.bufferRenderer.hasBuffer()
) {
selectedEntity.bufferRenderer.commitBuffer();
} else {
selectedEntity.bufferRenderer.clearBuffer();
@@ -527,13 +593,11 @@ export class CanvasToolModule extends CanvasModuleBase {
};
onStagePointerMove = async (e: KonvaEventObject<PointerEvent>) => {
e.evt.preventDefault();
if (!this.getCanDraw()) {
return;
}
try {
if (!this.getCanDraw()) {
return;
}
const tool = this.$tool.get();
const cursorPos = this.syncLastCursorPos();
@@ -561,7 +625,7 @@ export class CanvasToolModule extends CanvasModuleBase {
const settings = this.manager.stateApi.getSettings();
if (tool === 'brush' && bufferState.type === 'brush_line') {
if (tool === 'brush' && (bufferState.type === 'brush_line' || bufferState.type === 'brush_line_with_pressure')) {
const lastPoint = getLastPointOfLine(bufferState.points);
const minDistance = settings.brushWidth * this.config.BRUSH_SPACING_TARGET_SCALE;
if (!lastPoint || !isDistanceMoreThanMin(cursorPos, lastPoint, minDistance)) {
@@ -577,8 +641,16 @@ export class CanvasToolModule extends CanvasModuleBase {
}
bufferState.points.push(alignedPoint.x, alignedPoint.y);
if (bufferState.type === 'brush_line_with_pressure') {
bufferState.points.push(e.evt.pressure);
}
await selectedEntity.bufferRenderer.setBuffer(bufferState);
} else if (tool === 'eraser' && bufferState.type === 'eraser_line') {
} else if (
tool === 'eraser' &&
(bufferState.type === 'eraser_line' || bufferState.type === 'eraser_line_with_pressure')
) {
const lastPoint = getLastPointOfLine(bufferState.points);
const minDistance = settings.eraserWidth * this.config.BRUSH_SPACING_TARGET_SCALE;
if (!lastPoint || !isDistanceMoreThanMin(cursorPos, lastPoint, minDistance)) {
@@ -594,6 +666,11 @@ export class CanvasToolModule extends CanvasModuleBase {
}
bufferState.points.push(alignedPoint.x, alignedPoint.y);
if (bufferState.type === 'eraser_line_with_pressure') {
bufferState.points.push(e.evt.pressure);
}
await selectedEntity.bufferRenderer.setBuffer(bufferState);
} else if (tool === 'rect' && bufferState.type === 'rect') {
const normalizedPoint = offsetCoord(cursorPos, selectedEntity.state.position);
@@ -609,25 +686,26 @@ export class CanvasToolModule extends CanvasModuleBase {
}
};
onStagePointerLeave = (e: KonvaEventObject<PointerEvent>) => {
e.evt.preventDefault();
onStagePointerLeave = (_: PointerEvent) => {
try {
this.$cursorPos.set(null);
if (!this.getCanDraw()) {
return;
if (!this.getCanDraw()) {
return;
}
const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter();
if (
selectedEntity &&
selectedEntity.bufferRenderer.state?.type !== 'rect' &&
selectedEntity.bufferRenderer.hasBuffer()
) {
selectedEntity.bufferRenderer.commitBuffer();
}
} finally {
this.render();
}
this.$cursorPos.set(null);
const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter();
if (
selectedEntity &&
selectedEntity.bufferRenderer.state?.type !== 'rect' &&
selectedEntity.bufferRenderer.hasBuffer()
) {
selectedEntity.bufferRenderer.commitBuffer();
}
this.render();
};
onStageMouseWheel = (e: KonvaEventObject<WheelEvent>) => {

View File

@@ -6,6 +6,8 @@ import type { KonvaEventObject } from 'konva/lib/Node';
import type { Vector2d } from 'konva/lib/types';
import { clamp } from 'lodash-es';
import { customAlphabet } from 'nanoid';
import type { StrokeOptions } from 'perfect-freehand';
import getStroke from 'perfect-freehand';
import { assert } from 'tsafe';
/**
@@ -148,14 +150,32 @@ export const getLastPointOfLine = (points: number[]): Coordinate | null => {
if (points.length < 2) {
return null;
}
const x = points[points.length - 2];
const y = points[points.length - 1];
const x = points.at(-2);
const y = points.at(-1);
if (x === undefined || y === undefined) {
return null;
}
return { x, y };
};
/**
* Gets the last point of a line as a coordinate.
* @param points An array of numbers representing points as [x1, y1, x2, y2, ...]
* @returns The last point of the line as a coordinate, or null if the line has less than 1 point
*/
export const getLastPointOfLineWithPressure = (points: number[]): CoordinateWithPressure | null => {
if (points.length < 3) {
return null;
}
const x = points.at(-3);
const y = points.at(-2);
const pressure = points.at(-1);
if (x === undefined || y === undefined || pressure === undefined) {
return null;
}
return { x, y, pressure };
};
export function getIsPrimaryMouseDown(e: KonvaEventObject<MouseEvent>) {
return e.evt.buttons === 1;
}
@@ -436,7 +456,9 @@ export function loadImage(src: string): Promise<HTMLImageElement> {
*/
export const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 10);
export function getPrefixedId(prefix: CanvasEntityIdentifier['type'] | (string & Record<never, never>)): string {
export function getPrefixedId(
prefix: CanvasEntityIdentifier['type'] | CanvasObjectState['type'] | (string & Record<never, never>)
): string {
return `${prefix}:${nanoid()}`;
}
@@ -492,11 +514,32 @@ export const exhaustiveCheck = (value: never): never => {
assert(false, `Unhandled value: ${value}`);
};
type CoordinateWithPressure = {
x: number;
y: number;
pressure: number;
};
export const getLastPointOfLastLineWithPressure = (
objects: CanvasObjectState[],
type: 'brush_line_with_pressure' | 'eraser_line_with_pressure'
): CoordinateWithPressure | null => {
const lastObject = objects.at(-1);
if (!lastObject) {
return null;
}
if (lastObject.type === type) {
return getLastPointOfLineWithPressure(lastObject.points);
}
return null;
};
export const getLastPointOfLastLine = (
objects: CanvasObjectState[],
type: 'brush_line' | 'eraser_line'
): Coordinate | null => {
const lastObject = objects[objects.length - 1];
const lastObject = objects.at(-1);
if (!lastObject) {
return null;
}
@@ -540,3 +583,53 @@ export const getKonvaNodeDebugAttrs = (node: Konva.Node) => {
rotation: node.rotation(),
};
};
const average = (a: number, b: number) => (a + b) / 2;
function getSvgPathFromStroke(points: number[][], closed = true) {
const len = points.length;
if (len < 4) {
return '';
}
let a = points[0] as number[];
let b = points[1] as number[];
const c = points[2] as number[];
let result = `M${a[0]!.toFixed(2)},${a[1]!.toFixed(2)} Q${b[0]!.toFixed(
2
)},${b[1]!.toFixed(2)} ${average(b[0]!, c[0]!).toFixed(2)},${average(b[1]!, c[1]!).toFixed(2)} T`;
for (let i = 2, max = len - 1; i < max; i++) {
a = points[i]!;
b = points[i + 1]!;
result += `${average(a[0]!, b[0]!).toFixed(2)},${average(a[1]!, b[1]!).toFixed(2)} `;
}
if (closed) {
result += 'Z';
}
return result;
}
export const getSVGPathDataFromPoints = (points: number[], options?: StrokeOptions): string => {
const chunked: [number, number, number][] = [];
for (let i = 0; i < points.length; i += 3) {
chunked.push([points[i]!, points[i + 1]!, points[i + 2]!]);
}
return getSvgPathFromStroke(getStroke(chunked, options));
};
export const getPointerType = (e: KonvaEventObject<PointerEvent>): 'mouse' | 'pen' | 'touch' => {
if (e.evt.pointerType === 'mouse') {
return 'mouse';
}
if (e.evt.pointerType === 'pen') {
return 'pen';
}
return 'touch';
};

View File

@@ -78,6 +78,10 @@ type CanvasSettingsState = {
* Whether to show only the selected layer while transforming.
*/
isolatedTransformingPreview: boolean;
/**
* Whether to use pressure sensitivity for the brush and eraser tool when a pen device is used.
*/
pressureSensitivity: boolean;
};
const initialState: CanvasSettingsState = {
@@ -98,6 +102,7 @@ const initialState: CanvasSettingsState = {
isolatedStagingPreview: true,
isolatedFilteringPreview: true,
isolatedTransformingPreview: true,
pressureSensitivity: true,
};
export const canvasSettingsSlice = createSlice({

View File

@@ -947,7 +947,11 @@ export const canvasSlice = createSlice({
// 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({ ...brushLine, points: simplifyFlatNumbersArray(brushLine.points) });
entity.objects.push({
...brushLine,
// If the brush line is not pressure sensitive, we simplify the points to reduce the size of the state
points: brushLine.type === 'brush_line' ? simplifyFlatNumbersArray(brushLine.points) : brushLine.points,
});
},
entityEraserLineAdded: (state, action: PayloadAction<EntityEraserLineAddedPayload>) => {
const { entityIdentifier, eraserLine } = action.payload;
@@ -962,7 +966,11 @@ export const canvasSlice = createSlice({
// 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({ ...eraserLine, points: simplifyFlatNumbersArray(eraserLine.points) });
entity.objects.push({
...eraserLine,
// If the brush line is not pressure sensitive, we simplify the points to reduce the size of the state
points: eraserLine.type === 'eraser_line' ? simplifyFlatNumbersArray(eraserLine.points) : eraserLine.points,
});
},
entityRectAdded: (state, action: PayloadAction<EntityRectAddedPayload>) => {
const { entityIdentifier, rect } = action.payload;

View File

@@ -58,7 +58,10 @@ const zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'view', 'bbox', 'colorP
export type Tool = z.infer<typeof zTool>;
const zPoints = z.array(z.number()).refine((points) => points.length % 2 === 0, {
message: 'Must have an even number of points',
message: 'Must have an even number of coordinate components',
});
const zPointsWithPressure = z.array(z.number()).refine((points) => points.length % 3 === 0, {
message: 'Must have a number of components divisible by 3',
});
const zRgbColor = z.object({
@@ -110,6 +113,19 @@ const zCanvasBrushLineState = z.object({
});
export type CanvasBrushLineState = z.infer<typeof zCanvasBrushLineState>;
const zCanvasBrushLineWithPressureState = z.object({
id: zId,
type: z.literal('brush_line_with_pressure'),
strokeWidth: z.number().min(1),
points: zPointsWithPressure,
// thinning: z.number().min(0).max(1),
// streamline: z.number().min(0).max(1),
// smoothing: z.number().min(0).max(1),
color: zRgbaColor,
clip: zRect.nullable(),
});
export type CanvasBrushLineWithPressureState = z.infer<typeof zCanvasBrushLineWithPressureState>;
const zCanvasEraserLineState = z.object({
id: zId,
type: z.literal('eraser_line'),
@@ -119,6 +135,18 @@ const zCanvasEraserLineState = z.object({
});
export type CanvasEraserLineState = z.infer<typeof zCanvasEraserLineState>;
const zCanvasEraserLineWithPressureState = z.object({
id: zId,
type: z.literal('eraser_line_with_pressure'),
strokeWidth: z.number().min(1),
points: zPointsWithPressure,
// thinning: z.number().min(0).max(1),
// streamline: z.number().min(0).max(1),
// smoothing: z.number().min(0).max(1),
clip: zRect.nullable(),
});
export type CanvasEraserLineWithPressureState = z.infer<typeof zCanvasEraserLineWithPressureState>;
const zCanvasRectState = z.object({
id: zId,
type: z.literal('rect'),
@@ -139,6 +167,8 @@ const zCanvasObjectState = z.union([
zCanvasBrushLineState,
zCanvasEraserLineState,
zCanvasRectState,
zCanvasBrushLineWithPressureState,
zCanvasEraserLineWithPressureState,
]);
export type CanvasObjectState = z.infer<typeof zCanvasObjectState>;
@@ -359,8 +389,12 @@ export type EntityIdentifierPayload<
} & T;
export type EntityMovedPayload = EntityIdentifierPayload<{ position: Coordinate }>;
export type EntityBrushLineAddedPayload = EntityIdentifierPayload<{ brushLine: CanvasBrushLineState }>;
export type EntityEraserLineAddedPayload = EntityIdentifierPayload<{ eraserLine: CanvasEraserLineState }>;
export type EntityBrushLineAddedPayload = EntityIdentifierPayload<{
brushLine: CanvasBrushLineState | CanvasBrushLineWithPressureState;
}>;
export type EntityEraserLineAddedPayload = EntityIdentifierPayload<{
eraserLine: CanvasEraserLineState | CanvasEraserLineWithPressureState;
}>;
export type EntityRectAddedPayload = EntityIdentifierPayload<{ rect: CanvasRectState }>;
export type EntityRasterizedPayload = EntityIdentifierPayload<{
imageObject: CanvasImageState;