From e388a79446d0e391ec75ff7cc2a03b7947414901 Mon Sep 17 00:00:00 2001 From: tbolis Date: Tue, 10 Nov 2026 18:03:28 +0200 Subject: [PATCH] feat: draw the strokes of a pen as wide as the pressure on it The pressureSensitive property makes the strokes of the pencil which a pen draws as wide as the pressure on it, read from PointerEvent.pressure: lineWidth is the width at a medium pressure, and a stroke is between a tenth and twice of it. The strokes of a mouse and of a finger are drawn as before. The canvas keeps reading the mouse and the touches (the pinch zoom is made of them). A sensor listens to the pointer events of the page, which come before them, to know the type of the pointer and its pressure, and only while the property is on. A stroke is a filled path, the outline of the discs along the stroke, so that the canvas selects, moves, erases and copies it as any path. It is the type PressureStroke, and it is saved as the discs: the commands of the path are twenty times as long. The showcase has a Pen pressure switch for the pencil. --- CHANGELOG.md | 6 + README.md | 23 + .../components/SketchField/SketchField.tsx | 3 + .../tools/Highlighter/Highlighter.ts | 1 + .../SketchField/tools/Pencil/Pencil.ts | 1 + .../PressureBrush/PressureBrush.spec.tsx | 454 ++++++++++++++++++ .../tools/PressureBrush/PressureBrush.ts | 143 ++++++ .../tools/PressureBrush/PressureStroke.ts | 56 +++ .../tools/PressureBrush/geometry.spec.ts | 263 ++++++++++ .../tools/PressureBrush/geometry.ts | 200 ++++++++ .../SketchField/tools/PressureBrush/index.ts | 3 + .../SketchField/tools/PressureBrush/pen.ts | 63 +++ .../src/components/SketchField/tools/index.ts | 12 +- packages/react-sketch/src/types.ts | 8 + packages/showcase/README.md | 3 +- .../showcase/src/components/Properties.tsx | 22 + .../showcase/src/components/Workspace.tsx | 1 + packages/showcase/src/stores/config.ts | 3 + 18 files changed, 1263 insertions(+), 2 deletions(-) create mode 100644 packages/react-sketch/src/components/SketchField/tools/PressureBrush/PressureBrush.spec.tsx create mode 100644 packages/react-sketch/src/components/SketchField/tools/PressureBrush/PressureBrush.ts create mode 100644 packages/react-sketch/src/components/SketchField/tools/PressureBrush/PressureStroke.ts create mode 100644 packages/react-sketch/src/components/SketchField/tools/PressureBrush/geometry.spec.ts create mode 100644 packages/react-sketch/src/components/SketchField/tools/PressureBrush/geometry.ts create mode 100644 packages/react-sketch/src/components/SketchField/tools/PressureBrush/index.ts create mode 100644 packages/react-sketch/src/components/SketchField/tools/PressureBrush/pen.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 82ef779..cb13ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ### Added +- The `pressureSensitive` property (#136): the strokes of the pencil which a pen (a stylus) draws are as wide as + the pressure on it, read from `PointerEvent.pressure`. `lineWidth` is the width at a medium pressure, and a stroke + is between a tenth and twice of it. The strokes of a mouse and of a finger are drawn as before, and the canvas + still reads the mouse and the touches, so the pinch zoom is not changed. Such a stroke is a filled path, saved + as the type `PressureStroke` with the discs along it (a few kilobytes for a long stroke, where the commands of + the path would be a few hundred), one object and one undo step. The showcase has a Pen pressure switch for the pencil. - `toDataURL({ multiplier: "original" })` (#75): the drawing at the resolution of the background image, one pixel of the picture for each pixel of the image, at any size of the canvas. Without a background image the multiplier is 1. An image which is scaled differently in the two directions is exported at the larger diff --git a/README.md b/README.md index 0f8c314..edeb265 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,7 @@ All properties are optional. | `lineColor` | `string` | `"black"` | Color of the line. | | `lineWidth` | `number` | `10` | Width of the line. | | `lineStyle` | `Types.LineStyle` | `"solid"` | Style of the line: `"solid"`, `"dashed"`, `"dotted"` or `"dash-dot"`. See [Line style](#line-style). | +| `pressureSensitive` | `boolean` | `false` | Draw the strokes of a pen (a stylus) with a width which follows its pressure. Only the pencil has it. See [Pen pressure](#pen-pressure). | | `fillColor` | `string` | `"transparent"` | Fill color of the shapes, where applicable (rectangle, rectangle-label, circle). | | `backgroundColor` | `string` | `"transparent"` | Background color of the sketch, in hex or rgba format. | | `backgroundImage` | `string` | | URL or data URL of the background image. It is set when the component mounts and when it changes, and removed when it is gone. It takes the place of the background image of `value` and `defaultValue`. | @@ -491,6 +492,28 @@ The line is drawn on the top canvas of fabric only, it is never an object of the `toJSON()` or `toDataURL()`, it is not an undo step, `onObjectAdded` is not called for it, and `onChange` is not called when it is drawn. Switching to another tool clears what is left of the line. +### Pen pressure + +With `pressureSensitive`, the strokes of the pencil which a pen (a stylus) draws are as wide as the pressure on it: +`lineWidth` is the width at a medium pressure, a light pressure draws a thinner line (down to a tenth of it) and a +hard one a wider line (up to twice). A pen which reports a constant pressure draws a line of `lineWidth`. + +```tsx + +``` + +- The pressure is read from the pointer events of the page (`PointerEvent.pressure` and `pointerType`), which come + before the mouse and the touch events the canvas draws with, so the mouse, the touches and the pinch zoom work as + before. The strokes of a mouse and of a finger are drawn as they are, as they have no pressure. It needs a browser + which sends pointer events for the pen, which the current ones do. +- A stroke is a filled path, the outline of the stroke, and not a line with a width: it is the type `PressureStroke` + in `toJSON()` (a path of fabric, with the discs along the stroke as `discs` and not the commands of the path, + which are twenty times as long), and it is loaded as one by `value`, `defaultValue` and `fromJSON()`. A version + without this cannot load it. It is one object and one undo step, which is selected, moved, scaled, erased and + copied as any path. The color is its `fill`. +- `lineStyle` does not apply to it (it has no dashes), and neither does the straight line of the shift key. +- The highlighter does not have it. + ## Exports | Export | Description | diff --git a/packages/react-sketch/src/components/SketchField/SketchField.tsx b/packages/react-sketch/src/components/SketchField/SketchField.tsx index 84f67ba..156a556 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.tsx @@ -20,6 +20,7 @@ import { FabricCanvasTool } from "./tools"; import { create_tool } from "./tools/factory"; import { Eraser } from "./tools/Eraser"; import { Polygon } from "./tools/Polygon"; +import { releasePen } from "./tools/PressureBrush"; /** * The properties which always have a value, because SketchField provides @@ -292,6 +293,7 @@ class SketchField extends PureComponent { props.lineColor !== prevProps.lineColor || props.lineWidth !== prevProps.lineWidth || props.lineStyle !== prevProps.lineStyle || + props.pressureSensitive !== prevProps.pressureSensitive || props.fillColor !== prevProps.fillColor ) { this.configureTool(); @@ -324,6 +326,7 @@ class SketchField extends PureComponent { this.tool.dispose(); // The canvas element is released at once, so it can be used again (React // strict mode mounts twice). The rest is freed once the pending render is done. + releasePen(this.canvas); void this.canvas.dispose(); } diff --git a/packages/react-sketch/src/components/SketchField/tools/Highlighter/Highlighter.ts b/packages/react-sketch/src/components/SketchField/tools/Highlighter/Highlighter.ts index a96e9b8..b267eb2 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Highlighter/Highlighter.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Highlighter/Highlighter.ts @@ -16,6 +16,7 @@ export class Highlighter extends FabricCanvasTool { this.canvas.isDrawingMode = true; this.brush.width = props.lineWidth ? props.lineWidth : 1; this.setBrushStroke(undefined, this.brush.width); + this.setPressureSensitive(false); this.brush.color = hexToRgbA( color.startsWith("#") ? color : (colorNameToHex(color) ?? color) ); diff --git a/packages/react-sketch/src/components/SketchField/tools/Pencil/Pencil.ts b/packages/react-sketch/src/components/SketchField/tools/Pencil/Pencil.ts index 7b59185..fee5fed 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Pencil/Pencil.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Pencil/Pencil.ts @@ -14,6 +14,7 @@ export class Pencil extends FabricCanvasTool { this.brush.width = config.lineWidth ? config.lineWidth : 1; this.brush.color = config.lineColor ? config.lineColor : "black"; this.setBrushStroke(config.lineStyle, this.brush.width); + this.setPressureSensitive(config.pressureSensitive === true); } type(): Tool { diff --git a/packages/react-sketch/src/components/SketchField/tools/PressureBrush/PressureBrush.spec.tsx b/packages/react-sketch/src/components/SketchField/tools/PressureBrush/PressureBrush.spec.tsx new file mode 100644 index 0000000..e524743 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/PressureBrush/PressureBrush.spec.tsx @@ -0,0 +1,454 @@ +import { createRef } from "react"; +import { render } from "@testing-library/react"; +import * as fabric from "fabric"; +import SketchField from "../../SketchField"; +import { SketchProperties, Tool } from "../../../../types"; +import { PressureBrush } from "./PressureBrush"; +import { PressureStroke } from "./PressureStroke"; + +type Point = [number, number]; + +const mount = (props: SketchProperties = {}) => { + const ref = createRef(); + const view = render(); + const sketch = ref.current as SketchField; + const canvas = sketch["canvas"] as fabric.Canvas; + return { + ...view, + sketch, + canvas, + brush: () => canvas.freeDrawingBrush as PressureBrush, + update: (next: SketchProperties) => + view.rerender(), + }; +}; + +const fire = (canvas: fabric.Canvas, name: string, event: object): void => + (canvas as unknown as fabric.Observable>).fire(name, event); + +/** The pointer event which comes before the mouse event of the same input */ +const pointer = (type: string, pressure: number, name = "pointermove"): void => { + const event = new MouseEvent(name, { bubbles: true }); + Object.defineProperties(event, { + pointerType: { value: type }, + pressure: { value: pressure }, + }); + document.dispatchEvent(event); +}; + +interface Sample { + at: Point; + pressure?: number; +} + +/** + * A stroke as the browser delivers it: the pointer event, and then the mouse event the canvas + * reads. The pressure is of the pointer event. + */ +const stroke = (brush: PressureBrush, type: string, samples: Sample[]): void => { + const mouse = (name: string) => ({ e: new MouseEvent(name) }); + const [first, ...rest] = samples; + pointer(type, first.pressure ?? 0.5, "pointerdown"); + brush.onMouseDown(new fabric.Point(...first.at), mouse("mousedown") as never); + rest.forEach(({ at, pressure }) => { + pointer(type, pressure ?? 0.5); + brush.onMouseMove(new fabric.Point(...at), mouse("mousemove") as never); + }); + brush.onMouseUp(mouse("mouseup") as never); +}; + +const line = (from: Point, to: Point, pressure: number, parts = 8): Sample[] => + Array.from({ length: parts + 1 }, (_, i) => ({ + at: [ + from[0] + ((to[0] - from[0]) * i) / parts, + from[1] + ((to[1] - from[1]) * i) / parts, + ] as Point, + pressure, + })); + +const lastPath = (canvas: fabric.Canvas): fabric.Path => + canvas.getObjects().slice(-1)[0] as fabric.Path; + +/** The width of the stroke along it: the radius of each of its discs */ +const radii = (path: fabric.Path): number[] => + (path as PressureStroke).discs.map(([, , r]) => r); + +describe("pressureSensitive", () => { + describe("a stroke of a pen", () => { + it("is a filled path of the color, and not a line", () => { + const { canvas, brush } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + lineColor: "#ff0000", + }); + + stroke(brush(), "pen", line([20, 50], [220, 50], 0.5)); + + const path = lastPath(canvas); + expect(path).toBeInstanceOf(PressureStroke); + expect(path).toBeInstanceOf(fabric.Path); + expect(path.fill).toBe("#ff0000"); + expect(path.stroke).toBeNull(); + expect(canvas.getObjects()).toHaveLength(1); + }); + + it.each([ + [0.25, 5], + [0.5, 10], + [1, 20], + ])("is as wide as the pressure says: %p gives %p", (pressure, width) => { + const { canvas, brush } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + }); + + stroke(brush(), "pen", line([20, 80], [260, 80], pressure)); + + const { height, width: length } = lastPath(canvas).getBoundingRect(); + expect(height).toBeCloseTo(width, 0); + // The round ends: the line is as long as the stroke and half the width at each end + expect(length).toBeCloseTo(240 + width, 0); + }); + + it("is never narrower than a tenth of the line width, nor wider than twice", () => { + const { canvas, brush } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + }); + + stroke(brush(), "pen", line([20, 40], [200, 40], 0)); + stroke(brush(), "pen", line([20, 120], [200, 120], 1)); + + const [light, hard] = canvas.getObjects(); + expect(light.getBoundingRect().height).toBeCloseTo(1, 0); + expect(hard.getBoundingRect().height).toBeCloseTo(20, 0); + }); + + it("gets wider along the stroke when the pressure grows", () => { + const { canvas, brush } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + }); + const samples = Array.from({ length: 41 }, (_, i) => ({ + at: [20 + i * 6, 60] as Point, + pressure: 0.1 + (0.8 * i) / 40, + })); + + stroke(brush(), "pen", samples); + + const widths = radii(lastPath(canvas)); + expect(widths.length).toBeGreaterThan(40); + expect(widths[0]).toBeLessThan(widths[widths.length - 1] / 3); + widths + .slice(1) + .forEach((r, i) => expect(r).toBeGreaterThanOrEqual(widths[i] - 0.01)); + }); + + it("is one disc when the pen does not move", () => { + const { canvas, brush } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + }); + + stroke(brush(), "pen", [{ at: [100, 100], pressure: 0.75 }]); + + const path = lastPath(canvas); + expect(path.getBoundingRect().width).toBeCloseTo(15, 0); + expect(path.getBoundingRect().height).toBeCloseTo(15, 0); + }); + + it("is drawn where the pen was", () => { + const { canvas, brush } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + }); + + stroke(brush(), "pen", line([100, 50], [300, 90], 0.5)); + + const { left, top, width, height } = lastPath(canvas).getBoundingRect(); + expect(left).toBeCloseTo(95, 0); + expect(left + width).toBeCloseTo(305, 0); + expect(top).toBeLessThan(50); + expect(top + height).toBeGreaterThan(90); + }); + + it("keeps a color which is not opaque as it is, and draws it whole while the pen moves", () => { + const { canvas, brush } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + lineColor: "rgba(255, 0, 0, 0.5)", + }); + const clear = jest.spyOn(canvas, "clearContext"); + + stroke(brush(), "pen", line([20, 50], [220, 50], 0.5)); + + expect(lastPath(canvas).fill).toBe("rgba(255, 0, 0, 0.5)"); + // Cleared at each move, and once when the stroke was added + expect(clear.mock.calls.length).toBeGreaterThan(5); + }); + + it("is one object and one undo step, and tells it was created", () => { + const { canvas, brush, sketch } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + }); + const created = jest.fn(); + canvas.on("path:created", created); + + stroke(brush(), "pen", line([20, 50], [220, 50], 0.5)); + + expect(created).toHaveBeenCalledTimes(1); + expect(created.mock.calls[0][0].path).toBe(lastPath(canvas)); + expect(sketch.canUndo()).toBe(true); + sketch.undo(); + expect(canvas.getObjects()).toHaveLength(0); + sketch.redo(); + expect(canvas.getObjects()).toHaveLength(1); + }); + + it("is saved as the discs along it, and loaded as the same stroke", async () => { + const { canvas, brush } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + }); + stroke(brush(), "pen", line([20, 50], [220, 90], 0.75)); + const path = lastPath(canvas); + + const json = JSON.parse(JSON.stringify(path.toObject())); + const [loaded] = await fabric.util.enlivenObjects([json]); + + expect(json.type).toBe("PressureStroke"); + expect(json.path).toBeUndefined(); + expect(json.discs.length).toBeGreaterThan(10); + expect(loaded).toBeInstanceOf(PressureStroke); + expect(loaded.fill).toBe(path.fill); + expect(loaded.path).toEqual(path.path); + const [a, b] = [loaded.getBoundingRect(), path.getBoundingRect()]; + expect(a.left).toBeCloseTo(b.left, 6); + expect(a.top).toBeCloseTo(b.top, 6); + expect(a.width).toBeCloseTo(b.width, 6); + expect(a.height).toBeCloseTo(b.height, 6); + }); + + it("is saved in a few kilobytes, as a long stroke of the pencil is", () => { + const { canvas, brush } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + }); + const samples = Array.from({ length: 150 }, (_, i) => ({ + at: [20 + i * 4, 100 + 30 * Math.sin(i / 8)] as Point, + pressure: 0.3 + 0.4 * Math.abs(Math.sin(i / 20)), + })); + + stroke(brush(), "pen", samples); + + expect(JSON.stringify(lastPath(canvas).toObject()).length).toBeLessThan(12000); + }); + + it("is copied as a stroke which keeps its shape", async () => { + const { canvas, brush } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + }); + stroke(brush(), "pen", line([20, 50], [220, 90], 0.75)); + const path = lastPath(canvas) as PressureStroke; + + const copy = await path.clone(); + + expect(copy).toBeInstanceOf(PressureStroke); + expect((copy as PressureStroke).discs).toEqual(path.discs); + }); + + it("keeps its shape when it is moved, and undo and redo of the move", () => { + const { canvas, brush, sketch, update } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + }); + stroke(brush(), "pen", line([20, 50], [220, 90], 0.75)); + update({ tool: Tool.SELECT, pressureSensitive: true }); + const path = lastPath(canvas) as PressureStroke; + const before = path.getBoundingRect(); + + fire(canvas, "mouse:down", { e: new MouseEvent("mousedown"), target: path }); + path.set({ left: (path.left ?? 0) + 70, top: (path.top ?? 0) + 30 }); + path.setCoords(); + fire(canvas, "object:modified", { target: path }); + fire(canvas, "mouse:up", { e: new MouseEvent("mouseup") }); + expect(path.getBoundingRect().left).toBeCloseTo(before.left + 70, 3); + sketch.undo(); + + expect(path.getBoundingRect().left).toBeCloseTo(before.left, 3); + expect(path.getBoundingRect().top).toBeCloseTo(before.top, 3); + }); + + it("is not drawn when the canvas stops it, as it does for a second finger", () => { + const { canvas, brush } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + }); + const mouse = (name: string) => ({ e: new MouseEvent(name) }) as never; + pointer("pen", 0.5, "pointerdown"); + brush().onMouseDown(new fabric.Point(20, 20), mouse("mousedown")); + pointer("pen", 0.5); + brush().onMouseMove(new fabric.Point(80, 40), mouse("mousemove")); + (canvas as unknown as { _isCurrentlyDrawing: boolean })._isCurrentlyDrawing = + true; + + (canvas as unknown as { abortDrawing(): void }).abortDrawing(); + + expect( + (canvas as unknown as { _isCurrentlyDrawing: boolean })._isCurrentlyDrawing + ).toBe(false); + expect(canvas.getObjects()).toHaveLength(0); + // The next stroke is drawn + stroke(brush(), "pen", line([20, 50], [220, 50], 0.5)); + expect(canvas.getObjects()).toHaveLength(1); + expect(lastPath(canvas)).toBeInstanceOf(PressureStroke); + }); + + it("leaves out the points which are too close to the one before", () => { + const { canvas, brush } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + }); + const near = Array.from({ length: 30 }, () => ({ + at: [100.1, 100.1] as Point, + pressure: 0.5, + })); + + stroke(brush(), "pen", [{ at: [100, 100], pressure: 0.5 }, ...near]); + + // One disc, as the pen did not leave its place + expect(lastPath(canvas).getBoundingRect().width).toBeCloseTo(10, 0); + }); + }); + + describe("a stroke which is not of a pen", () => { + it.each(["mouse", "touch"])("is a line as before, for a %s", (type) => { + const { canvas, brush } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + lineColor: "#00aa00", + }); + + stroke(brush(), type, line([20, 50], [220, 50], 0.9)); + + const path = lastPath(canvas); + expect(path.fill).toBeNull(); + expect(path.stroke).toBe("#00aa00"); + expect(path.strokeWidth).toBe(10); + }); + + it("is a line as before, for a pen, when pressureSensitive is off", () => { + const { canvas, brush } = mount({ tool: Tool.PENCIL, lineColor: "#00aa00" }); + + stroke(brush(), "pen", line([20, 50], [220, 50], 1)); + + const path = lastPath(canvas); + expect(path.fill).toBeNull(); + expect(path.stroke).toBe("#00aa00"); + expect(path.strokeWidth).toBe(10); + }); + }); + + describe("when the property or the tool changes", () => { + it("draws a pen as a line again when it is turned off", () => { + const { canvas, brush, update } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + }); + stroke(brush(), "pen", line([20, 50], [220, 50], 1)); + + update({ tool: Tool.PENCIL, pressureSensitive: false }); + stroke(brush(), "pen", line([20, 90], [220, 90], 1)); + + const [pressed, plain] = canvas.getObjects() as fabric.Path[]; + expect(pressed.fill).not.toBeNull(); + expect(plain.fill).toBeNull(); + expect(plain.strokeWidth).toBe(10); + }); + + it("is not applied by the highlighter, which shares the brush", () => { + const { canvas, brush, update } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + }); + + update({ tool: Tool.HIGHLIGHTER, pressureSensitive: true }); + stroke(brush(), "pen", line([20, 50], [220, 50], 1)); + + const path = lastPath(canvas); + expect(path.fill).toBeNull(); + expect(path.strokeWidth).toBe(10); + }); + + it("is applied again when the pencil comes back", () => { + const { canvas, brush, update } = mount({ + tool: Tool.PENCIL, + pressureSensitive: true, + }); + update({ tool: Tool.HIGHLIGHTER, pressureSensitive: true }); + update({ tool: Tool.PENCIL, pressureSensitive: true }); + + stroke(brush(), "pen", line([20, 50], [220, 50], 1)); + + expect(lastPath(canvas).fill).not.toBeNull(); + }); + }); + + describe("the pointers of the page", () => { + it("are listened to only when the property is on", () => { + const add = jest.spyOn(document, "addEventListener"); + + mount({ tool: Tool.PENCIL }); + expect(add).not.toHaveBeenCalledWith( + "pointermove", + expect.anything(), + expect.anything() + ); + + mount({ tool: Tool.PENCIL, pressureSensitive: true }); + expect(add).toHaveBeenCalledWith( + "pointermove", + expect.anything(), + expect.anything() + ); + expect(add).toHaveBeenCalledWith( + "pointerdown", + expect.anything(), + expect.anything() + ); + }); + + it("stop being listened to when the canvas goes away", () => { + const remove = jest.spyOn(document, "removeEventListener"); + const { unmount } = mount({ tool: Tool.PENCIL, pressureSensitive: true }); + + unmount(); + + expect(remove).toHaveBeenCalledWith( + "pointermove", + expect.anything(), + expect.anything() + ); + expect(remove).toHaveBeenCalledWith( + "pointerdown", + expect.anything(), + expect.anything() + ); + }); + + it("stop being listened to when the property is turned off", () => { + const remove = jest.spyOn(document, "removeEventListener"); + const { update } = mount({ tool: Tool.PENCIL, pressureSensitive: true }); + + update({ tool: Tool.PENCIL, pressureSensitive: false }); + + expect(remove).toHaveBeenCalledWith( + "pointermove", + expect.anything(), + expect.anything() + ); + }); + }); +}); diff --git a/packages/react-sketch/src/components/SketchField/tools/PressureBrush/PressureBrush.ts b/packages/react-sketch/src/components/SketchField/tools/PressureBrush/PressureBrush.ts new file mode 100644 index 0000000..54be6f4 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/PressureBrush/PressureBrush.ts @@ -0,0 +1,143 @@ +import * as fabric from "fabric"; +import { addCapsule, StrokeSampler, type Disc } from "./geometry"; +import { penOf } from "./pen"; +import { PressureStroke, savedDisc } from "./PressureStroke"; + +/** + * The brush of the free drawing tools. It draws as the brush of fabric does, and the stroke + * of a pen is drawn with a width which follows its pressure when `pressureSensitive` is on: + * a path which is filled (a {@link PressureStroke}), and is the outline of the stroke, and not a line with a width. + * The strokes of a mouse and of a finger are not changed: they have no pressure. + */ +export class PressureBrush extends fabric.PencilBrush { + private pressured = false; + /** The stroke of a pen in progress */ + private sampler?: StrokeSampler; + /** How many of the discs of the stroke are on the top canvas */ + private drawn = 0; + + get pressureSensitive(): boolean { + return this.pressured; + } + + set pressureSensitive(on: boolean) { + this.pressured = on; + penOf(this.canvas).listen(on); + } + + onMouseDown(pointer: fabric.Point, event: { e: fabric.TPointerEvent }): void { + this.sampler = undefined; + if (!this.pressured || !penOf(this.canvas).pen) { + super.onMouseDown(pointer, event); + return; + } + if (!this.canvas._isMainEvent(event.e)) return; + this.sampler = new StrokeSampler(this.width); + this.drawn = 0; + this._reset(); + this.addPoint(pointer); + this.preview(); + } + + onMouseMove(pointer: fabric.Point, event: { e: fabric.TPointerEvent }): void { + if (!this.sampler) { + super.onMouseMove(pointer, event); + return; + } + if (!this.canvas._isMainEvent(event.e)) return; + if (this.limitedToCanvasSize === true && this._isOutSideCanvas(pointer)) return; + if (this.addPoint(pointer)) this.preview(); + } + + onMouseUp(event: { e: fabric.TPointerEvent }): boolean { + if (!this.sampler) return super.onMouseUp(event); + if (!this.canvas._isMainEvent(event.e)) return true; + this.sampler.finish(); + this.addPath(this.sampler); + this.sampler = undefined; + return false; + } + + /** Forget the stroke in progress, nothing is drawn of it */ + cancel(): void { + this.sampler = undefined; + } + + /** The brush of fabric draws the line again when the top canvas is drawn again */ + _render(ctx: CanvasRenderingContext2D = this.canvas.contextTop): void { + if (!this.sampler) { + super._render(ctx); + return; + } + this.paint(ctx, 0, true); + } + + /** + * Add the point of the pointer to the stroke, with the pressure it has now. Points which are + * too close to the one before are left out, as the brush of fabric does. + */ + private addPoint({ x, y }: fabric.Point): boolean { + const sampler = this.sampler as StrokeSampler; + const last = sampler.last; + const distance = this.decimate / this.canvas.getZoom(); + if (last && Math.hypot(x - last.x, y - last.y) < distance) return false; + sampler.add(x, y, penOf(this.canvas).pressure); + return true; + } + + /** Draw what the last points added to the stroke on the top canvas */ + private preview(): void { + const { contextTop: ctx } = this.canvas; + const sampler = this.sampler as StrokeSampler; + // A color which is not opaque would be darker where the pieces overlap, so it is drawn whole + if (this.needsFullRender()) { + this.canvas.clearContext(ctx); + this.paint(ctx, 0, true); + } else { + this.paint(ctx, Math.max(this.drawn - 1, 0), false); + } + this.drawn = sampler.discs.length; + } + + /** + * Fill the stroke from the disc at `from`, as one shape or one piece after the other + */ + private paint(ctx: CanvasRenderingContext2D, from: number, whole: boolean): void { + const { discs } = this.sampler as StrokeSampler; + const pieces: [Disc, Disc][] = []; + for (let i = from + 1; i < discs.length; i++) + pieces.push([discs[i - 1], discs[i]]); + // A stroke of one disc, which is all there is until the pen moves + if (!pieces.length && discs[from]) pieces.push([discs[from], discs[from]]); + this._saveAndTransform(ctx); + ctx.fillStyle = this.color; + if (whole) ctx.beginPath(); + for (const [a, b] of pieces) { + if (!whole) ctx.beginPath(); + addCapsule(ctx, a, b); + if (!whole) ctx.fill(); + } + if (whole) ctx.fill(); + ctx.restore(); + } + + /** The stroke is lifted: the outline of it is a path of the canvas */ + private addPath({ discs }: StrokeSampler): void { + if (!discs.length) { + this.canvas.requestRenderAll(); + return; + } + const path = new PressureStroke(discs.map(savedDisc), { + fill: this.color, + stroke: null, + strokeWidth: 0, + }); + this.canvas.clearContext(this.canvas.contextTop); + this.canvas.fire("before:path:created", { path }); + this.canvas.add(path); + this.canvas.requestRenderAll(); + path.setCoords(); + this._resetShadow(); + this.canvas.fire("path:created", { path }); + } +} diff --git a/packages/react-sketch/src/components/SketchField/tools/PressureBrush/PressureStroke.ts b/packages/react-sketch/src/components/SketchField/tools/PressureBrush/PressureStroke.ts new file mode 100644 index 0000000..30f0c8d --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/PressureBrush/PressureStroke.ts @@ -0,0 +1,56 @@ +import * as fabric from "fabric"; +import { strokePath, type Disc } from "./geometry"; + +/** A disc as it is saved: the x and the y of its center, and its radius */ +export type SavedDisc = [number, number, number]; + +const round = (value: number): number => Math.round(value * 100) / 100; + +/** The disc to save, at a hundredth of a pixel */ +export const savedDisc = ({ x, y, r }: Disc): SavedDisc => [ + round(x), + round(y), + round(r), +]; + +/** + * The stroke of a pen whose width follows its pressure. It is a path, which is filled, so + * the canvas selects, moves, erases, copies and draws it as it does the others. It is saved + * as the discs along it and not as the commands of the path, which are about twenty times + * as long: the path is made again when the stroke is loaded. + */ +export class PressureStroke extends fabric.Path { + static type = "PressureStroke"; + + readonly discs: SavedDisc[]; + + constructor(discs: SavedDisc[], options?: Partial) { + super( + strokePath( + discs.map(([x, y, r]) => ({ x, y, r })) + ) as fabric.TSimpleParsedCommand[], + options + ); + this.discs = discs; + } + + toObject< + T extends Omit< + Partial & fabric.TClassProperties, + keyof fabric.SerializedPathProps + >, + K extends keyof T = never, + >(propertiesToInclude: K[] = []) { + // The path is not saved: the discs are, and the path is made from them when it is loaded + const { path, ...object } = super.toObject(propertiesToInclude); + void path; + return { ...object, discs: this.discs } as unknown as Pick & + fabric.SerializedPathProps; + } + + static fromObject(object: Record): Promise { + return this._fromObject(object, { extraParam: "discs" }) as Promise; + } +} + +fabric.classRegistry.setClass(PressureStroke); diff --git a/packages/react-sketch/src/components/SketchField/tools/PressureBrush/geometry.spec.ts b/packages/react-sketch/src/components/SketchField/tools/PressureBrush/geometry.spec.ts new file mode 100644 index 0000000..1fb940d --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/PressureBrush/geometry.spec.ts @@ -0,0 +1,263 @@ +/** + * Real pixels: the canvas is drawn by node-canvas (the node build of fabric), as jsdom + * with the canvas mock draws nothing the shape of a stroke could be read from. + * + * @jest-environment node + */ +import * as fabric from "fabric"; +import { + capsulePath, + strokePath, + StrokeSampler, + widthFactor, + type Disc, +} from "./geometry"; + +jest.mock("fabric", () => jest.requireActual("fabric/node")); + +/** The alpha of the pixels of the commands, filled in black as the stroke of the brush is */ +const draw = (commands: (string | number)[][]) => { + const canvas = new fabric.Canvas(undefined, { width: 220, height: 160 }); + canvas.add( + new fabric.Path(commands as fabric.TSimpleParsedCommand[], { + fill: "#000000", + stroke: null, + strokeWidth: 0, + }) + ); + const element = canvas.toCanvasElement(1); + const { data, width } = ( + element.getContext("2d") as CanvasRenderingContext2D + ).getImageData(0, 0, element.width, element.height); + void canvas.dispose(); + return (x: number, y: number): number => + data[(Math.round(y) * width + Math.round(x)) * 4 + 3]; +}; + +const filled = (alpha: number) => alpha > 200; +const empty = (alpha: number) => alpha < 40; + +describe("widthFactor", () => { + it("is 1 at a medium pressure, and stays between a tenth and twice", () => { + expect(widthFactor(0.5)).toBe(1); + expect(widthFactor(1)).toBe(2); + expect(widthFactor(0.25)).toBe(0.5); + expect(widthFactor(0)).toBe(0.1); + expect(widthFactor(-1)).toBe(0.1); + expect(widthFactor(3)).toBe(2); + }); +}); + +describe("capsulePath", () => { + it("fills two discs of one width, and what is between them", () => { + const at = draw(capsulePath({ x: 50, y: 80, r: 10 }, { x: 150, y: 80, r: 10 })); + + expect(filled(at(100, 80))).toBe(true); + expect(filled(at(100, 71))).toBe(true); + expect(filled(at(100, 89))).toBe(true); + expect(empty(at(100, 68))).toBe(true); + expect(empty(at(100, 92))).toBe(true); + // The round ends + expect(filled(at(42, 80))).toBe(true); + expect(empty(at(37, 80))).toBe(true); + expect(filled(at(158, 80))).toBe(true); + expect(empty(at(163, 80))).toBe(true); + // The corners of a rectangle are not filled + expect(empty(at(41, 71))).toBe(true); + }); + + it("is as wide as each disc at its end, and between at the middle", () => { + const at = draw(capsulePath({ x: 50, y: 80, r: 20 }, { x: 150, y: 80, r: 5 })); + + expect(filled(at(50, 98))).toBe(true); + expect(empty(at(50, 103))).toBe(true); + expect(filled(at(150, 83))).toBe(true); + expect(empty(at(150, 87))).toBe(true); + // The tangents leave a width of 12 and a half at the middle, less the slant + expect(filled(at(100, 80 + 10))).toBe(true); + expect(empty(at(100, 80 + 16))).toBe(true); + }); + + const directions: [string, Disc, Disc][] = [ + [ + "to the right, to a wider one", + { x: 40, y: 80, r: 6 }, + { x: 170, y: 80, r: 18 }, + ], + [ + "to the left, to a wider one", + { x: 170, y: 80, r: 6 }, + { x: 40, y: 80, r: 18 }, + ], + ["down to the right", { x: 40, y: 30, r: 14 }, { x: 170, y: 130, r: 6 }], + ["up to the right", { x: 40, y: 130, r: 8 }, { x: 170, y: 30, r: 16 }], + ["up", { x: 100, y: 140, r: 10 }, { x: 100, y: 20, r: 10 }], + ["down to a narrower one", { x: 100, y: 40, r: 18 }, { x: 100, y: 140, r: 4 }], + ]; + + it.each(directions)("fills the same way %s", (_name, a, b) => { + const at = draw(capsulePath(a, b)); + + // The line between the centers, and the part of each disc which is not along it + for (const t of [0, 0.25, 0.5, 0.75, 1]) { + const x = a.x + (b.x - a.x) * t; + const y = a.y + (b.y - a.y) * t; + expect(filled(at(x, y))).toBe(true); + } + const angle = Math.atan2(b.y - a.y, b.x - a.x); + for (const disc of [a, b]) { + expect(filled(at(disc.x + disc.r * 0.8, disc.y))).toBe(true); + expect(filled(at(disc.x, disc.y + disc.r * 0.8))).toBe(true); + } + // Behind the first disc, and past the last one + expect( + empty(at(a.x - Math.cos(angle) * (a.r + 4), a.y - Math.sin(angle) * (a.r + 4))) + ).toBe(true); + expect( + empty(at(b.x + Math.cos(angle) * (b.r + 4), b.y + Math.sin(angle) * (b.r + 4))) + ).toBe(true); + // Next to the middle of the line, at the width of the area there + const middle = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, r: (a.r + b.r) / 2 }; + const normal = { x: -Math.sin(angle), y: Math.cos(angle) }; + for (const side of [1, -1]) { + expect( + filled( + at( + middle.x + side * normal.x * middle.r * 0.7, + middle.y + side * normal.y * middle.r * 0.7 + ) + ) + ).toBe(true); + expect( + empty( + at( + middle.x + side * normal.x * (middle.r + 5), + middle.y + side * normal.y * (middle.r + 5) + ) + ) + ).toBe(true); + } + }); + + it("is the larger disc when one is in the other", () => { + const at = draw(capsulePath({ x: 100, y: 80, r: 20 }, { x: 106, y: 80, r: 5 })); + + expect(filled(at(100, 98))).toBe(true); + expect(empty(at(100, 103))).toBe(true); + expect(filled(at(118, 80))).toBe(true); + expect(empty(at(124, 80))).toBe(true); + }); +}); + +describe("strokePath", () => { + it("is one disc for a stroke which is one point", () => { + const at = draw(strokePath([{ x: 100, y: 80, r: 12 }])); + + expect(filled(at(100, 80))).toBe(true); + expect(filled(at(100, 90))).toBe(true); + expect(empty(at(100, 95))).toBe(true); + }); + + it("has no hole where the pieces overlap, at a sharp turn of a wide stroke", () => { + // A corner: right, then down, with a width larger than the distance the turn is made in + const discs: Disc[] = [ + { x: 40, y: 40, r: 14 }, + { x: 90, y: 40, r: 14 }, + { x: 100, y: 46, r: 14 }, + { x: 104, y: 56, r: 14 }, + { x: 104, y: 110, r: 14 }, + ]; + const at = draw(strokePath(discs)); + + for (let x = 30; x <= 100; x += 5) expect(filled(at(x, 40))).toBe(true); + for (let y = 40; y <= 110; y += 5) { + expect(filled(at(104, y))).toBe(true); + } + // Across the overlapping pieces + for (let x = 80; x <= 112; x += 4) expect(filled(at(x, 50))).toBe(true); + expect(empty(at(60, 70))).toBe(true); + expect(empty(at(130, 80))).toBe(true); + }); + + it("is a disc when the discs do not leave their place", () => { + const at = draw( + strokePath([ + { x: 100, y: 80, r: 10 }, + { x: 100.05, y: 80, r: 10 }, + ]) + ); + + expect(filled(at(100, 80))).toBe(true); + expect(empty(at(100, 94))).toBe(true); + }); +}); + +describe("StrokeSampler", () => { + it("starts with the disc of the first point, as wide as its pressure says", () => { + const sampler = new StrokeSampler(20); + + sampler.add(10, 10, 1); + + expect(sampler.discs).toEqual([{ x: 10, y: 10, r: 20 }]); + }); + + it("follows the pressure along the stroke", () => { + const sampler = new StrokeSampler(20); + + for (let i = 0; i <= 40; i++) sampler.add(10 + i * 5, 50, 0.1 + (0.8 * i) / 40); + sampler.finish(); + + const radii = sampler.discs.map((disc) => disc.r); + expect(radii[0]).toBeCloseTo(2, 1); + expect(radii[radii.length - 1]).toBeGreaterThan(15); + // It only gets wider + radii + .slice(1) + .forEach((r, i) => expect(r).toBeGreaterThanOrEqual(radii[i] - 1e-9)); + }); + + it("ends at the last point, with the width of the pressure on it", () => { + const sampler = new StrokeSampler(10); + + sampler.add(0, 0, 0.5); + sampler.add(30, 0, 0.5); + sampler.add(60, 20, 0.5); + sampler.finish(); + + const last = sampler.discs[sampler.discs.length - 1]; + expect(last.x).toBeCloseTo(60, 6); + expect(last.y).toBeCloseTo(20, 6); + expect(last.r).toBeCloseTo(5, 6); + }); + + it("does not follow the noise of one reading of the pen", () => { + const sampler = new StrokeSampler(20); + sampler.add(0, 0, 0.5); + + sampler.add(5, 0, 1); + + // Half of the way between the two pressures, and not at the new one + expect(sampler.discs[sampler.discs.length - 1].r).toBeLessThan( + (20 / 2) * widthFactor(0.9) + ); + }); + + it("makes a smooth line: the discs are a few pixels apart, also where the line turns", () => { + const sampler = new StrokeSampler(8); + + [ + [0, 0], + [40, 0], + [80, 40], + [80, 90], + ].forEach(([x, y]) => sampler.add(x, y, 0.5)); + sampler.finish(); + + const { discs } = sampler; + discs.slice(1).forEach((disc, i) => { + expect( + Math.hypot(disc.x - discs[i].x, disc.y - discs[i].y) + ).toBeLessThanOrEqual(6); + }); + }); +}); diff --git a/packages/react-sketch/src/components/SketchField/tools/PressureBrush/geometry.ts b/packages/react-sketch/src/components/SketchField/tools/PressureBrush/geometry.ts new file mode 100644 index 0000000..abd561d --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/PressureBrush/geometry.ts @@ -0,0 +1,200 @@ +/** + * The shape of a stroke whose width follows the pressure of a pen: a line of discs, each + * as wide as the pen pressed at that point, and the area which joins one disc to the next. + * Every piece is drawn the same way round, so that the union of them is filled, with no + * hole where they overlap. + */ + +export interface Disc { + x: number; + y: number; + r: number; +} + +type Command = (string | number)[]; + +/** The narrowest and the widest a stroke gets, as a part of `lineWidth`. A pressure of 0.5 gives 1 */ +const MIN_FACTOR = 0.1; +const MAX_FACTOR = 2; + +/** How much of the new pressure is taken in, so that the noise of the pen does not make the width jump */ +const SMOOTHING = 0.5; + +/** The distance between the discs of a curve, and the most discs of one piece of it */ +const SPACING = 3; +const MAX_STEPS = 64; + +/** The width of the stroke as a part of `lineWidth`, for a pressure from 0 to 1 */ +export const widthFactor = (pressure: number): number => + Math.min(MAX_FACTOR, Math.max(MIN_FACTOR, 2 * pressure)); + +const round = (value: number): number => Math.round(value * 100) / 100; + +const pointOn = ({ x, y, r }: Disc, angle: number): [number, number] => [ + x + r * Math.cos(angle), + y + r * Math.sin(angle), +]; + +/** + * Where the two common tangents of the discs touch them: the angle of the line between + * their centers, and how far from it the tangents are. None when one disc is in the other. + */ +const tangents = ( + a: Disc, + b: Disc +): { angle: number; spread: number } | undefined => { + const dx = b.x - a.x; + const dy = b.y - a.y; + const distance = Math.hypot(dx, dy); + if (distance <= Math.abs(a.r - b.r)) return undefined; + return { angle: Math.atan2(dy, dx), spread: Math.acos((a.r - b.r) / distance) }; +}; + +/** The disc which is the area of two when one is in the other */ +const larger = (a: Disc, b: Disc): Disc => (a.r >= b.r ? a : b); + +/** + * The two discs and the area which joins them, as the commands of an SVG path. The arcs + * turn from the larger angle to the smaller, as the circle does. + */ +export const capsulePath = (a: Disc, b: Disc): Command[] => { + const common = tangents(a, b); + if (!common) { + const { x, y, r } = larger(a, b); + return [ + ["M", round(x - r), round(y)], + ["A", round(r), round(r), 0, 0, 0, round(x + r), round(y)], + ["A", round(r), round(r), 0, 0, 0, round(x - r), round(y)], + ["Z"], + ]; + } + const { angle, spread } = common; + const [ax1, ay1] = pointOn(a, angle + spread); + const [bx1, by1] = pointOn(b, angle + spread); + const [bx2, by2] = pointOn(b, angle - spread); + const [ax2, ay2] = pointOn(a, angle - spread); + // The cap at the far end is 2 * spread wide, and the one at the near end the rest of the turn + const farLarge = spread > Math.PI / 2 ? 1 : 0; + const nearLarge = spread < Math.PI / 2 ? 1 : 0; + return [ + ["M", round(ax1), round(ay1)], + ["L", round(bx1), round(by1)], + ["A", round(b.r), round(b.r), 0, farLarge, 0, round(bx2), round(by2)], + ["L", round(ax2), round(ay2)], + ["A", round(a.r), round(a.r), 0, nearLarge, 0, round(ax1), round(ay1)], + ["Z"], + ]; +}; + +/** The same as {@link capsulePath}, added to the path of a canvas context */ +export const addCapsule = (ctx: CanvasPath, a: Disc, b: Disc): void => { + const common = tangents(a, b); + if (!common) { + const { x, y, r } = larger(a, b); + ctx.moveTo(x + r, y); + ctx.arc(x, y, r, 2 * Math.PI, 0, true); + ctx.closePath(); + return; + } + const { angle, spread } = common; + const [ax1, ay1] = pointOn(a, angle + spread); + const [bx1, by1] = pointOn(b, angle + spread); + const [ax2, ay2] = pointOn(a, angle - spread); + ctx.moveTo(ax1, ay1); + ctx.lineTo(bx1, by1); + ctx.arc(b.x, b.y, b.r, angle + spread, angle - spread, true); + ctx.lineTo(ax2, ay2); + ctx.arc(a.x, a.y, a.r, angle - spread, angle + spread, true); + ctx.closePath(); +}; + +/** Whether the second disc adds nothing to the first, as it is at the same place and as wide */ +const same = (a: Disc, b: Disc): boolean => + Math.hypot(b.x - a.x, b.y - a.y) < 0.25 && Math.abs(b.r - a.r) < 0.25; + +/** The commands of the path of a whole stroke: its discs, and the area between each two */ +export const strokePath = (discs: Disc[]): Command[] => { + if (discs.length === 1) return capsulePath(discs[0], discs[0]); + const commands: Command[] = []; + for (let i = 1; i < discs.length; i++) { + if (!same(discs[i - 1], discs[i])) + commands.push(...capsulePath(discs[i - 1], discs[i])); + } + // Every disc was the same as the one before it: the stroke did not leave its place + return commands.length ? commands : capsulePath(discs[0], discs[0]); +}; + +const midpoint = (a: Disc, b: Disc): Disc => ({ + x: (a.x + b.x) / 2, + y: (a.y + b.y) / 2, + r: (a.r + b.r) / 2, +}); + +/** + * The discs along the curve from `start` to `end` which bends towards `control`, not + * counting `start`: the width goes from the one of `start` to the one of `end` + */ +const along = (start: Disc, control: Disc, end: Disc): Disc[] => { + const length = + Math.hypot(control.x - start.x, control.y - start.y) + + Math.hypot(end.x - control.x, end.y - control.y); + const steps = Math.min(MAX_STEPS, Math.max(1, Math.ceil(length / SPACING))); + const discs: Disc[] = []; + for (let step = 1; step <= steps; step++) { + const t = step / steps; + const u = 1 - t; + discs.push({ + x: u * u * start.x + 2 * u * t * control.x + t * t * end.x, + y: u * u * start.y + 2 * u * t * control.y + t * t * end.y, + r: start.r + (end.r - start.r) * t, + }); + } + return discs; +}; + +/** + * Turns the points of a stroke, with the pressure at each, into the discs along its + * smooth line. The line is the one the brush of fabric draws: it goes through the middle + * of each two points, bending towards the point between them. It is made as the points + * come, so what is drawn while the pen moves is what the stroke is when it is lifted. + */ +export class StrokeSampler { + /** The discs along the line, from its start to the middle of the last two points */ + readonly discs: Disc[] = []; + private readonly points: Disc[] = []; + private pressure?: number; + + /** @param width the width of the stroke at a pressure of 0.5 */ + constructor(private readonly width: number) {} + + /** The last point which was added */ + get last(): Disc | undefined { + return this.points[this.points.length - 1]; + } + + /** Add a point of the stroke */ + add(x: number, y: number, pressure: number): void { + this.pressure = + this.pressure === undefined + ? pressure + : this.pressure + SMOOTHING * (pressure - this.pressure); + const point = { x, y, r: (this.width / 2) * widthFactor(this.pressure) }; + this.points.push(point); + const i = this.points.length - 1; + if (i === 0) { + this.discs.push(point); + return; + } + const previous = this.points[i - 1]; + const start = i === 1 ? previous : midpoint(this.points[i - 2], previous); + this.discs.push(...along(start, previous, midpoint(previous, point))); + } + + /** End the stroke: the line goes on to the last point */ + finish(): void { + const n = this.points.length; + if (n < 2) return; + const start = midpoint(this.points[n - 2], this.points[n - 1]); + this.discs.push(...along(start, start, this.points[n - 1])); + } +} diff --git a/packages/react-sketch/src/components/SketchField/tools/PressureBrush/index.ts b/packages/react-sketch/src/components/SketchField/tools/PressureBrush/index.ts new file mode 100644 index 0000000..15eb371 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/PressureBrush/index.ts @@ -0,0 +1,3 @@ +export { PressureBrush } from "./PressureBrush"; +export { penOf, releasePen } from "./pen"; +export { PressureStroke } from "./PressureStroke"; diff --git a/packages/react-sketch/src/components/SketchField/tools/PressureBrush/pen.ts b/packages/react-sketch/src/components/SketchField/tools/PressureBrush/pen.ts new file mode 100644 index 0000000..f45f530 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/PressureBrush/pen.ts @@ -0,0 +1,63 @@ +import type * as fabric from "fabric"; + +/** + * Tells what kind of pointer is on the page, and how hard it presses. The canvas reads + * the mouse and the touches, which do not carry the pressure, and the pointer events do: + * they come before the mouse and the touch events of the same input, so the last one is + * the one of the stroke the canvas is about to draw. The canvas keeps listening to the + * mouse and the touches, as the pinch zoom is made of them. + */ +export class PenSensor { + /** The type of the last pointer which pressed or moved: "mouse", "touch" or "pen" */ + type = "mouse"; + /** How hard it pressed, from 0 to 1 */ + pressure = 0; + private listening = false; + + constructor(private readonly doc: Document) {} + + /** Whether the last pointer was a pen */ + get pen(): boolean { + return this.type === "pen"; + } + + /** Start or stop listening to the pointers of the page */ + listen(on: boolean): void { + if (on === this.listening) return; + this.listening = on; + const toggle = on ? "addEventListener" : "removeEventListener"; + // The capture phase comes before the listeners of the canvas, and nothing is prevented + this.doc[toggle]("pointerdown", this.onPointer, { + capture: true, + passive: true, + }); + this.doc[toggle]("pointermove", this.onPointer, { + capture: true, + passive: true, + }); + } + + private readonly onPointer = (event: Event): void => { + const { pointerType, pressure } = event as PointerEvent; + this.type = pointerType; + this.pressure = pressure; + }; +} + +/** The sensor of each canvas, made the first time it is asked for */ +const sensors = new WeakMap(); + +export const penOf = (canvas: fabric.Canvas): PenSensor => { + let sensor = sensors.get(canvas); + if (!sensor) { + sensor = new PenSensor(canvas.upperCanvasEl.ownerDocument); + sensors.set(canvas, sensor); + } + return sensor; +}; + +/** Stop listening for the canvas, which is going away */ +export const releasePen = (canvas: fabric.Canvas): void => { + sensors.get(canvas)?.listen(false); + sensors.delete(canvas); +}; diff --git a/packages/react-sketch/src/components/SketchField/tools/index.ts b/packages/react-sketch/src/components/SketchField/tools/index.ts index b7fa1db..29db794 100644 --- a/packages/react-sketch/src/components/SketchField/tools/index.ts +++ b/packages/react-sketch/src/components/SketchField/tools/index.ts @@ -5,6 +5,7 @@ import { LineStyle, SketchProperties, Tool } from "../../../types"; import { lineStroke } from "../../../utils"; import { EraserBrush } from "@react-sketch/eraser"; import { CanvasEvent, EventInput } from "../events"; +import { PressureBrush } from "./PressureBrush"; /** * Interface to support canvas events @@ -169,7 +170,16 @@ export abstract class FabricCanvasTool { const current = this.canvas.freeDrawingBrush; if (current && !(current instanceof EraserBrush)) return current; current?.dispose(); - return (this.canvas.freeDrawingBrush = new fabric.PencilBrush(this.canvas)); + return (this.canvas.freeDrawingBrush = new PressureBrush(this.canvas)); + } + + /** + * Set whether the strokes of a pen are as wide as the pressure on it. The brush is kept + * from a tool to the next, so a tool which does not draw them turns it off. + */ + protected setPressureSensitive(on: boolean): void { + const brush = this.brush; + if (brush instanceof PressureBrush) brush.pressureSensitive = on; } /** diff --git a/packages/react-sketch/src/types.ts b/packages/react-sketch/src/types.ts index 29afd06..0cb8703 100644 --- a/packages/react-sketch/src/types.ts +++ b/packages/react-sketch/src/types.ts @@ -189,6 +189,14 @@ export interface SketchProperties { * polygon draw it, the highlighter, eraser and laser pointer do not. */ lineStyle?: LineStyle; + /** + * Draw the strokes of a pen (a stylus) with a width which follows its pressure: `lineWidth` is + * the width at a medium pressure, and a light one is thinner (down to a tenth of it) and a + * hard one wider (up to twice). Only the pencil has it, and only the strokes of a pen: those + * of a mouse and of a finger are drawn as they are. Such a stroke is a filled path (the + * outline of the stroke) and not a line, so `lineStyle` does not apply to it. Defaults to false + */ + pressureSensitive?: boolean; /** The fill color of the shape when applicable. Defaults to "transparent" */ fillColor?: string; /** The background color of the sketch, in rgba or hex format. Defaults to "transparent" */ diff --git a/packages/showcase/README.md b/packages/showcase/README.md index 709be8b..83d923c 100644 --- a/packages/showcase/README.md +++ b/packages/showcase/README.md @@ -7,7 +7,8 @@ The canvas fills the window, and the panels float over it: - **Toolbar** (top): select, pan, rectangle, rectangle label, circle, polygon, line, arrow, pencil, highlighter, eraser and laser pointer. The keys of the tools are on the buttons and in the shortcuts dialog. -- **Properties** (left): line color, fill color, line weight and line style of the tool, and copy, paste and delete +- **Properties** (left): line color, fill color, line weight and line style of the tool (and, for the pencil, the + Pen pressure switch), and copy, paste and delete of the selected objects. - **Settings** (right, the gear): the **Scenario** tab (what the scenario shows and its actions), the **Canvas** tab (size, with a button for the original size of the background image, background color and image, pinch zoom, diff --git a/packages/showcase/src/components/Properties.tsx b/packages/showcase/src/components/Properties.tsx index 00517c1..719fead 100644 --- a/packages/showcase/src/components/Properties.tsx +++ b/packages/showcase/src/components/Properties.tsx @@ -8,12 +8,15 @@ import RemoveIcon from "@mui/icons-material/Remove"; import StyleIcon from "@mui/icons-material/Palette"; import Box from "@mui/material/Box"; import Divider from "@mui/material/Divider"; +import FormControlLabel from "@mui/material/FormControlLabel"; import IconButton from "@mui/material/IconButton"; import Stack from "@mui/material/Stack"; +import Switch from "@mui/material/Switch"; import ToggleButton from "@mui/material/ToggleButton"; import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; +import { Types } from "react-sketch"; import { observer } from "mobx-react-lite"; import { NO_LINE_STYLE, @@ -159,6 +162,25 @@ const Properties = observer((): ReactElement => { onChange={(value) => (CONFIG.lineWidth = value)} /> + {selectedTool === Types.Tool.PENCIL && ( + + + (CONFIG.pressureSensitive = event.target.checked) + } + /> + } + sx={{ ml: 0 }} + /> + + )} Line style diff --git a/packages/showcase/src/components/Workspace.tsx b/packages/showcase/src/components/Workspace.tsx index 995c7c4..9c57d3a 100644 --- a/packages/showcase/src/components/Workspace.tsx +++ b/packages/showcase/src/components/Workspace.tsx @@ -153,6 +153,7 @@ const Workspace = observer((): ReactElement => { lineColor={CONFIG.lineColor} lineWidth={CONFIG.lineWidth} lineStyle={CONFIG.lineStyle} + pressureSensitive={CONFIG.pressureSensitive} pinchZoom={CONFIG.pinchZoom} minZoom={CONFIG.minZoom} maxZoom={CONFIG.maxZoom} diff --git a/packages/showcase/src/stores/config.ts b/packages/showcase/src/stores/config.ts index a50a389..7096f72 100644 --- a/packages/showcase/src/stores/config.ts +++ b/packages/showcase/src/stores/config.ts @@ -35,6 +35,8 @@ export interface Config { lineColor: string; lineWidth: number; lineStyle: Types.LineStyle; + /** The strokes of a pen are as wide as the pressure on it (the pencil) */ + pressureSensitive: boolean; pinchZoom: boolean; minZoom: number; maxZoom: number; @@ -107,6 +109,7 @@ export class ConfigStore implements Config { public lineColor = "#000000"; public lineWidth = 1; public lineStyle: Types.LineStyle = "solid"; + public pressureSensitive = true; public pinchZoom = true; public minZoom = 0.5; public maxZoom = 8;