From 09d61683f7f5502b7005c132016b2c4665a43580 Mon Sep 17 00:00:00 2001 From: tbolis Date: Fri, 23 Oct 2026 18:32:38 +0300 Subject: [PATCH 01/33] feat: overlay image, backgroundImage property and crossOrigin option Brings back three features that forks of react-sketch had, written again for the TypeScript code and fabric 7. - setOverlayFromDataUrl() and removeOverlayImage() (#73): an image drawn above the objects, with the options of the background image. It is saved by toJSON(), drawn by toDataURL(), scaled by the resize and removed by clear(). From addOverlayImg of yerffejytnac. - The backgroundImage and backgroundImageOptions properties (#51), set when the component mounts and when they change, and kept when a value is loaded. From subforce-solutions. - The crossOrigin option of addImg(), setBackgroundFromDataUrl() and setOverlayFromDataUrl() (#33, #95). null shows an image of a server without CORS headers. From react-sketch2 of x5engine. The background and the overlay share one loader, with a counter by layer, so only the last image asked for a layer is set. --- CHANGELOG.md | 24 ++ README.md | 87 +++--- .../SketchField/SketchField.spec.tsx | 268 +++++++++++++++++- .../components/SketchField/SketchField.tsx | 144 ++++++++-- .../src/components/SketchField/resize.ts | 26 +- packages/react-sketch/src/types.ts | 23 +- 6 files changed, 485 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4786d7..f7b4073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 1.1.0 (unreleased) + +Features that forks of react-sketch had, written again for the TypeScript code and fabric 7. + +### Added + +- The overlay image: `setOverlayFromDataUrl()` and `removeOverlayImage()` (#73). The image is drawn above + the objects and takes no mouse events, so it can be an outline to draw within. It is saved by + `toJSON()`, drawn by `toDataURL()`, scaled when the canvas is resized, and removed by `clear()`. It + takes the options of `setBackgroundFromDataUrl()`. From `addOverlayImg` of @yerffejytnac. +- The `backgroundImage` and `backgroundImageOptions` properties (#51): the background image from a + property, set when the component mounts and when it changes. It takes the place of the background image + of `value` and `defaultValue`. From the `backgroundImage` property of @subforce-solutions. +- The `crossOrigin` option of `addImg()`, `setBackgroundFromDataUrl()` and `setOverlayFromDataUrl()` (#33, + #95). `addImg()` requests the image without CORS as before, unless it is given, so a canvas with a + cross-origin image can be exported. The background and the overlay images are requested with + `"anonymous"` as before, and `null` shows an image of a server without CORS headers, at the cost of a + tainted canvas. From `react-sketch2` of @x5engine. + +### Changed + +- `clear()` also removes the overlay image. +- When several images are asked for the overlay (or the background), only the last request is set. + ## 1.0.1 (unreleased) ### Fixed diff --git a/README.md b/README.md index a265da4..b6ec55d 100644 --- a/README.md +++ b/README.md @@ -197,41 +197,54 @@ export const Editor = ({ imageUrl }: { imageUrl: string }) => { }; ``` +### Background and overlay images + +- The background image is drawn under the objects. Set it with the `backgroundImage` property (and `backgroundImageOptions`), or with `setBackgroundFromDataUrl` and `removeBackgroundImage`. The property takes the place of the background image of a `value`. +- The overlay image is drawn above the objects, for example an outline to draw within. Set it with `setOverlayFromDataUrl` and remove it with `removeOverlayImage`. It takes no mouse events, so the objects below can still be selected, and `toJSON` and `toDataURL` include it. `clear()` removes it, and so does loading a `value` that has none. +- The background and the overlay images are requested with CORS (`crossOrigin: "anonymous"`), so the canvas can be exported. A server without CORS headers cannot be used that way: pass `crossOrigin: null` to show the image anyway, knowing that a cross-origin image taints the canvas and `toDataURL` throws a SecurityError. `addImg` requests the image without CORS, as before, unless you pass `crossOrigin`. + +```tsx +sketch.current?.setOverlayFromDataUrl(outlineUrl, { stretched: true }); +sketch.current?.addImg(photoUrl, { crossOrigin: "anonymous" }); // the canvas can still be exported +``` + ## Properties All properties are optional. -| Property | Type | Default | Description | -| ------------------ | ------------------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `tool` | `Types.Tool` or `string` | `"select"` | The tool to use, see [Tools](#tools). `null` is treated as `"select"`, an unknown name selects `"pencil"`. | -| `lineColor` | `string` | `"black"` | Color of the line. | -| `lineWidth` | `number` | `10` | Width of the line. | -| `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. | -| `opacity` | `number` | `1.0` | Accepted and not applied: it has no effect. It had no effect in 0.5.x either. | -| `undoSteps` | `number` | `25` | Number of undo/redo steps to keep. Read when the component mounts, a later change has no effect. | -| `imageFormat` | `string` | | Default format of `toDataURL`, `png` or `jpeg`, used when the options of the call have no `format`. Without it, `png`. | -| `value` | `object` or `string` | | Drawing to load, loaded again when it changes. See [Initial, loaded and saved drawings](#initial-loaded-and-saved-drawings). | -| `forceValue` | `boolean` | `false` | Load `value` on every update of the component, even when it did not change. | -| `defaultValue` | `object` or `string` | | Drawing loaded when the component mounts, unless `value` is set. | -| `widthCorrection` | `number` | `0` | Pixels subtracted from the width of the container when the canvas is resized. Compensates a border or padding of the container. | -| `heightCorrection` | `number` | `0` | Pixels subtracted from the height of the container when the canvas is resized. | -| `autoresize` | `boolean` | `true` | Resize the canvas, and scale its objects, when the window is resized. The canvas is always fitted to its container on mount and when `width` or `height` change. | -| `width` | `number` | undefined | Width of the sketch in pixels. When not set, the sketch takes the width of its parent element. | -| `height` | `number` | `512` | Height of the sketch in pixels. | -| `className` | `string` | | Class name of the container element of the canvas. | -| `style` | `CSS.Properties` | | Style of the container element of the canvas. | -| `onChange` | `(event?: Event) => void` | | Called 10 ms after every mouse up and mouse out on the canvas, whether or not the drawing changed (see [Known limitations](#known-limitations)), and after `undo`, `redo` and the load of a value. The argument is the DOM event after a mouse event, nothing otherwise. | -| `onObjectAdded` | `(event: Types.SketchEvent) => void` | no-op | An object was added: drawn, pasted, or loaded with a value. Not called when `undo` or `redo` add an object back. | -| `onObjectModified` | `(event: Types.SketchEvent) => void` | no-op | An object was modified by the user (moved, scaled, rotated). | -| `onObjectRemoved` | `(event: Types.SketchEvent) => void` | no-op | An object was removed from the sketch. | -| `onObjectMoving` | `(event: Types.SketchEvent) => void` | no-op | An object is being moved. | -| `onObjectScaling` | `(event: Types.SketchEvent) => void` | no-op | An object is being scaled. | -| `onObjectRotating` | `(event: Types.SketchEvent) => void` | no-op | An object is being rotated. | -| `onMouseDown` | `(event: Types.SketchEvent) => void` | no-op | Mouse down on the canvas. | -| `onMouseMove` | `(event: Types.SketchEvent) => void` | no-op | Mouse move on the canvas. | -| `onMouseUp` | `(event: Types.SketchEvent) => void` | no-op | Mouse up on the canvas. | -| `onMouseOut` | `(event: Types.SketchEvent) => void` | no-op | The mouse left the canvas. | +| Property | Type | Default | Description | +| ------------------------ | ------------------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `tool` | `Types.Tool` or `string` | `"select"` | The tool to use, see [Tools](#tools). `null` is treated as `"select"`, an unknown name selects `"pencil"`. | +| `lineColor` | `string` | `"black"` | Color of the line. | +| `lineWidth` | `number` | `10` | Width of the line. | +| `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`. | +| `backgroundImageOptions` | `Types.BackgroundOptions` | | Options of `backgroundImage`, those of `setBackgroundFromDataUrl`. Options written inline in a render do not load the image again when their content is the same. | +| `opacity` | `number` | `1.0` | Accepted and not applied: it has no effect. It had no effect in 0.5.x either. | +| `undoSteps` | `number` | `25` | Number of undo/redo steps to keep. Read when the component mounts, a later change has no effect. | +| `imageFormat` | `string` | | Default format of `toDataURL`, `png` or `jpeg`, used when the options of the call have no `format`. Without it, `png`. | +| `value` | `object` or `string` | | Drawing to load, loaded again when it changes. See [Initial, loaded and saved drawings](#initial-loaded-and-saved-drawings). | +| `forceValue` | `boolean` | `false` | Load `value` on every update of the component, even when it did not change. | +| `defaultValue` | `object` or `string` | | Drawing loaded when the component mounts, unless `value` is set. | +| `widthCorrection` | `number` | `0` | Pixels subtracted from the width of the container when the canvas is resized. Compensates a border or padding of the container. | +| `heightCorrection` | `number` | `0` | Pixels subtracted from the height of the container when the canvas is resized. | +| `autoresize` | `boolean` | `true` | Resize the canvas, and scale its objects, when the window is resized. The canvas is always fitted to its container on mount and when `width` or `height` change. | +| `width` | `number` | undefined | Width of the sketch in pixels. When not set, the sketch takes the width of its parent element. | +| `height` | `number` | `512` | Height of the sketch in pixels. | +| `className` | `string` | | Class name of the container element of the canvas. | +| `style` | `CSS.Properties` | | Style of the container element of the canvas. | +| `onChange` | `(event?: Event) => void` | | Called 10 ms after every mouse up and mouse out on the canvas, whether or not the drawing changed (see [Known limitations](#known-limitations)), and after `undo`, `redo` and the load of a value. The argument is the DOM event after a mouse event, nothing otherwise. | +| `onObjectAdded` | `(event: Types.SketchEvent) => void` | no-op | An object was added: drawn, pasted, or loaded with a value. Not called when `undo` or `redo` add an object back. | +| `onObjectModified` | `(event: Types.SketchEvent) => void` | no-op | An object was modified by the user (moved, scaled, rotated). | +| `onObjectRemoved` | `(event: Types.SketchEvent) => void` | no-op | An object was removed from the sketch. | +| `onObjectMoving` | `(event: Types.SketchEvent) => void` | no-op | An object is being moved. | +| `onObjectScaling` | `(event: Types.SketchEvent) => void` | no-op | An object is being scaled. | +| `onObjectRotating` | `(event: Types.SketchEvent) => void` | no-op | An object is being rotated. | +| `onMouseDown` | `(event: Types.SketchEvent) => void` | no-op | Mouse down on the canvas. | +| `onMouseMove` | `(event: Types.SketchEvent) => void` | no-op | Mouse move on the canvas. | +| `onMouseUp` | `(event: Types.SketchEvent) => void` | no-op | Mouse up on the canvas. | +| `onMouseOut` | `(event: Types.SketchEvent) => void` | no-op | The mouse left the canvas. | `Types.SketchEvent` is `{ e?: Event; target?: Types.SketchObject; [key: string]: unknown }`: `e` is the DOM event, `target` is the object the event is about (object events only). `Types.SketchObject` is @@ -252,12 +265,14 @@ throws. | `toDataURL(options?)` | `string` | The sketch as a data URL. Options: `format` (`"png"` or `"jpeg"`, default `imageFormat`, then `"png"`), `quality` (0 to 1, jpeg only, default 1), `multiplier` (default 1), and the crop area `left`, `top`, `width`, `height`. | | `toJSON(propertiesToInclude?)` | `Types.SketchData` | The sketch as an object. `propertiesToInclude` lists additional properties of the objects to include. | | `fromJSON(json?)` | `void` | Load a drawing (object or JSON string). Loads after 100 ms, starts the undo history again with the loaded objects, applies the tool configuration again and calls `onChange`. Does nothing for an empty argument. | -| `clear(propertiesToInclude?)` | `Types.SketchData` | Remove all objects and the background image, and reset the undo history. Returns the discarded content as JSON. The `backgroundColor` property is kept. | -| `addImg(dataUrl, options?)` | `void` | Add an image, given as URL or data URL, as an object. It is added when it has loaded. Options: `left`, `top` (default: random position), `scale` (default `0.5`). | +| `clear(propertiesToInclude?)` | `Types.SketchData` | Remove all objects, the background image and the overlay image, and reset the undo history. Returns the discarded content as JSON. The `backgroundColor` property is kept. | +| `addImg(dataUrl, options?)` | `void` | Add an image, given as URL or data URL, as an object. It is added when it has loaded. Options: `left`, `top` (default: random position), `scale` (default `0.5`), `crossOrigin` (see Background and overlay images). | | `addText(text, options?)` | `void` | Add an editable text. It is centered unless `left` or `top` are given. `options` are the options of a fabric text object, for example `fontSize`, `fill`. | -| `setBackgroundFromDataUrl(dataUrl, options?)` | `void` | Use an image as background. Options: `stretched` (fill the canvas, the proportions change), `stretchedX` (fit the width), `stretchedY` (fit the height), both together are `stretched`; any other option is an image option. | -| `removeBackgroundImage()` | `void` | Remove the background image, also one that is still loading. The objects and the `backgroundColor` are kept. Like `setBackgroundFromDataUrl`, it is not part of the undo history. | -| `zoom(factor)` | `void` | Scale the objects and the background image by a factor: 2 doubles their size, 0.5 halves it. | +| `setBackgroundFromDataUrl(dataUrl, options?)` | `void` | Use an image as background. Options: `stretched` (fill the canvas, the proportions change), `stretchedX` (fit the width), `stretchedY` (fit the height), both are `stretched`; `crossOrigin`; the others are image options. | +| `removeBackgroundImage()` | `void` | Remove the background image, also one that is still loading. The objects, the overlay image and the `backgroundColor` are kept. Like `setBackgroundFromDataUrl`, it is not part of the undo history. | +| `setOverlayFromDataUrl(dataUrl, options?)` | `void` | Use an image as overlay, above the objects. It takes no mouse events, `toJSON` and `toDataURL` include it, and it is not part of the undo history. The options are those of `setBackgroundFromDataUrl`. | +| `removeOverlayImage()` | `void` | Remove the overlay image, also one that is still loading. The objects, the background image and the `backgroundColor` are kept. | +| `zoom(factor)` | `void` | Scale the objects, the background image and the overlay image by a factor: 2 doubles their size, 0.5 halves it. | | `hasSelection()` | `boolean` | Whether an object is selected. | | `clearSelection()` | `void` | Deselect the selected object or objects. | | `removeSelected()` | `void` | Remove the selected object or objects. It can be undone. | diff --git a/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx b/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx index 6ae9113..05bdb4f 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx @@ -90,6 +90,21 @@ const stubImageLoads = () => return img; }); +/** Images that load when the test says so. Returns the loads, in the order they were asked for */ +const deferImageLoads = (): (() => void)[] => { + const loads: (() => void)[] = []; + jest.spyOn(window, "Image").mockImplementation(() => { + const img = document.createElement("img"); + img.width = 200; + img.height = 100; + Object.defineProperty(img, "src", { + set: () => loads.push(() => img.onload?.(new Event("load"))), + }); + return img; + }); + return loads; +}; + const rect = (left: number, top: number, fill = "red") => ({ type: "rect", left, @@ -333,6 +348,83 @@ describe("SketchField", () => { }); }); + describe("backgroundImage property", () => { + const url = "data:image/png;base64,00"; + + it("sets the background image when the component mounts", () => { + stubImageLoads(); + + const { canvas } = mount({ backgroundImage: url }); + + expect(canvas.backgroundImage).toBeInstanceOf(fabric.FabricImage); + }); + + it("fits the image as backgroundImageOptions say", () => { + stubImageLoads(); + layout(400, 300); + + const { canvas } = mount({ + backgroundImage: url, + backgroundImageOptions: { stretched: true }, + }); + + const image = canvas.backgroundImage as fabric.FabricImage; + expect([image.scaleX, image.scaleY]).toEqual([2, 3]); + }); + + it("follows the property, and removes the image when the property is gone", () => { + stubImageLoads(); + const { canvas, update } = mount({ backgroundImage: url }); + const first = canvas.backgroundImage; + + update({ backgroundImage: `${url}1` }); + expect(canvas.backgroundImage).toBeTruthy(); + expect(canvas.backgroundImage).not.toBe(first); + + update({}); + expect(canvas.backgroundImage).toBeFalsy(); + }); + + it("does not load the image again when the property and the options stay the same", () => { + const image = stubImageLoads(); + const { update } = mount({ + backgroundImage: url, + backgroundImageOptions: { stretched: true }, + }); + + // The options are a new object with the same content + update({ backgroundImage: url, backgroundImageOptions: { stretched: true } }); + + expect(image).toHaveBeenCalledTimes(1); + }); + + it("loads the image again when the options change", () => { + const image = stubImageLoads(); + const { update } = mount({ + backgroundImage: url, + backgroundImageOptions: { stretched: true }, + }); + + update({ backgroundImage: url, backgroundImageOptions: { stretchedX: true } }); + + expect(image).toHaveBeenCalledTimes(2); + }); + + it("is not dropped by the loading of a value", async () => { + jest.useFakeTimers(); + stubImageLoads(); + + const { canvas } = mount({ + backgroundImage: url, + defaultValue: { objects: [rect(10, 10)] }, + }); + await jest.advanceTimersByTimeAsync(100); + + expect(canvas.getObjects()).toHaveLength(1); + expect(canvas.backgroundImage).toBeTruthy(); + }); + }); + describe("size", () => { it("fits the canvas to its container when it mounts", () => { layout(400, 300); @@ -401,13 +493,16 @@ describe("SketchField", () => { expect(canvas.getWidth()).toEqual(500); }); - it("scales the background image with the canvas, as the objects", () => { + it("scales the background and overlay images with the canvas, as the objects", () => { stubImageLoads(); layout(400, 300); const { sketch, canvas } = mount(); sketch.setBackgroundFromDataUrl("data:image/png;base64,00", { stretched: true, }); + sketch.setOverlayFromDataUrl("data:image/png;base64,00", { + stretched: true, + }); layout(800, 600); window.dispatchEvent(new Event("resize")); @@ -415,6 +510,8 @@ describe("SketchField", () => { // 200 by 100 pixels, stretched to 400 by 300, then to 800 by 600 const image = canvas.backgroundImage as fabric.FabricImage; expect([image.scaleX, image.scaleY]).toEqual([4, 6]); + const overlay = canvas.overlayImage as fabric.FabricImage; + expect([overlay.scaleX, overlay.scaleY]).toEqual([4, 6]); }); it("does not follow the window when autoresize is off", () => { @@ -1453,17 +1550,7 @@ describe("SketchField", () => { }); describe("setBackgroundFromDataUrl", () => { - /** An image loads as soon as its source is set */ - const stubImages = () => - jest.spyOn(window, "Image").mockImplementation(() => { - const img = document.createElement("img"); - img.width = 200; - img.height = 100; - Object.defineProperty(img, "src", { - set: () => img.onload?.(new Event("load")), - }); - return img; - }); + const stubImages = stubImageLoads; it("sets the image as the background of the canvas", () => { stubImages(); @@ -1626,6 +1713,163 @@ describe("SketchField", () => { }); }); + describe("setOverlayFromDataUrl and removeOverlayImage", () => { + const url = "data:image/png;base64,00"; + + it("sets the image above the objects, and leaves the background alone", () => { + stubImageLoads(); + const { sketch, canvas } = mount(); + + sketch.setOverlayFromDataUrl(url); + + expect(canvas.overlayImage).toBeInstanceOf(fabric.FabricImage); + expect(canvas.backgroundImage).toBeFalsy(); + }); + + it("takes the options of the background image", () => { + stubImageLoads(); + layout(400, 300); + const { sketch, canvas } = mount(); + + sketch.setOverlayFromDataUrl(url, { stretched: true, opacity: 0.5 }); + + const image = canvas.overlayImage as fabric.FabricImage; + expect([image.scaleX, image.scaleY, image.opacity]).toEqual([2, 3, 0.5]); + }); + + it("is saved in the JSON", () => { + stubImageLoads(); + const { sketch } = mount(); + + sketch.setOverlayFromDataUrl(url); + + expect(sketch.toJSON().overlayImage).toBeDefined(); + }); + + it("is removed by removeOverlayImage, which keeps the objects and the background image", () => { + stubImageLoads(); + const { sketch, canvas } = mount(); + sketch.addText("hello"); + sketch.setBackgroundFromDataUrl(url); + sketch.setOverlayFromDataUrl(url); + + sketch.removeOverlayImage(); + + expect(canvas.overlayImage).toBeFalsy(); + expect(sketch.toJSON().overlayImage).toBeUndefined(); + expect(canvas.backgroundImage).toBeTruthy(); + expect(canvas.getObjects()).toHaveLength(1); + }); + + it("is not removed by removeBackgroundImage", () => { + stubImageLoads(); + const { sketch, canvas } = mount(); + sketch.setBackgroundFromDataUrl(url); + sketch.setOverlayFromDataUrl(url); + + sketch.removeBackgroundImage(); + + expect(canvas.backgroundImage).toBeFalsy(); + expect(canvas.overlayImage).toBeTruthy(); + }); + + it("is removed by clear(), with the objects", () => { + stubImageLoads(); + const { sketch, canvas } = mount(); + sketch.setOverlayFromDataUrl(url); + + sketch.clear(); + + expect(canvas.overlayImage).toBeFalsy(); + }); + + it("keeps the image that is still loading from being set once it is removed", () => { + const loads = deferImageLoads(); + const { sketch, canvas } = mount(); + sketch.setOverlayFromDataUrl(url); + + sketch.removeOverlayImage(); + loads.forEach((load) => load()); + + expect(canvas.overlayImage).toBeFalsy(); + }); + + it("does not cancel a background image that is loading too", () => { + const loads = deferImageLoads(); + const { sketch, canvas } = mount(); + sketch.setBackgroundFromDataUrl(url); + sketch.setOverlayFromDataUrl(url); + + loads.forEach((load) => load()); + + expect(canvas.backgroundImage).toBeTruthy(); + expect(canvas.overlayImage).toBeTruthy(); + }); + }); + + describe("crossOrigin of the images", () => { + const url = "data:image/png;base64,00"; + const crossOriginOf = (setAttribute: jest.SpyInstance) => + setAttribute.mock.calls + .filter(([name]) => name === "crossOrigin") + .map(([, value]) => value); + + it("is the one of the options for the background image", () => { + const setAttribute = jest.spyOn(HTMLImageElement.prototype, "setAttribute"); + const { sketch } = mount(); + + sketch.setBackgroundFromDataUrl(url, { crossOrigin: "use-credentials" }); + + expect(crossOriginOf(setAttribute)).toEqual(["use-credentials"]); + }); + + it("is not asked for when it is null, so a server without CORS headers can be used", () => { + const setAttribute = jest.spyOn(HTMLImageElement.prototype, "setAttribute"); + const { sketch } = mount(); + + sketch.setBackgroundFromDataUrl(url, { crossOrigin: null }); + + expect(crossOriginOf(setAttribute)).toEqual([]); + }); + + it("is anonymous for the overlay image, and null switches it off", () => { + const setAttribute = jest.spyOn(HTMLImageElement.prototype, "setAttribute"); + const { sketch } = mount(); + + sketch.setOverlayFromDataUrl(url); + sketch.setOverlayFromDataUrl(url, { crossOrigin: null }); + + expect(crossOriginOf(setAttribute)).toEqual(["anonymous"]); + }); + + it("is passed to the image of addImg when it is given, and not otherwise", async () => { + const fromURL = jest + .spyOn(fabric.FabricImage, "fromURL") + .mockImplementation(() => + Promise.resolve( + new fabric.FabricImage(document.createElement("img"), { + width: 100, + height: 60, + }) + ) + ); + const { sketch } = mount(); + + sketch.addImg("a", { crossOrigin: "anonymous" }); + sketch.addImg("b"); + await settle(); + + const imageOptions = { originX: "left", originY: "top" }; + expect(fromURL).toHaveBeenNthCalledWith( + 1, + "a", + { crossOrigin: "anonymous" }, + imageOptions + ); + expect(fromURL).toHaveBeenNthCalledWith(2, "b", {}, imageOptions); + }); + }); + describe("addText", () => { it("adds a text at the center of the canvas", () => { const { sketch, canvas } = mount(); diff --git a/packages/react-sketch/src/components/SketchField/SketchField.tsx b/packages/react-sketch/src/components/SketchField/SketchField.tsx index 909ef5e..095fed0 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.tsx @@ -93,6 +93,26 @@ class SketchCanvas extends fabric.Canvas { } } +/** + * The two layers of the canvas which hold an image: the background image is + * drawn under the objects, the overlay image above them + */ +type ImageLayer = "backgroundImage" | "overlayImage"; + +/** + * Whether two option objects have the same content, so that options written + * inline in a render do not count as a change + */ +const sameOptions = ( + a: BackgroundOptions = {}, + b: BackgroundOptions = {} +): boolean => { + const keys = Object.keys(a); + return ( + keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]) + ); +}; + /** * Sketch Tool based on FabricJS for React Applications * @@ -106,8 +126,11 @@ class SketchField extends PureComponent { private tool!: FabricCanvasTool; private history!: SketchHistory; private mounted = false; - /** Counts the background images asked for, so that only the last one is set */ - private backgroundLoads = 0; + /** Counts the images asked for by layer, so that only the last one of a layer is set */ + private readonly imageLoads: Record = { + backgroundImage: 0, + overlayImage: 0, + }; private clipboard: Promise | null = null; /** The pastes, which run one after the other as cloning is asynchronous */ private pasting: Promise = Promise.resolve(); @@ -179,7 +202,8 @@ class SketchField extends PureComponent { } componentDidMount(): void { - const { backgroundColor, undoSteps, value, defaultValue } = this.props; + const { backgroundColor, backgroundImage, undoSteps, value, defaultValue } = + this.props; this.mounted = true; // The defaults of fabric 7 that differ from the ones of fabric 4 are set back: @@ -210,6 +234,7 @@ class SketchField extends PureComponent { // initialize canvas with controlled value if exists this.fromJSON(value || defaultValue); + if (backgroundImage) this.applyBackgroundImage(); } componentDidUpdate(prevProps: Readonly): void { @@ -233,6 +258,14 @@ class SketchField extends PureComponent { this.setBackground(props.backgroundColor); } + if ( + props.backgroundImage !== prevProps.backgroundImage || + (props.backgroundImage && + !sameOptions(props.backgroundImageOptions, prevProps.backgroundImageOptions)) + ) { + this.applyBackgroundImage(); + } + if (props.value !== prevProps.value || (props.value && props.forceValue)) { this.fromJSON(props.value); } @@ -340,18 +373,23 @@ class SketchField extends PureComponent { * left: , * top: , * scale: + * crossOrigin: <"anonymous" | "use-credentials": request the image with CORS, so the canvas can be exported> * } */ addImg = (dataUrl: string, options: ImageOptions = {}): void => { const canvas = this.canvas; - fabric.FabricImage.fromURL(dataUrl, {}, { originX: "left", originY: "top" }) + const { crossOrigin, ...placement } = options; + fabric.FabricImage.fromURL(dataUrl, crossOrigin ? { crossOrigin } : {}, { + originX: "left", + originY: "top", + }) .then((image) => { if (!this.isCurrent(canvas)) return; const { left, top, scale } = { left: Math.random() * (canvas.getWidth() - (image.width ?? 0) * 0.5), top: Math.random() * (canvas.getHeight() - (image.height ?? 0) * 0.5), scale: 0.5, - ...options, + ...placement, }; image.scale(scale); image.set({ left, top }); @@ -468,6 +506,8 @@ class SketchField extends PureComponent { // The loaded objects follow the tool in use, for example they can't be // selected when drawing this.configureTool(); + // The property takes the place of the background image of the value + if (this.props.backgroundImage) this.applyBackgroundImage(); canvas.renderAll(); this.props.onChange?.(); }) @@ -571,23 +611,28 @@ class SketchField extends PureComponent { }; /** - * Sets the background from the dataUrl given - * - * @param dataUrl the dataUrl to be used as a background - * @param options stretched, stretchedX and stretchedY scale the image to the canvas, the other - * options are options of the image + * Sets an image on a layer of the canvas, in place of the one it has, and + * fits it as the options say. Only the last image asked for a layer is set, + * also when an earlier one loads later. */ - setBackgroundFromDataUrl = ( + private setImageLayer( + layer: ImageLayer, dataUrl: string, - options: BackgroundOptions = {} - ): void => { + options: BackgroundOptions + ): void { const canvas = this.canvas; - const load = ++this.backgroundLoads; + const load = ++this.imageLoads[layer]; + const { + stretched, + stretchedX, + stretchedY, + crossOrigin = "anonymous", + ...imageOptions + } = options; const img = new Image(); - img.setAttribute("crossOrigin", "anonymous"); - const { stretched, stretchedX, stretchedY, ...imageOptions } = options; + if (crossOrigin) img.setAttribute("crossOrigin", crossOrigin); img.onload = () => { - if (!this.isCurrent(canvas) || load !== this.backgroundLoads) return; + if (!this.isCurrent(canvas) || load !== this.imageLoads[layer]) return; const imgObj = new fabric.FabricImage(img, { originX: "left", originY: "top", @@ -605,22 +650,69 @@ class SketchField extends PureComponent { imgObj.scaleToHeight(canvas.getHeight()); } imgObj.set(imageOptions); - canvas.backgroundImage = imgObj; + canvas[layer] = imgObj; canvas.requestRenderAll(); }; img.src = dataUrl; - }; + } /** - * Removes the background image, also one that is still loading. The objects - * and the background color are kept, and the undo history is not changed - * (setting the background image is not part of it either). + * Removes the image of a layer, also one that is still loading. The objects + * and the other layers are kept. */ - removeBackgroundImage = (): void => { - this.backgroundLoads++; - this.canvas.backgroundImage = undefined; + private removeImageLayer(layer: ImageLayer): void { + this.imageLoads[layer]++; + this.canvas[layer] = undefined; this.canvas.requestRenderAll(); - }; + } + + /** + * Sets the background from the dataUrl given + * + * @param dataUrl the dataUrl to be used as a background + * @param options stretched, stretchedX and stretchedY scale the image to the canvas, crossOrigin + * says how the image is requested (anonymous by default), the other options are options of the image + */ + setBackgroundFromDataUrl = ( + dataUrl: string, + options: BackgroundOptions = {} + ): void => this.setImageLayer("backgroundImage", dataUrl, options); + + /** + * Sets the overlay image from the dataUrl given. The overlay image is drawn above the + * objects and does not take mouse events, so it can be an outline to draw within. It is + * saved by `toJSON` and in the image of `toDataURL`, and it is not part of the undo history. + * + * @param dataUrl the dataUrl to be used as an overlay + * @param options the options of `setBackgroundFromDataUrl` + */ + setOverlayFromDataUrl = (dataUrl: string, options: BackgroundOptions = {}): void => + this.setImageLayer("overlayImage", dataUrl, options); + + /** + * Removes the background image, also one that is still loading. The objects, + * the overlay image and the background color are kept, and the undo history is + * not changed (setting the background image is not part of it either). + */ + removeBackgroundImage = (): void => this.removeImageLayer("backgroundImage"); + + /** + * Removes the overlay image, also one that is still loading. The objects, the + * background image and the background color are kept. + */ + removeOverlayImage = (): void => this.removeImageLayer("overlayImage"); + + /** + * Set the image of the `backgroundImage` property, or remove the image when it is gone + */ + private applyBackgroundImage(): void { + const { backgroundImage, backgroundImageOptions } = this.props; + if (backgroundImage) { + this.setBackgroundFromDataUrl(backgroundImage, backgroundImageOptions); + } else { + this.removeBackgroundImage(); + } + } /** * Add a text to the center of the canvas diff --git a/packages/react-sketch/src/components/SketchField/resize.ts b/packages/react-sketch/src/components/SketchField/resize.ts index 8e3e528..021a1da 100644 --- a/packages/react-sketch/src/components/SketchField/resize.ts +++ b/packages/react-sketch/src/components/SketchField/resize.ts @@ -1,25 +1,27 @@ import * as fabric from "fabric"; /** - * Scale the content of the canvas, the objects and the background image, and - * move it by the same factors. + * Scale the content of the canvas, the objects and the background and overlay + * images, and move it by the same factors. * - * The background image is scaled and not resized: a bigger width of an image - * does not stretch it. + * The images are scaled and not resized: a bigger width of an image does not + * stretch it. */ export const scaleContent = ( canvas: fabric.Canvas, xFactor: number, yFactor: number ): void => { - [canvas.backgroundImage, ...canvas.getObjects()].forEach((obj) => { - if (!obj) return; - obj.scaleX = (obj.scaleX ?? 1) * xFactor; - obj.scaleY = (obj.scaleY ?? 1) * yFactor; - obj.left = (obj.left ?? 0) * xFactor; - obj.top = (obj.top ?? 0) * yFactor; - obj.setCoords(); - }); + [canvas.backgroundImage, canvas.overlayImage, ...canvas.getObjects()].forEach( + (obj) => { + if (!obj) return; + obj.scaleX = (obj.scaleX ?? 1) * xFactor; + obj.scaleY = (obj.scaleY ?? 1) * yFactor; + obj.left = (obj.left ?? 0) * xFactor; + obj.top = (obj.top ?? 0) * yFactor; + obj.setCoords(); + } + ); }; /** diff --git a/packages/react-sketch/src/types.ts b/packages/react-sketch/src/types.ts index 6c91c04..88302c9 100644 --- a/packages/react-sketch/src/types.ts +++ b/packages/react-sketch/src/types.ts @@ -99,12 +99,25 @@ export interface ImageOptions { top?: number; /** Initial scale of the image. Defaults to 0.5 */ scale?: number; + /** + * How the image is requested: with "anonymous" or "use-credentials" the server + * must send CORS headers, and the canvas can be exported afterwards. Without it + * the image is requested as before, and a cross-origin image taints the canvas + * (`toDataURL` throws) + */ + crossOrigin?: "anonymous" | "use-credentials" | null; } /** - * Options of `setBackgroundFromDataUrl` + * Options of `setBackgroundFromDataUrl` and `setOverlayFromDataUrl` */ export interface BackgroundOptions { + /** + * How the image is requested. Defaults to "anonymous", so the canvas can be + * exported. Use null for a server without CORS headers: the image is shown, + * and a cross-origin image taints the canvas (`toDataURL` throws) + */ + crossOrigin?: "anonymous" | "use-credentials" | null; /** Scale the image to the width and the height of the canvas */ stretched?: boolean; /** Scale the image to the width of the canvas */ @@ -139,6 +152,14 @@ export interface SketchProperties { fillColor?: string; /** The background color of the sketch, in rgba or hex format. Defaults to "transparent" */ backgroundColor?: 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` + */ + backgroundImage?: string; + /** Options of `backgroundImage`, the ones of `setBackgroundFromDataUrl` */ + backgroundImageOptions?: BackgroundOptions; /** The opacity of the object. Accepted for compatibility, it is not applied (as in 0.5.x). Defaults to 1.0 */ opacity?: number; /** Number of undo/redo steps to maintain, read when the component mounts. Defaults to 25 */ From 9a795835c7eaa6d9cd9249d75f1a03a10d0925a0 Mon Sep 17 00:00:00 2001 From: tbolis Date: Fri, 23 Oct 2026 18:42:52 +0300 Subject: [PATCH 02/33] showcase: overlay section, backgroundImage tab and a CORS switch - An Overlay section: a sample outline, an image of your own, and remove. - A backgroundImage tab, which sets the background image through the property, with a URL field and the fit switches as the options. - A switch for "Load Image from URL" that asks for the image with CORS. --- CHANGELOG.md | 3 + .../src/components/Canvas/Scenarios/index.tsx | 31 +++++++++- .../showcase/src/components/Canvas/index.tsx | 17 +++++- .../src/components/Tools/Background/index.tsx | 2 +- .../components/Tools/ImageControls/index.tsx | 23 +++++++- .../src/components/Tools/Overlay/index.tsx | 59 +++++++++++++++++++ .../showcase/src/components/Tools/index.tsx | 4 +- packages/showcase/src/data/outlineDataUrl.ts | 15 +++++ packages/showcase/src/stores/config.ts | 10 +++- 9 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 packages/showcase/src/components/Tools/Overlay/index.tsx create mode 100644 packages/showcase/src/data/outlineDataUrl.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f7b4073..ab17bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ Features that forks of react-sketch had, written again for the TypeScript code a cross-origin image can be exported. The background and the overlay images are requested with `"anonymous"` as before, and `null` shows an image of a server without CORS headers, at the cost of a tainted canvas. From `react-sketch2` of @x5engine. +- The showcase has an Overlay section (a sample outline, an image of your own, remove), a + `backgroundImage` tab that uses the property, and a CORS switch for "Load Image from URL". The fit + switches of the Background section also apply to the background image that is already set. ### Changed diff --git a/packages/showcase/src/components/Canvas/Scenarios/index.tsx b/packages/showcase/src/components/Canvas/Scenarios/index.tsx index de0bd63..cce139e 100644 --- a/packages/showcase/src/components/Canvas/Scenarios/index.tsx +++ b/packages/showcase/src/components/Canvas/Scenarios/index.tsx @@ -3,6 +3,7 @@ import Button from "@mui/material/Button"; import FormControlLabel from "@mui/material/FormControlLabel"; import Stack from "@mui/material/Stack"; import Switch from "@mui/material/Switch"; +import TextField from "@mui/material/TextField"; import Tab from "@mui/material/Tab"; import Tabs from "@mui/material/Tabs"; import Typography from "@mui/material/Typography"; @@ -16,6 +17,7 @@ const SCENARIOS: { value: Scenario; label: string }[] = [ { value: "default-value", label: "defaultValue" }, { value: "controlled-value", label: "value + forceValue" }, { value: "add-img", label: "addImg" }, + { value: "background-image", label: "backgroundImage" }, ]; const selectScenario = (scenario: Scenario) => { @@ -47,7 +49,8 @@ const ScenarioTabs = ({ scenario }: Pick): ReactElement => ( const ScenarioControls = ({ scenario, forceValue, -}: Pick): ReactElement => { + backgroundImageUrl, +}: Pick): ReactElement => { switch (scenario) { case "default-value": return ( @@ -99,6 +102,32 @@ const ScenarioControls = ({ ); + case "background-image": + return ( + <> + + The background image comes from the backgroundImage property, with the + fit switches of the Background section as backgroundImageOptions. The + canvas sets it when the URL changes, and removes it when the URL is + empty. + + + (CONFIG.backgroundImageUrl = event.target.value)} + /> + + + + ); default: return ( diff --git a/packages/showcase/src/components/Canvas/index.tsx b/packages/showcase/src/components/Canvas/index.tsx index 882b141..5a745b4 100644 --- a/packages/showcase/src/components/Canvas/index.tsx +++ b/packages/showcase/src/components/Canvas/index.tsx @@ -17,7 +17,11 @@ const SketchCanvas = (props: Config): ReactElement => { return ( - + { } width={props.controlledSize ? props.sketchWidth : undefined} height={props.controlledSize ? props.sketchHeight : undefined} + backgroundImage={ + props.scenario === "background-image" + ? props.backgroundImageUrl || undefined + : undefined + } + // A new object on every render, with the same content: the canvas does not load the image again + backgroundImageOptions={{ + stretched: props.stretched, + stretchedX: props.stretchedX, + stretchedY: props.stretchedY, + }} defaultValue={props.scenario === "default-value" ? defaultValue : undefined} value={controlled ? props.controlledValue : undefined} forceValue={controlled && props.forceValue} diff --git a/packages/showcase/src/components/Tools/Background/index.tsx b/packages/showcase/src/components/Tools/Background/index.tsx index ef1c05f..500d87d 100644 --- a/packages/showcase/src/components/Tools/Background/index.tsx +++ b/packages/showcase/src/components/Tools/Background/index.tsx @@ -20,7 +20,7 @@ type BackgroundProps = Pick< | "stretchedY" >; -const visuallyHidden = { +export const visuallyHidden = { position: "absolute", width: 1, height: 1, diff --git a/packages/showcase/src/components/Tools/ImageControls/index.tsx b/packages/showcase/src/components/Tools/ImageControls/index.tsx index a69c6b7..a3d3a2f 100644 --- a/packages/showcase/src/components/Tools/ImageControls/index.tsx +++ b/packages/showcase/src/components/Tools/ImageControls/index.tsx @@ -1,13 +1,23 @@ import type { ReactElement } from "react"; import Button from "@mui/material/Button"; +import FormControlLabel from "@mui/material/FormControlLabel"; import Stack from "@mui/material/Stack"; +import Switch from "@mui/material/Switch"; import TextField from "@mui/material/TextField"; import { CONFIG, type Config } from "../../../stores/config"; import { withSketch } from "../../Canvas/sketchRef"; import Section from "../Section"; -const ImageControls = ({ imageUrl }: Pick): ReactElement => { - const addImage = () => withSketch((sketch) => sketch.addImg(CONFIG.imageUrl)); +const ImageControls = ({ + imageUrl, + imageCors, +}: Pick): ReactElement => { + const addImage = () => + withSketch((sketch) => + sketch.addImg(CONFIG.imageUrl, { + crossOrigin: CONFIG.imageCors ? "anonymous" : undefined, + }) + ); return (
@@ -20,6 +30,15 @@ const ImageControls = ({ imageUrl }: Pick): ReactElement => value={imageUrl} onChange={(event) => (CONFIG.imageUrl = event.target.value)} /> + (CONFIG.imageCors = event.target.checked)} + /> + } + /> diff --git a/packages/showcase/src/components/Tools/Overlay/index.tsx b/packages/showcase/src/components/Tools/Overlay/index.tsx new file mode 100644 index 0000000..06ba1d5 --- /dev/null +++ b/packages/showcase/src/components/Tools/Overlay/index.tsx @@ -0,0 +1,59 @@ +import type { ChangeEvent, ReactElement } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import outlineDataUrl from "../../../data/outlineDataUrl"; +import { readAsDataUrl } from "../../../utils/files"; +import { withSketch } from "../../Canvas/sketchRef"; +import { visuallyHidden } from "../Background"; +import Section from "../Section"; + +/** + * Use the image as the overlay of the canvas, above the drawing. It fills the canvas, + * as an outline to draw within does. + */ +const setOverlay = (dataUrl: string): void => + withSketch((sketch) => sketch.setOverlayFromDataUrl(dataUrl, { stretched: true })); + +const chooseImage = async (event: ChangeEvent): Promise => { + const image = Array.from(event.target.files ?? []).find((file) => + file.type.startsWith("image/") + ); + // The same file can be chosen again + event.target.value = ""; + if (image) setOverlay(await readAsDataUrl(image)); +}; + +const Overlay = (): ReactElement => ( +
+ + An image drawn above the drawing, for example an outline to draw within. It + takes no mouse events, and the download includes it. + + + + + + +
+); + +export default Overlay; diff --git a/packages/showcase/src/components/Tools/index.tsx b/packages/showcase/src/components/Tools/index.tsx index 634056e..757e59f 100644 --- a/packages/showcase/src/components/Tools/index.tsx +++ b/packages/showcase/src/components/Tools/index.tsx @@ -8,6 +8,7 @@ import CanvasSize from "./CanvasSize"; import ColorSelector from "./ColorSelector"; import ImageControls from "./ImageControls"; import LabeledSlider from "./LabeledSlider"; +import Overlay from "./Overlay"; import Section from "./Section"; import TextControls from "./TextControls"; import ToolSelector from "./ToolSelector"; @@ -60,8 +61,9 @@ const SketchTools = (props: Config): ReactElement => { stretchedX={props.stretchedX} stretchedY={props.stretchedY} /> + - + ); }; diff --git a/packages/showcase/src/data/outlineDataUrl.ts b/packages/showcase/src/data/outlineDataUrl.ts new file mode 100644 index 0000000..5e0e78c --- /dev/null +++ b/packages/showcase/src/data/outlineDataUrl.ts @@ -0,0 +1,15 @@ +/** + * A coloring page, as an SVG data URL: the outline of a house with a sun, to use as + * the overlay image. The fill is transparent, so the drawing below shows through. + */ +const outline = ` + + + + + + + +`; + +export default `data:image/svg+xml;charset=utf-8,${encodeURIComponent(outline)}`; diff --git a/packages/showcase/src/stores/config.ts b/packages/showcase/src/stores/config.ts index 5a8e422..d71a45c 100644 --- a/packages/showcase/src/stores/config.ts +++ b/packages/showcase/src/stores/config.ts @@ -6,7 +6,11 @@ import sampleImage from "../assets/sample.png"; * The usage scenarios of the component, each one is shown in its own tab */ export type Scenario = - "playground" | "default-value" | "controlled-value" | "add-img"; + | "playground" + | "default-value" + | "controlled-value" + | "add-img" + | "background-image"; export interface Config { scenario: Scenario; @@ -25,6 +29,8 @@ export interface Config { stretchedY: boolean; text: string; imageUrl: string; + imageCors: boolean; + backgroundImageUrl: string; controlledValue: object | undefined; forceValue: boolean; canUndo: boolean; @@ -71,6 +77,8 @@ export class ConfigStore implements Config { public stretchedY = false; public text = "a text, cool!"; public imageUrl = sampleImage; + public imageCors = false; + public backgroundImageUrl = sampleImage; public controlledValue: object | undefined = undefined; public forceValue = true; public canUndo = false; From ff911580cd575484252793555e62edfa26cff4fb Mon Sep 17 00:00:00 2001 From: tbolis Date: Tue, 27 Oct 2026 15:03:15 +0200 Subject: [PATCH 03/33] feat: eraser tool Erases parts of the objects with @erase2d/fabric, one stroke is one undo step, and the erased parts are saved by toJSON and loaded by fromJSON (#82). --- CHANGELOG.md | 9 + README.md | 34 ++-- packages/react-sketch/jest.config.js | 9 + packages/react-sketch/package.json | 1 + .../components/SketchField/SketchField.tsx | 8 + .../src/components/SketchField/events.ts | 2 + .../SketchField/history/SketchHistory.ts | 39 +++- .../SketchField/tools/Eraser/Eraser.spec.tsx | 166 ++++++++++++++++++ .../SketchField/tools/Eraser/Eraser.ts | 81 +++++++++ .../SketchField/tools/Eraser/index.ts | 1 + .../components/SketchField/tools/factory.ts | 4 + .../src/components/SketchField/tools/index.ts | 9 +- packages/react-sketch/src/index.spec.tsx | 3 +- packages/react-sketch/src/types.ts | 2 + .../components/Tools/ToolSelector/index.tsx | 2 + yarn.lock | 5 + 16 files changed, 357 insertions(+), 18 deletions(-) create mode 100644 packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.spec.tsx create mode 100644 packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.ts create mode 100644 packages/react-sketch/src/components/SketchField/tools/Eraser/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f4786d7..ad49b6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.1.0 (unreleased) + +### Added + +- The eraser tool (#82): `tool="eraser"`, `Tools.Eraser`, `Types.Tool.ERASER`. It erases parts of the + objects with the width of `lineWidth`, one stroke is one undo step, and the erased parts are saved by + `toJSON()` and loaded by `fromJSON()`. The background color and the background image are not erased. + It uses `@erase2d/fabric`, which is part of the bundle. + ## 1.0.1 (unreleased) ### Fixed diff --git a/README.md b/README.md index a265da4..5537ef7 100644 --- a/README.md +++ b/README.md @@ -271,25 +271,33 @@ throws. Use the tools by name (`"pencil"`), with `Tools` (`Tools.Pencil`) or with the enumeration `Types.Tool` (`Types.Tool.PENCIL`). -| Tool | `Tools` | Value | Description | -| --------------- | ---------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| Select | `Tools.Select` | `select` | Disables drawing. Objects can be selected, moved, scaled and rotated. | -| Pencil | `Tools.Pencil` | `pencil` | Free drawing. | -| Line | `Tools.Line` | `line` | Straight lines. | -| Arrow | `Tools.Arrow` | `arrow` | Lines with an arrow head. | -| Rectangle | `Tools.Rectangle` | `rectangle` | Rectangles, filled with `fillColor`. | -| Rectangle label | `Tools.RectangleLabel` | `rectangle-label` | Rectangles with a text box above them. The text box follows the rectangle when it is moved or scaled. | -| Circle | `Tools.Circle` | `circle` | Circles, filled with `fillColor`. The drag from the first to the second point is the diameter. | -| Pan | `Tools.Pan` | `pan` | Disables drawing. Dragging moves the whole canvas, useful after zooming (contributed by [wmaillard](https://github.com/wmaillard)). | -| Highlighter | `Tools.Highlighter` | `highlighter` | Free drawing in a semi-transparent color. `lineColor` must be a hex code or a color name; any other value falls back to the default color. | -| Default tool | `Tools.DefaultTool` | `default-tool` | Disables drawing and selection. Use it to show a drawing that is not edited. | +| Tool | `Tools` | Value | Description | +| --------------- | ---------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| Select | `Tools.Select` | `select` | Disables drawing. Objects can be selected, moved, scaled and rotated. | +| Pencil | `Tools.Pencil` | `pencil` | Free drawing. | +| Line | `Tools.Line` | `line` | Straight lines. | +| Arrow | `Tools.Arrow` | `arrow` | Lines with an arrow head. | +| Rectangle | `Tools.Rectangle` | `rectangle` | Rectangles, filled with `fillColor`. | +| Rectangle label | `Tools.RectangleLabel` | `rectangle-label` | Rectangles with a text box above them. The text box follows the rectangle when it is moved or scaled. | +| Circle | `Tools.Circle` | `circle` | Circles, filled with `fillColor`. The drag from the first to the second point is the diameter. | +| Pan | `Tools.Pan` | `pan` | Disables drawing. Dragging moves the whole canvas, useful after zooming (contributed by [wmaillard](https://github.com/wmaillard)). | +| Highlighter | `Tools.Highlighter` | `highlighter` | Free drawing in a semi-transparent color. `lineColor` must be a hex code or a color name; any other value falls back to the default color. | +| Eraser | `Tools.Eraser` | `eraser` | Erases parts of the objects, with the width of `lineWidth`. The background color and the background image are not erased. See [Eraser](#eraser). | +| Default tool | `Tools.DefaultTool` | `default-tool` | Disables drawing and selection. Use it to show a drawing that is not edited. | + +### Eraser + +The eraser cuts the strokes it draws out of the objects it crosses: a line, a shape, an image or a text. +A stroke is one undo step. The erased parts are kept on the objects, so `toJSON()` saves them and `value`, +`defaultValue` and `fromJSON()` load them. An object is only cut where the eraser crosses it, the rest of +the sketch stays, and the background color and the background image are not erased. ## Exports | Export | Description | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `SketchField` | The component (named export). Also usable as a type, for the `ref`. | -| `Tools` | The tools by name: `Select`, `Pencil`, `Line`, `Arrow`, `Rectangle`, `RectangleLabel`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`. | +| `Tools` | The tools by name: `Select`, `Pencil`, `Line`, `Arrow`, `Rectangle`, `RectangleLabel`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`, `Eraser`. | | `Types` | Namespace of the types: `Tool` (enumeration), `Tools`, `SketchProperties`, `SketchEvent`, `SketchObject`, `SketchValue`, `SketchData`, `DataUrlOptions`, `ImageOptions`, `BackgroundOptions`, `TextOptions`. | | default export | The component, which also carries `SketchField` and `Tools` (as in 0.5.x). | diff --git a/packages/react-sketch/jest.config.js b/packages/react-sketch/jest.config.js index a683205..1c82403 100644 --- a/packages/react-sketch/jest.config.js +++ b/packages/react-sketch/jest.config.js @@ -20,7 +20,16 @@ module.exports = { roots: ["/src"], testMatch: ["/src/**/*.spec.ts?(x)"], moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"], + // @erase2d/fabric is published as ES modules only, so it is compiled too + transformIgnorePatterns: ["/node_modules/(?!@erase2d)"], transform: { + "^.+\\.m?js$": [ + "ts-jest", + { + tsconfig: { allowJs: true, module: "commonjs", target: "es2020" }, + diagnostics: false, + }, + ], "^.+\\.tsx?$": [ "ts-jest", { diff --git a/packages/react-sketch/package.json b/packages/react-sketch/package.json index e68f9ae..5b96be3 100644 --- a/packages/react-sketch/package.json +++ b/packages/react-sketch/package.json @@ -46,6 +46,7 @@ "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "devDependencies": { + "@erase2d/fabric": "^1.2.2", "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", diff --git a/packages/react-sketch/src/components/SketchField/SketchField.tsx b/packages/react-sketch/src/components/SketchField/SketchField.tsx index 909ef5e..a6061ac 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.tsx @@ -16,6 +16,7 @@ import { SketchHistory } from "./history/SketchHistory"; import { autoresize, scaleContent } from "./resize"; import { FabricCanvasTool } from "./tools"; import { create_tool } from "./tools/factory"; +import { Eraser } from "./tools/Eraser"; /** * The properties which always have a value, because SketchField provides @@ -272,6 +273,13 @@ class SketchField extends PureComponent { private selectTool(): void { this.withToolProps((props) => { this.tool = create_tool(this.canvas, props); + if (this.tool instanceof Eraser) { + this.tool.onErase = (erasures) => { + if (!this.mounted) return; + this.history.onErased(erasures); + this.props.onChange?.(); + }; + } }); } diff --git a/packages/react-sketch/src/components/SketchField/events.ts b/packages/react-sketch/src/components/SketchField/events.ts index b29e4aa..38b8885 100644 --- a/packages/react-sketch/src/components/SketchField/events.ts +++ b/packages/react-sketch/src/components/SketchField/events.ts @@ -65,6 +65,8 @@ export class FabricCanvasEventsManager * redo add back are not new, so they are not recorded nor announced. */ public onObjectAdded({ event, props }: EventInput): void { + // Only erasable objects are affected by the eraser + if (event.target) (event.target as { erasable?: boolean }).erasable = true; if (this.history.replaying) return; if (event.target) this.history.onObjectAdded(event.target); props.onObjectAdded?.(sketchEvent(event)); diff --git a/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts b/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts index 1a3941a..e71bc16 100644 --- a/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts +++ b/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts @@ -1,5 +1,6 @@ import * as fabric from "fabric"; import { UndoHistory } from "./index"; +import type { Erasure } from "../tools/Eraser"; /** * One undoable action of the sketch @@ -7,7 +8,8 @@ import { UndoHistory } from "./index"; type HistoryEntry = | { action: "add"; object: fabric.FabricObject } | { action: "remove"; object: fabric.FabricObject } - | { action: "modify"; object: fabric.FabricObject; before: string; after: string }; + | { action: "modify"; object: fabric.FabricObject; before: string; after: string } + | { action: "erase"; erasures: Erasure[] }; /** * Properties of the serialized state which are not restored: the type cannot @@ -70,6 +72,14 @@ export class SketchHistory { this.entries.keep({ action: "modify", object, before, after }); } + /** + * Record one stroke of the eraser, which is one action whatever the number + * of objects it erased + */ + onErased(erasures: Erasure[]): void { + this.entries.keep({ action: "erase", erasures }); + } + /** * An object is added while the mouse is down (it is resized by the move of * the mouse), so its state is only final on mouse up. @@ -132,10 +142,35 @@ export class SketchHistory { this.pending = null; } + /** + * Take the stroke off the erasing group of the object, or put it back. A group + * which the stroke created goes away with it, the object is as it was. + */ + private applyErasure( + { object, group, source, created }: Erasure, + forward: boolean + ): void { + if (forward) { + if (created) object.clipPath = group; + else group.add(source); + } else if (created) { + object.clipPath = undefined; + } else { + group.remove(source); + } + group.set("dirty", true); + object.set("dirty", true); + } + private apply(entry: HistoryEntry, forward: boolean): void { - const { object } = entry; this.applying = true; try { + if (entry.action === "erase") { + entry.erasures.forEach((erasure) => this.applyErasure(erasure, forward)); + this.canvas.renderAll(); + return; + } + const { object } = entry; switch (entry.action) { case "add": case "remove": diff --git a/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.spec.tsx b/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.spec.tsx new file mode 100644 index 0000000..533f8fc --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.spec.tsx @@ -0,0 +1,166 @@ +import { createRef } from "react"; +import { render } from "@testing-library/react"; +import * as fabric from "fabric"; +import { EraserBrush } from "@erase2d/fabric"; +import SketchField from "../../SketchField"; +import { SketchProperties, Tool, Tools } from "../../../../types"; + +const mount = (props: SketchProperties = {}) => { + const ref = createRef(); + const view = render(); + const sketch = ref.current as SketchField; + return { ...view, sketch, canvas: sketch["canvas"] as fabric.Canvas }; +}; + +const rect = () => + new fabric.Rect({ + left: 10, + top: 10, + width: 100, + height: 100, + originX: "left", + originY: "top", + fill: "red", + }); + +/** Erase with one stroke from `from` to `to`, and wait for it to be committed */ +const stroke = async ( + canvas: fabric.Canvas, + from: [number, number], + to: [number, number] +): Promise => { + const brush = canvas.freeDrawingBrush as EraserBrush; + const at = ([x, y]: [number, number]) => new fabric.Point(x, y); + const event = (type: string) => ({ e: new MouseEvent(type) }) as never; + brush.onMouseDown(at(from), event("mousedown")); + brush.onMouseMove(at([(from[0] + to[0]) / 2, from[1]]), event("mousemove")); + brush.onMouseMove(at(to), event("mousemove")); + brush.onMouseUp(event("mouseup")); + await new Promise((resolve) => setTimeout(resolve, 20)); +}; + +describe("Eraser", () => { + it("is a tool of the component, with the legacy name", () => { + expect(Tools.Eraser).toEqual(Tool.ERASER); + }); + + it("draws with an eraser brush of the width of the line", () => { + const { canvas } = mount({ lineWidth: 24 }); + + expect(canvas.isDrawingMode).toBe(true); + expect(canvas.freeDrawingBrush).toBeInstanceOf(EraserBrush); + expect(canvas.freeDrawingBrush?.width).toEqual(24); + }); + + it("follows the line width when it changes", () => { + const { canvas, rerender } = mount({ lineWidth: 24 }); + + rerender(); + + expect(canvas.freeDrawingBrush?.width).toEqual(40); + }); + + it("gives the pencil its own brush again, when the tool changes", () => { + const { canvas, rerender } = mount(); + + rerender(); + + expect(canvas.freeDrawingBrush).toBeInstanceOf(fabric.PencilBrush); + expect(canvas.freeDrawingBrush).not.toBeInstanceOf(EraserBrush); + }); + + it("marks the added objects as erasable, the loaded ones too", () => { + const { canvas } = mount(); + const added = rect(); + + canvas.add(added); + + expect((added as unknown as { erasable: boolean }).erasable).toBe(true); + }); + + describe("a stroke", () => { + it("erases a part of the object it crosses", async () => { + const { canvas } = mount({ lineWidth: 20 }); + const object = rect(); + canvas.add(object); + + await stroke(canvas, [30, 50], [90, 50]); + + expect(object.clipPath?.type).toEqual("clipping"); + expect(canvas.getObjects()).toEqual([object]); + }); + + it("leaves alone an object it does not cross", async () => { + const { canvas } = mount({ lineWidth: 20 }); + const object = rect(); + canvas.add(object); + + await stroke(canvas, [150, 150], [190, 150]); + + expect(object.clipPath).toBeUndefined(); + }); + + it("is one undo step, and redo erases again", async () => { + const { canvas, sketch } = mount({ lineWidth: 20 }); + const object = rect(); + canvas.add(object); + await stroke(canvas, [30, 50], [90, 50]); + expect(sketch.canUndo()).toBe(true); + + sketch.undo(); + expect(object.clipPath).toBeUndefined(); + + sketch.redo(); + expect(object.clipPath?.type).toEqual("clipping"); + + // Undo twice goes back past the stroke, to the object being added + sketch.undo(); + sketch.undo(); + expect(canvas.getObjects()).toEqual([]); + }); + + it("undoes the strokes one by one on the same object", async () => { + const { canvas, sketch } = mount({ lineWidth: 20 }); + const object = rect(); + canvas.add(object); + await stroke(canvas, [30, 30], [90, 30]); + await stroke(canvas, [30, 80], [90, 80]); + const group = object.clipPath as fabric.Group; + expect(group.size()).toEqual(2); + + sketch.undo(); + expect((object.clipPath as fabric.Group).size()).toEqual(1); + + sketch.undo(); + expect(object.clipPath).toBeUndefined(); + + sketch.redo(); + sketch.redo(); + expect((object.clipPath as fabric.Group).size()).toEqual(2); + }); + + it("calls onChange", async () => { + const onChange = jest.fn(); + const { canvas } = mount({ lineWidth: 20, onChange }); + canvas.add(rect()); + + await stroke(canvas, [30, 50], [90, 50]); + + expect(onChange).toHaveBeenCalled(); + }); + }); + + it("is saved by toJSON and comes back with fromJSON", async () => { + const { canvas, sketch } = mount({ lineWidth: 20 }); + canvas.add(rect()); + await stroke(canvas, [30, 50], [90, 50]); + const saved = JSON.parse(JSON.stringify(sketch.toJSON())); + expect(saved.objects[0].clipPath.type).toEqual("clipping"); + + const [loaded] = await fabric.util.enlivenObjects( + saved.objects + ); + + expect(loaded.clipPath?.type).toEqual("clipping"); + }); +}); diff --git a/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.ts b/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.ts new file mode 100644 index 0000000..b3c3f72 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.ts @@ -0,0 +1,81 @@ +import * as fabric from "fabric"; +import { ClippingGroup, EraserBrush, type ErasingEvent } from "@erase2d/fabric"; +import { FabricCanvasTool } from "../index"; +import { SketchProperties, Tool } from "../../../../types"; + +/** + * What one stroke of the eraser did to an object: `source` is the part of the + * stroke that was added to `group`, the erasing group of the object. + * `created` is true when the object had no erasing group before the stroke. + */ +export interface Erasure { + object: fabric.FabricObject; + group: ClippingGroup; + source: fabric.FabricObject; + created: boolean; +} + +/** + * Erases parts of the objects, not the background. The strokes are kept on the + * objects as a clip path, so they are saved by `toJSON` and loaded by `fromJSON`. + */ +export class Eraser extends FabricCanvasTool { + /** Called, after each stroke which erased something, with what it did */ + onErase?: (erasures: Erasure[]) => void; + + constructor(canvas: fabric.Canvas) { + super(canvas); + } + + configureCanvas(props: SketchProperties): void { + const brush = this.eraserBrush; + this.canvas.isDrawingMode = true; + this.canvas.freeDrawingCursor = "default"; + brush.width = props.lineWidth ? props.lineWidth : 1; + } + + type(): Tool { + return Tool.ERASER; + } + + /** + * The eraser brush of the canvas. It is created the first time it is needed, + * and the canvas keeps it for the next stroke. + */ + private get eraserBrush(): EraserBrush { + const current = this.canvas.freeDrawingBrush; + if (current instanceof EraserBrush) return current; + const brush = new EraserBrush(this.canvas); + // The stroke is committed here, and not by the brush, to know what it changed + brush.on("end", (event: ErasingEvent<"end">) => { + event.preventDefault(); + void this.commit(brush, event.detail); + }); + this.canvas.freeDrawingBrush = brush; + return brush; + } + + private async commit( + brush: EraserBrush, + stroke: { path: fabric.Path; targets: fabric.FabricObject[] } + ): Promise { + const created = new Map( + stroke.targets.map((o) => [o, !(o.clipPath instanceof ClippingGroup)]) + ); + const sources = await brush.commit(stroke); + const erasures: Erasure[] = []; + sources.forEach((source, object) => { + // The background and the overlay image are not erased, they are not erasable + const group = object.clipPath; + if (group instanceof ClippingGroup && created.has(object)) { + erasures.push({ + object, + group, + source, + created: created.get(object) as boolean, + }); + } + }); + if (erasures.length) this.onErase?.(erasures); + } +} diff --git a/packages/react-sketch/src/components/SketchField/tools/Eraser/index.ts b/packages/react-sketch/src/components/SketchField/tools/Eraser/index.ts new file mode 100644 index 0000000..7e21510 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Eraser/index.ts @@ -0,0 +1 @@ +export { Eraser, type Erasure } from "./Eraser"; diff --git a/packages/react-sketch/src/components/SketchField/tools/factory.ts b/packages/react-sketch/src/components/SketchField/tools/factory.ts index 6f6d29b..88d93fc 100644 --- a/packages/react-sketch/src/components/SketchField/tools/factory.ts +++ b/packages/react-sketch/src/components/SketchField/tools/factory.ts @@ -4,6 +4,7 @@ import { FabricCanvasTool } from "./index"; import { Arrow } from "./Arrow"; import { Circle } from "./Circle"; import { DefaultTool } from "./DefaultTool"; +import { Eraser } from "./Eraser"; import { Highlighter } from "./Highlighter"; import { Line } from "./Line"; import { Pan } from "./Pan"; @@ -50,6 +51,9 @@ export const create_tool = ( case Tool.HIGHLIGHTER: toolInstance = new Highlighter(canvas); break; + case Tool.ERASER: + toolInstance = new Eraser(canvas); + break; case Tool.DEFAULT_TOOL: toolInstance = new DefaultTool(canvas); break; diff --git a/packages/react-sketch/src/components/SketchField/tools/index.ts b/packages/react-sketch/src/components/SketchField/tools/index.ts index 1437e88..5a88942 100644 --- a/packages/react-sketch/src/components/SketchField/tools/index.ts +++ b/packages/react-sketch/src/components/SketchField/tools/index.ts @@ -2,6 +2,7 @@ import * as fabric from "fabric"; import { SketchProperties, Tool } from "../../../types"; +import { EraserBrush } from "@erase2d/fabric"; import { CanvasEvent, EventInput } from "../events"; /** @@ -97,10 +98,14 @@ export abstract class FabricCanvasTool { /** * The brush of the free drawing tools. Fabric does not create one by - * itself, so it is created the first time it is needed. + * itself, so it is created the first time it is needed. The brush of the + * eraser is not one to draw with, it is replaced. */ protected get brush(): fabric.BaseBrush { - return (this.canvas.freeDrawingBrush ??= new fabric.PencilBrush(this.canvas)); + const current = this.canvas.freeDrawingBrush; + if (current && !(current instanceof EraserBrush)) return current; + current?.dispose(); + return (this.canvas.freeDrawingBrush = new fabric.PencilBrush(this.canvas)); } /** diff --git a/packages/react-sketch/src/index.spec.tsx b/packages/react-sketch/src/index.spec.tsx index 4121317..998979e 100644 --- a/packages/react-sketch/src/index.spec.tsx +++ b/packages/react-sketch/src/index.spec.tsx @@ -7,7 +7,7 @@ describe("exports", () => { expect(typeof SketchField).toEqual("function"); }); - it("exports Tools with the names and values of 0.5.x", () => { + it("exports Tools with the names and values of 0.5.x, and the Eraser", () => { expect(Tools).toEqual({ Select: "select", Pencil: "pencil", @@ -19,6 +19,7 @@ describe("exports", () => { Pan: "pan", DefaultTool: "default-tool", Highlighter: "highlighter", + Eraser: "eraser", }); }); diff --git a/packages/react-sketch/src/types.ts b/packages/react-sketch/src/types.ts index 6c91c04..e2a4ec6 100644 --- a/packages/react-sketch/src/types.ts +++ b/packages/react-sketch/src/types.ts @@ -14,6 +14,7 @@ export enum Tool { SELECT = "select", PAN = "pan", HIGHLIGHTER = "highlighter", + ERASER = "eraser", DEFAULT_TOOL = "default-tool", } @@ -32,6 +33,7 @@ export const Tools = { Pan: Tool.PAN, DefaultTool: Tool.DEFAULT_TOOL, Highlighter: Tool.HIGHLIGHTER, + Eraser: Tool.ERASER, } as const; /** diff --git a/packages/showcase/src/components/Tools/ToolSelector/index.tsx b/packages/showcase/src/components/Tools/ToolSelector/index.tsx index 1fb2e56..41cf165 100644 --- a/packages/showcase/src/components/Tools/ToolSelector/index.tsx +++ b/packages/showcase/src/components/Tools/ToolSelector/index.tsx @@ -9,6 +9,7 @@ import RectangleLabelIcon from "@mui/icons-material/Label"; import CircleIcon from "@mui/icons-material/RadioButtonUnchecked"; import PanIcon from "@mui/icons-material/PanTool"; import HighlighterIcon from "@mui/icons-material/Highlight"; +import EraserIcon from "@mui/icons-material/AutoFixNormal"; import ToggleButton from "@mui/material/ToggleButton"; import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; import Section from "../Section"; @@ -32,6 +33,7 @@ const TOOLS = [ { value: Types.Tool.CIRCLE, label: "Circle", icon: }, { value: Types.Tool.PAN, label: "Pan", icon: }, { value: Types.Tool.HIGHLIGHTER, label: "Highlighter", icon: }, + { value: Types.Tool.ERASER, label: "Eraser", icon: }, ]; const ToolSelector = (props: ToolSelectorProps): ReactElement => { diff --git a/yarn.lock b/yarn.lock index 204c0eb..a0b19c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -497,6 +497,11 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== +"@erase2d/fabric@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@erase2d/fabric/-/fabric-1.2.2.tgz#fccb515824c59a9a3b75d2a625f76157b11074a0" + integrity sha512-tltv9hwdlluKkhBKanwj40jdz6ATJ+G5/lF7qQXKV5kNFsbQPWYpojgrWyRi0FB6dOYIHf4U/RjbS3+27dFnuQ== + "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.10.1" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6" From 0e8d3833e9f9057310e9cf0215d3a92319bcd08e Mon Sep 17 00:00:00 2001 From: tbolis Date: Tue, 27 Oct 2026 15:20:57 +0200 Subject: [PATCH 04/33] test: more tests of the eraser, and keep the own clip path of an object when a stroke is undone An object which had a clip path got it wrapped in the erasing group by the first stroke, and undo removed the group with it, so the clip path was lost. --- .../SketchField/tools/Eraser/Eraser.spec.tsx | 179 ++++++++++++++++++ .../SketchField/tools/Eraser/Eraser.ts | 7 +- 2 files changed, 183 insertions(+), 3 deletions(-) diff --git a/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.spec.tsx b/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.spec.tsx index 533f8fc..e904185 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.spec.tsx +++ b/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.spec.tsx @@ -163,4 +163,183 @@ describe("Eraser", () => { expect(loaded.clipPath?.type).toEqual("clipping"); }); + + describe("with several objects and actions", () => { + it("is one undo step for a stroke over several objects", async () => { + const { canvas, sketch } = mount({ lineWidth: 20 }); + const first = rect(); + const second = rect(); + second.set({ left: 130 }); + canvas.add(first, second); + + await stroke(canvas, [30, 50], [190, 50]); + expect(first.clipPath?.type).toEqual("clipping"); + expect(second.clipPath?.type).toEqual("clipping"); + + sketch.undo(); + expect(first.clipPath).toBeUndefined(); + expect(second.clipPath).toBeUndefined(); + + sketch.redo(); + expect(first.clipPath?.type).toEqual("clipping"); + expect(second.clipPath?.type).toEqual("clipping"); + }); + + it.each([ + [ + "a circle", + () => + new fabric.Circle({ + left: 10, + top: 10, + radius: 50, + originX: "left", + originY: "top", + }), + ], + [ + "a path, as the pencil draws", + () => + new fabric.Path("M 10 50 L 110 50", { + stroke: "black", + strokeWidth: 30, + fill: "", + }), + ], + [ + "a text", + () => + new fabric.Textbox("Hello", { + left: 10, + top: 10, + width: 200, + fontSize: 80, + originX: "left", + originY: "top", + }), + ], + ])("erases %s, and undo and redo work", async (_name, create) => { + const { canvas, sketch } = mount({ lineWidth: 10 }); + const object = create(); + canvas.add(object); + + await stroke(canvas, [40, 55], [70, 55]); + expect(object.clipPath?.type).toEqual("clipping"); + + sketch.undo(); + expect(object.clipPath).toBeUndefined(); + + sketch.redo(); + expect(object.clipPath?.type).toEqual("clipping"); + }); + + it("drops the redo of an undone stroke when a new stroke is made", async () => { + const { canvas, sketch } = mount({ lineWidth: 20 }); + canvas.add(rect()); + await stroke(canvas, [30, 30], [90, 30]); + sketch.undo(); + expect(sketch.canRedo()).toBe(true); + + await stroke(canvas, [30, 70], [90, 70]); + + expect(sketch.canRedo()).toBe(false); + }); + + it("drops the redo of an undone stroke when an object is added", async () => { + const { canvas, sketch } = mount({ lineWidth: 20 }); + canvas.add(rect()); + await stroke(canvas, [30, 30], [90, 30]); + sketch.undo(); + + canvas.add(rect()); + + expect(sketch.canRedo()).toBe(false); + }); + + it("keeps no history after clear(), the erased parts are gone with the objects", async () => { + const { canvas, sketch } = mount({ lineWidth: 20 }); + canvas.add(rect()); + await stroke(canvas, [30, 50], [90, 50]); + + sketch.clear(); + + expect(canvas.getObjects()).toEqual([]); + expect(sketch.canUndo()).toBe(false); + sketch.undo(); + expect(canvas.getObjects()).toEqual([]); + }); + + it("counts a stroke as a step of the undo limit", async () => { + const { canvas, sketch } = mount({ lineWidth: 10, undoSteps: 2 }); + const object = rect(); + canvas.add(object); + for (const y of [20, 40, 60, 80]) await stroke(canvas, [30, y], [90, y]); + expect((object.clipPath as fabric.Group).size()).toEqual(4); + + while (sketch.canUndo()) sketch.undo(); + + // The oldest stroke is out of the history, it cannot be undone + expect((object.clipPath as fabric.Group).size()).toEqual(1); + }); + + it("keeps the clip path an object had, when its stroke is undone", async () => { + const { canvas, sketch } = mount({ lineWidth: 20 }); + const object = rect(); + const own = new fabric.Rect({ + width: 80, + height: 80, + absolutePositioned: false, + }); + object.clipPath = own; + canvas.add(object); + + await stroke(canvas, [30, 50], [90, 50]); + sketch.undo(); + + const group = object.clipPath as fabric.Group; + expect(group).toBeDefined(); + expect(group.getObjects()).toContain(own); + }); + }); + + it("is loaded with the component, from a value saved by toJSON", async () => { + const first = mount({ lineWidth: 20 }); + first.canvas.add(rect()); + await stroke(first.canvas, [30, 50], [90, 50]); + const saved = JSON.parse(JSON.stringify(first.sketch.toJSON())); + first.unmount(); + + jest.useFakeTimers(); + try { + const { canvas, sketch } = mount({ lineWidth: 20, defaultValue: saved }); + await jest.advanceTimersByTimeAsync(200); + const [loaded] = canvas.getObjects(); + + expect(loaded.clipPath?.type).toEqual("clipping"); + expect((loaded as unknown as { erasable: boolean }).erasable).toBe(true); + // The loaded object can be erased more + jest.useRealTimers(); + await stroke(canvas, [30, 70], [90, 70]); + expect((loaded.clipPath as fabric.Group).size()).toEqual(2); + sketch.undo(); + expect((loaded.clipPath as fabric.Group).size()).toEqual(1); + } finally { + jest.useRealTimers(); + } + }); + + it("does not record a stroke which ends after the component is unmounted", async () => { + const onChange = jest.fn(); + const { canvas, sketch, unmount } = mount({ lineWidth: 20, onChange }); + canvas.add(rect()); + const history = sketch["history"]; + const onErased = jest.spyOn(history, "onErased"); + + const ending = stroke(canvas, [30, 50], [90, 50]); + unmount(); + await ending; + + expect(onErased).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + }); }); diff --git a/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.ts b/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.ts index b3c3f72..f7a1e1d 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.ts @@ -59,9 +59,10 @@ export class Eraser extends FabricCanvasTool { brush: EraserBrush, stroke: { path: fabric.Path; targets: fabric.FabricObject[] } ): Promise { - const created = new Map( - stroke.targets.map((o) => [o, !(o.clipPath instanceof ClippingGroup)]) - ); + // An object with a clip path of its own gets it wrapped in the erasing group + // by the first stroke, and undo takes only the stroke off that group, so the + // clip path is not lost + const created = new Map(stroke.targets.map((o) => [o, !o.clipPath])); const sources = await brush.commit(stroke); const erasures: Erasure[] = []; sources.forEach((source, object) => { From 243c12413ba58cf01f61cdda4f44108e6db55d3d Mon Sep 17 00:00:00 2001 From: tbolis Date: Tue, 27 Oct 2026 15:40:06 +0200 Subject: [PATCH 05/33] feat: take the code of @erase2d/fabric 1.2.2 into packages/eraser The eraser code is copied from the TypeScript source of erase2d (MIT) into the private workspace package @react-sketch/eraser, instead of being a dependency. react-sketch bundles it, and the license is kept in packages/eraser/LICENSE.md and in the bundle (dist/index.js.LICENSE.txt). The package is built before the typecheck, the build and the tests. Changes against the original: the code of @erase2d/core is in the package, the lint rules of the repository are applied, and the erasing effect restores an opacity of 0. --- CHANGELOG.md | 2 +- package.json | 13 +- packages/eraser/LICENSE.md | 7 + packages/eraser/README.md | 15 + packages/eraser/package.json | 19 + packages/eraser/src/ClippingGroup.ts | 54 ++ packages/eraser/src/EraserBrush.ts | 563 ++++++++++++++++++ packages/eraser/src/ErasingEffect.ts | 123 ++++ packages/eraser/src/augmentation.ts | 23 + packages/eraser/src/erase.ts | 61 ++ packages/eraser/src/index.ts | 11 + packages/eraser/src/isTransparent.ts | 62 ++ packages/eraser/src/util.ts | 53 ++ packages/eraser/tsconfig.build.json | 9 + packages/eraser/tsconfig.json | 17 + packages/react-sketch/jest.config.js | 9 - packages/react-sketch/package.json | 2 +- .../SketchField/tools/Eraser/Eraser.spec.tsx | 2 +- .../SketchField/tools/Eraser/Eraser.ts | 2 +- .../src/components/SketchField/tools/index.ts | 2 +- yarn.lock | 5 - 21 files changed, 1029 insertions(+), 25 deletions(-) create mode 100644 packages/eraser/LICENSE.md create mode 100644 packages/eraser/README.md create mode 100644 packages/eraser/package.json create mode 100644 packages/eraser/src/ClippingGroup.ts create mode 100644 packages/eraser/src/EraserBrush.ts create mode 100644 packages/eraser/src/ErasingEffect.ts create mode 100644 packages/eraser/src/augmentation.ts create mode 100644 packages/eraser/src/erase.ts create mode 100644 packages/eraser/src/index.ts create mode 100644 packages/eraser/src/isTransparent.ts create mode 100644 packages/eraser/src/util.ts create mode 100644 packages/eraser/tsconfig.build.json create mode 100644 packages/eraser/tsconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ad49b6d..16e6e6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - The eraser tool (#82): `tool="eraser"`, `Tools.Eraser`, `Types.Tool.ERASER`. It erases parts of the objects with the width of `lineWidth`, one stroke is one undo step, and the erased parts are saved by `toJSON()` and loaded by `fromJSON()`. The background color and the background image are not erased. - It uses `@erase2d/fabric`, which is part of the bundle. + Its brush is the code of `@erase2d/fabric` (MIT), kept in `packages/eraser` and part of the bundle. ## 1.0.1 (unreleased) diff --git a/package.json b/package.json index d1bb002..6c7cce3 100644 --- a/package.json +++ b/package.json @@ -11,16 +11,17 @@ }, "scripts": { "start": "yarn workspace showcase start", - "build": "yarn workspace react-sketch build && yarn workspace showcase build", - "typecheck": "yarn workspace react-sketch typecheck && yarn workspace showcase typecheck", - "clean": "node -e \"for (const d of ['packages/react-sketch/dist', 'packages/showcase/build']) require('fs').rmSync(d, { recursive: true, force: true })\"", + "build:eraser": "yarn workspace @react-sketch/eraser build", + "build": "yarn build:eraser && yarn workspace react-sketch build && yarn workspace showcase build", + "typecheck": "yarn workspace @react-sketch/eraser typecheck && yarn build:eraser && yarn workspace react-sketch typecheck && yarn workspace showcase typecheck", + "clean": "node -e \"for (const d of ['packages/eraser/dist', 'packages/react-sketch/dist', 'packages/showcase/build']) require('fs').rmSync(d, { recursive: true, force: true })\"", "lint": "eslint \"packages/**/*.{ts,tsx}\"", "lint:fix": "eslint --fix \"packages/**/*.{ts,tsx}\"", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "yarn workspace react-sketch test", - "test:watch": "yarn workspace react-sketch test --watch", - "test:coverage": "yarn workspace react-sketch test:coverage", + "test": "yarn build:eraser && yarn workspace react-sketch test", + "test:watch": "yarn build:eraser && yarn workspace react-sketch test --watch", + "test:coverage": "yarn build:eraser && yarn workspace react-sketch test:coverage", "prepare": "husky" }, "devDependencies": { diff --git a/packages/eraser/LICENSE.md b/packages/eraser/LICENSE.md new file mode 100644 index 0000000..5ce9a3a --- /dev/null +++ b/packages/eraser/LICENSE.md @@ -0,0 +1,7 @@ +2024 Shachar Nencel @ShaMan123 + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/eraser/README.md b/packages/eraser/README.md new file mode 100644 index 0000000..9068817 --- /dev/null +++ b/packages/eraser/README.md @@ -0,0 +1,15 @@ +# @react-sketch/eraser + +The eraser brush of react-sketch. It is the code of +[@erase2d/fabric](https://github.com/ShaMan123/erase2d) 1.2.2 (`packages/fabric` and `packages/core` +of that repository, MIT, see [LICENSE.md](LICENSE.md)), kept here to change it when react-sketch needs it. + +The package is private. `react-sketch` bundles it, so nothing is installed for it by the users of the +library. + +Changes against the original: + +- The code of `@erase2d/core` is in `src/erase.ts`, and imported from there. +- The lint rules of this repository are applied (no short-circuit statements, no `any`). +- The erasing effect restores an object whose opacity is 0, the original skipped it and left the object + invisible. diff --git a/packages/eraser/package.json b/packages/eraser/package.json new file mode 100644 index 0000000..82f4edb --- /dev/null +++ b/packages/eraser/package.json @@ -0,0 +1,19 @@ +{ + "name": "@react-sketch/eraser", + "version": "1.2.2", + "private": true, + "description": "The eraser brush for fabric, from @erase2d/fabric 1.2.2, used by react-sketch", + "license": "MIT", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "fabric": "^7.4.0" + }, + "devDependencies": { + "fabric": "^7.4.0" + } +} diff --git a/packages/eraser/src/ClippingGroup.ts b/packages/eraser/src/ClippingGroup.ts new file mode 100644 index 0000000..d4c9ba8 --- /dev/null +++ b/packages/eraser/src/ClippingGroup.ts @@ -0,0 +1,54 @@ +import { + FabricObject, + FixedLayout, + Group, + GroupProps, + LayoutManager, + Path, + classRegistry, +} from "fabric"; + +export class ClippingGroup extends Group { + static type = "clipping"; + + private blockErasing = false; + + constructor(objects: FabricObject[], options: Partial) { + super(objects, { + originX: "center", + originY: "center", + left: 0, + top: 0, + layoutManager: new LayoutManager(new FixedLayout()), + ...options, + }); + } + + drawObject(ctx: CanvasRenderingContext2D) { + const paths: Path[] = []; + const objects: FabricObject[] = []; + this._objects.forEach((object) => + (object instanceof Path ? paths : objects).push(object) + ); + + ctx.save(); + ctx.fillStyle = "black"; + ctx.fillRect(-this.width / 2, -this.height / 2, this.width, this.height); + ctx.restore(); + + if (!this.blockErasing) { + paths.forEach((path) => { + path.render(ctx); + }); + } + + objects.forEach((object) => { + object.globalCompositeOperation = object.inverted + ? "destination-out" + : "source-in"; + object.render(ctx); + }); + } +} + +classRegistry.setClass(ClippingGroup); diff --git a/packages/eraser/src/EraserBrush.ts b/packages/eraser/src/EraserBrush.ts new file mode 100644 index 0000000..68d998e --- /dev/null +++ b/packages/eraser/src/EraserBrush.ts @@ -0,0 +1,563 @@ +/*! + * The eraser is the code of @erase2d/fabric 1.2.2 (https://github.com/ShaMan123/erase2d), + * changed for react-sketch. Its license: + * + * 2024 Shachar Nencel @ShaMan123 + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this + * software and associated documentation files (the “Software”), to deal in the Software + * without restriction, including without limitation the rights to use, copy, modify, + * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR + * PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT + * OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +import { erase, type Region } from "./erase"; +import * as fabric from "fabric"; +import { FabricObject, Group, Path } from "fabric"; +import { ClippingGroup } from "./ClippingGroup"; +import { draw } from "./ErasingEffect"; + +export type EventDetailMap = { + start: fabric.TEvent; + move: fabric.TEvent; + end: { + path: fabric.Path; + targets: fabric.FabricObject[]; + }; + redraw: { type: "start" | "render" }; + cancel: never; +}; + +export type ErasingEventType = keyof EventDetailMap; + +export type ErasingEvent = CustomEvent< + EventDetailMap[T] +>; + +function walk(objects: FabricObject[], path: Path): FabricObject[] { + return objects.flatMap((object) => { + if (!object.erasable || !object.intersectsWithObject(path)) { + return []; + } else if (object instanceof Group && object.erasable === "deep") { + return walk(object.getObjects(), path); + } else { + return [object]; + } + }); +} + +const assertClippingGroup = (object: fabric.FabricObject) => { + const curr = object.clipPath; + + if (curr instanceof ClippingGroup) { + return curr; + } + + const strokeWidth = object.strokeWidth; + const strokeWidthFactor = new fabric.Point(strokeWidth, strokeWidth); + const strokeVector = object.strokeUniform + ? strokeWidthFactor.divide(object.getObjectScaling()) + : strokeWidthFactor; + + const next = new ClippingGroup([], { + width: object.width + strokeVector.x, + height: object.height + strokeVector.y, + }); + + if (curr) { + const { x, y } = curr.translateToOriginPoint( + new fabric.Point(), + curr.originX, + curr.originY + ); + curr.originX = curr.originY = "center"; + fabric.util.sendObjectToPlane( + curr, + undefined, + fabric.util.createTranslateMatrix(x, y) + ); + next.add(curr as FabricObject); + } + + return (object.clipPath = next); +}; + +export function commitErasing( + object: fabric.FabricObject, + sourceInObjectPlane: fabric.Path +) { + const clipPath = assertClippingGroup(object); + clipPath.add(sourceInObjectPlane); + clipPath.set("dirty", true); + object.set("dirty", true); +} + +export async function eraseObject(object: fabric.FabricObject, source: fabric.Path) { + const clone = await source.clone(); + fabric.util.sendObjectToPlane(clone, undefined, object.calcTransformMatrix()); + commitErasing(object, clone); + return clone; +} + +export async function eraseCanvasDrawable( + object: fabric.FabricObject, + vpt: fabric.TMat2D | undefined, + source: fabric.Path +) { + const clone = await source.clone(); + const d = + vpt && + object.translateToOriginPoint( + new fabric.Point(), + object.originX, + object.originY + ); + fabric.util.sendObjectToPlane( + clone, + undefined, + d + ? fabric.util.multiplyTransformMatrixArray([ + [1, 0, 0, 1, d.x, d.y], + // apply vpt from center of drawable + vpt, + [1, 0, 0, 1, -d.x, -d.y], + object.calcTransformMatrix(), + ]) + : object.calcTransformMatrix() + ); + commitErasing(object, clone); + return clone; +} + +const setCanvasDimensions = ( + el: HTMLCanvasElement, + ctx: CanvasRenderingContext2D, + { width, height }: fabric.TSize, + retinaScaling = 1 +) => { + el.width = width; + el.height = height; + if (retinaScaling > 1) { + el.setAttribute("width", (width * retinaScaling).toString()); + el.setAttribute("height", (height * retinaScaling).toString()); + ctx.scale(retinaScaling, retinaScaling); + } +}; + +/** + * Supports **selective** erasing: only erasable objects are affected by the eraser brush. + * + * Supports **{@link inverted}** erasing: the brush can "undo" erasing. + * + * Supports **alpha** erasing: setting the alpha channel of the `color` property controls the eraser intensity. + * + * In order to support selective erasing, the brush clips the entire canvas and + * masks all non-erasable objects over the erased path, see {@link draw}. + * + * If **{@link inverted}** draws all objects, erasable objects without their eraser, over the erased path. + * This achieves the desired effect of seeming to erase or undo erasing on erasable objects only. + * + * After erasing is done the `end` event {@link ErasingEndEvent} is fired, after which erasing will be committed to the tree. + * @example + * canvas = new Canvas(); + * const eraser = new EraserBrush(canvas); + * canvas.freeDrawingBrush = eraser; + * canvas.isDrawingMode = true; + * eraser.on('start', (e) => { + * console.log('started erasing'); + * // prevent erasing + * e.preventDefault(); + * }); + * eraser.on('end', (e) => { + * const { targets: erasedTargets, path } = e.detail; + * e.preventDefault(); // prevent erasing being committed to the tree + * eraser.commit({ targets: erasedTargets, path }); // commit manually since default was prevented + * }); + * + * In case of performance issues trace {@link drawEffect} calls and consider preventing it from executing + * @example + * const eraser = new EraserBrush(canvas); + * eraser.on('redraw', (e) => { + * // prevent effect redraw on pointer down (e.g. useful if canvas didn't change) + * e.detail.type === 'start' && e.preventDefault()); + * // prevent effect redraw after canvas has rendered (effect will become stale) + * e.detail.type === 'render' && e.preventDefault()); + * }); + */ +export class EraserBrush extends fabric.PencilBrush { + /** + * When set to `true` the brush will create a visual effect of undoing erasing + */ + inverted = false; + + effectContext: CanvasRenderingContext2D; + + private eventEmitter: EventTarget; + private active = false; + private _disposer?: VoidFunction; + /** + * number of points drawn by the last render, see {@link _getDirtyRegion} + */ + private _renderedPoints = 0; + + constructor(canvas: fabric.Canvas) { + super(canvas); + const el = document.createElement("canvas"); + const ctx = el.getContext("2d"); + if (!ctx) { + throw new Error("Failed to get context"); + } + setCanvasDimensions(el, ctx, canvas, this.canvas.getRetinaScaling()); + this.effectContext = ctx; + this.eventEmitter = new EventTarget(); + } + + /** + * @returns disposer make sure to call it to avoid memory leaks + */ + on( + type: T, + cb: (evt: ErasingEvent) => unknown, + options?: boolean | AddEventListenerOptions + ) { + this.eventEmitter.addEventListener(type, cb as EventListener, options); + return () => + this.eventEmitter.removeEventListener(type, cb as EventListener, options); + } + + drawEffect() { + draw( + this.effectContext, + { + opacity: new fabric.Color(this.color).getAlpha(), + inverted: this.inverted, + }, + { canvas: this.canvas } + ); + } + + /** + * @override + */ + _setBrushStyles(ctx: CanvasRenderingContext2D = this.canvas.contextTop) { + super._setBrushStyles(ctx); + ctx.strokeStyle = "black"; + } + + /** + * @override strictly speaking the eraser needs a full render only if it has opacity set. + * However since {@link PencilBrush} is designed for subclassing that is what we have to work with. + */ + needsFullRender(): boolean { + return true; + } + + /** + * @override erase + * + * Composites the entire canvas. + * Pointer moves render through {@link _renderMove} instead. + */ + _render(ctx: CanvasRenderingContext2D = this.canvas.getTopContext()): void { + super._render(ctx); + erase(this.canvas.getContext(), ctx, this.effectContext); + this._renderedPoints = this["_points"].length; + } + + /** + * The pixel bounds of the part of the path that changed since the last render. + * The path is smoothed through the midpoints of its points, + * so adding a point changes the curve from the 2 points before the last rendered one. + * @returns `undefined` when the entire canvas has to be composited: + * a straight line moves its end point instead of adding one, and a shadow is drawn outside of the stroke. + */ + private _getDirtyRegion(): Region | undefined { + if (this["_hasStraightLine"] || this.shadow) { + return; + } + const points: fabric.Point[] = this["_points"]; + const from = Math.max(0, this._renderedPoints - 3); + if (from === 0 || points.length - from < 2) { + return; + } + const { canvas } = this; + const el = canvas.getTopContext().canvas; + const scale = canvas.getRetinaScaling(); + const t = fabric.util.multiplyTransformMatrices( + [scale, 0, 0, scale, 0, 0], + canvas.viewportTransform + ); + // how far the stroke extends from its points: square caps reach the corners, miter joins up to the miter limit + const extent = Math.max( + this.strokeLineCap === "square" ? Math.SQRT2 : 1, + this.strokeLineJoin === "miter" ? this.strokeMiterLimit : 1 + ); + // in pixels, with a margin for antialiasing + const pad = + (this.width / 2) * + extent * + Math.max(Math.hypot(t[0], t[1]), Math.hypot(t[2], t[3])) + + 2; + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (let i = from; i < points.length; i++) { + const { x, y } = fabric.util.transformPoint(points[i], t); + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + const x = Math.max(0, Math.floor(minX - pad)); + const y = Math.max(0, Math.floor(minY - pad)); + const width = Math.min(el.width, Math.ceil(maxX + pad)) - x; + const height = Math.min(el.height, Math.ceil(maxY + pad)) - y; + if (width <= 0 || height <= 0) { + return; + } + return { x, y, width, height }; + } + + /** + * Renders only {@link region}: the rest of the top context already holds the effect of the previous render + * and the rest of the main context is already erased by it. + */ + private _renderRegion(region: Region) { + const ctx = this.canvas.getTopContext(); + ctx.save(); + const transform = ctx.getTransform(); + ctx.resetTransform(); + ctx.beginPath(); + ctx.rect(region.x, region.y, region.width, region.height); + ctx.clip(); + ctx.clearRect(region.x, region.y, region.width, region.height); + ctx.setTransform(transform); + // the full path is stroked but only the region is rasterized + fabric.PencilBrush.prototype._render.call(this, ctx); + ctx.restore(); + erase(this.canvas.getContext(), ctx, this.effectContext, region); + this._renderedPoints = this["_points"].length; + } + + /** + * Renders the point added by a pointer move: + * only the part of the path that changed if possible, see {@link _getDirtyRegion}, the entire canvas otherwise. + */ + private _renderMove() { + const region = this._getDirtyRegion(); + if (region) { + this._renderRegion(region); + return; + } + this.canvas.clearContext(this.canvas.contextTop); + this._render(); + } + + /** + * @override {@link drawEffect} + */ + onMouseDown( + pointer: fabric.Point, + context: fabric.TEvent + ): void { + if ( + !this.eventEmitter.dispatchEvent( + new CustomEvent("start", { detail: context, cancelable: true }) + ) + ) { + return; + } + + this.active = true; + + if ( + this.eventEmitter.dispatchEvent( + new CustomEvent("redraw", { + detail: { type: "start" }, + cancelable: true, + }) + ) + ) { + this.drawEffect(); + } + + // consider a different approach + this._disposer = this.canvas.on("after:render", ({ ctx }) => { + if (ctx !== this.canvas.getContext()) { + return; + } + if ( + this.eventEmitter.dispatchEvent( + new CustomEvent("redraw", { + detail: { type: "render" }, + cancelable: true, + }) + ) + ) { + this.drawEffect(); + } + this._render(); + }); + + super.onMouseDown(pointer, context); + } + + /** + * @override run if active. + * Same as {@link fabric.PencilBrush#onMouseMove} except that it does not clear the entire top context, + * since {@link _renderMove} may redraw only the part of it that changed. + */ + onMouseMove( + pointer: fabric.Point, + context: fabric.TEvent + ): void { + if ( + !this.active || + !this.eventEmitter.dispatchEvent( + new CustomEvent("move", { detail: context, cancelable: true }) + ) + ) { + return; + } + const { e } = context; + if (!this.canvas._isMainEvent(e)) { + return; + } + this.drawStraightLine = !!this.straightLineKey && e[this.straightLineKey]; + if (this.limitedToCanvasSize === true && this._isOutSideCanvas(pointer)) { + return; + } + if (this._addPoint(pointer) && this["_points"].length > 1) { + this._renderMove(); + } + } + + /** + * @override run if active, dispose of {@link drawEffect} listener + */ + onMouseUp(context: fabric.TEvent): boolean { + if (this.active) super.onMouseUp(context); + this.active = false; + this._disposer?.(); + delete this._disposer; + return false; + } + + /** + * @override {@link fabric.PencilBrush} logic + */ + convertPointsToSVGPath(points: fabric.Point[]): fabric.util.TSimplePathData { + return super.convertPointsToSVGPath( + this.decimate ? this.decimatePoints(points, this.decimate) : points + ); + } + + /** + * @override + */ + createPath(pathData: fabric.util.TSimplePathData) { + const path = super.createPath(pathData); + path.set( + this.inverted + ? { + globalCompositeOperation: "source-over", + stroke: "white", + } + : { + globalCompositeOperation: "destination-out", + stroke: "black", + opacity: new fabric.Color(this.color).getAlpha(), + } + ); + return path; + } + + async commit({ + path, + targets, + }: EventDetailMap["end"]): Promise> { + return new Map( + await Promise.all([ + ...targets.map(async (object) => { + return [object, await eraseObject(object, path)] as const; + }), + ...( + [ + [ + this.canvas.backgroundImage, + !this.canvas.backgroundVpt ? this.canvas.viewportTransform : undefined, + ], + [ + this.canvas.overlayImage, + !this.canvas.overlayVpt ? this.canvas.viewportTransform : undefined, + ], + ] as const + ) + .filter(([object]) => !!object?.erasable) + .map(async ([object, vptFlag]) => { + return [ + object, + await eraseCanvasDrawable(object as FabricObject, vptFlag, path), + ] as [fabric.FabricObject, fabric.Path]; + }), + ]) + ); + } + + /** + * @override handle events + */ + _finalizeAndAddPath(): void { + const points = this["_points"]; + + if (points.length < 2) { + this.eventEmitter.dispatchEvent( + new CustomEvent("cancel", { + cancelable: false, + }) + ); + return; + } + + const path = this.createPath(this.convertPointsToSVGPath(points)); + const targets = walk(this.canvas.getObjects(), path); + + if ( + this.eventEmitter.dispatchEvent( + new CustomEvent("end", { + detail: { + path, + targets, + }, + cancelable: true, + }) + ) + ) { + void this.commit({ path, targets }); + } + + this.canvas.clearContext(this.canvas.contextTop); + this.canvas.requestRenderAll(); + + this._resetShadow(); + } + + dispose() { + const { canvas } = this.effectContext; + // prompt GC + canvas.width = canvas.height = 0; + // release ref? + // delete this.effectContext + } +} diff --git a/packages/eraser/src/ErasingEffect.ts b/packages/eraser/src/ErasingEffect.ts new file mode 100644 index 0000000..942158d --- /dev/null +++ b/packages/eraser/src/ErasingEffect.ts @@ -0,0 +1,123 @@ +import { Canvas, FabricObject, Group } from "fabric"; +import { ClippingGroup } from "./ClippingGroup"; + +function walk(objects: FabricObject[]): FabricObject[] { + return objects.flatMap((object) => { + if (!object.erasable || object.isNotVisible()) { + return []; + } else if (object instanceof Group && object.erasable === "deep") { + return walk(object.getObjects()); + } else { + return [object]; + } + }); +} + +function drawCanvas( + ctx: CanvasRenderingContext2D, + canvas: Canvas, + objects: FabricObject[] +) { + canvas.clearContext(ctx); + + ctx.imageSmoothingEnabled = canvas.imageSmoothingEnabled; + ctx.imageSmoothingQuality = "high"; + // @ts-expect-error node-canvas stuff + ctx.patternQuality = "best"; + + canvas._renderBackground(ctx); + + ctx.save(); + ctx.transform(...canvas.viewportTransform); + objects.forEach((object) => object.render(ctx)); + ctx.restore(); + + const clipPath = canvas.clipPath; + if (clipPath) { + // fabric crap + clipPath._set("canvas", canvas); + clipPath.shouldCache(); + clipPath._transformDone = true; + // @ts-expect-error fabric type crap + clipPath.renderCache({ forClipping: true }); + canvas.drawClipPathOnCanvas( + ctx, + clipPath as unknown as Parameters[1] + ); + } + + canvas._renderOverlay(ctx); +} + +/** + * Prepare the pattern for the erasing brush + * This pattern will be drawn on the top context after clipping the main context, + * achieving a visual effect of erasing only erasable objects. + * + * This is designed to support erasing a collection with both erasable and non-erasable objects while maintaining object stacking.\ + * Iterates over collections to allow nested selective erasing.\ + * Prepares objects before rendering the pattern brush.\ + * If brush is **NOT** inverted render all non-erasable objects.\ + * If brush is inverted render all objects, erasable objects without their eraser. + * This will render the erased parts as if they were not erased in the first place, achieving an undo effect. + * + * Caveat: + * Does not support erasing effects of shadows + * + */ +export function draw( + ctx: CanvasRenderingContext2D, + { inverted, opacity }: { inverted: boolean; opacity: number }, + { + canvas, + objects = canvas._objectsToRender || canvas._objects, + background = canvas.backgroundImage, + overlay = canvas.overlayImage, + }: { + canvas: Canvas; + objects?: FabricObject[]; + background?: FabricObject; + overlay?: FabricObject; + } +) { + // prepare tree + const alpha = 1 - opacity; + const restore = walk([ + ...objects, + ...([background, overlay] as FabricObject[]).filter((d) => !!d), + ]).map((object) => { + if (!inverted) { + // render only non-erasable objects + const opacity = object.opacity; + object.opacity *= alpha; + object.parent?.set("dirty", true); + return { object, opacity }; + } else if (object.clipPath instanceof ClippingGroup) { + // render all objects without eraser + object.clipPath["blockErasing"] = true; + object.clipPath.set("dirty", true); + object.set("dirty", true); + return { object, clipPath: object.clipPath }; + } + return undefined; + }); + + // draw + drawCanvas(ctx, canvas, objects); + + // restore + restore.forEach((entry) => { + if (!entry) { + return; + } + // An opacity of 0 is a value to restore too + if (entry.opacity !== undefined) { + entry.object.opacity = entry.opacity; + entry.object.parent?.set("dirty", true); + } else if (entry.clipPath) { + entry.clipPath["blockErasing"] = false; + entry.clipPath.set("dirty", true); + entry.object.set("dirty", true); + } + }); +} diff --git a/packages/eraser/src/augmentation.ts b/packages/eraser/src/augmentation.ts new file mode 100644 index 0000000..3501c4d --- /dev/null +++ b/packages/eraser/src/augmentation.ts @@ -0,0 +1,23 @@ +import "fabric"; + +declare module "fabric" { + interface FabricObjectProps { + /** + * Indicates whether this object can be erased by the {@link EraserBrush}\ + * The `deep` option introduces fine grained control over a {@link Group#erasable} property.\ + * When set to `true` the eraser will erase the entire object.\ + * When set to `deep` the eraser will erase nested objects if they are erasable, + * leaving the group and the other objects untouched.\ + * When set to `false` the object and its descendants are untouched.\ + * + * When using `true` on a group Consider using {@link applyEraser} on layout changes + * in order to propagate the eraser to descendants so that new descendants won't be affected by it. + */ + erasable: boolean | "deep"; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface FabricObject extends FabricObjectProps {} +} + +export {}; diff --git a/packages/eraser/src/erase.ts b/packages/eraser/src/erase.ts new file mode 100644 index 0000000..611f849 --- /dev/null +++ b/packages/eraser/src/erase.ts @@ -0,0 +1,61 @@ +/** + * A rectangle in canvas pixels (ignoring any transform) + */ +export type Region = { x: number; y: number; width: number; height: number }; + +export const drawImage = ( + destination: CanvasRenderingContext2D, + source: CanvasRenderingContext2D, + globalCompositeOperation: GlobalCompositeOperation = "source-over", + region?: Region +) => { + destination.save(); + destination.imageSmoothingEnabled = true; + destination.imageSmoothingQuality = "high"; + destination.globalCompositeOperation = globalCompositeOperation; + destination.resetTransform(); + if (region) { + const { x, y, width, height } = region; + // composite only the region, the rest of the destination is left as is + destination.beginPath(); + destination.rect(x, y, width, height); + destination.clip(); + destination.drawImage(source.canvas, x, y, width, height, x, y, width, height); + } else { + destination.drawImage(source.canvas, 0, 0); + } + destination.restore(); +}; + +/** + * + * @param destination context to erase + * @param source context on which the path is drawn upon + * @param erasingEffect effect to apply to {@link source} after clipping {@link destination}: + * - drawing all non erasable visuals to achieve a selective erasing effect. + * - drawing all erasable visuals without their erasers to achieve an undo erasing effect. + * @param region limits erasing to a rectangle in canvas pixels, the rest of both contexts is left as is + */ +export const erase = ( + destination: CanvasRenderingContext2D, + source: CanvasRenderingContext2D, + erasingEffect?: CanvasRenderingContext2D, + region?: Region +) => { + // clip destination + drawImage(destination, source, "destination-out", region); + + // draw erasing effect + if (erasingEffect) { + drawImage(source, erasingEffect, "source-in", region); + } else { + source.save(); + source.resetTransform(); + if (region) { + source.clearRect(region.x, region.y, region.width, region.height); + } else { + source.clearRect(0, 0, source.canvas.width, source.canvas.height); + } + source.restore(); + } +}; diff --git a/packages/eraser/src/index.ts b/packages/eraser/src/index.ts new file mode 100644 index 0000000..912f3f5 --- /dev/null +++ b/packages/eraser/src/index.ts @@ -0,0 +1,11 @@ +import "./augmentation"; + +export { ClippingGroup } from "./ClippingGroup"; +export { + eraseCanvasDrawable, + eraseObject, + EraserBrush, + type ErasingEvent, + type ErasingEventType, +} from "./EraserBrush"; +export { isTransparent } from "./isTransparent"; diff --git a/packages/eraser/src/isTransparent.ts b/packages/eraser/src/isTransparent.ts new file mode 100644 index 0000000..10432b0 --- /dev/null +++ b/packages/eraser/src/isTransparent.ts @@ -0,0 +1,62 @@ +import { type FabricObject } from "fabric"; + +let messageID = -1; + +const isImageDataTransparent = (imageData: ImageData) => + imageData.data.every((x, i) => i % 4 !== 3 || x === 0); + +function isTransparent(object: FabricObject, worker?: Worker) { + // should also move to offscreen + const canvas = object.toCanvasElement({ + // multiplier: 0.1, + enableRetinaScaling: false, + viewportTransform: false, + withoutTransform: true, + withoutShadow: true, + }); + + const id = ++messageID; + return new Promise((resolve, reject) => { + const imageData = canvas + .getContext("2d") + ?.getImageData(0, 0, canvas.width, canvas.height); + + if (!imageData) { + reject(); + } else if (!worker) { + resolve(isImageDataTransparent(imageData)); + } else { + const messageHandler = ( + e: MessageEvent<{ messageID: number; isTransparent: boolean }> + ) => { + if (e.data.messageID === id) { + worker.removeEventListener("message", messageHandler); + resolve(e.data.isTransparent); + } + }; + worker.addEventListener("message", messageHandler); + worker.postMessage( + { + imageData, + messageID: id, + }, + [] + ); + } + }); +} + +isTransparent.installWorker = function installWorker() { + addEventListener( + "message", + (e: MessageEvent<{ imageData: ImageData; messageID: number }>) => { + const { imageData, messageID } = e.data; + postMessage({ + isTransparent: isImageDataTransparent(imageData), + messageID, + }); + } + ); +}; + +export { isTransparent }; diff --git a/packages/eraser/src/util.ts b/packages/eraser/src/util.ts new file mode 100644 index 0000000..8ee4820 --- /dev/null +++ b/packages/eraser/src/util.ts @@ -0,0 +1,53 @@ +import { FabricObject, Path, TMat2D, util } from "fabric"; + +/** + * Utility to apply a clip path to an object, merging with its existing clip path. + * @param {FabricObject} object The eraser path in canvas coordinate plane + * @param {FabricObject} clipPath The clipPath to apply to the path + * @param {TMat2D} t The transform matrix of the object that the clip path belongs to + * @returns {Path} path with clip path + */ +export function clipObject(object: FabricObject, clipPath: FabricObject, t: TMat2D) { + // when passing down a clip path it becomes relative to the parent + // so we transform it accordingly and set `absolutePositioned` to false + clipPath.absolutePositioned = false; + util.applyTransformToObject( + clipPath, + util.multiplyTransformMatrixArray([ + util.calcPlaneChangeMatrix( + clipPath.absolutePositioned ? undefined : t, + object.calcTransformMatrix() + ), + clipPath.calcTransformMatrix(), + ]) + ); + + // We need to clip `path` with both `clipPath` and it's own clip path if existing (`path.clipPath`) + // so in turn `path` erases an object only where it overlaps with all it's clip paths, regardless of how many there are. + // this is done because both clip paths may have nested clip paths of their own (this method walks down a collection => this may reccur), + // so we can't assign one to the other's clip path property. + return (object.clipPath = object.clipPath + ? util.mergeClipPaths(clipPath, object.clipPath as FabricObject) + : clipPath); +} + +/** + * Utility to apply a clip path to a path. + * Used to preserve clipping on eraser paths in nested objects. + * Called when a group has a clip path that should be applied to the path before applying erasing on the group's objects. + * @param {Path} path The eraser path + * @param {FabricObject} object The clipPath to apply to path belongs to object + * @returns {Promise} + */ +export function cloneAndClip( + path: Path, + object: FabricObject & Required> +) { + const t = object.calcTransformMatrix(); + return Promise.all([ + path.clone(), + object.clipPath.clone(["absolutePositioned", "inverted"]), + ]).then(([clonedPath, clonedClipPath]) => + clipObject(clonedPath, clonedClipPath as FabricObject, t) + ); +} diff --git a/packages/eraser/tsconfig.build.json b/packages/eraser/tsconfig.build.json new file mode 100644 index 0000000..000607d --- /dev/null +++ b/packages/eraser/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "rootDir": "src", + "outDir": "dist" + }, + "exclude": ["dist", "node_modules"] +} diff --git a/packages/eraser/tsconfig.json b/packages/eraser/tsconfig.json new file mode 100644 index 0000000..46d5116 --- /dev/null +++ b/packages/eraser/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2019", + "module": "node16", + "moduleResolution": "node16", + "lib": ["dom", "dom.iterable", "esnext"], + "types": [], + "strict": true, + "noImplicitReturns": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true, + "sourceMap": true + }, + "include": ["src"] +} diff --git a/packages/react-sketch/jest.config.js b/packages/react-sketch/jest.config.js index 1c82403..a683205 100644 --- a/packages/react-sketch/jest.config.js +++ b/packages/react-sketch/jest.config.js @@ -20,16 +20,7 @@ module.exports = { roots: ["/src"], testMatch: ["/src/**/*.spec.ts?(x)"], moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"], - // @erase2d/fabric is published as ES modules only, so it is compiled too - transformIgnorePatterns: ["/node_modules/(?!@erase2d)"], transform: { - "^.+\\.m?js$": [ - "ts-jest", - { - tsconfig: { allowJs: true, module: "commonjs", target: "es2020" }, - diagnostics: false, - }, - ], "^.+\\.tsx?$": [ "ts-jest", { diff --git a/packages/react-sketch/package.json b/packages/react-sketch/package.json index 5b96be3..e18e139 100644 --- a/packages/react-sketch/package.json +++ b/packages/react-sketch/package.json @@ -46,7 +46,7 @@ "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "devDependencies": { - "@erase2d/fabric": "^1.2.2", + "@react-sketch/eraser": "1.2.2", "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", diff --git a/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.spec.tsx b/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.spec.tsx index e904185..40b2b9a 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.spec.tsx +++ b/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.spec.tsx @@ -1,7 +1,7 @@ import { createRef } from "react"; import { render } from "@testing-library/react"; import * as fabric from "fabric"; -import { EraserBrush } from "@erase2d/fabric"; +import { EraserBrush } from "@react-sketch/eraser"; import SketchField from "../../SketchField"; import { SketchProperties, Tool, Tools } from "../../../../types"; diff --git a/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.ts b/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.ts index f7a1e1d..4bff2cb 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.ts @@ -1,5 +1,5 @@ import * as fabric from "fabric"; -import { ClippingGroup, EraserBrush, type ErasingEvent } from "@erase2d/fabric"; +import { ClippingGroup, EraserBrush, type ErasingEvent } from "@react-sketch/eraser"; import { FabricCanvasTool } from "../index"; import { SketchProperties, Tool } from "../../../../types"; diff --git a/packages/react-sketch/src/components/SketchField/tools/index.ts b/packages/react-sketch/src/components/SketchField/tools/index.ts index 5a88942..9708db9 100644 --- a/packages/react-sketch/src/components/SketchField/tools/index.ts +++ b/packages/react-sketch/src/components/SketchField/tools/index.ts @@ -2,7 +2,7 @@ import * as fabric from "fabric"; import { SketchProperties, Tool } from "../../../types"; -import { EraserBrush } from "@erase2d/fabric"; +import { EraserBrush } from "@react-sketch/eraser"; import { CanvasEvent, EventInput } from "../events"; /** diff --git a/yarn.lock b/yarn.lock index a0b19c8..204c0eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -497,11 +497,6 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== -"@erase2d/fabric@^1.2.2": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@erase2d/fabric/-/fabric-1.2.2.tgz#fccb515824c59a9a3b75d2a625f76157b11074a0" - integrity sha512-tltv9hwdlluKkhBKanwj40jdz6ATJ+G5/lF7qQXKV5kNFsbQPWYpojgrWyRi0FB6dOYIHf4U/RjbS3+27dFnuQ== - "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.10.1" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6" From 02fc053da388ba24f55ceb3d887f12995e7fdf61 Mon Sep 17 00:00:00 2001 From: tbolis Date: Tue, 27 Oct 2026 15:53:56 +0200 Subject: [PATCH 06/33] feat: the cursor of the eraser is a circle of the size of the brush A brush too big for a cursor image (over 124 pixels) keeps the crosshair. Each tool now starts from the default cursor of free drawing, so the cursor of the eraser is not kept by the highlighter. --- CHANGELOG.md | 3 +- README.md | 26 ++++++------- .../SketchField/tools/Eraser/Eraser.spec.tsx | 38 +++++++++++++++++++ .../SketchField/tools/Eraser/Eraser.ts | 23 ++++++++++- .../components/SketchField/tools/factory.ts | 3 +- 5 files changed, 77 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16e6e6f..6138248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ - The eraser tool (#82): `tool="eraser"`, `Tools.Eraser`, `Types.Tool.ERASER`. It erases parts of the objects with the width of `lineWidth`, one stroke is one undo step, and the erased parts are saved by - `toJSON()` and loaded by `fromJSON()`. The background color and the background image are not erased. + `toJSON()` and loaded by `fromJSON()`. The background color and the background image are not erased. The cursor is a circle of the size + of the brush (the crosshair for a brush over 124 pixels). Its brush is the code of `@erase2d/fabric` (MIT), kept in `packages/eraser` and part of the bundle. ## 1.0.1 (unreleased) diff --git a/README.md b/README.md index 5537ef7..0a20d8e 100644 --- a/README.md +++ b/README.md @@ -271,19 +271,19 @@ throws. Use the tools by name (`"pencil"`), with `Tools` (`Tools.Pencil`) or with the enumeration `Types.Tool` (`Types.Tool.PENCIL`). -| Tool | `Tools` | Value | Description | -| --------------- | ---------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| Select | `Tools.Select` | `select` | Disables drawing. Objects can be selected, moved, scaled and rotated. | -| Pencil | `Tools.Pencil` | `pencil` | Free drawing. | -| Line | `Tools.Line` | `line` | Straight lines. | -| Arrow | `Tools.Arrow` | `arrow` | Lines with an arrow head. | -| Rectangle | `Tools.Rectangle` | `rectangle` | Rectangles, filled with `fillColor`. | -| Rectangle label | `Tools.RectangleLabel` | `rectangle-label` | Rectangles with a text box above them. The text box follows the rectangle when it is moved or scaled. | -| Circle | `Tools.Circle` | `circle` | Circles, filled with `fillColor`. The drag from the first to the second point is the diameter. | -| Pan | `Tools.Pan` | `pan` | Disables drawing. Dragging moves the whole canvas, useful after zooming (contributed by [wmaillard](https://github.com/wmaillard)). | -| Highlighter | `Tools.Highlighter` | `highlighter` | Free drawing in a semi-transparent color. `lineColor` must be a hex code or a color name; any other value falls back to the default color. | -| Eraser | `Tools.Eraser` | `eraser` | Erases parts of the objects, with the width of `lineWidth`. The background color and the background image are not erased. See [Eraser](#eraser). | -| Default tool | `Tools.DefaultTool` | `default-tool` | Disables drawing and selection. Use it to show a drawing that is not edited. | +| Tool | `Tools` | Value | Description | +| --------------- | ---------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Select | `Tools.Select` | `select` | Disables drawing. Objects can be selected, moved, scaled and rotated. | +| Pencil | `Tools.Pencil` | `pencil` | Free drawing. | +| Line | `Tools.Line` | `line` | Straight lines. | +| Arrow | `Tools.Arrow` | `arrow` | Lines with an arrow head. | +| Rectangle | `Tools.Rectangle` | `rectangle` | Rectangles, filled with `fillColor`. | +| Rectangle label | `Tools.RectangleLabel` | `rectangle-label` | Rectangles with a text box above them. The text box follows the rectangle when it is moved or scaled. | +| Circle | `Tools.Circle` | `circle` | Circles, filled with `fillColor`. The drag from the first to the second point is the diameter. | +| Pan | `Tools.Pan` | `pan` | Disables drawing. Dragging moves the whole canvas, useful after zooming (contributed by [wmaillard](https://github.com/wmaillard)). | +| Highlighter | `Tools.Highlighter` | `highlighter` | Free drawing in a semi-transparent color. `lineColor` must be a hex code or a color name; any other value falls back to the default color. | +| Eraser | `Tools.Eraser` | `eraser` | Erases parts of the objects, with the width of `lineWidth`. The cursor shows the size of the brush. The background color and the background image are not erased. See [Eraser](#eraser). | +| Default tool | `Tools.DefaultTool` | `default-tool` | Disables drawing and selection. Use it to show a drawing that is not edited. | ### Eraser diff --git a/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.spec.tsx b/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.spec.tsx index 40b2b9a..e7c8be5 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.spec.tsx +++ b/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.spec.tsx @@ -60,6 +60,44 @@ describe("Eraser", () => { expect(canvas.freeDrawingBrush?.width).toEqual(40); }); + describe("the cursor", () => { + it("is a circle of the size of the brush, with its center as hotspot", () => { + const { canvas } = mount({ lineWidth: 24 }); + + const cursor = canvas.freeDrawingCursor; + expect(cursor).toMatch(/^url\("data:image\/svg\+xml,/); + const svg = decodeURIComponent( + cursor.split('"')[1].split(",").slice(1).join(",") + ); + expect(svg).toContain('width="28"'); + expect(svg).toContain('r="12"'); + // 24 pixels of brush, 2 of margin on each side + expect(cursor).toMatch(/ 14 14, crosshair$/); + }); + + it("follows the line width", () => { + const { canvas, rerender } = mount({ lineWidth: 24 }); + + rerender(); + + expect(decodeURIComponent(canvas.freeDrawingCursor)).toContain('width="44"'); + }); + + it("is the crosshair for a brush too big for a cursor", () => { + const { canvas } = mount({ lineWidth: 200 }); + + expect(canvas.freeDrawingCursor).toEqual("crosshair"); + }); + + it("is not kept by the next tool", () => { + const { canvas, rerender } = mount({ lineWidth: 24 }); + + rerender(); + + expect(canvas.freeDrawingCursor).toEqual("crosshair"); + }); + }); + it("gives the pencil its own brush again, when the tool changes", () => { const { canvas, rerender } = mount(); diff --git a/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.ts b/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.ts index 4bff2cb..3e635a8 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Eraser/Eraser.ts @@ -15,6 +15,27 @@ export interface Erasure { created: boolean; } +/** The largest cursor image the browsers show (Chrome and Firefox: 128 pixels) */ +const MAX_CURSOR_SIZE = 128; + +/** + * The cursor of the eraser: a circle of the size of the brush, in black and + * white so that it shows on any color. It falls back to the crosshair for a + * brush the browser cannot show as a cursor. + */ +const brushCursor = (width: number): string => { + const size = Math.ceil(width) + 4; + if (size > MAX_CURSOR_SIZE) return "crosshair"; + const c = size / 2; + const r = width / 2; + const svg = + `` + + `` + + `` + + ``; + return `url("data:image/svg+xml,${encodeURIComponent(svg)}") ${c} ${c}, crosshair`; +}; + /** * Erases parts of the objects, not the background. The strokes are kept on the * objects as a clip path, so they are saved by `toJSON` and loaded by `fromJSON`. @@ -30,8 +51,8 @@ export class Eraser extends FabricCanvasTool { configureCanvas(props: SketchProperties): void { const brush = this.eraserBrush; this.canvas.isDrawingMode = true; - this.canvas.freeDrawingCursor = "default"; brush.width = props.lineWidth ? props.lineWidth : 1; + this.canvas.freeDrawingCursor = brushCursor(brush.width); } type(): Tool { diff --git a/packages/react-sketch/src/components/SketchField/tools/factory.ts b/packages/react-sketch/src/components/SketchField/tools/factory.ts index 88d93fc..12b30d1 100644 --- a/packages/react-sketch/src/components/SketchField/tools/factory.ts +++ b/packages/react-sketch/src/components/SketchField/tools/factory.ts @@ -62,8 +62,9 @@ export const create_tool = ( toolInstance = new Pencil(canvas); } - // Bring the cursor back to default, in case the previous tool changed it + // Bring the cursors back to default, in case the previous tool changed them canvas.defaultCursor = "default"; + canvas.freeDrawingCursor = "crosshair"; toolInstance.configureCanvas(props); return toolInstance; }; From 78b55e05c75c9d03804bfc6426aacd819b4d37ef Mon Sep 17 00:00:00 2001 From: tbolis Date: Tue, 27 Oct 2026 15:55:53 +0200 Subject: [PATCH 07/33] improving .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7e95ed3..5bddfb0 100644 --- a/.gitignore +++ b/.gitignore @@ -182,3 +182,4 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk +.vscode/ From 62d8a49fe124728f2f760da964776ca42810ba37 Mon Sep 17 00:00:00 2001 From: tbolis Date: Tue, 27 Oct 2026 16:16:54 +0200 Subject: [PATCH 08/33] docs: write the 1.1.0 entry of the CHANGELOG for the eraser --- CHANGELOG.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6138248..8e6e74a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,25 @@ ### Added -- The eraser tool (#82): `tool="eraser"`, `Tools.Eraser`, `Types.Tool.ERASER`. It erases parts of the - objects with the width of `lineWidth`, one stroke is one undo step, and the erased parts are saved by - `toJSON()` and loaded by `fromJSON()`. The background color and the background image are not erased. The cursor is a circle of the size - of the brush (the crosshair for a brush over 124 pixels). - Its brush is the code of `@erase2d/fabric` (MIT), kept in `packages/eraser` and part of the bundle. +- The eraser tool (#82): `tool="eraser"`, `Tools.Eraser` and `Types.Tool.ERASER`. It erases parts of the + objects (lines, shapes, images, text) with the width of `lineWidth`, and its cursor is a circle of the + size of the brush (the crosshair for a brush over 124 pixels, the most a browser shows). One stroke is + one undo step. The erased parts are kept on the objects, so `toJSON()` saves them and `value`, + `defaultValue` and `fromJSON()` load them. The background color and the background image are not + erased. The brush is the code of `@erase2d/fabric` by @ShaMan123 (MIT). +- The showcase has an Eraser button in the tool selector. + +### Changed + +- Each tool starts from the default cursor of free drawing (the crosshair), so the cursor of the pencil or + of the eraser is not kept by the highlighter. + +### Packaging and tooling + +- The code of `@erase2d/fabric` 1.2.2 is in the private package `packages/eraser` + (`@react-sketch/eraser`), with its license, and `react-sketch` bundles it. Nothing new is installed for + the users of the library, and the externals of the bundle are still `react` and `fabric`. The license is + in `dist/index.js.LICENSE.txt`. `yarn typecheck`, `yarn build` and `yarn test` build the package first. ## 1.0.1 (unreleased) From 8cfbdc0cb2901ff7a6a297a18bf692285c17c1ed Mon Sep 17 00:00:00 2001 From: tbolis Date: Wed, 28 Oct 2026 16:35:29 +0200 Subject: [PATCH 09/33] feat: laser pointer tool A line which follows the pointer, gets thinner towards its start and disappears about a second after it was drawn. It is drawn on the top canvas of fabric only (a BaseBrush in the private package packages/laserpointer, bundled into react-sketch), so it is not an object of the sketch: it is not in toJSON() or toDataURL(), not an undo step, and neither onObjectAdded nor onChange is called for it. Tools get a dispose() hook, called when another tool takes over and when the component unmounts, so the line does not outlive the laser pointer. --- package.json | 14 +- packages/laserpointer/README.md | 26 ++ packages/laserpointer/package.json | 19 + packages/laserpointer/src/LaserBrush.ts | 166 +++++++++ packages/laserpointer/src/index.ts | 1 + packages/laserpointer/tsconfig.build.json | 9 + packages/laserpointer/tsconfig.json | 17 + packages/react-sketch/package.json | 1 + .../components/SketchField/SketchField.tsx | 2 + .../src/components/SketchField/events.ts | 6 +- .../tools/LaserPointer/LaserPointer.spec.tsx | 339 ++++++++++++++++++ .../tools/LaserPointer/LaserPointer.ts | 51 +++ .../SketchField/tools/LaserPointer/index.ts | 1 + .../components/SketchField/tools/factory.ts | 4 + .../src/components/SketchField/tools/index.ts | 6 + packages/react-sketch/src/index.spec.tsx | 3 +- packages/react-sketch/src/types.ts | 2 + 17 files changed, 658 insertions(+), 9 deletions(-) create mode 100644 packages/laserpointer/README.md create mode 100644 packages/laserpointer/package.json create mode 100644 packages/laserpointer/src/LaserBrush.ts create mode 100644 packages/laserpointer/src/index.ts create mode 100644 packages/laserpointer/tsconfig.build.json create mode 100644 packages/laserpointer/tsconfig.json create mode 100644 packages/react-sketch/src/components/SketchField/tools/LaserPointer/LaserPointer.spec.tsx create mode 100644 packages/react-sketch/src/components/SketchField/tools/LaserPointer/LaserPointer.ts create mode 100644 packages/react-sketch/src/components/SketchField/tools/LaserPointer/index.ts diff --git a/package.json b/package.json index 6c7cce3..8ac51bc 100644 --- a/package.json +++ b/package.json @@ -12,16 +12,18 @@ "scripts": { "start": "yarn workspace showcase start", "build:eraser": "yarn workspace @react-sketch/eraser build", - "build": "yarn build:eraser && yarn workspace react-sketch build && yarn workspace showcase build", - "typecheck": "yarn workspace @react-sketch/eraser typecheck && yarn build:eraser && yarn workspace react-sketch typecheck && yarn workspace showcase typecheck", - "clean": "node -e \"for (const d of ['packages/eraser/dist', 'packages/react-sketch/dist', 'packages/showcase/build']) require('fs').rmSync(d, { recursive: true, force: true })\"", + "build:laserpointer": "yarn workspace @react-sketch/laserpointer build", + "build:packages": "yarn build:eraser && yarn build:laserpointer", + "build": "yarn build:packages && yarn workspace react-sketch build && yarn workspace showcase build", + "typecheck": "yarn workspace @react-sketch/eraser typecheck && yarn workspace @react-sketch/laserpointer typecheck && yarn build:packages && yarn workspace react-sketch typecheck && yarn workspace showcase typecheck", + "clean": "node -e \"for (const d of ['packages/eraser/dist', 'packages/laserpointer/dist', 'packages/react-sketch/dist', 'packages/showcase/build']) require('fs').rmSync(d, { recursive: true, force: true })\"", "lint": "eslint \"packages/**/*.{ts,tsx}\"", "lint:fix": "eslint --fix \"packages/**/*.{ts,tsx}\"", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "yarn build:eraser && yarn workspace react-sketch test", - "test:watch": "yarn build:eraser && yarn workspace react-sketch test --watch", - "test:coverage": "yarn build:eraser && yarn workspace react-sketch test:coverage", + "test": "yarn build:packages && yarn workspace react-sketch test", + "test:watch": "yarn build:packages && yarn workspace react-sketch test --watch", + "test:coverage": "yarn build:packages && yarn workspace react-sketch test:coverage", "prepare": "husky" }, "devDependencies": { diff --git a/packages/laserpointer/README.md b/packages/laserpointer/README.md new file mode 100644 index 0000000..5f46bfa --- /dev/null +++ b/packages/laserpointer/README.md @@ -0,0 +1,26 @@ +# @react-sketch/laserpointer + +The laser pointer brush of react-sketch: a line that follows the pointer, fades out after a moment and +disappears, as the laser pointer of a whiteboard does. + +The line is drawn on the top canvas of fabric (`canvas.contextTop`) and never added to the canvas, so it +is not an object of the drawing: it is not in `toJSON()`, not in `toDataURL()` and not in the undo +history. + +The package is private. `react-sketch` bundles it, so nothing is installed for it by the users of the +library. + +```ts +import { LaserBrush } from "@react-sketch/laserpointer"; + +const brush = new LaserBrush(canvas); +brush.color = "red"; +brush.width = 6; +brush.duration = 1000; // milliseconds a part of the line takes to fade out +canvas.freeDrawingBrush = brush; +canvas.isDrawingMode = true; +``` + +The line is thickest at the pointer and gets thinner towards its start, until it is gone. Set `shadow` +(a `fabric.Shadow`, as for any brush) to make it glow. Call `dispose()` when the brush is not used any +more: it stops the animation and clears what is left of the line. diff --git a/packages/laserpointer/package.json b/packages/laserpointer/package.json new file mode 100644 index 0000000..65c8719 --- /dev/null +++ b/packages/laserpointer/package.json @@ -0,0 +1,19 @@ +{ + "name": "@react-sketch/laserpointer", + "version": "1.1.0", + "private": true, + "description": "The laser pointer brush for fabric, a line that fades out and is never part of the drawing, used by react-sketch", + "license": "MIT", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "fabric": "^7.4.0" + }, + "devDependencies": { + "fabric": "^7.4.0" + } +} diff --git a/packages/laserpointer/src/LaserBrush.ts b/packages/laserpointer/src/LaserBrush.ts new file mode 100644 index 0000000..89f7961 --- /dev/null +++ b/packages/laserpointer/src/LaserBrush.ts @@ -0,0 +1,166 @@ +import * as fabric from "fabric"; + +/** A point of a trail, with the moment it was added (milliseconds) */ +interface TrailPoint { + x: number; + y: number; + time: number; +} + +/** + * The line of one drag. While the pointer is down (`held`) the newest point + * is as fresh as the pointer, so the head of the line does not fade. + */ +interface Trail { + points: TrailPoint[]; + held: boolean; +} + +/** A move of the pointer shorter than this (pixels) is not a new point */ +const MIN_DISTANCE = 1; + +const now = (): number => performance.now(); + +/** + * A brush which draws a line that fades out and disappears, as a laser + * pointer does. Nothing is added to the canvas: the line is drawn on the top + * canvas only, so it is never part of the drawing (objects, JSON, image, undo). + * + * It follows `color` and `width` like any brush, and `shadow` makes it glow. + * The line is as thick as `width` at the pointer and gets thinner towards its + * start, until it is gone `duration` milliseconds after that part was drawn. + */ +export class LaserBrush extends fabric.BaseBrush { + /** Milliseconds a part of the line takes to fade out */ + duration = 1000; + + private trails: Trail[] = []; + private frame?: number; + + onMouseDown(pointer: fabric.Point, { e }: fabric.TBrushEventData): void { + if (!this.canvas._isMainEvent(e)) return; + const trail: Trail = { points: [], held: true }; + this.trails.push(trail); + this.addPoint(trail, pointer); + this.animate(); + } + + onMouseMove(pointer: fabric.Point, { e }: fabric.TBrushEventData): void { + const trail = this.trails.find((t) => t.held); + if (!trail || !this.canvas._isMainEvent(e)) return; + this.addPoint(trail, pointer); + } + + onMouseUp({ e }: fabric.TBrushEventData): boolean { + // The line stays on its own, it fades whatever the pointer does next + if (!this.canvas._isMainEvent(e)) return true; + this.trails.forEach((trail) => (trail.held = false)); + return false; + } + + /** + * Draw the lines on the top canvas. The canvas calls it when it renders the + * top canvas while a line is drawn, the animation calls it on every frame. + */ + _render(): void { + const ctx = this.canvas.contextTop; + const time = now(); + this._saveAndTransform(ctx); + this._setShadow(); + ctx.fillStyle = this.color; + // One path for every line, filled once, so the parts which overlap are not drawn twice + ctx.beginPath(); + this.trails.forEach((trail) => this.outline(ctx, trail, time)); + ctx.fill(); + ctx.restore(); + } + + /** + * Stop the animation and clear what is left of the lines. The brush is not + * used after that. + */ + dispose(): void { + if (this.frame !== undefined) fabric.util.cancelAnimFrame(this.frame); + this.frame = undefined; + this.trails = []; + if (!this.gone) this.canvas.clearContext(this.canvas.contextTop); + } + + /** The canvas was disposed, so its contexts are not there any more */ + private get gone(): boolean { + return !!(this.canvas.disposed || this.canvas.destroyed); + } + + private addPoint(trail: Trail, { x, y }: fabric.Point): void { + const last = trail.points[trail.points.length - 1]; + if (last && Math.hypot(x - last.x, y - last.y) < MIN_DISTANCE) return; + trail.points.push({ x, y, time: now() }); + } + + private animate(): void { + if (this.frame !== undefined) return; + this.frame = fabric.util.requestAnimFrame(() => this.tick()); + } + + private tick(): void { + this.frame = undefined; + if (this.gone) { + this.trails = []; + return; + } + this.fade(now()); + this.canvas.clearContext(this.canvas.contextTop); + // The animation runs while there is something to show + if (!this.trails.length) return; + this._render(); + this.animate(); + } + + /** + * Drop the points which are faded out, and the lines which have none left + */ + private fade(time: number): void { + this.trails.forEach((trail) => { + const { points } = trail; + if (trail.held) points[points.length - 1].time = time; + const alive = points.findIndex((p) => time - p.time < this.duration); + points.splice(0, alive < 0 ? points.length : alive); + }); + this.trails = this.trails.filter((trail) => trail.points.length); + } + + /** + * Add the shape of the line to the current path: a circle at each point + * (the cap and the joins of a round line) and a quadrilateral between each + * two points, each as thick as the line is where it is. + */ + private outline( + ctx: CanvasRenderingContext2D, + { points }: Trail, + time: number + ): void { + const radius = (p: TrailPoint): number => + (this.width / 2) * Math.max(0, 1 - (time - p.time) / this.duration); + + points.forEach((from, i) => { + const r = radius(from); + ctx.moveTo(from.x + r, from.y); + ctx.arc(from.x, from.y, r, 0, 2 * Math.PI); + + const to = points[i + 1]; + if (!to) return; + const length = Math.hypot(to.x - from.x, to.y - from.y); + // Unit vector which is perpendicular to the segment + const nx = -(to.y - from.y) / length; + const ny = (to.x - from.x) / length; + const r2 = radius(to); + // Clockwise, as the circles are, so that the shapes which overlap add up + // and do not cancel each other out (non-zero winding) + ctx.moveTo(from.x - nx * r, from.y - ny * r); + ctx.lineTo(to.x - nx * r2, to.y - ny * r2); + ctx.lineTo(to.x + nx * r2, to.y + ny * r2); + ctx.lineTo(from.x + nx * r, from.y + ny * r); + ctx.closePath(); + }); + } +} diff --git a/packages/laserpointer/src/index.ts b/packages/laserpointer/src/index.ts new file mode 100644 index 0000000..215ab61 --- /dev/null +++ b/packages/laserpointer/src/index.ts @@ -0,0 +1 @@ +export { LaserBrush } from "./LaserBrush"; diff --git a/packages/laserpointer/tsconfig.build.json b/packages/laserpointer/tsconfig.build.json new file mode 100644 index 0000000..000607d --- /dev/null +++ b/packages/laserpointer/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "rootDir": "src", + "outDir": "dist" + }, + "exclude": ["dist", "node_modules"] +} diff --git a/packages/laserpointer/tsconfig.json b/packages/laserpointer/tsconfig.json new file mode 100644 index 0000000..46d5116 --- /dev/null +++ b/packages/laserpointer/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2019", + "module": "node16", + "moduleResolution": "node16", + "lib": ["dom", "dom.iterable", "esnext"], + "types": [], + "strict": true, + "noImplicitReturns": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true, + "sourceMap": true + }, + "include": ["src"] +} diff --git a/packages/react-sketch/package.json b/packages/react-sketch/package.json index e18e139..a781e7a 100644 --- a/packages/react-sketch/package.json +++ b/packages/react-sketch/package.json @@ -47,6 +47,7 @@ }, "devDependencies": { "@react-sketch/eraser": "1.2.2", + "@react-sketch/laserpointer": "1.1.0", "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", diff --git a/packages/react-sketch/src/components/SketchField/SketchField.tsx b/packages/react-sketch/src/components/SketchField/SketchField.tsx index 6709951..08dcc13 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.tsx @@ -278,6 +278,7 @@ class SketchField extends PureComponent { window.removeEventListener("resize", this._resize_listener); this.timers.forEach((id) => window.clearTimeout(id)); this.timers.clear(); + 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. void this.canvas.dispose(); @@ -304,6 +305,7 @@ class SketchField extends PureComponent { * Use the tool of the properties, from now on */ private selectTool(): void { + this.tool?.dispose(); this.withToolProps((props) => { this.tool = create_tool(this.canvas, props); if (this.tool instanceof Eraser) { diff --git a/packages/react-sketch/src/components/SketchField/events.ts b/packages/react-sketch/src/components/SketchField/events.ts index 38b8885..01838f3 100644 --- a/packages/react-sketch/src/components/SketchField/events.ts +++ b/packages/react-sketch/src/components/SketchField/events.ts @@ -1,5 +1,5 @@ import type { FabricObject, TPointerEvent } from "fabric"; -import { SketchEvent, SketchProperties } from "../../types"; +import { SketchEvent, SketchProperties, Tool } from "../../types"; import { SketchHistory } from "./history/SketchHistory"; import { AbstractEventAware, CanvasEventAware, FabricCanvasTool } from "./tools"; @@ -121,7 +121,9 @@ export class FabricCanvasEventsManager * Tell the application that the sketch may have changed, shortly after * the mouse was released or left the canvas. */ - private notifyChange({ event, props }: EventInput): void { + private notifyChange({ event, tool, props }: EventInput): void { + // The line of the laser pointer is not part of the sketch + if (tool.type() === Tool.LASER_POINTER) return; const { onChange } = props; if (onChange) this.schedule(() => onChange(event.e), 10); } diff --git a/packages/react-sketch/src/components/SketchField/tools/LaserPointer/LaserPointer.spec.tsx b/packages/react-sketch/src/components/SketchField/tools/LaserPointer/LaserPointer.spec.tsx new file mode 100644 index 0000000..f44fc2c --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/LaserPointer/LaserPointer.spec.tsx @@ -0,0 +1,339 @@ +import { createRef } from "react"; +import { act, render } from "@testing-library/react"; +import * as fabric from "fabric"; +import { LaserBrush } from "@react-sketch/laserpointer"; +import SketchField from "../../SketchField"; +import { SketchProperties, Tool, Tools } from "../../../../types"; + +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 LaserBrush, + }; +}; + +type Point = [number, number]; + +const at = ([x, y]: Point) => new fabric.Point(x, y); +const event = (type: string) => ({ e: new MouseEvent(type) }) as never; +const down = (brush: LaserBrush, point: Point) => + brush.onMouseDown(at(point), event("mousedown")); +const move = (brush: LaserBrush, point: Point) => + brush.onMouseMove(at(point), event("mousemove")); +const up = (brush: LaserBrush) => brush.onMouseUp(event("mouseup")); + +/** Let the time pass, frame by frame as the animation runs */ +const wait = (ms: number) => act(() => jest.advanceTimersByTime(ms)); + +const inside = (brush: LaserBrush) => + brush as unknown as { + trails: { points: unknown[]; held: boolean }[]; + frame?: number; + }; +const trails = (brush: LaserBrush) => inside(brush).trails; + +describe("LaserPointer", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("is a tool of the component, with the legacy name", () => { + expect(Tools.LaserPointer).toEqual(Tool.LASER_POINTER); + }); + + it("draws with a laser brush of the color and the width of the line", () => { + const { canvas } = mount({ lineColor: "#ff0000", lineWidth: 8 }); + + expect(canvas.isDrawingMode).toBe(true); + expect(canvas.freeDrawingBrush).toBeInstanceOf(LaserBrush); + expect(canvas.freeDrawingBrush?.color).toEqual("#ff0000"); + expect(canvas.freeDrawingBrush?.width).toEqual(8); + }); + + it("follows the line color and width when they change", () => { + const { canvas, rerender } = mount({ lineColor: "#ff0000", lineWidth: 8 }); + const brush = canvas.freeDrawingBrush; + + rerender( + + ); + + expect(canvas.freeDrawingBrush).toBe(brush); + expect(brush?.color).toEqual("#00ff00"); + expect(brush?.width).toEqual(3); + }); + + it("glows in its color", () => { + const { canvas } = mount({ lineColor: "#ff0000", lineWidth: 8 }); + + expect(canvas.freeDrawingBrush?.shadow?.color).toEqual("#ff0000"); + expect(canvas.freeDrawingBrush?.shadow?.blur).toBeGreaterThan(0); + }); + + describe("a line", () => { + it("is drawn on the top canvas while the pointer moves", async () => { + const { canvas, brush } = mount({ lineWidth: 10 }); + + down(brush(), [10, 10]); + move(brush(), [60, 40]); + await wait(16); + + expect(canvas.contextTop.fill).toHaveBeenCalled(); + expect(canvas.contextTop.clearRect).toHaveBeenCalled(); + }); + + it("is thinner towards its start", async () => { + const { canvas, brush } = mount({ lineWidth: 10 }); + const arc = canvas.contextTop.arc as jest.Mock; + + down(brush(), [10, 10]); + await wait(200); + move(brush(), [60, 40]); + await wait(200); + move(brush(), [90, 20]); + arc.mockClear(); + await wait(16); + + const [start, middle, head] = arc.mock.calls.map(([, , radius]) => radius); + // The head is as fresh as the pointer is, so it has the full width + expect(head).toEqual(5); + expect(middle).toBeLessThan(head); + expect(start).toBeLessThan(middle); + expect(start).toBeGreaterThan(0); + }); + + it("is not an object of the sketch", async () => { + const onObjectAdded = jest.fn(); + const { sketch, canvas, brush } = mount({ onObjectAdded }); + + down(brush(), [10, 10]); + move(brush(), [60, 40]); + move(brush(), [90, 20]); + up(brush()); + await wait(16); + + expect(canvas.getObjects()).toHaveLength(0); + expect(sketch.toJSON().objects).toEqual([]); + expect(sketch.canUndo()).toBe(false); + expect(onObjectAdded).not.toHaveBeenCalled(); + }); + + it("keeps the objects as they were", async () => { + const { sketch, canvas, brush } = mount(); + const rect = new fabric.Rect({ left: 10, top: 10, width: 50, height: 50 }); + canvas.add(rect); + const before = JSON.stringify(sketch.toJSON()); + + down(brush(), [10, 10]); + move(brush(), [60, 40]); + up(brush()); + await wait(2000); + + expect(JSON.stringify(sketch.toJSON())).toEqual(before); + expect(canvas.getObjects()).toEqual([rect]); + }); + + it("does not tell the application that the sketch changed", async () => { + const onChange = jest.fn(); + const onMouseUp = jest.fn(); + const { canvas } = mount({ onChange, onMouseUp }); + const fire = (name: string) => + (canvas as unknown as fabric.Observable>).fire(name, { + e: new MouseEvent(name), + }); + + fire("mouse:up"); + fire("mouse:out"); + await wait(50); + + expect(onMouseUp).toHaveBeenCalledTimes(1); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("is gone after its duration", async () => { + const { canvas, brush } = mount({ lineWidth: 10 }); + + down(brush(), [10, 10]); + move(brush(), [60, 40]); + up(brush()); + await wait(500); + expect(trails(brush())).toHaveLength(1); + + await wait(600); + expect(trails(brush())).toHaveLength(0); + // The top canvas is cleared, nothing is left on it + const clear = canvas.contextTop.clearRect as jest.Mock; + expect(clear).toHaveBeenCalled(); + }); + + it("fades for as long as the duration is", async () => { + const { brush } = mount(); + brush().duration = 200; + + down(brush(), [10, 10]); + move(brush(), [60, 40]); + up(brush()); + await wait(100); + expect(trails(brush())).toHaveLength(1); + + await wait(150); + expect(trails(brush())).toHaveLength(0); + }); + + it("keeps its head while the pointer is down, even when it does not move", async () => { + const { brush } = mount(); + + down(brush(), [10, 10]); + move(brush(), [60, 40]); + await wait(3000); + + expect(trails(brush())).toHaveLength(1); + expect(trails(brush())[0].points).toHaveLength(1); + + up(brush()); + await wait(1100); + expect(trails(brush())).toHaveLength(0); + }); + + it("is one line for each drag, and they fade on their own", async () => { + const { brush } = mount(); + + down(brush(), [10, 10]); + move(brush(), [60, 40]); + up(brush()); + await wait(600); + down(brush(), [100, 100]); + move(brush(), [160, 140]); + up(brush()); + expect(trails(brush())).toHaveLength(2); + + await wait(500); + expect(trails(brush())).toHaveLength(1); + }); + + it("ignores a move shorter than a pixel", () => { + const { brush } = mount(); + + down(brush(), [10, 10]); + move(brush(), [10.4, 10.4]); + + expect(trails(brush())[0].points).toHaveLength(1); + }); + + it("is a dot when the pointer does not move", async () => { + const { canvas, brush } = mount({ lineWidth: 10 }); + const arc = canvas.contextTop.arc as jest.Mock; + + down(brush(), [10, 10]); + up(brush()); + arc.mockClear(); + await wait(16); + + expect(arc).toHaveBeenCalledTimes(1); + expect(arc.mock.calls[0].slice(0, 2)).toEqual([10, 10]); + }); + + it("is not started by a secondary touch", () => { + const { brush } = mount(); + + brush().onMouseDown(at([10, 10]), { + e: { isPrimary: false } as PointerEvent, + } as never); + + expect(trails(brush())).toHaveLength(0); + }); + + it("stops the animation when nothing is left to show", async () => { + const { brush } = mount(); + + down(brush(), [10, 10]); + move(brush(), [60, 40]); + up(brush()); + await wait(1100); + + expect(jest.getTimerCount()).toEqual(0); + }); + }); + + describe("another tool", () => { + it("takes over without the line, which is gone with the laser pointer", async () => { + const { canvas, brush, rerender } = mount(); + const laser = brush(); + + down(laser, [10, 10]); + move(laser, [60, 40]); + rerender(); + + expect(trails(laser)).toHaveLength(0); + expect(inside(laser).frame).toBeUndefined(); + expect(canvas.freeDrawingBrush).toBeInstanceOf(fabric.PencilBrush); + }); + + it("gets a brush of its own, for the eraser too", () => { + const { canvas, rerender } = mount(); + + rerender(); + + expect(canvas.freeDrawingBrush).not.toBeInstanceOf(LaserBrush); + expect(canvas.freeDrawingBrush).toBeDefined(); + }); + + it("is not drawn on, when the tool does not draw", () => { + const { canvas, rerender } = mount(); + + rerender(); + + expect(canvas.freeDrawingBrush).toBeUndefined(); + expect(canvas.isDrawingMode).toBe(false); + }); + + it("gives the laser pointer a new brush when it comes back", () => { + const { canvas, rerender } = mount(); + const first = canvas.freeDrawingBrush; + + rerender(); + rerender(); + + expect(canvas.freeDrawingBrush).toBeInstanceOf(LaserBrush); + expect(canvas.freeDrawingBrush).not.toBe(first); + }); + }); + + describe("the component", () => { + it("stops the animation when it is unmounted", async () => { + const { brush, unmount } = mount(); + + down(brush(), [10, 10]); + move(brush(), [60, 40]); + await wait(16); + unmount(); + + expect(jest.getTimerCount()).toEqual(0); + }); + + it("does not draw on a canvas which is disposed", async () => { + const { canvas, brush } = mount(); + const laser = brush(); + + down(laser, [10, 10]); + move(laser, [60, 40]); + // Disposing the canvas without telling the tool, as a caller of the brush could + void canvas.dispose(); + + await expect(wait(100)).resolves.not.toThrow(); + expect(trails(laser)).toHaveLength(0); + }); + }); +}); diff --git a/packages/react-sketch/src/components/SketchField/tools/LaserPointer/LaserPointer.ts b/packages/react-sketch/src/components/SketchField/tools/LaserPointer/LaserPointer.ts new file mode 100644 index 0000000..9752923 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/LaserPointer/LaserPointer.ts @@ -0,0 +1,51 @@ +import * as fabric from "fabric"; +import { LaserBrush } from "@react-sketch/laserpointer"; +import { FabricCanvasTool } from "../index"; +import { SketchProperties, Tool } from "../../../../types"; + +/** + * A pointer to show something on the sketch: the line follows the pointer, + * fades out after a moment and disappears. It is drawn on the top canvas + * only, so it is not an object of the sketch: nothing is added, it is not in + * `toJSON()` or `toDataURL()`, and it is not an undo step. + */ +export class LaserPointer extends FabricCanvasTool { + constructor(canvas: fabric.Canvas) { + super(canvas); + } + + configureCanvas(props: SketchProperties): void { + const brush = this.laserBrush; + this.canvas.isDrawingMode = true; + brush.width = props.lineWidth ? props.lineWidth : 1; + brush.color = props.lineColor ? props.lineColor : "red"; + brush.shadow = new fabric.Shadow({ + color: brush.color, + blur: Math.max(brush.width * 2, 8), + }); + } + + type(): Tool { + return Tool.LASER_POINTER; + } + + /** The line is gone with the tool, it does not fade on while another tool is in use */ + dispose(): void { + const current = this.canvas.freeDrawingBrush; + if (!(current instanceof LaserBrush)) return; + current.dispose(); + this.canvas.freeDrawingBrush = undefined; + } + + /** + * The laser brush of the canvas. It is created the first time it is needed, + * and the canvas keeps it for the next line. + */ + private get laserBrush(): LaserBrush { + const current = this.canvas.freeDrawingBrush; + if (current instanceof LaserBrush) return current; + const brush = new LaserBrush(this.canvas); + this.canvas.freeDrawingBrush = brush; + return brush; + } +} diff --git a/packages/react-sketch/src/components/SketchField/tools/LaserPointer/index.ts b/packages/react-sketch/src/components/SketchField/tools/LaserPointer/index.ts new file mode 100644 index 0000000..3c08691 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/LaserPointer/index.ts @@ -0,0 +1 @@ +export { LaserPointer } from "./LaserPointer"; diff --git a/packages/react-sketch/src/components/SketchField/tools/factory.ts b/packages/react-sketch/src/components/SketchField/tools/factory.ts index 12b30d1..68ecfb6 100644 --- a/packages/react-sketch/src/components/SketchField/tools/factory.ts +++ b/packages/react-sketch/src/components/SketchField/tools/factory.ts @@ -6,6 +6,7 @@ import { Circle } from "./Circle"; import { DefaultTool } from "./DefaultTool"; import { Eraser } from "./Eraser"; import { Highlighter } from "./Highlighter"; +import { LaserPointer } from "./LaserPointer"; import { Line } from "./Line"; import { Pan } from "./Pan"; import { Pencil } from "./Pencil"; @@ -54,6 +55,9 @@ export const create_tool = ( case Tool.ERASER: toolInstance = new Eraser(canvas); break; + case Tool.LASER_POINTER: + toolInstance = new LaserPointer(canvas); + break; case Tool.DEFAULT_TOOL: toolInstance = new DefaultTool(canvas); break; diff --git a/packages/react-sketch/src/components/SketchField/tools/index.ts b/packages/react-sketch/src/components/SketchField/tools/index.ts index 9708db9..b0e5669 100644 --- a/packages/react-sketch/src/components/SketchField/tools/index.ts +++ b/packages/react-sketch/src/components/SketchField/tools/index.ts @@ -77,6 +77,12 @@ export abstract class FabricCanvasTool { abstract configureCanvas(props: SketchProperties): void; + /** + * Called when another tool takes over, and when the canvas goes away, for + * the tools which keep something running on the canvas + */ + dispose(): void {} + doMouseUp(event: CanvasEvent): void {} doMouseDown(event: CanvasEvent): void {} diff --git a/packages/react-sketch/src/index.spec.tsx b/packages/react-sketch/src/index.spec.tsx index 998979e..603ab56 100644 --- a/packages/react-sketch/src/index.spec.tsx +++ b/packages/react-sketch/src/index.spec.tsx @@ -7,7 +7,7 @@ describe("exports", () => { expect(typeof SketchField).toEqual("function"); }); - it("exports Tools with the names and values of 0.5.x, and the Eraser", () => { + it("exports Tools with the names and values of 0.5.x, and the new tools", () => { expect(Tools).toEqual({ Select: "select", Pencil: "pencil", @@ -20,6 +20,7 @@ describe("exports", () => { DefaultTool: "default-tool", Highlighter: "highlighter", Eraser: "eraser", + LaserPointer: "laser-pointer", }); }); diff --git a/packages/react-sketch/src/types.ts b/packages/react-sketch/src/types.ts index 2cd6f11..a81865c 100644 --- a/packages/react-sketch/src/types.ts +++ b/packages/react-sketch/src/types.ts @@ -15,6 +15,7 @@ export enum Tool { PAN = "pan", HIGHLIGHTER = "highlighter", ERASER = "eraser", + LASER_POINTER = "laser-pointer", DEFAULT_TOOL = "default-tool", } @@ -34,6 +35,7 @@ export const Tools = { DefaultTool: Tool.DEFAULT_TOOL, Highlighter: Tool.HIGHLIGHTER, Eraser: Tool.ERASER, + LaserPointer: Tool.LASER_POINTER, } as const; /** From 4438e8704e5e3277c6ecc12431e08709a921eadc Mon Sep 17 00:00:00 2001 From: tbolis Date: Wed, 28 Oct 2026 16:35:37 +0200 Subject: [PATCH 10/33] showcase: a laser pointer button, with a line of its own The laser pointer keeps its own line color and weight (red, 8px to start with), as a thin black line is hardly seen, and the other tools get theirs back when it is left. --- .../components/Tools/ToolSelector/index.tsx | 14 +++++++++ .../showcase/src/components/Tools/index.tsx | 30 +++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/showcase/src/components/Tools/ToolSelector/index.tsx b/packages/showcase/src/components/Tools/ToolSelector/index.tsx index 41cf165..bc1efb8 100644 --- a/packages/showcase/src/components/Tools/ToolSelector/index.tsx +++ b/packages/showcase/src/components/Tools/ToolSelector/index.tsx @@ -10,6 +10,8 @@ import CircleIcon from "@mui/icons-material/RadioButtonUnchecked"; import PanIcon from "@mui/icons-material/PanTool"; import HighlighterIcon from "@mui/icons-material/Highlight"; import EraserIcon from "@mui/icons-material/AutoFixNormal"; +import LaserPointerIcon from "@mui/icons-material/AdsClick"; +import Typography from "@mui/material/Typography"; import ToggleButton from "@mui/material/ToggleButton"; import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; import Section from "../Section"; @@ -34,6 +36,11 @@ const TOOLS = [ { value: Types.Tool.PAN, label: "Pan", icon: }, { value: Types.Tool.HIGHLIGHTER, label: "Highlighter", icon: }, { value: Types.Tool.ERASER, label: "Eraser", icon: }, + { + value: Types.Tool.LASER_POINTER, + label: "Laser pointer", + icon: , + }, ]; const ToolSelector = (props: ToolSelectorProps): ReactElement => { @@ -55,6 +62,13 @@ const ToolSelector = (props: ToolSelectorProps): ReactElement => { ))} + {props.selected === Types.Tool.LASER_POINTER && ( + + Drag over the canvas to point at something. The line fades out and is not + part of the drawing: it is not saved, exported or undone. It has the color + and the weight of the line. + + )}
); }; diff --git a/packages/showcase/src/components/Tools/index.tsx b/packages/showcase/src/components/Tools/index.tsx index 757e59f..3901a42 100644 --- a/packages/showcase/src/components/Tools/index.tsx +++ b/packages/showcase/src/components/Tools/index.tsx @@ -1,4 +1,5 @@ import type { MouseEvent, ReactElement } from "react"; +import { Types } from "react-sketch"; import AddIcon from "@mui/icons-material/Add"; import RemoveIcon from "@mui/icons-material/Remove"; import Paper from "@mui/material/Paper"; @@ -13,6 +14,25 @@ import Section from "./Section"; import TextControls from "./TextControls"; import ToolSelector from "./ToolSelector"; +interface LineSettings { + lineColor: string; + lineWidth: number; +} + +/** The line of the laser pointer, a thin black one is hardly seen */ +let laserLine: LineSettings = { lineColor: "#ff0000", lineWidth: 8 }; +/** The line of the other tools, kept while the laser pointer is in use */ +let otherLine: LineSettings = { + lineColor: CONFIG.lineColor, + lineWidth: CONFIG.lineWidth, +}; + +const swapLine = (leaving: LineSettings): LineSettings => { + const { lineColor, lineWidth } = CONFIG; + Object.assign(CONFIG, leaving); + return { lineColor, lineWidth }; +}; + /** * Handle the selection of the Sketch tool * @@ -20,9 +40,15 @@ import ToolSelector from "./ToolSelector"; * @param {string | null} value of the selected buttons, null when the selected one is clicked again */ const handleSelectTool = (event: MouseEvent, value: string | null) => { - if (value !== null) { - CONFIG.selectedTool = value; + if (value === null) return; + const laser = Types.Tool.LASER_POINTER; + // The laser pointer keeps its own color and weight, apart from the other tools + if (value === laser && CONFIG.selectedTool !== laser) { + otherLine = swapLine(laserLine); + } else if (value !== laser && CONFIG.selectedTool === laser) { + laserLine = swapLine(otherLine); } + CONFIG.selectedTool = value; }; const SketchTools = (props: Config): ReactElement => { From e26e5c8d1f74a7bb81497af585e5e0cab858ab91 Mon Sep 17 00:00:00 2001 From: tbolis Date: Wed, 28 Oct 2026 16:35:44 +0200 Subject: [PATCH 11/33] docs: write the laser pointer in the README and the 1.1.0 entry of the CHANGELOG --- CHANGELOG.md | 13 ++++++++++++- README.md | 15 ++++++++++++++- packages/react-sketch/README.md | 2 +- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 350abca..7f598d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.1.0 (unreleased) -The eraser tool, and features that forks of react-sketch had, written again for the TypeScript code and fabric 7. +The eraser and laser pointer tools, and features that forks of react-sketch had, written again for the TypeScript code and fabric 7. ### Added @@ -28,6 +28,14 @@ The eraser tool, and features that forks of react-sketch had, written again for - The showcase has an Overlay section (a sample outline, an image of your own, remove), a `backgroundImage` tab that uses the property, and a CORS switch for "Load Image from URL". The fit switches of the Background section also apply to the background image that is already set. +- The laser pointer tool: `tool="laser-pointer"`, `Tools.LaserPointer` and `Types.Tool.LASER_POINTER`. A + line follows the pointer, gets thinner towards its start and disappears about a second after it was drawn, + with the color and the weight of the line (`lineColor`, `lineWidth`) and a glow. It is only drawn on the + top canvas of fabric, so it is not an object of the sketch: it is not in `toJSON()` or `toDataURL()`, it + is not an undo step, and neither `onObjectAdded` nor `onChange` is called for it. Another tool clears what + is left of the line. +- The showcase has a Laser pointer button in the tool selector. The laser pointer keeps its own line color + and weight (red, 8px to start with), apart from the other tools. ### Changed @@ -42,6 +50,9 @@ The eraser tool, and features that forks of react-sketch had, written again for (`@react-sketch/eraser`), with its license, and `react-sketch` bundles it. Nothing new is installed for the users of the library, and the externals of the bundle are still `react` and `fabric`. The license is in `dist/index.js.LICENSE.txt`. `yarn typecheck`, `yarn build` and `yarn test` build the package first. +- The laser pointer brush is in the private package `packages/laserpointer` (`@react-sketch/laserpointer`), + which `react-sketch` bundles too. `yarn build:packages` builds both private packages, and + `yarn typecheck`, `yarn build` and `yarn test` run it first. ## 1.0.1 (unreleased) diff --git a/README.md b/README.md index a1ae29a..0d22f11 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,7 @@ Use the tools by name (`"pencil"`), with `Tools` (`Tools.Pencil`) or with the en | Pan | `Tools.Pan` | `pan` | Disables drawing. Dragging moves the whole canvas, useful after zooming (contributed by [wmaillard](https://github.com/wmaillard)). | | Highlighter | `Tools.Highlighter` | `highlighter` | Free drawing in a semi-transparent color. `lineColor` must be a hex code or a color name; any other value falls back to the default color. | | Eraser | `Tools.Eraser` | `eraser` | Erases parts of the objects, with the width of `lineWidth`. The cursor shows the size of the brush. The background color and the background image are not erased. See [Eraser](#eraser). | +| Laser pointer | `Tools.LaserPointer` | `laser-pointer` | A line that follows the pointer, fades out and disappears. It is not part of the drawing. See [Laser pointer](#laser-pointer). | | Default tool | `Tools.DefaultTool` | `default-tool` | Disables drawing and selection. Use it to show a drawing that is not edited. | ### Eraser @@ -307,12 +308,22 @@ A stroke is one undo step. The erased parts are kept on the objects, so `toJSON( `defaultValue` and `fromJSON()` load them. An object is only cut where the eraser crosses it, the rest of the sketch stays, and the background color and the background image are not erased. +### Laser pointer + +The laser pointer points at something on the sketch: the line follows the pointer, gets thinner towards its +start and disappears about a second after it was drawn. It has the color (`lineColor`) and the weight +(`lineWidth`) of the line, and it glows in its color. + +The line is drawn on the top canvas of fabric only, it is never an object of the sketch. It is not in +`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. + ## Exports | Export | Description | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `SketchField` | The component (named export). Also usable as a type, for the `ref`. | -| `Tools` | The tools by name: `Select`, `Pencil`, `Line`, `Arrow`, `Rectangle`, `RectangleLabel`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`, `Eraser`. | +| `Tools` | The tools by name: `Select`, `Pencil`, `Line`, `Arrow`, `Rectangle`, `RectangleLabel`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`, `Eraser`, `LaserPointer`. | | `Types` | Namespace of the types: `Tool` (enumeration), `Tools`, `SketchProperties`, `SketchEvent`, `SketchObject`, `SketchValue`, `SketchData`, `DataUrlOptions`, `ImageOptions`, `BackgroundOptions`, `TextOptions`. | | default export | The component, which also carries `SketchField` and `Tools` (as in 0.5.x). | @@ -338,6 +349,8 @@ This is a Yarn 1 workspaces monorepo. | Path | Description | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `packages/react-sketch` | The library, published to npm as `react-sketch`. The build output is `dist/index.js` (UMD bundle, `react` and `fabric` are external) and `dist/index.d.ts` (type declarations). | +| `packages/eraser` | The eraser brush, from `@erase2d/fabric` (private, bundled into `react-sketch`). | +| `packages/laserpointer` | The laser pointer brush (private, bundled into `react-sketch`). | | `packages/showcase` | Demo application (not published): a tools panel and the `defaultValue`, `value` and `addImg` scenarios. | | `.github/workflows` | Continuous integration ([ci.yml](.github/workflows/ci.yml)). | diff --git a/packages/react-sketch/README.md b/packages/react-sketch/README.md index 305c4b8..27119d8 100644 --- a/packages/react-sketch/README.md +++ b/packages/react-sketch/README.md @@ -45,7 +45,7 @@ export const Drawing = () => { ``` Exports: `SketchField` (the component), `Tools` (`Select`, `Pencil`, `Line`, `Arrow`, `Rectangle`, -`RectangleLabel`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`), `Types` (namespace with the types and +`RectangleLabel`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`, `Eraser`, `LaserPointer`), `Types` (namespace with the types and the `Tool` enumeration) and a default export, which is the component with `SketchField` and `Tools` attached (as in 0.5.x). From 7bee65f68573b0c4bd2cedc218e9ee464743e415 Mon Sep 17 00:00:00 2001 From: tbolis Date: Wed, 28 Oct 2026 19:17:50 +0200 Subject: [PATCH 12/33] feat: polygon tool tool="polygon", Tools.Polygon and Types.Tool.POLYGON (#96). Each click or tap places a vertex where the pointer is released. The polygon is closed by a click or tap on the first vertex, a double click or double tap, or the Enter key, and the Escape key cancels it. The polygon in progress is drawn on the top canvas only, it becomes one fabric.Polygon and one undo step when it is closed. FabricCanvasTool gets finish(), called before dispose() when another tool takes over, and notifiesChange(), which replaces the check of the laser pointer in the events manager. --- .../components/SketchField/SketchField.tsx | 7 + .../src/components/SketchField/events.ts | 5 +- .../tools/LaserPointer/LaserPointer.ts | 5 + .../tools/Polygon/Polygon.spec.tsx | 805 ++++++++++++++++++ .../SketchField/tools/Polygon/Polygon.ts | 268 ++++++ .../SketchField/tools/Polygon/index.ts | 1 + .../components/SketchField/tools/factory.ts | 4 + .../src/components/SketchField/tools/index.ts | 16 + packages/react-sketch/src/index.spec.tsx | 1 + packages/react-sketch/src/types.ts | 2 + 10 files changed, 1111 insertions(+), 3 deletions(-) create mode 100644 packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx create mode 100644 packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts create mode 100644 packages/react-sketch/src/components/SketchField/tools/Polygon/index.ts diff --git a/packages/react-sketch/src/components/SketchField/SketchField.tsx b/packages/react-sketch/src/components/SketchField/SketchField.tsx index 08dcc13..ca2e29a 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.tsx @@ -17,6 +17,7 @@ import { autoresize, scaleContent } from "./resize"; import { FabricCanvasTool } from "./tools"; import { create_tool } from "./tools/factory"; import { Eraser } from "./tools/Eraser"; +import { Polygon } from "./tools/Polygon"; /** * The properties which always have a value, because SketchField provides @@ -305,9 +306,15 @@ class SketchField extends PureComponent { * Use the tool of the properties, from now on */ private selectTool(): void { + this.tool?.finish(); this.tool?.dispose(); this.withToolProps((props) => { this.tool = create_tool(this.canvas, props); + if (this.tool instanceof Polygon) { + this.tool.onComplete = () => { + if (this.mounted) this.props.onChange?.(); + }; + } if (this.tool instanceof Eraser) { this.tool.onErase = (erasures) => { if (!this.mounted) return; diff --git a/packages/react-sketch/src/components/SketchField/events.ts b/packages/react-sketch/src/components/SketchField/events.ts index 01838f3..c74146b 100644 --- a/packages/react-sketch/src/components/SketchField/events.ts +++ b/packages/react-sketch/src/components/SketchField/events.ts @@ -1,5 +1,5 @@ import type { FabricObject, TPointerEvent } from "fabric"; -import { SketchEvent, SketchProperties, Tool } from "../../types"; +import { SketchEvent, SketchProperties } from "../../types"; import { SketchHistory } from "./history/SketchHistory"; import { AbstractEventAware, CanvasEventAware, FabricCanvasTool } from "./tools"; @@ -122,8 +122,7 @@ export class FabricCanvasEventsManager * the mouse was released or left the canvas. */ private notifyChange({ event, tool, props }: EventInput): void { - // The line of the laser pointer is not part of the sketch - if (tool.type() === Tool.LASER_POINTER) return; + if (!tool.notifiesChange()) return; const { onChange } = props; if (onChange) this.schedule(() => onChange(event.e), 10); } diff --git a/packages/react-sketch/src/components/SketchField/tools/LaserPointer/LaserPointer.ts b/packages/react-sketch/src/components/SketchField/tools/LaserPointer/LaserPointer.ts index 9752923..8fd5341 100644 --- a/packages/react-sketch/src/components/SketchField/tools/LaserPointer/LaserPointer.ts +++ b/packages/react-sketch/src/components/SketchField/tools/LaserPointer/LaserPointer.ts @@ -29,6 +29,11 @@ export class LaserPointer extends FabricCanvasTool { return Tool.LASER_POINTER; } + /** The line is not part of the sketch, so there is nothing to tell */ + notifiesChange(): boolean { + return false; + } + /** The line is gone with the tool, it does not fade on while another tool is in use */ dispose(): void { const current = this.canvas.freeDrawingBrush; diff --git a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx new file mode 100644 index 0000000..306993b --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx @@ -0,0 +1,805 @@ +import { createRef } from "react"; +import { act, render, waitFor } from "@testing-library/react"; +import * as fabric from "fabric"; +import SketchField from "../../SketchField"; +import { SketchProperties, Tool, Tools } from "../../../../types"; + +type Point = [number, number]; + +const mount = (props: SketchProperties = {}) => { + const ref = createRef(); + const view = render(); + const sketch = ref.current as SketchField; + return { ...view, sketch, canvas: sketch["canvas"] as fabric.Canvas }; +}; + +const mouseEvent = (type: string, [x, y]: Point): MouseEvent => + new MouseEvent(type, { + clientX: x, + clientY: y, + buttons: type === "mouseup" ? 0 : 1, + bubbles: true, + cancelable: true, + }); + +/** Let the time pass, so the next click is not the second of a double click */ +const wait = (ms = 500) => act(() => jest.advanceTimersByTime(ms)); + +const hover = (canvas: fabric.Canvas, point: Point) => + canvas.upperCanvasEl.dispatchEvent(mouseEvent("mousemove", point)); + +/** Press and release the mouse at the point, a while after the last click */ +const click = (canvas: fabric.Canvas, point: Point) => { + wait(); + canvas.upperCanvasEl.dispatchEvent(mouseEvent("mousedown", point)); + document.dispatchEvent(mouseEvent("mouseup", point)); +}; + +/** The second click follows the first at once, where the first was */ +const doubleClick = (canvas: fabric.Canvas, point: Point) => { + click(canvas, point); + wait(100); + canvas.upperCanvasEl.dispatchEvent(mouseEvent("mousedown", point)); + document.dispatchEvent(mouseEvent("mouseup", point)); +}; + +/** + * A touch event of the page: jsdom cannot build one with touches in it. The + * finger which changed is `point`, `others` are the fingers which stay down. + */ +const touchEvent = ( + type: string, + point: Point, + { id = 0, others = [] as Touch[] } = {} +): Event => { + const [clientX, clientY] = point; + const changed = { identifier: id, clientX, clientY } as Touch; + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperties(event, { + touches: { value: type === "touchend" ? others : [...others, changed] }, + changedTouches: { value: [changed] }, + }); + return event; +}; + +const tap = (canvas: fabric.Canvas, point: Point) => { + wait(); + canvas.upperCanvasEl.dispatchEvent(touchEvent("touchstart", point)); + document.dispatchEvent(touchEvent("touchend", point)); +}; + +const polygons = (canvas: fabric.Canvas) => + canvas + .getObjects() + .filter((o): o is fabric.Polygon => o instanceof fabric.Polygon); + +/** + * The vertices of the polygon in canvas coordinates: its points are in its own + * space, which is centered on `pathOffset`, and the matrix of the object puts + * that space on the canvas. + */ +const vertices = (polygon: fabric.Polygon): Point[] => { + const matrix = polygon.calcTransformMatrix(); + return polygon.points.map(({ x, y }) => { + const p = fabric.util.transformPoint( + new fabric.Point(x - polygon.pathOffset.x, y - polygon.pathOffset.y), + matrix + ); + return [Math.round(p.x), Math.round(p.y)]; + }); +}; + +const drawn = (canvas: fabric.Canvas, method: "lineTo" | "moveTo" | "arc") => + (canvas.contextTop[method] as jest.Mock).mock.calls; + +const keydown = (key: string, target: EventTarget = document) => { + const event = new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }); + target.dispatchEvent(event); + return event; +}; + +/** Three vertices, a triangle which is not closed yet */ +const triangle = (canvas: fabric.Canvas) => { + click(canvas, [100, 100]); + click(canvas, [200, 100]); + click(canvas, [150, 180]); +}; + +describe("Polygon", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("is a tool of the component, with the legacy name", () => { + expect(Tools.Polygon).toEqual(Tool.POLYGON); + expect(Tool.POLYGON).toEqual("polygon"); + }); + + describe("vertices", () => { + it("places one where the mouse is released, even after a drag", () => { + const { canvas } = mount(); + + canvas.upperCanvasEl.dispatchEvent(mouseEvent("mousedown", [10, 10])); + document.dispatchEvent(mouseEvent("mousemove", [40, 50])); + document.dispatchEvent(mouseEvent("mouseup", [40, 50])); + click(canvas, [200, 100]); + click(canvas, [150, 180]); + click(canvas, [40, 50]); + + expect(vertices(polygons(canvas)[0])).toEqual([ + [40, 50], + [200, 100], + [150, 180], + ]); + }); + + it("does not place one for a press which ends outside of the canvas", () => { + const { canvas } = mount(); + + canvas.upperCanvasEl.dispatchEvent(mouseEvent("mousedown", [10, 10])); + canvas.upperCanvasEl.dispatchEvent(mouseEvent("mouseout", [-5, 10])); + document.dispatchEvent(mouseEvent("mouseup", [-5, 10])); + click(canvas, [200, 100]); + click(canvas, [150, 180]); + keydown("Enter"); + + expect(polygons(canvas)).toHaveLength(0); + }); + + it("are in canvas coordinates, whatever the zoom of the view is", () => { + const { canvas } = mount(); + canvas.setZoom(2); + + click(canvas, [100, 100]); + click(canvas, [300, 100]); + click(canvas, [200, 200]); + keydown("Enter"); + + expect(vertices(polygons(canvas)[0])).toEqual([ + [50, 50], + [150, 50], + [100, 100], + ]); + }); + }); + + describe("preview", () => { + it("shows the sides placed and the one which follows the pointer, on the top canvas", () => { + const { canvas } = mount({ lineColor: "#ff0000", lineWidth: 4 }); + const ctx = canvas.contextTop; + const strokes: [unknown, number][] = []; + (ctx.stroke as jest.Mock).mockImplementation(() => + strokes.push([ctx.strokeStyle, ctx.lineWidth]) + ); + + click(canvas, [100, 100]); + click(canvas, [200, 100]); + hover(canvas, [150, 180]); + + expect(strokes.at(-1)).toEqual(["#ff0000", 4]); + expect(drawn(canvas, "moveTo").at(-1)).toEqual([100, 100]); + expect(drawn(canvas, "lineTo").slice(-2)).toEqual([ + [200, 100], + [150, 180], + ]); + expect(canvas.getObjects()).toHaveLength(0); + }); + + it("has a dot on each vertex", () => { + const { canvas } = mount(); + + click(canvas, [100, 100]); + click(canvas, [200, 100]); + + const centers = drawn(canvas, "arc").map(([x, y]) => [x, y]); + expect(centers.slice(-2)).toEqual([ + [100, 100], + [200, 100], + ]); + }); + + it("draws again after the canvas renders, which clears its top layer", () => { + const { canvas } = mount(); + click(canvas, [100, 100]); + click(canvas, [200, 100]); + (canvas.contextTop.lineTo as jest.Mock).mockClear(); + + canvas.renderAll(); + + expect(drawn(canvas, "lineTo")).toContainEqual([200, 100]); + }); + + it("follows the pointer on touch only while the finger is down", () => { + const { canvas } = mount(); + tap(canvas, [100, 100]); + tap(canvas, [200, 100]); + (canvas.contextTop.lineTo as jest.Mock).mockClear(); + + canvas.upperCanvasEl.dispatchEvent(touchEvent("touchstart", [150, 180])); + document.dispatchEvent(touchEvent("touchmove", [160, 190])); + + expect(drawn(canvas, "lineTo").slice(-2)).toEqual([ + [200, 100], + [160, 190], + ]); + // The vertex is where the finger is lifted + document.dispatchEvent(touchEvent("touchend", [165, 195])); + keydown("Enter"); + expect(vertices(polygons(canvas)[0])).toEqual([ + [100, 100], + [200, 100], + [165, 195], + ]); + }); + + it("rings the first vertex when a click there would close the polygon", () => { + const { canvas } = mount(); + triangle(canvas); + (canvas.contextTop.arc as jest.Mock).mockClear(); + + hover(canvas, [104, 103]); + + // The three dots, then the ring at the first vertex, as wide as the radius + expect(drawn(canvas, "arc").at(-1)).toEqual([100, 100, 10, 0, Math.PI * 2]); + }); + + it("has no ring far from the first vertex, nor with fewer than three vertices", () => { + const { canvas } = mount(); + click(canvas, [100, 100]); + click(canvas, [200, 100]); + (canvas.contextTop.arc as jest.Mock).mockClear(); + + hover(canvas, [101, 101]); + const two = drawn(canvas, "arc").length; + click(canvas, [150, 180]); + (canvas.contextTop.arc as jest.Mock).mockClear(); + hover(canvas, [150, 300]); + + expect(two).toEqual(2); + expect(drawn(canvas, "arc")).toHaveLength(3); + }); + + it("is gone when the mouse leaves the canvas, and the vertices stay", () => { + const { canvas } = mount(); + click(canvas, [100, 100]); + click(canvas, [200, 100]); + hover(canvas, [150, 180]); + (canvas.contextTop.lineTo as jest.Mock).mockClear(); + + canvas.upperCanvasEl.dispatchEvent(mouseEvent("mouseout", [150, 300])); + + expect(drawn(canvas, "lineTo")).toEqual([[200, 100]]); + }); + }); + + describe("closing", () => { + it("by a click on the first vertex adds one polygon with the vertices", () => { + const { canvas } = mount(); + triangle(canvas); + + click(canvas, [103, 104]); + + expect(polygons(canvas)).toHaveLength(1); + expect(canvas.getObjects()).toHaveLength(1); + expect(vertices(polygons(canvas)[0])).toEqual([ + [100, 100], + [200, 100], + [150, 180], + ]); + }); + + it("by a click within 10 pixels of the first vertex, and not by one farther", () => { + const { canvas } = mount(); + triangle(canvas); + + click(canvas, [100, 112]); + expect(polygons(canvas)).toHaveLength(0); + + click(canvas, [100, 91]); + expect(polygons(canvas)).toHaveLength(1); + // The click which closed is not a vertex, the one before it was + expect(vertices(polygons(canvas)[0])).toEqual([ + [100, 100], + [200, 100], + [150, 180], + [100, 112], + ]); + }); + + it("by a tap within 20 pixels of the first vertex", () => { + const { canvas } = mount(); + tap(canvas, [100, 100]); + tap(canvas, [200, 100]); + tap(canvas, [150, 180]); + + tap(canvas, [100, 118]); + + expect(vertices(polygons(canvas)[0])).toEqual([ + [100, 100], + [200, 100], + [150, 180], + ]); + }); + + it("by a double click, with the vertices placed so far", () => { + const { canvas } = mount(); + triangle(canvas); + + doubleClick(canvas, [250, 250]); + + expect(polygons(canvas)).toHaveLength(1); + expect(vertices(polygons(canvas)[0])).toEqual([ + [100, 100], + [200, 100], + [150, 180], + [250, 250], + ]); + }); + + it("by a double tap", () => { + const { canvas } = mount(); + tap(canvas, [100, 100]); + tap(canvas, [200, 100]); + tap(canvas, [150, 180]); + tap(canvas, [250, 250]); + wait(100); + canvas.upperCanvasEl.dispatchEvent(touchEvent("touchstart", [252, 249])); + document.dispatchEvent(touchEvent("touchend", [252, 249])); + + expect(vertices(polygons(canvas)[0])).toHaveLength(4); + }); + + it("by the Enter key", () => { + const { canvas } = mount(); + triangle(canvas); + + const event = keydown("Enter"); + + expect(vertices(polygons(canvas)[0])).toEqual([ + [100, 100], + [200, 100], + [150, 180], + ]); + expect(event.defaultPrevented).toBe(true); + }); + + it("is not by clicks a while apart, or farther apart than a double click", () => { + const { canvas } = mount(); + triangle(canvas); + + click(canvas, [250, 250]); + click(canvas, [300, 250]); + + expect(polygons(canvas)).toHaveLength(0); + }); + + it("does nothing with fewer than three vertices", () => { + const { canvas } = mount(); + click(canvas, [100, 100]); + doubleClick(canvas, [200, 100]); + + const enter = keydown("Enter"); + + expect(polygons(canvas)).toHaveLength(0); + expect(enter.defaultPrevented).toBe(false); + // The polygon goes on, a click by the first vertex is a vertex for now + click(canvas, [101, 101]); + keydown("Enter"); + expect(vertices(polygons(canvas)[0])).toEqual([ + [100, 100], + [200, 100], + [101, 101], + ]); + }); + + it("lets a self-intersecting polygon be", () => { + const { canvas } = mount(); + click(canvas, [100, 100]); + click(canvas, [200, 200]); + click(canvas, [200, 100]); + click(canvas, [100, 200]); + + keydown("Enter"); + + expect(polygons(canvas)).toHaveLength(1); + expect(vertices(polygons(canvas)[0])).toHaveLength(4); + }); + + it("leaves the top canvas clean", () => { + const { canvas } = mount(); + triangle(canvas); + (canvas.contextTop.clearRect as jest.Mock).mockClear(); + + keydown("Enter"); + + expect(canvas.contextTop.clearRect).toHaveBeenCalled(); + }); + + it("keeps the radius to the first vertex in screen pixels, whatever the zoom", () => { + const { canvas } = mount(); + canvas.setZoom(2); + click(canvas, [200, 200]); + click(canvas, [400, 200]); + click(canvas, [300, 300]); + + // 12 screen pixels are 6 canvas units, which would be near in canvas units + click(canvas, [200, 212]); + expect(polygons(canvas)).toHaveLength(0); + + click(canvas, [200, 190]); + expect(polygons(canvas)).toHaveLength(1); + }); + }); + + describe("cancelling", () => { + it("by the Escape key drops the polygon, and nothing is left on the canvas", () => { + const { sketch, canvas } = mount(); + triangle(canvas); + + const event = keydown("Escape"); + keydown("Enter"); + + expect(event.defaultPrevented).toBe(true); + expect(canvas.getObjects()).toHaveLength(0); + expect(sketch.canUndo()).toBe(false); + }); + + it("starts the next polygon from nothing", () => { + const { canvas } = mount(); + triangle(canvas); + keydown("Escape"); + + click(canvas, [300, 300]); + click(canvas, [400, 300]); + click(canvas, [350, 380]); + keydown("Enter"); + + expect(vertices(polygons(canvas)[0])).toEqual([ + [300, 300], + [400, 300], + [350, 380], + ]); + }); + }); + + describe("keys", () => { + it("are listened to only while a polygon is in progress", () => { + const { canvas } = mount(); + const add = jest.spyOn(document, "addEventListener"); + const remove = jest.spyOn(document, "removeEventListener"); + const keys = (spy: jest.SpyInstance) => + spy.mock.calls.filter(([type]) => type === "keydown").length; + + expect(keys(add)).toEqual(0); + click(canvas, [100, 100]); + expect(keys(add)).toEqual(1); + click(canvas, [200, 100]); + expect(keys(add)).toEqual(1); + keydown("Escape"); + expect(keys(remove)).toEqual(1); + }); + + it("are left to the fields the user types in", () => { + const { canvas } = mount(); + const input = document.body.appendChild(document.createElement("input")); + triangle(canvas); + + const enter = keydown("Enter", input); + keydown("Escape", input); + + expect(polygons(canvas)).toHaveLength(0); + expect(enter.defaultPrevented).toBe(false); + keydown("Enter"); + expect(polygons(canvas)).toHaveLength(1); + input.remove(); + }); + }); + + describe("the polygon", () => { + it("has the color, the weight and the fill of the line", () => { + const { canvas } = mount({ + lineColor: "#ff0000", + lineWidth: 6, + fillColor: "#00ff00", + }); + triangle(canvas); + keydown("Enter"); + + const [polygon] = polygons(canvas); + expect(polygon.stroke).toEqual("#ff0000"); + expect(polygon.strokeWidth).toEqual(6); + expect(polygon.fill).toEqual("#00ff00"); + expect(polygon.strokeUniform).toBe(true); + }); + + it("is not filled by default, and has the default line of the component", () => { + const { canvas } = mount(); + triangle(canvas); + keydown("Enter"); + + const [polygon] = polygons(canvas); + expect(polygon.fill).toEqual("transparent"); + expect(polygon.stroke).toEqual("black"); + expect(polygon.strokeWidth).toEqual(10); + }); + + it("has the color of the line when it is closed, if the line changed on the way", () => { + const { canvas, rerender } = mount({ lineColor: "#ff0000" }); + click(canvas, [100, 100]); + click(canvas, [200, 100]); + rerender( + + ); + click(canvas, [150, 180]); + keydown("Enter"); + + const [polygon] = polygons(canvas); + expect(polygon.stroke).toEqual("#0000ff"); + expect(polygon.strokeWidth).toEqual(3); + }); + + it("has its vertices in `points`, in the space of the object", () => { + const { canvas } = mount(); + triangle(canvas); + keydown("Enter"); + + const [polygon] = polygons(canvas); + expect(polygon.points.map(({ x, y }) => [x, y])).toEqual([ + [100, 100], + [200, 100], + [150, 180], + ]); + expect(polygon.pathOffset.x).toBeCloseTo(150); + expect(polygon.pathOffset.y).toBeCloseTo(140); + }); + }); + + describe("in the sketch", () => { + it("is nothing but the preview until it is closed", () => { + const onObjectAdded = jest.fn(); + const onChange = jest.fn(); + const { sketch, canvas } = mount({ onObjectAdded, onChange }); + + triangle(canvas); + wait(); + + expect(canvas.getObjects()).toHaveLength(0); + expect(sketch.toJSON().objects).toHaveLength(0); + expect(sketch.canUndo()).toBe(false); + expect(onObjectAdded).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("is not in the export, and the preview is there after it", () => { + const { sketch, canvas } = mount(); + triangle(canvas); + + expect(() => sketch.toDataURL()).not.toThrow(); + + (canvas.contextTop.lineTo as jest.Mock).mockClear(); + hover(canvas, [150, 300]); + expect(drawn(canvas, "lineTo").at(-1)).toEqual([150, 300]); + }); + + it("is one undo step, which undo and redo take off and put back", () => { + const { sketch, canvas } = mount(); + triangle(canvas); + keydown("Enter"); + + expect(sketch.canUndo()).toBe(true); + sketch.undo(); + expect(canvas.getObjects()).toHaveLength(0); + expect(sketch.canUndo()).toBe(false); + sketch.redo(); + expect(polygons(canvas)).toHaveLength(1); + expect(sketch.canRedo()).toBe(false); + }); + + it("calls onObjectAdded once, with the polygon as the target", () => { + const onObjectAdded = jest.fn(); + const { canvas } = mount({ onObjectAdded }); + triangle(canvas); + expect(onObjectAdded).not.toHaveBeenCalled(); + + click(canvas, [100, 100]); + + expect(onObjectAdded).toHaveBeenCalledTimes(1); + expect(onObjectAdded.mock.calls[0][0].target).toBe(polygons(canvas)[0]); + }); + + it("calls onChange once, when it is closed, whether by the mouse or by a key", () => { + const onChange = jest.fn(); + const { canvas } = mount({ onChange }); + triangle(canvas); + wait(); + expect(onChange).not.toHaveBeenCalled(); + + click(canvas, [100, 100]); + wait(); + expect(onChange).toHaveBeenCalledTimes(1); + + triangle(canvas); + keydown("Enter"); + wait(); + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it("does not call onChange for a polygon which is cancelled", () => { + const onChange = jest.fn(); + const { canvas } = mount({ onChange }); + triangle(canvas); + + keydown("Escape"); + wait(); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("still calls onMouseDown and onMouseUp for each click", () => { + const onMouseDown = jest.fn(); + const onMouseUp = jest.fn(); + const { canvas } = mount({ onMouseDown, onMouseUp }); + + triangle(canvas); + + expect(onMouseDown).toHaveBeenCalledTimes(3); + expect(onMouseUp).toHaveBeenCalledTimes(3); + }); + + it("is saved by toJSON and loaded by fromJSON", async () => { + const { sketch, canvas } = mount({ + lineColor: "#ff0000", + fillColor: "#00ff00", + }); + triangle(canvas); + keydown("Enter"); + const json = JSON.parse(JSON.stringify(sketch.toJSON())); + + const other = mount({ tool: Tool.SELECT }); + other.sketch.fromJSON(json); + await waitFor(() => expect(polygons(other.canvas)).toHaveLength(1)); + + expect(json.objects[0].type).toEqual("Polygon"); + const [loaded] = polygons(other.canvas); + expect(vertices(loaded)).toEqual(vertices(polygons(canvas)[0])); + expect(loaded.stroke).toEqual("#ff0000"); + expect(loaded.fill).toEqual("#00ff00"); + }); + + it("can be selected and moved as a whole, once the tool is another", () => { + const { canvas, rerender } = mount(); + triangle(canvas); + keydown("Enter"); + const [polygon] = polygons(canvas); + expect(polygon.selectable).toBe(false); + + rerender(); + + expect(polygon.selectable).toBe(true); + expect(polygon.evented).toBe(true); + }); + + it("can be cut by the eraser", () => { + const { canvas } = mount(); + triangle(canvas); + keydown("Enter"); + + expect((polygons(canvas)[0] as { erasable?: boolean }).erasable).toBe(true); + }); + }); + + describe("another tool", () => { + it("closes the polygon with three vertices, and keeps it", () => { + const onChange = jest.fn(); + const { sketch, canvas, rerender } = mount({ onChange }); + triangle(canvas); + + rerender(); + + expect(vertices(polygons(canvas)[0])).toEqual([ + [100, 100], + [200, 100], + [150, 180], + ]); + expect(sketch.canUndo()).toBe(true); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("drops the polygon with fewer vertices", () => { + const onChange = jest.fn(); + const { canvas, rerender } = mount({ onChange }); + click(canvas, [100, 100]); + click(canvas, [200, 100]); + + rerender(); + + expect(canvas.getObjects()).toHaveLength(0); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("leaves the top canvas clean and the keys alone", () => { + const { canvas, rerender } = mount(); + triangle(canvas); + (canvas.contextTop.clearRect as jest.Mock).mockClear(); + + rerender(); + const enter = keydown("Enter"); + + expect(canvas.contextTop.clearRect).toHaveBeenCalled(); + expect(enter.defaultPrevented).toBe(false); + expect(polygons(canvas)).toHaveLength(1); + }); + + it("finds the tool as it was left when it comes back", () => { + const { canvas, rerender } = mount(); + triangle(canvas); + rerender(); + rerender(); + + click(canvas, [300, 300]); + click(canvas, [400, 300]); + click(canvas, [350, 380]); + keydown("Enter"); + + expect(polygons(canvas)).toHaveLength(2); + expect(vertices(polygons(canvas)[1])).toEqual([ + [300, 300], + [400, 300], + [350, 380], + ]); + }); + }); + + describe("unmounting", () => { + it("drops the polygon in progress, and its keys", () => { + const onObjectAdded = jest.fn(); + const onChange = jest.fn(); + const { canvas, unmount } = mount({ onObjectAdded, onChange }); + triangle(canvas); + + unmount(); + const enter = keydown("Enter"); + wait(); + + expect(canvas.getObjects()).toHaveLength(0); + expect(onObjectAdded).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + expect(enter.defaultPrevented).toBe(false); + }); + }); + + describe("a second finger", () => { + // The canvas does not pass its events on, so it is not a vertex nor a pointer + it("is not a pointer of the polygon", () => { + const { canvas } = mount(); + tap(canvas, [100, 100]); + tap(canvas, [200, 100]); + wait(); + const first = { identifier: 0, clientX: 150, clientY: 180 } as Touch; + + canvas.upperCanvasEl.dispatchEvent(touchEvent("touchstart", [150, 180])); + canvas.upperCanvasEl.dispatchEvent( + touchEvent("touchstart", [300, 300], { id: 1, others: [first] }) + ); + document.dispatchEvent( + touchEvent("touchend", [300, 300], { id: 1, others: [first] }) + ); + document.dispatchEvent(touchEvent("touchend", [150, 180])); + keydown("Enter"); + + expect(vertices(polygons(canvas)[0])).toEqual([ + [100, 100], + [200, 100], + [150, 180], + ]); + }); + }); +}); diff --git a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts new file mode 100644 index 0000000..273d004 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts @@ -0,0 +1,268 @@ +import * as fabric from "fabric"; +import { CanvasEvent } from "../../events"; +import { FabricCanvasTool } from "../index"; +import { SketchProperties, Tool } from "../../../../types"; + +/** The fewest vertices of a polygon */ +const MIN_VERTICES = 3; + +/** How near (in screen pixels) the pointer must be to the first vertex to close the polygon */ +const CLOSE_RADIUS = 10; +const CLOSE_RADIUS_TOUCH = 20; + +/** Two releases this close in time (milliseconds) and in space (the radius above) are a double click */ +const DOUBLE_CLICK_TIME = 400; + +/** Whether the element is one the user types in, which keeps its keys */ +const isEditable = (target: EventTarget | null): boolean => { + const element = target as HTMLElement | null; + return ( + !!element && + (element.isContentEditable || + ["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName)) + ); +}; + +/** + * Draws a closed shape of straight sides: each click or tap places a vertex. + * The polygon is closed by a click or tap on the first vertex, a double click + * or double tap, or the Enter key, and the Escape key cancels it. + * + * The polygon in progress is drawn on the top canvas only, it is not an + * object of the sketch until it is closed. Then it is one `fabric.Polygon`, + * which is one undo step. + */ +export class Polygon extends FabricCanvasTool { + /** Called when a polygon is closed, with the polygon which was added to the canvas */ + onComplete?: (polygon: fabric.Polygon) => void; + + private width?: number; + private color?: string; + private fill?: string; + /** The vertices of the polygon in progress, in canvas coordinates */ + private vertices: fabric.Point[] = []; + /** Where the pointer is, null while it is out of the canvas */ + private pointerAt: fabric.Point | null = null; + private isDown = false; + private isTouch = false; + private lastRelease?: { time: number; point: fabric.Point }; + + constructor(canvas: fabric.Canvas) { + super(canvas); + } + + configureCanvas(props: SketchProperties): void { + this.setInteractive(false); + this.width = props.lineWidth ? props.lineWidth : 1; + this.color = props.lineColor ? props.lineColor : "black"; + this.fill = props.fillColor ? props.fillColor : "transparent"; + if (this.vertices.length) this.paint(); + } + + type(): Tool { + return Tool.POLYGON; + } + + /** + * The application is told when a polygon is closed, and not after each + * vertex + */ + notifiesChange(): boolean { + return false; + } + + doMouseDown(event: CanvasEvent): void { + this.isDown = true; + this.isTouch = this.touch(event); + this.pointerAt = this.point(event); + this.paint(); + } + + doMouseMove(event: CanvasEvent): void { + if (!this.vertices.length) return; + this.pointerAt = this.point(event); + this.paint(); + } + + /** A press which leaves the canvas places nothing */ + doMouseOut(): void { + this.isDown = false; + this.pointerAt = null; + this.paint(); + } + + /** The vertex is where the pointer is released */ + doMouseUp(event: CanvasEvent): void { + if (!this.isDown) return; + this.isDown = false; + const point = this.point(event); + const now = Date.now(); + const doubleClick = + !!this.lastRelease && + now - this.lastRelease.time < DOUBLE_CLICK_TIME && + this.isNear(point, this.lastRelease.point); + this.lastRelease = { time: now, point }; + + // The first click of a double click has placed its vertex, the second is not another one + if (doubleClick) { + this.close(); + return; + } + if ( + this.vertices.length >= MIN_VERTICES && + this.isNear(point, this.vertices[0]) + ) { + this.close(); + return; + } + if (!this.vertices.length) this.begin(); + this.vertices.push(point); + this.pointerAt = point; + this.paint(); + } + + /** Another tool takes over: the polygon is kept when it has the vertices for one */ + finish(): void { + if (!this.close()) this.discard(); + } + + /** The polygon in progress is dropped, a closed one stays in the sketch */ + dispose(): void { + this.discard(); + this.lastRelease = undefined; + } + + /** + * Add the polygon to the canvas, when there are vertices enough for it + * + * @returns false when there are not, and nothing was done + */ + private close(): boolean { + if (this.vertices.length < MIN_VERTICES) return false; + const polygon = new fabric.Polygon( + this.vertices.map(({ x, y }) => ({ x, y })), + { + stroke: this.color, + strokeWidth: this.width, + fill: this.fill, + strokeLineJoin: "round", + strokeUniform: true, + selectable: false, + evented: false, + } + ); + this.discard(); + this.canvas.add(polygon); + this.onComplete?.(polygon); + return true; + } + + /** Forget the polygon in progress and take it off the top canvas */ + private discard(): void { + if (!this.vertices.length) return; + this.vertices = []; + this.pointerAt = null; + this.canvas.off("after:render", this.repaint); + this.keyTarget.removeEventListener("keydown", this.onKeyDown); + const ctx = this.canvas.contextTop; + if (ctx) this.canvas.clearContext(ctx); + } + + /** The first vertex is placed: the keys and the repaints of the canvas matter from now on */ + private begin(): void { + this.canvas.on("after:render", this.repaint); + this.keyTarget.addEventListener("keydown", this.onKeyDown); + } + + /** The keys are listened to on the document of the canvas, only while a polygon is in progress */ + private get keyTarget(): Document { + return this.canvas.upperCanvasEl.ownerDocument; + } + + private readonly onKeyDown = (event: KeyboardEvent): void => { + if (isEditable(event.target)) return; + if (event.key === "Escape") { + event.preventDefault(); + this.discard(); + } else if (event.key === "Enter" && this.close()) { + event.preventDefault(); + } + }; + + /** The canvas clears its top layer when it renders, so the polygon in progress is drawn again */ + private readonly repaint = (): void => this.paint(); + + private point(event: CanvasEvent): fabric.Point { + const { x, y } = this.pointer(event); + return new fabric.Point(x, y); + } + + private touch(event: CanvasEvent): boolean { + return !!event.e && fabric.util.isTouchEvent(event.e); + } + + /** The screen size of a pixel in canvas coordinates: the radii do not grow with the zoom */ + private get pixel(): number { + return 1 / this.canvas.getZoom(); + } + + private get radius(): number { + return (this.isTouch ? CLOSE_RADIUS_TOUCH : CLOSE_RADIUS) * this.pixel; + } + + private isNear(a: fabric.Point, b: fabric.Point): boolean { + return a.distanceFrom(b) <= this.radius; + } + + /** Whether a click where the pointer is would close the polygon */ + private get closing(): boolean { + return ( + this.vertices.length >= MIN_VERTICES && + !!this.pointerAt && + this.isNear(this.pointerAt, this.vertices[0]) + ); + } + + /** + * Draw the polygon in progress on the top canvas: the sides placed, the one + * which follows the pointer, a dot on each vertex, and a ring on the first + * vertex when a click there would close the polygon. + */ + private paint(): void { + if (!this.vertices.length) return; + const { canvas, vertices, pointerAt } = this; + const ctx = canvas.contextTop; + // The canvas takes its top context away while it exports, and renders then + if (!ctx) return; + const [a, b, c, d, e, f] = canvas.viewportTransform; + const color = this.color ?? "black"; + const width = this.width ?? 1; + const dot = Math.max(width, 3) / 2 + this.pixel; + + canvas.clearContext(ctx); + ctx.save(); + ctx.transform(a, b, c, d, e, f); + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = width; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + ctx.beginPath(); + vertices.forEach(({ x, y }, i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); + if (pointerAt) ctx.lineTo(pointerAt.x, pointerAt.y); + ctx.stroke(); + vertices.forEach(({ x, y }) => { + ctx.beginPath(); + ctx.arc(x, y, dot, 0, Math.PI * 2); + ctx.fill(); + }); + if (this.closing) { + ctx.lineWidth = 2 * this.pixel; + ctx.beginPath(); + ctx.arc(vertices[0].x, vertices[0].y, this.radius, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.restore(); + canvas.contextTopDirty = true; + } +} diff --git a/packages/react-sketch/src/components/SketchField/tools/Polygon/index.ts b/packages/react-sketch/src/components/SketchField/tools/Polygon/index.ts new file mode 100644 index 0000000..b6a360c --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Polygon/index.ts @@ -0,0 +1 @@ +export { Polygon } from "./Polygon"; diff --git a/packages/react-sketch/src/components/SketchField/tools/factory.ts b/packages/react-sketch/src/components/SketchField/tools/factory.ts index 68ecfb6..cfa3f19 100644 --- a/packages/react-sketch/src/components/SketchField/tools/factory.ts +++ b/packages/react-sketch/src/components/SketchField/tools/factory.ts @@ -10,6 +10,7 @@ import { LaserPointer } from "./LaserPointer"; import { Line } from "./Line"; import { Pan } from "./Pan"; import { Pencil } from "./Pencil"; +import { Polygon } from "./Polygon"; import { Rectangle } from "./Rectangle"; import { RectangleLabel } from "./RectangleLabel"; import { Select } from "./Select"; @@ -43,6 +44,9 @@ export const create_tool = ( case Tool.RECTANGLE_LABEL: toolInstance = new RectangleLabel(canvas); break; + case Tool.POLYGON: + toolInstance = new Polygon(canvas); + break; case Tool.SELECT: toolInstance = new Select(canvas); break; diff --git a/packages/react-sketch/src/components/SketchField/tools/index.ts b/packages/react-sketch/src/components/SketchField/tools/index.ts index b0e5669..34852cf 100644 --- a/packages/react-sketch/src/components/SketchField/tools/index.ts +++ b/packages/react-sketch/src/components/SketchField/tools/index.ts @@ -77,12 +77,28 @@ export abstract class FabricCanvasTool { abstract configureCanvas(props: SketchProperties): void; + /** + * Called when another tool takes over, before `dispose()`, for the tools + * which have something unfinished to complete (it is not called when the + * canvas goes away) + */ + finish(): void {} + /** * Called when another tool takes over, and when the canvas goes away, for * the tools which keep something running on the canvas */ dispose(): void {} + /** + * Whether the application is told about a change (`onChange`) when the + * mouse is released or leaves the canvas. The tools which do not change the + * sketch on every release say no, and tell the application themselves. + */ + notifiesChange(): boolean { + return true; + } + doMouseUp(event: CanvasEvent): void {} doMouseDown(event: CanvasEvent): void {} diff --git a/packages/react-sketch/src/index.spec.tsx b/packages/react-sketch/src/index.spec.tsx index 603ab56..f54873a 100644 --- a/packages/react-sketch/src/index.spec.tsx +++ b/packages/react-sketch/src/index.spec.tsx @@ -15,6 +15,7 @@ describe("exports", () => { Arrow: "arrow", Rectangle: "rectangle", RectangleLabel: "rectangle-label", + Polygon: "polygon", Circle: "circle", Pan: "pan", DefaultTool: "default-tool", diff --git a/packages/react-sketch/src/types.ts b/packages/react-sketch/src/types.ts index a81865c..f2a953c 100644 --- a/packages/react-sketch/src/types.ts +++ b/packages/react-sketch/src/types.ts @@ -11,6 +11,7 @@ export enum Tool { PENCIL = "pencil", RECTANGLE = "rectangle", RECTANGLE_LABEL = "rectangle-label", + POLYGON = "polygon", SELECT = "select", PAN = "pan", HIGHLIGHTER = "highlighter", @@ -30,6 +31,7 @@ export const Tools = { Arrow: Tool.ARROW, Rectangle: Tool.RECTANGLE, RectangleLabel: Tool.RECTANGLE_LABEL, + Polygon: Tool.POLYGON, Circle: Tool.CIRCLE, Pan: Tool.PAN, DefaultTool: Tool.DEFAULT_TOOL, From f913a685fd1022f287f3e6d82738eb9cbd8662fa Mon Sep 17 00:00:00 2001 From: tbolis Date: Wed, 28 Oct 2026 19:17:53 +0200 Subject: [PATCH 13/33] showcase: a polygon button, with a hint --- .../showcase/src/components/Tools/ToolSelector/index.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/showcase/src/components/Tools/ToolSelector/index.tsx b/packages/showcase/src/components/Tools/ToolSelector/index.tsx index bc1efb8..36bafbd 100644 --- a/packages/showcase/src/components/Tools/ToolSelector/index.tsx +++ b/packages/showcase/src/components/Tools/ToolSelector/index.tsx @@ -6,6 +6,7 @@ import LineIcon from "@mui/icons-material/Straighten"; import ArrowIcon from "@mui/icons-material/NorthEast"; import RectangleIcon from "@mui/icons-material/CropSquare"; import RectangleLabelIcon from "@mui/icons-material/Label"; +import PolygonIcon from "@mui/icons-material/Pentagon"; import CircleIcon from "@mui/icons-material/RadioButtonUnchecked"; import PanIcon from "@mui/icons-material/PanTool"; import HighlighterIcon from "@mui/icons-material/Highlight"; @@ -32,6 +33,7 @@ const TOOLS = [ label: "Rectangle label", icon: , }, + { value: Types.Tool.POLYGON, label: "Polygon", icon: }, { value: Types.Tool.CIRCLE, label: "Circle", icon: }, { value: Types.Tool.PAN, label: "Pan", icon: }, { value: Types.Tool.HIGHLIGHTER, label: "Highlighter", icon: }, @@ -62,6 +64,12 @@ const ToolSelector = (props: ToolSelectorProps): ReactElement => { ))} + {props.selected === Types.Tool.POLYGON && ( + + Click to add the points. Click the first point or double-click to close the + polygon, or press Enter. Escape cancels it. + + )} {props.selected === Types.Tool.LASER_POINTER && ( Drag over the canvas to point at something. The line fades out and is not From 3fe6d32c451111508e88ec1a5443e290629e0558 Mon Sep 17 00:00:00 2001 From: tbolis Date: Wed, 28 Oct 2026 19:17:54 +0200 Subject: [PATCH 14/33] docs: write the polygon in the README and the 1.1.0 entry of the CHANGELOG --- CHANGELOG.md | 12 +++++++++- README.md | 41 ++++++++++++++++++++++++++++++++- packages/react-sketch/README.md | 2 +- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f598d5..f80d038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.1.0 (unreleased) -The eraser and laser pointer tools, and features that forks of react-sketch had, written again for the TypeScript code and fabric 7. +The eraser, laser pointer and polygon tools, and features that forks of react-sketch had, written again for the TypeScript code and fabric 7. ### Added @@ -36,6 +36,16 @@ The eraser and laser pointer tools, and features that forks of react-sketch had, is left of the line. - The showcase has a Laser pointer button in the tool selector. The laser pointer keeps its own line color and weight (red, 8px to start with), apart from the other tools. +- The polygon tool (#96): `tool="polygon"`, `Tools.Polygon` and `Types.Tool.POLYGON`. Each click or tap + places a vertex, where the pointer is released, and a line follows the pointer from the last one (on + touch, while the finger is down). The polygon is closed by a click or tap on the first vertex (within 10 + pixels, 20 on touch), by a double click or double tap, or by the Enter key, and the Escape key cancels + it. It needs three vertices. It takes `lineColor`, `lineWidth` and `fillColor` as the rectangle does. The + polygon in progress is only drawn on the top canvas: when it is closed it is one `fabric.Polygon` and one + undo step, `onObjectAdded` is called once with it, and `onChange` is called once, not for each vertex. Its + vertices are in `points` of the object. Switching to another tool keeps a polygon with three vertices and + drops one with fewer. +- The showcase has a Polygon button in the tool selector, with a hint of how to close the polygon. ### Changed diff --git a/README.md b/README.md index 0d22f11..cdfd1a2 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,7 @@ Use the tools by name (`"pencil"`), with `Tools` (`Tools.Pencil`) or with the en | Arrow | `Tools.Arrow` | `arrow` | Lines with an arrow head. | | Rectangle | `Tools.Rectangle` | `rectangle` | Rectangles, filled with `fillColor`. | | Rectangle label | `Tools.RectangleLabel` | `rectangle-label` | Rectangles with a text box above them. The text box follows the rectangle when it is moved or scaled. | +| Polygon | `Tools.Polygon` | `polygon` | Closed shapes of straight sides, filled with `fillColor`. Each click or tap is a vertex. See [Polygon](#polygon). | | Circle | `Tools.Circle` | `circle` | Circles, filled with `fillColor`. The drag from the first to the second point is the diameter. | | Pan | `Tools.Pan` | `pan` | Disables drawing. Dragging moves the whole canvas, useful after zooming (contributed by [wmaillard](https://github.com/wmaillard)). | | Highlighter | `Tools.Highlighter` | `highlighter` | Free drawing in a semi-transparent color. `lineColor` must be a hex code or a color name; any other value falls back to the default color. | @@ -301,6 +302,44 @@ Use the tools by name (`"pencil"`), with `Tools` (`Tools.Pencil`) or with the en | Laser pointer | `Tools.LaserPointer` | `laser-pointer` | A line that follows the pointer, fades out and disappears. It is not part of the drawing. See [Laser pointer](#laser-pointer). | | Default tool | `Tools.DefaultTool` | `default-tool` | Disables drawing and selection. Use it to show a drawing that is not edited. | +### Polygon + +Each click or tap places a vertex of the polygon, where the pointer is released, and a line follows the +pointer from the last vertex (with a finger, while it is down, so the vertex is where it is lifted). A +polygon needs three vertices, and it is closed in one of three ways, which do nothing with fewer: + +- a click or tap on the first vertex, within 10 pixels (20 with a finger), which is ringed when the pointer + is near enough; +- a double click or double tap, which closes with the vertices placed so far; +- the Enter key. + +The Escape key cancels the polygon in progress. The keys are listened to on the document only while a +polygon is in progress, and not when the focus is in a field to type in. + +The polygon in progress is drawn on the top canvas of fabric only. It is not an object of the sketch (it is +not in `toJSON()`, `toDataURL()` or the undo history) until it is closed. Then it is one `fabric.Polygon` +with the color (`lineColor`), the weight (`lineWidth`) and the fill (`fillColor`, transparent by default) of +the line, and it is one undo step: `onObjectAdded` is called once with the polygon as `target`, and +`onChange` is called once, not for each vertex. `onMouseDown` and `onMouseUp` are called for every click. +Switching to another tool closes a polygon which has three vertices and keeps it, and drops one with fewer. +Unmounting drops the polygon in progress. A polygon which crosses itself is drawn as it is. + +Once it is closed the polygon is a shape like the others: the select tool moves, scales and rotates it as a +whole, the eraser cuts it, and `toJSON()` and `fromJSON()` save and load it. The vertices are the `points` of +the object, in its own space, which is centered on `pathOffset`. To get them in canvas coordinates: + +```js +const matrix = polygon.calcTransformMatrix(); +const vertices = polygon.points.map(({ x, y }) => + fabric.util.transformPoint( + new fabric.Point(x - polygon.pathOffset.x, y - polygon.pathOffset.y), + matrix + ) +); +``` + +Moving the vertices of a polygon which is closed is not supported. + ### Eraser The eraser cuts the strokes it draws out of the objects it crosses: a line, a shape, an image or a text. @@ -323,7 +362,7 @@ not called when it is drawn. Switching to another tool clears what is left of th | Export | Description | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `SketchField` | The component (named export). Also usable as a type, for the `ref`. | -| `Tools` | The tools by name: `Select`, `Pencil`, `Line`, `Arrow`, `Rectangle`, `RectangleLabel`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`, `Eraser`, `LaserPointer`. | +| `Tools` | The tools by name: `Select`, `Pencil`, `Line`, `Arrow`, `Rectangle`, `RectangleLabel`, `Polygon`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`, `Eraser`, `LaserPointer`. | | `Types` | Namespace of the types: `Tool` (enumeration), `Tools`, `SketchProperties`, `SketchEvent`, `SketchObject`, `SketchValue`, `SketchData`, `DataUrlOptions`, `ImageOptions`, `BackgroundOptions`, `TextOptions`. | | default export | The component, which also carries `SketchField` and `Tools` (as in 0.5.x). | diff --git a/packages/react-sketch/README.md b/packages/react-sketch/README.md index 27119d8..4daa810 100644 --- a/packages/react-sketch/README.md +++ b/packages/react-sketch/README.md @@ -45,7 +45,7 @@ export const Drawing = () => { ``` Exports: `SketchField` (the component), `Tools` (`Select`, `Pencil`, `Line`, `Arrow`, `Rectangle`, -`RectangleLabel`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`, `Eraser`, `LaserPointer`), `Types` (namespace with the types and +`RectangleLabel`, `Polygon`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`, `Eraser`, `LaserPointer`), `Types` (namespace with the types and the `Tool` enumeration) and a default export, which is the component with `SketchField` and `Tools` attached (as in 0.5.x). From 6d145fc43102334fc32a01c746b08d9d602696c0 Mon Sep 17 00:00:00 2001 From: tbolis Date: Wed, 28 Oct 2026 19:22:23 +0200 Subject: [PATCH 15/33] test: the polygon with the eraser, the select tool and the export, by what they do The eraser cuts the polygon, the cut is saved and undone. The select tool moves, scales and rotates it as a whole, by drags of the mouse. The export has nothing of the polygon in progress, and has the polygon once it is closed. --- .../tools/Polygon/Polygon.spec.tsx | 163 ++++++++++++++++-- 1 file changed, 144 insertions(+), 19 deletions(-) diff --git a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx index 306993b..18260ab 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx +++ b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx @@ -1,6 +1,7 @@ import { createRef } from "react"; import { act, render, waitFor } from "@testing-library/react"; import * as fabric from "fabric"; +import { EraserBrush } from "@react-sketch/eraser"; import SketchField from "../../SketchField"; import { SketchProperties, Tool, Tools } from "../../../../types"; @@ -102,6 +103,57 @@ const keydown = (key: string, target: EventTarget = document) => { return event; }; +/** Press the mouse at `from`, drag it to `to` and release it, on the canvas */ +const drag = (canvas: fabric.Canvas, from: Point, to: Point) => { + const middle: Point = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]; + canvas.upperCanvasEl.dispatchEvent(mouseEvent("mousedown", from)); + document.dispatchEvent(mouseEvent("mousemove", middle)); + document.dispatchEvent(mouseEvent("mousemove", to)); + document.dispatchEvent(mouseEvent("mouseup", to)); +}; + +/** Erase with one stroke from `from` to `to`, and wait for it to be committed */ +const erase = async (canvas: fabric.Canvas, from: Point, to: Point) => { + const brush = canvas.freeDrawingBrush as EraserBrush; + const event = (type: string) => ({ e: new MouseEvent(type) }) as never; + brush.onMouseDown(new fabric.Point(...from), event("mousedown")); + brush.onMouseMove(new fabric.Point(...to), event("mousemove")); + brush.onMouseUp(event("mouseup")); + await act(async () => { + await jest.advanceTimersByTimeAsync(20); + }); +}; + +/** + * What the export of the sketch draws: the canvas element which `toDataURL()` + * turns into the image, and the kinds of drawing it received. + */ +const exported = (sketch: SketchField): string[] => { + const spy = jest + .spyOn(HTMLCanvasElement.prototype, "toDataURL") + .mockImplementation(() => "data:,"); + let image: HTMLCanvasElement; + try { + sketch.toDataURL(); + [image] = spy.mock.contexts as HTMLCanvasElement[]; + } finally { + spy.mockRestore(); + } + const context = image.getContext("2d") as unknown as { + __getDrawCalls(): { type: string }[]; + }; + return context.__getDrawCalls().map(({ type }) => type); +}; + +/** The lengths of the sides of the polygon, which do not change when it is moved or rotated */ +const sides = (polygon: fabric.Polygon): number[] => { + const points = vertices(polygon); + return points.map(([x, y], i) => { + const [nx, ny] = points[(i + 1) % points.length]; + return Math.hypot(nx - x, ny - y); + }); +}; + /** Three vertices, a triangle which is not closed yet */ const triangle = (canvas: fabric.Canvas) => { click(canvas, [100, 100]); @@ -578,15 +630,21 @@ describe("Polygon", () => { expect(onChange).not.toHaveBeenCalled(); }); - it("is not in the export, and the preview is there after it", () => { + it("is not in the export until it is closed, and the preview is there after it", () => { const { sketch, canvas } = mount(); triangle(canvas); + hover(canvas, [150, 300]); - expect(() => sketch.toDataURL()).not.toThrow(); - + // Nothing is drawn but the background: an object is drawn from its own cache (drawImage) + const before = exported(sketch); + expect(before).not.toContain("drawImage"); + expect(before).not.toContain("stroke"); (canvas.contextTop.lineTo as jest.Mock).mockClear(); - hover(canvas, [150, 300]); - expect(drawn(canvas, "lineTo").at(-1)).toEqual([150, 300]); + hover(canvas, [160, 310]); + expect(drawn(canvas, "lineTo").at(-1)).toEqual([160, 310]); + + keydown("Enter"); + expect(exported(sketch)).toContain("drawImage"); }); it("is one undo step, which undo and redo take off and put back", () => { @@ -674,25 +732,92 @@ describe("Polygon", () => { expect(loaded.fill).toEqual("#00ff00"); }); - it("can be selected and moved as a whole, once the tool is another", () => { - const { canvas, rerender } = mount(); - triangle(canvas); - keydown("Enter"); - const [polygon] = polygons(canvas); - expect(polygon.selectable).toBe(false); + describe("with the select tool", () => { + /** A closed triangle, and the select tool in use */ + const closedTriangle = () => { + const view = mount(); + triangle(view.canvas); + keydown("Enter"); + view.rerender(); + return { ...view, polygon: polygons(view.canvas)[0] }; + }; - rerender(); + it("moves as a whole when it is dragged, and the move can be undone", () => { + const { sketch, canvas, polygon } = closedTriangle(); + const before = vertices(polygon); + + drag(canvas, [150, 140], [190, 160]); + + expect(canvas.getActiveObject()).toBe(polygon); + expect(vertices(polygon)).toEqual(before.map(([x, y]) => [x + 40, y + 20])); + sketch.undo(); + expect(vertices(polygon)).toEqual(before); + }); + + it("scales as a whole when a corner is dragged", () => { + const { canvas, polygon } = closedTriangle(); + drag(canvas, [150, 140], [150, 140]); + const before = sides(polygon); + const { x, y } = polygon.oCoords.br; + + drag(canvas, [x, y], [x + 60, y + 60]); + + const ratios = sides(polygon).map((side, i) => side / before[i]); + expect(ratios[0]).toBeGreaterThan(1.1); + ratios.forEach((ratio) => expect(ratio).toBeCloseTo(ratios[0], 1)); + }); - expect(polygon.selectable).toBe(true); - expect(polygon.evented).toBe(true); + it("rotates as a whole when its handle is dragged", () => { + const { canvas, polygon } = closedTriangle(); + drag(canvas, [150, 140], [150, 140]); + const before = sides(polygon); + const beforeVertices = vertices(polygon); + const center = polygon.getCenterPoint(); + const { x, y } = polygon.oCoords.mtr; + // The handle goes a quarter of a turn around the center + const to: Point = [center.x - (y - center.y), center.y + (x - center.x)]; + + drag(canvas, [x, y], to); + + expect(Math.abs(polygon.angle)).toBeGreaterThan(45); + expect(vertices(polygon)).not.toEqual(beforeVertices); + sides(polygon).forEach((side, i) => expect(side).toBeCloseTo(before[i], 0)); + }); }); - it("can be cut by the eraser", () => { - const { canvas } = mount(); - triangle(canvas); - keydown("Enter"); + describe("with the eraser", () => { + it("is cut where a stroke crosses it, and the cut is saved and can be undone", async () => { + const { sketch, canvas, rerender } = mount(); + triangle(canvas); + keydown("Enter"); + const [polygon] = polygons(canvas); + rerender(); + + await erase(canvas, [90, 130], [210, 130]); + + expect(polygon.clipPath?.type).toEqual("clipping"); + expect( + (sketch.toJSON().objects as { clipPath?: { type: string } }[])[0].clipPath + ?.type + ).toEqual("clipping"); + sketch.undo(); + expect(polygon.clipPath).toBeUndefined(); + expect(polygons(canvas)).toHaveLength(1); + sketch.redo(); + expect(polygon.clipPath?.type).toEqual("clipping"); + }); + + it("is not cut by a stroke which misses it", async () => { + const { canvas, rerender } = mount(); + triangle(canvas); + keydown("Enter"); + const [polygon] = polygons(canvas); + rerender(); - expect((polygons(canvas)[0] as { erasable?: boolean }).erasable).toBe(true); + await erase(canvas, [300, 400], [400, 400]); + + expect(polygon.clipPath).toBeUndefined(); + }); }); }); From 663adf147afca15ed845b1a93dc019fe051d12ff Mon Sep 17 00:00:00 2001 From: tbolis Date: Wed, 28 Oct 2026 19:24:04 +0200 Subject: [PATCH 16/33] showcase: the hint of the polygon is one line --- packages/showcase/src/components/Tools/ToolSelector/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/showcase/src/components/Tools/ToolSelector/index.tsx b/packages/showcase/src/components/Tools/ToolSelector/index.tsx index 36bafbd..8671791 100644 --- a/packages/showcase/src/components/Tools/ToolSelector/index.tsx +++ b/packages/showcase/src/components/Tools/ToolSelector/index.tsx @@ -66,8 +66,8 @@ const ToolSelector = (props: ToolSelectorProps): ReactElement => { {props.selected === Types.Tool.POLYGON && ( - Click to add the points. Click the first point or double-click to close the - polygon, or press Enter. Escape cancels it. + Click to add points. Close: first point, double-click or Enter. Cancel: + Esc. )} {props.selected === Types.Tool.LASER_POINTER && ( From 649d572679b2491cc7794e4a86446f4d3f57f7b1 Mon Sep 17 00:00:00 2001 From: tbolis Date: Wed, 28 Oct 2026 19:28:37 +0200 Subject: [PATCH 17/33] test: the polygon when the view of the canvas changes on the way The vertices are in canvas coordinates, so a zoom or a pan of the view in the middle of a polygon keeps the vertices placed, places the next ones in the new view, draws the preview in it, and keeps the radius of the first vertex in screen pixels. --- .../tools/Polygon/Polygon.spec.tsx | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx index 18260ab..a7e4f08 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx +++ b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx @@ -901,6 +901,100 @@ describe("Polygon", () => { }); }); + /** + * A pinch zoom moves the view of the canvas (its viewport transform), the + * objects stay where they are. The vertices are in canvas coordinates, so + * the view can change in the middle of a polygon. + */ + describe("when the view changes on the way", () => { + /** Two vertices at the first view, which are (100, 100) and (200, 100) */ + const started = () => { + const view = mount(); + click(view.canvas, [100, 100]); + click(view.canvas, [200, 100]); + return view; + }; + + it("keeps the vertices placed, and places the next in the new view, when it zooms", () => { + const { canvas } = started(); + + // The zoom is about the point (150, 100) of the canvas, which stays where it is on the screen + canvas.zoomToPoint(new fabric.Point(150, 100), 2); + canvas.renderAll(); + click(canvas, [150, 180]); + keydown("Enter"); + + expect(canvas.getZoom()).toEqual(2); + expect(vertices(polygons(canvas)[0])).toEqual([ + [100, 100], + [200, 100], + [150, 140], + ]); + }); + + it("keeps the vertices placed, and places the next in the new view, when it pans", () => { + const { canvas } = started(); + + canvas.relativePan(new fabric.Point(30, 40)); + canvas.renderAll(); + click(canvas, [150, 180]); + keydown("Enter"); + + expect(vertices(polygons(canvas)[0])).toEqual([ + [100, 100], + [200, 100], + [120, 140], + ]); + }); + + it("draws the preview in the new view, from the same vertices", () => { + const { canvas } = started(); + (canvas.contextTop.lineTo as jest.Mock).mockClear(); + + canvas.zoomToPoint(new fabric.Point(150, 100), 2); + canvas.renderAll(); + + // The sides keep their canvas coordinates, and the context carries the zoom + expect(drawn(canvas, "lineTo").at(-1)).toEqual([200, 100]); + expect((canvas.contextTop.transform as jest.Mock).mock.calls.at(-1)).toEqual([ + 2, 0, 0, 2, -150, -100, + ]); + }); + + it("follows the pointer in the new view", () => { + const { canvas } = started(); + canvas.zoomToPoint(new fabric.Point(150, 100), 2); + canvas.renderAll(); + (canvas.contextTop.lineTo as jest.Mock).mockClear(); + + hover(canvas, [150, 180]); + + expect(drawn(canvas, "lineTo").slice(-2)).toEqual([ + [200, 100], + [150, 140], + ]); + }); + + it("closes by the first vertex within 10 screen pixels of it in the new view", () => { + const { canvas } = started(); + click(canvas, [150, 180]); + + canvas.setZoom(2); + canvas.renderAll(); + // The first vertex is at (200, 200) of the screen now + click(canvas, [200, 212]); + expect(polygons(canvas)).toHaveLength(0); + click(canvas, [200, 192]); + + expect(vertices(polygons(canvas)[0])).toEqual([ + [100, 100], + [200, 100], + [150, 180], + [100, 106], + ]); + }); + }); + describe("a second finger", () => { // The canvas does not pass its events on, so it is not a vertex nor a pointer it("is not a pointer of the polygon", () => { From 2aeaad1fb0e3d034cdfcc9b9bc5eda3190e44cde Mon Sep 17 00:00:00 2001 From: tbolis Date: Wed, 28 Oct 2026 19:32:07 +0200 Subject: [PATCH 18/33] feat: the polygon in progress follows zoom() and the resize of the canvas zoom() and the resize of the canvas scale the objects, so they scale the vertices of the polygon in progress too, through the new scale() hook of the tools. autoresize() returns the factors it scaled by. --- CHANGELOG.md | 3 +- README.md | 3 +- .../components/SketchField/SketchField.tsx | 9 +- .../src/components/SketchField/resize.ts | 6 +- .../tools/Polygon/Polygon.spec.tsx | 97 +++++++++++++++++++ .../SketchField/tools/Polygon/Polygon.ts | 10 ++ .../src/components/SketchField/tools/index.ts | 6 ++ 7 files changed, 129 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f80d038..ffec6fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,8 @@ The eraser, laser pointer and polygon tools, and features that forks of react-sk polygon in progress is only drawn on the top canvas: when it is closed it is one `fabric.Polygon` and one undo step, `onObjectAdded` is called once with it, and `onChange` is called once, not for each vertex. Its vertices are in `points` of the object. Switching to another tool keeps a polygon with three vertices and - drops one with fewer. + drops one with fewer, and `zoom()` and the resize of the canvas scale the polygon in progress with the + objects. - The showcase has a Polygon button in the tool selector, with a hint of how to close the polygon. ### Changed diff --git a/README.md b/README.md index cdfd1a2..229181e 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,8 @@ with the color (`lineColor`), the weight (`lineWidth`) and the fill (`fillColor` the line, and it is one undo step: `onObjectAdded` is called once with the polygon as `target`, and `onChange` is called once, not for each vertex. `onMouseDown` and `onMouseUp` are called for every click. Switching to another tool closes a polygon which has three vertices and keeps it, and drops one with fewer. -Unmounting drops the polygon in progress. A polygon which crosses itself is drawn as it is. +Unmounting drops the polygon in progress. `zoom()` and the resize of the canvas scale the polygon in progress +with the objects. A polygon which crosses itself is drawn as it is. Once it is closed the polygon is a shape like the others: the select tool moves, scales and rotates it as a whole, the eraser cuts it, and `toJSON()` and `fromJSON()` save and load it. The vertices are the `points` of diff --git a/packages/react-sketch/src/components/SketchField/SketchField.tsx b/packages/react-sketch/src/components/SketchField/SketchField.tsx index ca2e29a..497d3b1 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.tsx @@ -339,7 +339,13 @@ class SketchField extends PureComponent { const container = this.containerEl.current; if (!container) return; const { widthCorrection, heightCorrection } = this.props; - autoresize(this.canvas, container, widthCorrection, heightCorrection); + const factors = autoresize( + this.canvas, + container, + widthCorrection, + heightCorrection + ); + if (factors) this.tool.scale(factors.x, factors.y); } /** @@ -426,6 +432,7 @@ class SketchField extends PureComponent { */ zoom = (factor: number): void => { scaleContent(this.canvas, factor, factor); + this.tool.scale(factor, factor); this.canvas.renderAll(); this.canvas.calcOffset(); }; diff --git a/packages/react-sketch/src/components/SketchField/resize.ts b/packages/react-sketch/src/components/SketchField/resize.ts index 021a1da..e9065cd 100644 --- a/packages/react-sketch/src/components/SketchField/resize.ts +++ b/packages/react-sketch/src/components/SketchField/resize.ts @@ -35,18 +35,19 @@ export const scaleContent = ( * @param container the HTMLElement we want to follow the size of * @param widthCorrection pixels subtracted from the width of the container * @param heightCorrection pixels subtracted from the height of the container + * @returns the factors the content was scaled by, undefined when nothing was done */ export const autoresize = ( canvas: fabric.Canvas, container: HTMLElement, widthCorrection = 0, heightCorrection = 0 -): void => { +): { x: number; y: number } | undefined => { const width = container.offsetWidth - widthCorrection; const height = container.clientHeight - heightCorrection; const prevWidth = canvas.getWidth(); const prevHeight = canvas.getHeight(); - if (width <= 0 || height <= 0 || !prevWidth || !prevHeight) return; + if (width <= 0 || height <= 0 || !prevWidth || !prevHeight) return undefined; const wfactor = parseFloat((width / prevWidth).toFixed(2)); const hfactor = parseFloat((height / prevHeight).toFixed(2)); @@ -55,4 +56,5 @@ export const autoresize = ( scaleContent(canvas, wfactor, hfactor); canvas.renderAll(); canvas.calcOffset(); + return { x: wfactor, y: hfactor }; }; diff --git a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx index a7e4f08..1bb2140 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx +++ b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx @@ -154,6 +154,16 @@ const sides = (polygon: fabric.Polygon): number[] => { }); }; +/** The spies of the layout of the container, which the test restores when it ends */ +const layouts: jest.SpyInstance[] = []; + +/** What the browser would lay out for the container of the sketch */ +const layout = (width: number, height: number) => + layouts.push( + jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(width), + jest.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(height) + ); + /** Three vertices, a triangle which is not closed yet */ const triangle = (canvas: fabric.Canvas) => { click(canvas, [100, 100]); @@ -168,6 +178,7 @@ describe("Polygon", () => { afterEach(() => { jest.useRealTimers(); + layouts.splice(0).forEach((spy) => spy.mockRestore()); }); it("is a tool of the component, with the legacy name", () => { @@ -995,6 +1006,92 @@ describe("Polygon", () => { }); }); + /** + * `zoom()` and the resize of the canvas scale the objects, so they scale the + * polygon in progress too: it is closed where the objects of the sketch are. + */ + describe("when the sketch is scaled on the way", () => { + /** A polygon which is closed, and another one with two vertices placed */ + const started = () => { + const view = mount(); + triangle(view.canvas); + keydown("Enter"); + click(view.canvas, [300, 300]); + click(view.canvas, [400, 300]); + return view; + }; + + it("follows the objects when zoom() is called", () => { + const { sketch, canvas } = started(); + + sketch.zoom(2); + click(canvas, [700, 640]); + keydown("Enter"); + + const [closed, other] = polygons(canvas); + expect(vertices(closed)).toEqual([ + [200, 200], + [400, 200], + [300, 360], + ]); + expect(vertices(other)).toEqual([ + [600, 600], + [800, 600], + [700, 640], + ]); + }); + + it("draws the preview at the new size", () => { + const { sketch, canvas } = started(); + hover(canvas, [350, 350]); + (canvas.contextTop.lineTo as jest.Mock).mockClear(); + + sketch.zoom(2); + + expect(drawn(canvas, "lineTo").slice(-2)).toEqual([ + [800, 600], + [700, 700], + ]); + }); + + it("follows the objects when the canvas is resized with the window", () => { + const { canvas } = started(); + // The container is twice as wide and three times as high + layout(canvas.getWidth() * 2, canvas.getHeight() * 3); + + window.dispatchEvent(new Event("resize")); + click(canvas, [700, 1500]); + keydown("Enter"); + + const [closed, other] = polygons(canvas); + expect(vertices(closed)).toEqual([ + [200, 300], + [400, 300], + [300, 540], + ]); + expect(vertices(other)).toEqual([ + [600, 900], + [800, 900], + [700, 1500], + ]); + }); + + it("is left alone by a resize which changes nothing", () => { + const { canvas } = started(); + + // The container has no size to fit to, as when it is hidden + window.dispatchEvent(new Event("resize")); + click(canvas, [350, 380]); + keydown("Enter"); + + expect(vertices(polygons(canvas)[1])).toEqual([ + [300, 300], + [400, 300], + [350, 380], + ]); + }); + }); + describe("a second finger", () => { // The canvas does not pass its events on, so it is not a vertex nor a pointer it("is not a pointer of the polygon", () => { diff --git a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts index 273d004..4a9b5a4 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts @@ -121,6 +121,16 @@ export class Polygon extends FabricCanvasTool { this.paint(); } + /** The vertices follow the objects when the sketch is scaled (zoom, resize) */ + scale(xFactor: number, yFactor: number): void { + const scaled = ({ x, y }: fabric.Point) => + new fabric.Point(x * xFactor, y * yFactor); + this.vertices = this.vertices.map(scaled); + if (this.pointerAt) this.pointerAt = scaled(this.pointerAt); + this.lastRelease = undefined; + this.paint(); + } + /** Another tool takes over: the polygon is kept when it has the vertices for one */ finish(): void { if (!this.close()) this.discard(); diff --git a/packages/react-sketch/src/components/SketchField/tools/index.ts b/packages/react-sketch/src/components/SketchField/tools/index.ts index 34852cf..cca977a 100644 --- a/packages/react-sketch/src/components/SketchField/tools/index.ts +++ b/packages/react-sketch/src/components/SketchField/tools/index.ts @@ -90,6 +90,12 @@ export abstract class FabricCanvasTool { */ dispose(): void {} + /** + * Called when the sketch scales its objects (`zoom()`, and the resize of the + * canvas), for the tools which hold coordinates of their own + */ + scale(xFactor: number, yFactor: number): void {} + /** * Whether the application is told about a change (`onChange`) when the * mouse is released or leaves the canvas. The tools which do not change the From 239e71ef276086a3c4f972af9e7c6cbad1be81ab Mon Sep 17 00:00:00 2001 From: tbolis Date: Wed, 28 Oct 2026 19:35:24 +0200 Subject: [PATCH 19/33] showcase: the hint of the polygon is one line at any width The visible hint is short enough for the narrowest layout (a container of 228 pixels), and the button has the full instructions as its tooltip. --- .../src/components/Tools/ToolSelector/index.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/showcase/src/components/Tools/ToolSelector/index.tsx b/packages/showcase/src/components/Tools/ToolSelector/index.tsx index 8671791..2e6514f 100644 --- a/packages/showcase/src/components/Tools/ToolSelector/index.tsx +++ b/packages/showcase/src/components/Tools/ToolSelector/index.tsx @@ -33,7 +33,13 @@ const TOOLS = [ label: "Rectangle label", icon: , }, - { value: Types.Tool.POLYGON, label: "Polygon", icon: }, + { + value: Types.Tool.POLYGON, + label: "Polygon", + title: + "Polygon: click to add points, close it with the first point, a double click or Enter, Esc cancels", + icon: , + }, { value: Types.Tool.CIRCLE, label: "Circle", icon: }, { value: Types.Tool.PAN, label: "Pan", icon: }, { value: Types.Tool.HIGHLIGHTER, label: "Highlighter", icon: }, @@ -59,15 +65,14 @@ const ToolSelector = (props: ToolSelectorProps): ReactElement => { key={tool.value} value={tool.value} aria-label={tool.label} - title={tool.label}> + title={tool.title ?? tool.label}> {tool.icon} ))} {props.selected === Types.Tool.POLYGON && ( - Click to add points. Close: first point, double-click or Enter. Cancel: - Esc. + Click points. First point closes. )} {props.selected === Types.Tool.LASER_POINTER && ( From ff76ca1799e5aec006977a362b679d45211f52da Mon Sep 17 00:00:00 2001 From: tbolis Date: Wed, 28 Oct 2026 20:20:10 +0200 Subject: [PATCH 20/33] ci: run on Node.js 26 too, with 22 and 24 engines already allows Node.js 26. The README and the 1.1.0 entry of the CHANGELOG say which versions the CI runs on. --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 1 + README.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c027fd5..5e2ff8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - node: [22, 24] + node: [22, 24, 26] env: HUSKY: "0" steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f598d5..4f7ea3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ The eraser and laser pointer tools, and features that forks of react-sketch had, - The laser pointer brush is in the private package `packages/laserpointer` (`@react-sketch/laserpointer`), which `react-sketch` bundles too. `yarn build:packages` builds both private packages, and `yarn typecheck`, `yarn build` and `yarn test` run it first. +- CI also runs on Node.js 26, with 22 and 24 (`engines` already allowed it). ## 1.0.1 (unreleased) diff --git a/README.md b/README.md index 0d22f11..40ab645 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,7 @@ not called when it is drawn. Switching to another tool clears what is left of th - React: `^17.0.0 || ^18.0.0 || ^19.0.0` (peer dependency). The test suite runs on React 19. - Browsers: browsers with HTML5 canvas support. No polyfills are included and Internet Explorer 11 is not supported. - Server-side rendering has not been tested. Render the component in the browser only. -- Node.js: the package has no runtime requirement for Node.js. Developing in this repository requires Node.js 22.22.1 or later (`.nvmrc` has 24) and Yarn 1. +- Node.js: the package has no runtime requirement for Node.js. Developing in this repository requires Node.js 22.22.1 or later (`.nvmrc` has 24) and Yarn 1. The CI runs the checks and the tests on Node.js 22, 24 and 26. ## Repository layout From 067410ca866bbbeb312cf3de88b1904b598d7a05 Mon Sep 17 00:00:00 2001 From: tbolis Date: Thu, 29 Oct 2026 18:17:53 +0200 Subject: [PATCH 21/33] fix: polygon review fixes - The first click after a polygon is closed or cancelled is a vertex, it was dropped as the second click of a double click. - A double click needs the two releases within 4 pixels (10 on touch), so two vertices placed quickly near one another do not close the polygon. - The polygon in progress is dropped when the sketch is cleared, a value is loaded, or an undo or a redo is done, through the new reset() hook of the tools. - The keys are taken when the focus is on the page or in the sketch, not on a button or a dialog. Of two sketches, the one pressed last takes them. --- README.md | 8 +- .../components/SketchField/SketchField.tsx | 15 +- .../tools/Polygon/Polygon.spec.tsx | 169 ++++++++++++++++++ .../SketchField/tools/Polygon/Polygon.ts | 63 +++++-- .../src/components/SketchField/tools/index.ts | 7 + 5 files changed, 241 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 229181e..d34d5f5 100644 --- a/README.md +++ b/README.md @@ -310,11 +310,15 @@ polygon needs three vertices, and it is closed in one of three ways, which do no - a click or tap on the first vertex, within 10 pixels (20 with a finger), which is ringed when the pointer is near enough; -- a double click or double tap, which closes with the vertices placed so far; +- a double click or double tap (two releases within 400 ms and 4 pixels, 10 with a finger), which closes with + the vertices placed so far; - the Enter key. The Escape key cancels the polygon in progress. The keys are listened to on the document only while a -polygon is in progress, and not when the focus is in a field to type in. +polygon is in progress, and only when the focus is on the page or in the sketch: a button, a field or a +dialog which has the focus keeps its keys. With more than one sketch on a page, the keys go to the one which +was pressed last. A polygon in progress is dropped when the sketch is cleared, when a value is loaded, and +when an undo or a redo is done. The polygon in progress is drawn on the top canvas of fabric only. It is not an object of the sketch (it is not in `toJSON()`, `toDataURL()` or the undo history) until it is closed. Then it is one `fabric.Polygon` diff --git a/packages/react-sketch/src/components/SketchField/SketchField.tsx b/packages/react-sketch/src/components/SketchField/SketchField.tsx index 497d3b1..501aac1 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.tsx @@ -224,7 +224,10 @@ class SketchField extends PureComponent { this.history = new SketchHistory(this.canvas, undoSteps); // Loading a value clears the canvas first: the history starts again with the // loaded objects, and only when the load works - this.canvas.onClear = () => this.history.clear(); + this.canvas.onClear = () => { + this.history.clear(); + this.tool.reset(); + }; // Bind Events to Actions this._bind_canvas_events( @@ -441,14 +444,20 @@ class SketchField extends PureComponent { * Perform an undo operation on canvas, if it cannot undo it will leave the canvas intact */ undo = (): void => { - if (this.history.undo()) this.props.onChange?.(); + if (this.history.undo()) { + this.tool.reset(); + this.props.onChange?.(); + } }; /** * Perform a redo operation on canvas, if it cannot redo it will leave the canvas intact */ redo = (): void => { - if (this.history.redo()) this.props.onChange?.(); + if (this.history.redo()) { + this.tool.reset(); + this.props.onChange?.(); + } }; /** diff --git a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx index 1bb2140..879e096 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx +++ b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx @@ -444,6 +444,25 @@ describe("Polygon", () => { expect(polygons(canvas)).toHaveLength(0); }); + it("is not by two vertices placed quickly, near one another", () => { + const { canvas } = mount(); + triangle(canvas); + + click(canvas, [250, 250]); + wait(100); + canvas.upperCanvasEl.dispatchEvent(mouseEvent("mousedown", [258, 250])); + document.dispatchEvent(mouseEvent("mouseup", [258, 250])); + keydown("Enter"); + + expect(vertices(polygons(canvas)[0])).toEqual([ + [100, 100], + [200, 100], + [150, 180], + [250, 250], + [258, 250], + ]); + }); + it("does nothing with fewer than three vertices", () => { const { canvas } = mount(); click(canvas, [100, 100]); @@ -533,6 +552,43 @@ describe("Polygon", () => { }); }); + describe("the first click of the next polygon", () => { + it("is a vertex, also right after a polygon was closed where it is", () => { + const { canvas } = mount(); + triangle(canvas); + click(canvas, [101, 101]); + expect(polygons(canvas)).toHaveLength(1); + + wait(100); + canvas.upperCanvasEl.dispatchEvent(mouseEvent("mousedown", [101, 101])); + document.dispatchEvent(mouseEvent("mouseup", [101, 101])); + click(canvas, [300, 100]); + click(canvas, [300, 200]); + keydown("Enter"); + + expect(polygons(canvas)).toHaveLength(2); + expect(vertices(polygons(canvas)[1])).toEqual([ + [101, 101], + [300, 100], + [300, 200], + ]); + }); + + it("is a vertex, also right after a polygon was cancelled", () => { + const { canvas } = mount(); + triangle(canvas); + keydown("Escape"); + + canvas.upperCanvasEl.dispatchEvent(mouseEvent("mousedown", [150, 180])); + document.dispatchEvent(mouseEvent("mouseup", [150, 180])); + click(canvas, [300, 100]); + click(canvas, [300, 200]); + keydown("Enter"); + + expect(vertices(polygons(canvas)[0])[0]).toEqual([150, 180]); + }); + }); + describe("keys", () => { it("are listened to only while a polygon is in progress", () => { const { canvas } = mount(); @@ -564,6 +620,119 @@ describe("Polygon", () => { expect(polygons(canvas)).toHaveLength(1); input.remove(); }); + + it("are left to a button the focus is on", () => { + const { canvas } = mount(); + const button = document.body.appendChild(document.createElement("button")); + triangle(canvas); + + const enter = keydown("Enter", button); + keydown("Escape", button); + + expect(polygons(canvas)).toHaveLength(0); + expect(enter.defaultPrevented).toBe(false); + keydown("Enter"); + expect(polygons(canvas)).toHaveLength(1); + button.remove(); + }); + + it("are taken when the focus is on the page, or in the sketch", () => { + const { canvas } = mount(); + triangle(canvas); + keydown("Enter", document.body); + expect(polygons(canvas)).toHaveLength(1); + + triangle(canvas); + keydown("Enter", canvas.wrapperEl); + expect(polygons(canvas)).toHaveLength(2); + }); + + it("are taken by the sketch which was pressed last, of two", () => { + const first = mount(); + const second = mount(); + triangle(first.canvas); + triangle(second.canvas); + + keydown("Enter"); + expect(polygons(second.canvas)).toHaveLength(1); + expect(polygons(first.canvas)).toHaveLength(0); + + // The first is the one pressed now + click(first.canvas, [300, 300]); + keydown("Escape"); + expect(polygons(first.canvas)).toHaveLength(0); + first.unmount(); + second.unmount(); + }); + }); + + describe("when the content of the canvas is replaced", () => { + /** A polygon in progress, which the user has not closed */ + const inProgress = (canvas: fabric.Canvas) => { + triangle(canvas); + keydown("Enter"); + click(canvas, [300, 100]); + click(canvas, [350, 100]); + click(canvas, [320, 160]); + }; + + it("drops the polygon in progress when the sketch is cleared", () => { + const { sketch, canvas } = mount(); + inProgress(canvas); + + sketch.clear(); + const enter = keydown("Enter"); + + expect(polygons(canvas)).toHaveLength(0); + expect(enter.defaultPrevented).toBe(false); + click(canvas, [100, 100]); + click(canvas, [200, 100]); + keydown("Enter"); + expect(polygons(canvas)).toHaveLength(0); + }); + + it("drops the polygon in progress when a value is loaded", async () => { + const { sketch, canvas } = mount(); + triangle(canvas); + keydown("Enter"); + const value = sketch.toJSON(); + click(canvas, [300, 100]); + click(canvas, [350, 100]); + click(canvas, [320, 160]); + + sketch.fromJSON(value); + await act(async () => { + await jest.advanceTimersByTimeAsync(200); + }); + keydown("Enter"); + + expect(polygons(canvas)).toHaveLength(1); + }); + + it("drops the polygon in progress when an undo or a redo is done", () => { + const { sketch, canvas } = mount(); + inProgress(canvas); + sketch.undo(); + keydown("Enter"); + expect(polygons(canvas)).toHaveLength(0); + + inProgress(canvas); + keydown("Escape"); + sketch.redo(); + keydown("Enter"); + expect(polygons(canvas)).toHaveLength(1); + }); + + it("keeps the polygon in progress when there is nothing to undo or redo", () => { + const { sketch, canvas } = mount(); + triangle(canvas); + + sketch.undo(); + sketch.redo(); + keydown("Enter"); + + expect(polygons(canvas)).toHaveLength(1); + }); }); describe("the polygon", () => { diff --git a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts index 4a9b5a4..c55c3c4 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts @@ -10,18 +10,20 @@ const MIN_VERTICES = 3; const CLOSE_RADIUS = 10; const CLOSE_RADIUS_TOUCH = 20; -/** Two releases this close in time (milliseconds) and in space (the radius above) are a double click */ +/** + * Two releases this close in time (milliseconds) and in space (screen pixels) + * are a double click. The radius is smaller than the one which closes the + * polygon, so that two vertices placed quickly, near one another, are not one. + */ const DOUBLE_CLICK_TIME = 400; +const DOUBLE_CLICK_RADIUS = 4; +const DOUBLE_CLICK_RADIUS_TOUCH = 10; -/** Whether the element is one the user types in, which keeps its keys */ -const isEditable = (target: EventTarget | null): boolean => { - const element = target as HTMLElement | null; - return ( - !!element && - (element.isContentEditable || - ["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName)) - ); -}; +/** + * The canvas the pointer was last pressed on. Its polygon takes the keys when + * the focus is on no element, so that of two sketches on a page only one does. + */ +let pointed: fabric.Canvas | null = null; /** * Draws a closed shape of straight sides: each click or tap places a vertex. @@ -72,6 +74,7 @@ export class Polygon extends FabricCanvasTool { } doMouseDown(event: CanvasEvent): void { + pointed = this.canvas; this.isDown = true; this.isTouch = this.touch(event); this.pointerAt = this.point(event); @@ -100,7 +103,11 @@ export class Polygon extends FabricCanvasTool { const doubleClick = !!this.lastRelease && now - this.lastRelease.time < DOUBLE_CLICK_TIME && - this.isNear(point, this.lastRelease.point); + this.isNear( + point, + this.lastRelease.point, + this.reach(DOUBLE_CLICK_RADIUS, DOUBLE_CLICK_RADIUS_TOUCH) + ); this.lastRelease = { time: now, point }; // The first click of a double click has placed its vertex, the second is not another one @@ -136,10 +143,14 @@ export class Polygon extends FabricCanvasTool { if (!this.close()) this.discard(); } + /** The content of the canvas is replaced: the polygon in progress is dropped */ + reset(): void { + this.discard(); + } + /** The polygon in progress is dropped, a closed one stays in the sketch */ dispose(): void { this.discard(); - this.lastRelease = undefined; } /** @@ -169,6 +180,9 @@ export class Polygon extends FabricCanvasTool { /** Forget the polygon in progress and take it off the top canvas */ private discard(): void { + // The next click does not follow a click of the polygon which is gone + this.lastRelease = undefined; + if (pointed === this.canvas) pointed = null; if (!this.vertices.length) return; this.vertices = []; this.pointerAt = null; @@ -189,8 +203,20 @@ export class Polygon extends FabricCanvasTool { return this.canvas.upperCanvasEl.ownerDocument; } + /** + * Whether the key is for the polygon: not when the focus is on a button, a + * field or a dialog, which keep their keys. The canvas is not one to take + * the focus, so the focus is on no element (the page) after a click on it. + */ + private takesKey(target: EventTarget | null): boolean { + const { body, documentElement } = this.keyTarget; + if (target === this.keyTarget || target === body || target === documentElement) + return pointed === this.canvas; + return this.canvas.wrapperEl.contains(target as Node | null); + } + private readonly onKeyDown = (event: KeyboardEvent): void => { - if (isEditable(event.target)) return; + if (!this.takesKey(event.target)) return; if (event.key === "Escape") { event.preventDefault(); this.discard(); @@ -216,12 +242,17 @@ export class Polygon extends FabricCanvasTool { return 1 / this.canvas.getZoom(); } + /** A radius in screen pixels, in canvas coordinates: the one for a finger when the last press was one */ + private reach(mouse: number, touch: number): number { + return (this.isTouch ? touch : mouse) * this.pixel; + } + private get radius(): number { - return (this.isTouch ? CLOSE_RADIUS_TOUCH : CLOSE_RADIUS) * this.pixel; + return this.reach(CLOSE_RADIUS, CLOSE_RADIUS_TOUCH); } - private isNear(a: fabric.Point, b: fabric.Point): boolean { - return a.distanceFrom(b) <= this.radius; + private isNear(a: fabric.Point, b: fabric.Point, radius = this.radius): boolean { + return a.distanceFrom(b) <= radius; } /** Whether a click where the pointer is would close the polygon */ diff --git a/packages/react-sketch/src/components/SketchField/tools/index.ts b/packages/react-sketch/src/components/SketchField/tools/index.ts index cca977a..3142a0b 100644 --- a/packages/react-sketch/src/components/SketchField/tools/index.ts +++ b/packages/react-sketch/src/components/SketchField/tools/index.ts @@ -90,6 +90,13 @@ export abstract class FabricCanvasTool { */ dispose(): void {} + /** + * Called when the content of the canvas is replaced (it is cleared, a value + * is loaded, an undo or a redo is done), for the tools which hold something + * drawn on it + */ + reset(): void {} + /** * Called when the sketch scales its objects (`zoom()`, and the resize of the * canvas), for the tools which hold coordinates of their own From 84949476e875df3f29783fe653ea0cb24df6858d Mon Sep 17 00:00:00 2001 From: tbolis Date: Fri, 30 Oct 2026 10:31:42 +0200 Subject: [PATCH 22/33] fix: an arrow which is drawn is not selectable The group of the line and the head was selectable and evented, as a group is unless it says not. A press on an earlier arrow selected it and a drag moved it, so an arrow could not be drawn over or across another one. The group is frozen as the other shapes are, and the select tool makes it selectable again. --- CHANGELOG.md | 6 ++ .../SketchField/tools/Arrow/Arrow.spec.tsx | 89 +++++++++++++++++++ .../SketchField/tools/Arrow/Arrow.ts | 6 +- 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.spec.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 646b85e..6b6694c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,12 @@ The eraser, laser pointer and polygon tools, and features that forks of react-sk - `clear()` also removes the overlay image. - When several images are asked for the overlay (or the background), only the last request is set. +### Fixed + +- The arrow tool: an arrow which is drawn is not selectable, as the other shapes are. A press on an earlier + arrow selected it and a drag moved it, instead of drawing the new arrow, so an arrow could not be drawn + over or across another one. + ### Packaging and tooling - The code of `@erase2d/fabric` 1.2.2 is in the private package `packages/eraser` diff --git a/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.spec.tsx b/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.spec.tsx new file mode 100644 index 0000000..001385e --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.spec.tsx @@ -0,0 +1,89 @@ +import { createRef } from "react"; +import { render } from "@testing-library/react"; +import * as fabric from "fabric"; +import SketchField from "../../SketchField"; +import { SketchProperties, Tool } from "../../../../types"; + +type Point = [number, number]; + +const mount = (props: SketchProperties = {}) => { + const ref = createRef(); + const view = render(); + const sketch = ref.current as SketchField; + return { ...view, sketch, canvas: sketch["canvas"] as fabric.Canvas }; +}; + +const mouseEvent = (type: string, [x, y]: Point): MouseEvent => + new MouseEvent(type, { + clientX: x, + clientY: y, + buttons: type === "mouseup" ? 0 : 1, + bubbles: true, + cancelable: true, + }); + +/** Press the mouse at `from`, drag it to `to` and release it, as the browser tells the canvas */ +const drag = (canvas: fabric.Canvas, from: Point, to: Point) => { + const middle: Point = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]; + canvas.upperCanvasEl.dispatchEvent(mouseEvent("mousedown", from)); + document.dispatchEvent(mouseEvent("mousemove", middle)); + document.dispatchEvent(mouseEvent("mousemove", to)); + document.dispatchEvent(mouseEvent("mouseup", to)); +}; + +const arrows = (canvas: fabric.Canvas) => + canvas.getObjects().filter((o): o is fabric.Group => o instanceof fabric.Group); + +/** Where the arrow is: the line and the head move with the group */ +const position = (arrow: fabric.Group) => [arrow.left, arrow.top]; + +describe("Arrow", () => { + it("is frozen once it is drawn, like the other shapes: it cannot be selected or moved", () => { + const { canvas } = mount(); + drag(canvas, [100, 100], [300, 100]); + + const [arrow] = arrows(canvas); + expect(arrow.selectable).toBe(false); + expect(arrow.evented).toBe(false); + }); + + it("does not select or move an arrow which a new arrow starts on", () => { + const { canvas } = mount(); + drag(canvas, [100, 100], [300, 100]); + const [first] = arrows(canvas); + const before = position(first); + + drag(canvas, [200, 100], [200, 300]); + + expect(canvas.getActiveObject()).toBeUndefined(); + expect(position(first)).toEqual(before); + }); + + it("can start on a previous arrow and cross it", () => { + const { canvas } = mount(); + drag(canvas, [100, 100], [300, 100]); + const [first] = arrows(canvas); + const before = position(first); + + drag(canvas, [200, 50], [200, 150]); + drag(canvas, [150, 100], [250, 100]); + + const drawn = arrows(canvas); + expect(drawn).toHaveLength(3); + expect(position(first)).toEqual(before); + const [, second, third] = drawn; + expect(second.getBoundingRect().height).toBeGreaterThan(90); + expect(third.getBoundingRect().width).toBeGreaterThan(90); + }); + + it("can be selected and moved again with the select tool", () => { + const { canvas, rerender, sketch } = mount(); + drag(canvas, [100, 100], [300, 100]); + const [arrow] = arrows(canvas); + + rerender(); + + expect(arrow.selectable).toBe(true); + expect(arrow.evented).toBe(true); + }); +}); diff --git a/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts b/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts index 418691f..0de822d 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts @@ -82,11 +82,15 @@ export class Arrow extends FabricCanvasTool { this.isDown = false; if (!this.line || !this.head) return; this.canvas.remove(this.line, this.head); - // fabric 7 centers groups by default, the arrow keeps its top left origin + // fabric 7 centers groups by default, the arrow keeps its top left origin. + // A group is selectable and evented unless it says not: a press on it + // would select it and a drag would move it, instead of drawing the next arrow this.canvas.add( new fabric.Group([this.line, this.head], { originX: "left", originY: "top", + selectable: false, + evented: false, }) ); this.line = this.head = null; From 1db84de08a235373cb7a71f20affd5c2b54b5470 Mon Sep 17 00:00:00 2001 From: tbolis Date: Fri, 30 Oct 2026 16:14:03 +0200 Subject: [PATCH 23/33] fix: an arrow is one undo step The line and the head which are shown while the arrow is drawn were recorded in the history and announced by onObjectAdded and onObjectRemoved, as objects of the sketch. An arrow took three undos, and onObjectAdded was called three times. The tools get an isPreview() hook: the objects it says are previews are neither recorded nor announced. --- CHANGELOG.md | 3 + .../src/components/SketchField/events.ts | 11 +++- .../SketchField/tools/Arrow/Arrow.spec.tsx | 58 +++++++++++++++++++ .../SketchField/tools/Arrow/Arrow.ts | 5 ++ .../src/components/SketchField/tools/index.ts | 9 +++ 5 files changed, 84 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b6694c..9f6a150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,9 @@ The eraser, laser pointer and polygon tools, and features that forks of react-sk - The arrow tool: an arrow which is drawn is not selectable, as the other shapes are. A press on an earlier arrow selected it and a drag moved it, instead of drawing the new arrow, so an arrow could not be drawn over or across another one. +- The arrow tool: an arrow is one undo step, and `onObjectAdded` is called once with it, and + `onObjectRemoved` is not called. The line and the head which are shown while the arrow is drawn were + recorded and announced too, so an arrow took three undos, and `onObjectAdded` was called three times. ### Packaging and tooling diff --git a/packages/react-sketch/src/components/SketchField/events.ts b/packages/react-sketch/src/components/SketchField/events.ts index c74146b..07ac3d5 100644 --- a/packages/react-sketch/src/components/SketchField/events.ts +++ b/packages/react-sketch/src/components/SketchField/events.ts @@ -64,7 +64,8 @@ export class FabricCanvasEventsManager * Action when an object is added to the canvas. The objects which undo and * redo add back are not new, so they are not recorded nor announced. */ - public onObjectAdded({ event, props }: EventInput): void { + public onObjectAdded({ event, tool, props }: EventInput): void { + if (this.isPreview(event, tool)) return; // Only erasable objects are affected by the eraser if (event.target) (event.target as { erasable?: boolean }).erasable = true; if (this.history.replaying) return; @@ -77,7 +78,8 @@ export class FabricCanvasEventsManager props.onObjectModified?.(sketchEvent(event)); } - public onObjectRemoved({ event, props }: EventInput): void { + public onObjectRemoved({ event, tool, props }: EventInput): void { + if (this.isPreview(event, tool)) return; props.onObjectRemoved?.(sketchEvent(event)); } @@ -117,6 +119,11 @@ export class FabricCanvasEventsManager props.onMouseUp?.(sketchEvent(event)); } + /** Whether the object of the event is one the tool only shows while it draws */ + private isPreview({ target }: CanvasEvent, tool: FabricCanvasTool): boolean { + return !!target && tool.isPreview(target); + } + /** * Tell the application that the sketch may have changed, shortly after * the mouse was released or left the canvas. diff --git a/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.spec.tsx b/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.spec.tsx index 001385e..ccccc93 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.spec.tsx +++ b/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.spec.tsx @@ -86,4 +86,62 @@ describe("Arrow", () => { expect(arrow.selectable).toBe(true); expect(arrow.evented).toBe(true); }); + + describe("in the history", () => { + it("is one undo step, which undo and redo take off and put back", () => { + const { sketch, canvas } = mount(); + drag(canvas, [100, 100], [300, 100]); + const [arrow] = arrows(canvas); + + sketch.undo(); + expect(canvas.getObjects()).toHaveLength(0); + expect(sketch.canUndo()).toBe(false); + + sketch.redo(); + expect(canvas.getObjects()).toEqual([arrow]); + expect(sketch.canRedo()).toBe(false); + }); + + it("takes one undo for each arrow, and brings none of the line and head loose", () => { + const { sketch, canvas } = mount(); + drag(canvas, [100, 100], [300, 100]); + drag(canvas, [100, 200], [300, 200]); + const [first, second] = arrows(canvas); + + sketch.undo(); + expect(canvas.getObjects()).toEqual([first]); + sketch.undo(); + expect(canvas.getObjects()).toHaveLength(0); + expect(sketch.canUndo()).toBe(false); + + sketch.redo(); + sketch.redo(); + expect(canvas.getObjects()).toEqual([first, second]); + expect(sketch.canRedo()).toBe(false); + }); + + it("calls onObjectAdded once with the arrow, and onObjectRemoved not at all", () => { + const onObjectAdded = jest.fn(); + const onObjectRemoved = jest.fn(); + const { canvas } = mount({ onObjectAdded, onObjectRemoved }); + drag(canvas, [100, 100], [300, 100]); + + expect(onObjectAdded).toHaveBeenCalledTimes(1); + expect(onObjectAdded.mock.calls[0][0].target).toBe(arrows(canvas)[0]); + expect(onObjectRemoved).not.toHaveBeenCalled(); + }); + + it("still tells the application of the objects it removes", () => { + const onObjectRemoved = jest.fn(); + const { sketch, canvas } = mount({ onObjectRemoved }); + drag(canvas, [100, 100], [300, 100]); + const [arrow] = arrows(canvas); + + canvas.setActiveObject(arrow); + sketch.removeSelected(); + + expect(onObjectRemoved).toHaveBeenCalledTimes(1); + expect(onObjectRemoved.mock.calls[0][0].target).toBe(arrow); + }); + }); }); diff --git a/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts b/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts index 0de822d..d3a0614 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts @@ -100,6 +100,11 @@ export class Arrow extends FabricCanvasTool { this.isDown = false; } + /** The line and the head are the arrow until it is released: then the group is */ + isPreview(object: fabric.FabricObject): boolean { + return object === this.line || object === this.head; + } + type(): Tool { return Tool.ARROW; } diff --git a/packages/react-sketch/src/components/SketchField/tools/index.ts b/packages/react-sketch/src/components/SketchField/tools/index.ts index 3142a0b..9eb29ae 100644 --- a/packages/react-sketch/src/components/SketchField/tools/index.ts +++ b/packages/react-sketch/src/components/SketchField/tools/index.ts @@ -97,6 +97,15 @@ export abstract class FabricCanvasTool { */ reset(): void {} + /** + * Whether the object is one which the tool shows while it draws, and takes + * off for the final one. It is not an action of the user, so it is not in + * the history, and `onObjectAdded` and `onObjectRemoved` are not called for it. + */ + isPreview(object: fabric.FabricObject): boolean { + return false; + } + /** * Called when the sketch scales its objects (`zoom()`, and the resize of the * canvas), for the tools which hold coordinates of their own From 5fb064511e65c5db69893e7998e9cf96aa1edda5 Mon Sep 17 00:00:00 2001 From: tbolis Date: Fri, 30 Oct 2026 16:28:46 +0200 Subject: [PATCH 24/33] feat: line style (dashed, dotted, dash-dot) The lineStyle property and the Types.LineStyle type: solid (the default), dashed, dotted or dash-dot, with the dashes and the dots following lineWidth. The pencil, line, arrow (its shaft), rectangle, rectangle label, circle and polygon draw it, and the polygon in progress is shown in it. The brush is shared by the tools, so the highlighter takes it back to solid. The style is saved with the object by toJSON() and loaded by fromJSON(). The showcase has the line styles in the Line section. --- CHANGELOG.md | 6 + README.md | 29 +- .../components/SketchField/SketchField.tsx | 1 + .../SketchField/tools/Arrow/Arrow.ts | 6 +- .../SketchField/tools/Circle/Circle.ts | 7 +- .../tools/Highlighter/Highlighter.ts | 1 + .../components/SketchField/tools/Line/Line.ts | 6 +- .../SketchField/tools/LineStyle.spec.tsx | 302 ++++++++++++++++++ .../SketchField/tools/Pencil/Pencil.ts | 1 + .../SketchField/tools/Polygon/Polygon.ts | 13 +- .../SketchField/tools/Rectangle/Rectangle.ts | 6 +- .../tools/RectangleLabel/RectangleLabel.ts | 6 +- .../src/components/SketchField/tools/index.ts | 15 +- packages/react-sketch/src/types.ts | 12 + packages/react-sketch/src/utils/index.ts | 40 +++ packages/react-sketch/src/utils/utils.spec.ts | 30 +- .../showcase/src/components/Canvas/index.tsx | 1 + .../Tools/LineStyleSelector/index.tsx | 71 ++++ .../showcase/src/components/Tools/index.tsx | 14 +- packages/showcase/src/stores/config.ts | 2 + 20 files changed, 552 insertions(+), 17 deletions(-) create mode 100644 packages/react-sketch/src/components/SketchField/tools/LineStyle.spec.tsx create mode 100644 packages/showcase/src/components/Tools/LineStyleSelector/index.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f6a150..5999cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,12 @@ The eraser, laser pointer and polygon tools, and features that forks of react-sk drops one with fewer, and `zoom()` and the resize of the canvas scale the polygon in progress with the objects. - The showcase has a Polygon button in the tool selector, with a hint of how to close the polygon. +- The `lineStyle` property and the `Types.LineStyle` type: `"solid"` (the default), `"dashed"`, `"dotted"` or + `"dash-dot"`, with the dashes and the dots following `lineWidth`. The pencil, line, arrow (its shaft), + rectangle, rectangle label, circle and polygon draw it, and the polygon in progress is shown in it. A + change is for the next drawing. It is saved by `toJSON()` and loaded by `fromJSON()` with the object. + The highlighter, the eraser and the laser pointer do not draw it. +- The showcase has the line styles in the Line section. ### Changed diff --git a/README.md b/README.md index 20656a9..37d3bf9 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,7 @@ All properties are optional. | `tool` | `Types.Tool` or `string` | `"select"` | The tool to use, see [Tools](#tools). `null` is treated as `"select"`, an unknown name selects `"pencil"`. | | `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). | | `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`. | @@ -302,6 +303,22 @@ Use the tools by name (`"pencil"`), with `Tools` (`Tools.Pencil`) or with the en | Laser pointer | `Tools.LaserPointer` | `laser-pointer` | A line that follows the pointer, fades out and disappears. It is not part of the drawing. See [Laser pointer](#laser-pointer). | | Default tool | `Tools.DefaultTool` | `default-tool` | Disables drawing and selection. Use it to show a drawing that is not edited. | +### Line style + +`lineStyle` sets how the line is drawn: `"solid"` (the default), `"dashed"`, `"dotted"` or `"dash-dot"`. The +dashes and the dots follow `lineWidth` (a line thinner than 2 pixels has the pattern of one of 2), so a +thick line has long dashes and wide dots. + +```tsx + +``` + +The pencil, line, arrow, rectangle, rectangle label, circle and polygon draw it. The head of an arrow and the +text box of a rectangle label are not dashed, and the highlighter, the eraser and the laser pointer do not +draw it. A change of `lineStyle` is for the next drawing: what is drawn already keeps its style. The style is +saved by `toJSON()` and loaded by `fromJSON()` with the object (`strokeDashArray` and `strokeLineCap`), and +the polygon in progress is shown in it. + ### Polygon Each click or tap places a vertex of the polygon, where the pointer is released, and a line follows the @@ -364,12 +381,12 @@ not called when it is drawn. Switching to another tool clears what is left of th ## Exports -| Export | Description | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `SketchField` | The component (named export). Also usable as a type, for the `ref`. | -| `Tools` | The tools by name: `Select`, `Pencil`, `Line`, `Arrow`, `Rectangle`, `RectangleLabel`, `Polygon`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`, `Eraser`, `LaserPointer`. | -| `Types` | Namespace of the types: `Tool` (enumeration), `Tools`, `SketchProperties`, `SketchEvent`, `SketchObject`, `SketchValue`, `SketchData`, `DataUrlOptions`, `ImageOptions`, `BackgroundOptions`, `TextOptions`. | -| default export | The component, which also carries `SketchField` and `Tools` (as in 0.5.x). | +| Export | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SketchField` | The component (named export). Also usable as a type, for the `ref`. | +| `Tools` | The tools by name: `Select`, `Pencil`, `Line`, `Arrow`, `Rectangle`, `RectangleLabel`, `Polygon`, `Circle`, `Pan`, `DefaultTool`, `Highlighter`, `Eraser`, `LaserPointer`. | +| `Types` | Namespace of the types: `Tool` (enumeration), `Tools`, `LineStyle`, `SketchProperties`, `SketchEvent`, `SketchObject`, `SketchValue`, `SketchData`, `DataUrlOptions`, `ImageOptions`, `BackgroundOptions`, `TextOptions`. | +| default export | The component, which also carries `SketchField` and `Tools` (as in 0.5.x). | ## Known limitations diff --git a/packages/react-sketch/src/components/SketchField/SketchField.tsx b/packages/react-sketch/src/components/SketchField/SketchField.tsx index 501aac1..b40c287 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.tsx @@ -254,6 +254,7 @@ class SketchField extends PureComponent { } else if ( props.lineColor !== prevProps.lineColor || props.lineWidth !== prevProps.lineWidth || + props.lineStyle !== prevProps.lineStyle || props.fillColor !== prevProps.fillColor ) { this.configureTool(); diff --git a/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts b/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts index d3a0614..02acc06 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts @@ -1,11 +1,13 @@ import * as fabric from "fabric"; import { CanvasEvent } from "../../events"; import { FabricCanvasTool } from "../index"; -import { SketchProperties, Tool } from "../../../../types"; +import { LineStyle, SketchProperties, Tool } from "../../../../types"; +import { lineStroke } from "../../../../utils"; export class Arrow extends FabricCanvasTool { private width: number; private color?: string; + private style?: LineStyle; private isDown: boolean; private startX: number; private startY: number; @@ -26,6 +28,7 @@ export class Arrow extends FabricCanvasTool { this.setInteractive(false); this.width = props.lineWidth ? props.lineWidth : 1; this.color = props.lineColor ? props.lineColor : "black"; + this.style = props.lineStyle; } doMouseDown(event: CanvasEvent): void { @@ -37,6 +40,7 @@ export class Arrow extends FabricCanvasTool { strokeWidth: this.width, fill: this.color, stroke: this.color, + ...lineStroke(this.style, this.width), originX: "center", originY: "center", selectable: false, diff --git a/packages/react-sketch/src/components/SketchField/tools/Circle/Circle.ts b/packages/react-sketch/src/components/SketchField/tools/Circle/Circle.ts index 3ba678f..61ada03 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Circle/Circle.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Circle/Circle.ts @@ -1,12 +1,13 @@ import * as fabric from "fabric"; import { CanvasEvent } from "../../events"; import { FabricCanvasTool } from "../index"; -import { SketchProperties, Tool } from "../../../../types"; -import { linearDistance } from "../../../../utils"; +import { LineStyle, SketchProperties, Tool } from "../../../../types"; +import { linearDistance, lineStroke } from "../../../../utils"; export class Circle extends FabricCanvasTool { private width?: number; private color?: string; + private style?: LineStyle; private fill?: string; private isDown: boolean; private startX: number; @@ -25,6 +26,7 @@ export class Circle extends FabricCanvasTool { this.setInteractive(false); this.width = props.lineWidth ? props.lineWidth : 1; this.color = props.lineColor ? props.lineColor : "black"; + this.style = props.lineStyle; this.fill = props.fillColor ? props.fillColor : "transparent"; } @@ -40,6 +42,7 @@ export class Circle extends FabricCanvasTool { originY: "center", strokeWidth: this.width, stroke: this.color, + ...lineStroke(this.style, this.width ?? 1), fill: this.fill, selectable: false, evented: false, 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 bd902f9..a96e9b8 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Highlighter/Highlighter.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Highlighter/Highlighter.ts @@ -15,6 +15,7 @@ export class Highlighter extends FabricCanvasTool { const color = props.lineColor ? props.lineColor : "black"; this.canvas.isDrawingMode = true; this.brush.width = props.lineWidth ? props.lineWidth : 1; + this.setBrushStroke(undefined, this.brush.width); this.brush.color = hexToRgbA( color.startsWith("#") ? color : (colorNameToHex(color) ?? color) ); diff --git a/packages/react-sketch/src/components/SketchField/tools/Line/Line.ts b/packages/react-sketch/src/components/SketchField/tools/Line/Line.ts index f8f8a5f..c4fef20 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Line/Line.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Line/Line.ts @@ -1,11 +1,13 @@ import * as fabric from "fabric"; import { CanvasEvent } from "../../events"; import { FabricCanvasTool } from "../index"; -import { SketchProperties, Tool } from "../../../../types"; +import { LineStyle, SketchProperties, Tool } from "../../../../types"; +import { lineStroke } from "../../../../utils"; export class Line extends FabricCanvasTool { private width?: number; private color?: string; + private style?: LineStyle; private isDown: boolean; private line: fabric.Line | null; @@ -19,6 +21,7 @@ export class Line extends FabricCanvasTool { this.setInteractive(false); this.width = props.lineWidth ? props.lineWidth : 1; this.color = props.lineColor ? props.lineColor : "black"; + this.style = props.lineStyle; } doMouseDown(event: CanvasEvent): void { @@ -34,6 +37,7 @@ export class Line extends FabricCanvasTool { strokeWidth: this.width, fill: this.color, stroke: this.color, + ...lineStroke(this.style, this.width ?? 1), originX: "center", originY: "center", selectable: false, diff --git a/packages/react-sketch/src/components/SketchField/tools/LineStyle.spec.tsx b/packages/react-sketch/src/components/SketchField/tools/LineStyle.spec.tsx new file mode 100644 index 0000000..2be5caa --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/tools/LineStyle.spec.tsx @@ -0,0 +1,302 @@ +import { createRef } from "react"; +import { act, render } from "@testing-library/react"; +import * as fabric from "fabric"; +import SketchField from "../SketchField"; +import { LineStyle, SketchProperties, Tool } from "../../../types"; + +type Point = [number, number]; + +const mount = (props: SketchProperties = {}) => { + const ref = createRef(); + const view = render(); + const sketch = ref.current as SketchField; + return { ...view, sketch, canvas: sketch["canvas"] as fabric.Canvas }; +}; + +const mouseEvent = (type: string, [x, y]: Point): MouseEvent => + new MouseEvent(type, { + clientX: x, + clientY: y, + buttons: type === "mouseup" ? 0 : 1, + bubbles: true, + cancelable: true, + }); + +/** Press the mouse at `from`, drag it to `to` and release it, as the browser tells the canvas */ +const drag = (canvas: fabric.Canvas, from: Point, to: Point) => { + canvas.upperCanvasEl.dispatchEvent(mouseEvent("mousedown", from)); + document.dispatchEvent(mouseEvent("mousemove", to)); + document.dispatchEvent(mouseEvent("mouseup", to)); +}; + +/** Draw a free hand stroke, through the brush as the canvas does */ +const stroke = (canvas: fabric.Canvas, points: Point[]): void => { + const brush = canvas.freeDrawingBrush as fabric.BaseBrush; + const event = (type: string, [x, y]: Point) => ({ + e: new MouseEvent(type, { clientX: x, clientY: y }), + pointer: new fabric.Point(x, y), + }); + const [first, ...rest] = points; + brush.onMouseDown(new fabric.Point(...first), event("mousedown", first)); + rest.forEach((point) => + brush.onMouseMove(new fabric.Point(...point), event("mousemove", point)) + ); + brush.onMouseUp(event("mouseup", points[points.length - 1])); +}; + +/** The object which carries the line of what was drawn: the shaft of an arrow */ +const lined = (object: fabric.FabricObject): fabric.FabricObject => { + if (object instanceof fabric.Group) return object.getObjects()[0]; + return object; +}; + +/** Run the gesture, and return the object with the line which it added to the canvas */ +const drawn = (canvas: fabric.Canvas, gesture: (canvas: fabric.Canvas) => void) => { + const before = canvas.getObjects(); + gesture(canvas); + const added = canvas + .getObjects() + .filter((o) => !before.includes(o) && !(o instanceof fabric.Textbox)); + expect(added).toHaveLength(1); + return lined(added[0]); +}; + +const STYLES: [LineStyle, number[], string][] = [ + ["dashed", [12, 8], "butt"], + ["dotted", [0, 8], "round"], + ["dash-dot", [8, 12, 0, 12], "round"], +]; + +describe("lineStyle", () => { + describe.each<[string, Tool, (canvas: fabric.Canvas) => void]>([ + ["line", Tool.LINE, (canvas) => drag(canvas, [10, 10], [200, 90])], + ["arrow", Tool.ARROW, (canvas) => drag(canvas, [10, 10], [200, 90])], + ["rectangle", Tool.RECTANGLE, (canvas) => drag(canvas, [10, 10], [200, 90])], + [ + "rectangle label", + Tool.RECTANGLE_LABEL, + (canvas) => drag(canvas, [10, 10], [200, 90]), + ], + ["circle", Tool.CIRCLE, (canvas) => drag(canvas, [10, 10], [200, 90])], + [ + "polygon", + Tool.POLYGON, + (canvas) => { + [ + [10, 10], + [200, 10], + [100, 90], + ].forEach((point) => { + act(() => jest.advanceTimersByTime(500)); + canvas.upperCanvasEl.dispatchEvent( + mouseEvent("mousedown", point as Point) + ); + document.dispatchEvent(mouseEvent("mouseup", point as Point)); + }); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + }, + ], + [ + "pencil", + Tool.PENCIL, + (canvas) => + stroke(canvas, [ + [10, 10], + [100, 50], + [200, 90], + ]), + ], + ])("the %s", (_name, tool, gesture) => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it.each(STYLES)( + "draws a %s line, in the length of the width", + (style, dash, cap) => { + const { canvas } = mount({ tool, lineStyle: style, lineWidth: 4 }); + + const object = drawn(canvas, gesture); + + expect(object.strokeDashArray).toEqual(dash); + expect(object.strokeLineCap).toEqual(cap); + } + ); + + it("draws a solid line by default, and when it is solid", () => { + const defaults = mount({ tool, lineWidth: 4 }); + const unstyled = drawn(defaults.canvas, gesture); + defaults.unmount(); + const { canvas } = mount({ tool, lineWidth: 4, lineStyle: "solid" }); + const solid = drawn(canvas, gesture); + + expect(unstyled.strokeDashArray ?? null).toBeNull(); + expect(solid.strokeDashArray ?? null).toBeNull(); + expect(solid.strokeLineCap).toEqual(unstyled.strokeLineCap); + }); + + it("changes with the property for the next drawing, and leaves the last", () => { + const { canvas, rerender, sketch } = mount({ + tool, + lineStyle: "dashed", + lineWidth: 4, + }); + const before = drawn(canvas, gesture); + + rerender( + + ); + const after = drawn(canvas, gesture); + + expect(before.strokeDashArray).toEqual([12, 8]); + expect(after.strokeDashArray).toEqual([0, 8]); + }); + }); + + it("is saved by toJSON and loaded by fromJSON", async () => { + jest.useFakeTimers(); + const { sketch, canvas } = mount({ + tool: Tool.RECTANGLE, + lineStyle: "dotted", + lineWidth: 4, + }); + drag(canvas, [10, 10], [200, 90]); + const value = sketch.toJSON(); + + sketch.clear(); + sketch.fromJSON(value); + await act(async () => { + await jest.advanceTimersByTimeAsync(200); + }); + + const [rect] = canvas.getObjects(); + expect(rect.strokeDashArray).toEqual([0, 8]); + expect(rect.strokeLineCap).toEqual("round"); + jest.useRealTimers(); + }); + + describe("the arrow", () => { + it("has a solid head, with the shaft in the line style", () => { + const { canvas } = mount({ + tool: Tool.ARROW, + lineStyle: "dashed", + lineWidth: 4, + }); + drag(canvas, [10, 10], [200, 90]); + + const [group] = canvas.getObjects() as fabric.Group[]; + const [shaft, head] = group.getObjects(); + expect(shaft.strokeDashArray).toEqual([12, 8]); + expect(head.strokeDashArray ?? null).toBeNull(); + }); + }); + + describe("the brush", () => { + it("is dashed for the pencil, and the highlighter takes it back to solid", () => { + const { canvas, rerender, sketch } = mount({ + tool: Tool.PENCIL, + lineStyle: "dotted", + lineWidth: 4, + }); + const brush = canvas.freeDrawingBrush as fabric.BaseBrush; + expect(brush.strokeDashArray).toEqual([0, 8]); + expect(brush.strokeLineCap).toEqual("round"); + + rerender( + + ); + + expect(brush.strokeDashArray).toBeNull(); + expect(brush.strokeLineCap).toEqual("round"); + }); + + it("is solid again, with its round ends, when the pencil goes back to solid", () => { + const { canvas, rerender, sketch } = mount({ + tool: Tool.PENCIL, + lineStyle: "dashed", + lineWidth: 4, + }); + const brush = canvas.freeDrawingBrush as fabric.BaseBrush; + expect(brush.strokeLineCap).toEqual("butt"); + + rerender( + + ); + + expect(brush.strokeDashArray).toBeNull(); + expect(brush.strokeLineCap).toEqual("round"); + }); + + it("follows the width of the line", () => { + const { canvas, rerender, sketch } = mount({ + tool: Tool.PENCIL, + lineStyle: "dashed", + lineWidth: 4, + }); + rerender( + + ); + + expect((canvas.freeDrawingBrush as fabric.BaseBrush).strokeDashArray).toEqual([ + 30, 20, + ]); + }); + }); + + describe("the polygon in progress", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("draws its sides in the line style, and the dots and the ring without it", () => { + const { canvas } = mount({ + tool: Tool.POLYGON, + lineStyle: "dashed", + lineWidth: 4, + }); + [ + [10, 10], + [200, 10], + ].forEach((point) => { + act(() => jest.advanceTimersByTime(500)); + canvas.upperCanvasEl.dispatchEvent(mouseEvent("mousedown", point as Point)); + document.dispatchEvent(mouseEvent("mouseup", point as Point)); + }); + + const dashes = (canvas.contextTop.setLineDash as jest.Mock).mock.calls.map( + ([dash]) => dash + ); + expect(dashes).toContainEqual([12, 8]); + // Reset right after the sides, so the dots and the ring are unbroken + expect(dashes[dashes.length - 1]).toEqual([]); + }); + }); +}); 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 ea3d61a..7b59185 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Pencil/Pencil.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Pencil/Pencil.ts @@ -13,6 +13,7 @@ export class Pencil extends FabricCanvasTool { this.canvas.freeDrawingCursor = "default"; this.brush.width = config.lineWidth ? config.lineWidth : 1; this.brush.color = config.lineColor ? config.lineColor : "black"; + this.setBrushStroke(config.lineStyle, this.brush.width); } type(): Tool { diff --git a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts index c55c3c4..47bfd91 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts @@ -1,7 +1,8 @@ import * as fabric from "fabric"; import { CanvasEvent } from "../../events"; import { FabricCanvasTool } from "../index"; -import { SketchProperties, Tool } from "../../../../types"; +import { LineStyle, SketchProperties, Tool } from "../../../../types"; +import { lineStroke } from "../../../../utils"; /** The fewest vertices of a polygon */ const MIN_VERTICES = 3; @@ -41,6 +42,7 @@ export class Polygon extends FabricCanvasTool { private width?: number; private color?: string; private fill?: string; + private style?: LineStyle; /** The vertices of the polygon in progress, in canvas coordinates */ private vertices: fabric.Point[] = []; /** Where the pointer is, null while it is out of the canvas */ @@ -58,6 +60,7 @@ export class Polygon extends FabricCanvasTool { this.width = props.lineWidth ? props.lineWidth : 1; this.color = props.lineColor ? props.lineColor : "black"; this.fill = props.fillColor ? props.fillColor : "transparent"; + this.style = props.lineStyle; if (this.vertices.length) this.paint(); } @@ -165,6 +168,7 @@ export class Polygon extends FabricCanvasTool { { stroke: this.color, strokeWidth: this.width, + ...lineStroke(this.style, this.width ?? 1), fill: this.fill, strokeLineJoin: "round", strokeUniform: true, @@ -287,11 +291,16 @@ export class Polygon extends FabricCanvasTool { ctx.fillStyle = color; ctx.lineWidth = width; ctx.lineJoin = "round"; - ctx.lineCap = "round"; + // The sides are in the line style of the polygon, the dots and the ring are not + const { strokeDashArray, strokeLineCap } = lineStroke(this.style, width); + ctx.lineCap = strokeLineCap ?? "round"; + ctx.setLineDash(strokeDashArray ?? []); ctx.beginPath(); vertices.forEach(({ x, y }, i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); if (pointerAt) ctx.lineTo(pointerAt.x, pointerAt.y); ctx.stroke(); + ctx.setLineDash([]); + ctx.lineCap = "round"; vertices.forEach(({ x, y }) => { ctx.beginPath(); ctx.arc(x, y, dot, 0, Math.PI * 2); diff --git a/packages/react-sketch/src/components/SketchField/tools/Rectangle/Rectangle.ts b/packages/react-sketch/src/components/SketchField/tools/Rectangle/Rectangle.ts index 7c21720..3f053f4 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Rectangle/Rectangle.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Rectangle/Rectangle.ts @@ -1,11 +1,13 @@ import * as fabric from "fabric"; import { CanvasEvent } from "../../events"; import { FabricCanvasTool } from "../index"; -import { SketchProperties, Tool } from "../../../../types"; +import { LineStyle, SketchProperties, Tool } from "../../../../types"; +import { lineStroke } from "../../../../utils"; export class Rectangle extends FabricCanvasTool { private width?: number; private color?: string; + private style?: LineStyle; private fill?: string; private isDown: boolean; private startX: number; @@ -24,6 +26,7 @@ export class Rectangle extends FabricCanvasTool { this.setInteractive(false); this.width = props.lineWidth ? props.lineWidth : 1; this.color = props.lineColor ? props.lineColor : "black"; + this.style = props.lineStyle; this.fill = props.fillColor ? props.fillColor : "transparent"; } @@ -41,6 +44,7 @@ export class Rectangle extends FabricCanvasTool { height: 0, stroke: this.color, strokeWidth: this.width, + ...lineStroke(this.style, this.width ?? 1), fill: this.fill, transparentCorners: false, selectable: false, diff --git a/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabel.ts b/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabel.ts index 5ce09c4..050c725 100644 --- a/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabel.ts +++ b/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabel.ts @@ -2,13 +2,15 @@ import * as fabric from "fabric"; import { CanvasEvent } from "../../events"; import { FabricCanvasTool } from "../index"; import { RectangleLabelObject } from "./RectangleLabelObject"; -import { SketchProperties, Tool } from "../../../../types"; +import { LineStyle, SketchProperties, Tool } from "../../../../types"; +import { lineStroke } from "../../../../utils"; const MAX_FONT_SIZE = 12; export class RectangleLabel extends FabricCanvasTool { private width?: number; private color?: string; + private style?: LineStyle; private fill?: string; private isDown: boolean; private startX: number; @@ -27,6 +29,7 @@ export class RectangleLabel extends FabricCanvasTool { this.setInteractive(false); this.width = props.lineWidth ? props.lineWidth : 1; this.color = props.lineColor ? props.lineColor : "black"; + this.style = props.lineStyle; this.fill = props.fillColor ? props.fillColor : "transparent"; } @@ -47,6 +50,7 @@ export class RectangleLabel extends FabricCanvasTool { height: 0, stroke: this.color, strokeWidth: this.width, + ...lineStroke(this.style, this.width ?? 1), fill: this.fill, transparentCorners: false, selectable: false, diff --git a/packages/react-sketch/src/components/SketchField/tools/index.ts b/packages/react-sketch/src/components/SketchField/tools/index.ts index 9eb29ae..2352771 100644 --- a/packages/react-sketch/src/components/SketchField/tools/index.ts +++ b/packages/react-sketch/src/components/SketchField/tools/index.ts @@ -1,7 +1,8 @@ /*eslint @typescript-eslint/no-unused-vars: 0,@typescript-eslint/no-empty-function: 0*/ import * as fabric from "fabric"; -import { SketchProperties, Tool } from "../../../types"; +import { LineStyle, SketchProperties, Tool } from "../../../types"; +import { lineStroke } from "../../../utils"; import { EraserBrush } from "@react-sketch/eraser"; import { CanvasEvent, EventInput } from "../events"; @@ -152,6 +153,18 @@ export abstract class FabricCanvasTool { return (this.canvas.freeDrawingBrush = new fabric.PencilBrush(this.canvas)); } + /** + * Set the line style of the brush. The brush is kept from a tool to the + * next, so a tool which does not draw a style (the highlighter) calls this + * with none, and the style of the pencil is not left on it. + */ + protected setBrushStroke(style: LineStyle | undefined, width: number): void { + const brush = this.brush; + brush.strokeDashArray = null; + brush.strokeLineCap = "round"; + Object.assign(brush, lineStroke(style, width)); + } + /** * Switch the canvas between "select" mode (objects can be selected and * moved) and "draw" mode (objects are frozen, so a drag always draws). diff --git a/packages/react-sketch/src/types.ts b/packages/react-sketch/src/types.ts index f2a953c..bbd7b12 100644 --- a/packages/react-sketch/src/types.ts +++ b/packages/react-sketch/src/types.ts @@ -140,6 +140,12 @@ export interface BackgroundOptions { */ export type TextOptions = Record; +/** + * How a line is drawn: unbroken, in dashes, in dots, or in dashes and dots. + * The lengths follow `lineWidth`. + */ +export type LineStyle = "solid" | "dashed" | "dotted" | "dash-dot"; + /** * Properties of the SketchField component */ @@ -154,6 +160,12 @@ export interface SketchProperties { lineColor?: string; /** The width of the line. Defaults to 10 */ lineWidth?: number; + /** + * The style of the line, see {@link LineStyle}. Defaults to "solid". The + * pencil, line, arrow (its shaft), rectangle, rectangle label, circle and + * polygon draw it, the highlighter, eraser and laser pointer do not. + */ + lineStyle?: LineStyle; /** 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/react-sketch/src/utils/index.ts b/packages/react-sketch/src/utils/index.ts index 51b08d0..049e9bb 100644 --- a/packages/react-sketch/src/utils/index.ts +++ b/packages/react-sketch/src/utils/index.ts @@ -1,3 +1,5 @@ +import type { LineStyle } from "../types"; + interface Point { x: number; y: number; @@ -17,6 +19,44 @@ export const linearDistance = (point1: Point, point2: Point): number => { return Math.sqrt(xs * xs + ys * ys); }; +/** + * The pattern of each line style, in units of the line width: the length of a + * dash and of the gap after it, and so on. A dash of length 0 is a dot, which + * is round because the ends of the line are. + */ +const LINE_STYLES: Record< + Exclude, + { pattern: number[]; cap: "butt" | "round" } +> = { + dashed: { pattern: [3, 2], cap: "butt" }, + dotted: { pattern: [0, 2], cap: "round" }, + "dash-dot": { pattern: [2, 3, 0, 3], cap: "round" }, +}; + +/** A line thinner than this has the pattern of this one, so that it is seen */ +const MIN_PATTERN_UNIT = 2; + +/** + * The options of a canvas object (or brush) which draw a line in a style. + * They are none for a solid line, and for a style which is not known, so the + * object keeps what it has. + * + * @param style the style of the line + * @param width the width of the line + */ +export const lineStroke = ( + style: LineStyle | undefined, + width: number +): { strokeDashArray?: number[]; strokeLineCap?: "butt" | "round" } => { + const found = style && style !== "solid" ? LINE_STYLES[style] : undefined; + if (!found) return {}; + const unit = Math.max(width, MIN_PATTERN_UNIT); + return { + strokeDashArray: found.pattern.map((length) => length * unit), + strokeLineCap: found.cap, + }; +}; + /** * Convert a hex color code to its rgba equivalent (with 0.4 alpha) * diff --git a/packages/react-sketch/src/utils/utils.spec.ts b/packages/react-sketch/src/utils/utils.spec.ts index 45e3d8e..032bebe 100644 --- a/packages/react-sketch/src/utils/utils.spec.ts +++ b/packages/react-sketch/src/utils/utils.spec.ts @@ -1,7 +1,7 @@ /** * @jest-environment jsdom */ -import { colorNameToHex, hexToRgbA, linearDistance } from "./index"; +import { colorNameToHex, hexToRgbA, lineStroke, linearDistance } from "./index"; describe("Utils", () => { describe("linearDistance", () => { @@ -37,4 +37,32 @@ describe("Utils", () => { expect(colorNameToHex("constructor")).toBeUndefined(); }); }); + + describe("lineStroke", () => { + it("has nothing for a solid line, or no style, or one which is not known", () => { + expect(lineStroke("solid", 4)).toEqual({}); + expect(lineStroke(undefined, 4)).toEqual({}); + expect(lineStroke("wavy" as never, 4)).toEqual({}); + }); + + it("makes the pattern of each style from the width", () => { + expect(lineStroke("dashed", 4)).toEqual({ + strokeDashArray: [12, 8], + strokeLineCap: "butt", + }); + expect(lineStroke("dotted", 4)).toEqual({ + strokeDashArray: [0, 8], + strokeLineCap: "round", + }); + expect(lineStroke("dash-dot", 4)).toEqual({ + strokeDashArray: [8, 12, 0, 12], + strokeLineCap: "round", + }); + }); + + it("gives a thin line the pattern of a line of 2, so that it is seen", () => { + expect(lineStroke("dashed", 1)).toEqual(lineStroke("dashed", 2)); + expect(lineStroke("dotted", 0.5)).toEqual(lineStroke("dotted", 2)); + }); + }); }); diff --git a/packages/showcase/src/components/Canvas/index.tsx b/packages/showcase/src/components/Canvas/index.tsx index 5a745b4..27e3214 100644 --- a/packages/showcase/src/components/Canvas/index.tsx +++ b/packages/showcase/src/components/Canvas/index.tsx @@ -42,6 +42,7 @@ const SketchCanvas = (props: Config): ReactElement => { tool={props.selectedTool} lineColor={props.lineColor} lineWidth={props.lineWidth} + lineStyle={props.lineStyle} fillColor={props.fillWithColor ? props.fillColor : "transparent"} backgroundColor={ props.fillWithBackgroundColor ? props.backgroundColor : "transparent" diff --git a/packages/showcase/src/components/Tools/LineStyleSelector/index.tsx b/packages/showcase/src/components/Tools/LineStyleSelector/index.tsx new file mode 100644 index 0000000..e3abb7c --- /dev/null +++ b/packages/showcase/src/components/Tools/LineStyleSelector/index.tsx @@ -0,0 +1,71 @@ +import type { MouseEvent, ReactElement } from "react"; +import { Types } from "react-sketch"; +import ToggleButton from "@mui/material/ToggleButton"; +import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; +import { CONFIG } from "../../../stores/config"; + +interface LineStyleSelectorProps { + selected: Types.LineStyle; + /** Whether the tool in use draws the style, the highlighter, the eraser and the laser pointer do not */ + disabled?: boolean; +} + +/** A sample of each style: the dashes of the line, as the canvas draws them with a line of 2 */ +const STYLES: { + value: Types.LineStyle; + label: string; + dash?: string; + round?: boolean; +}[] = [ + { value: "solid", label: "Solid" }, + { value: "dashed", label: "Dashed", dash: "6 4" }, + { value: "dotted", label: "Dotted", dash: "0 4", round: true }, + { value: "dash-dot", label: "Dash and dot", dash: "4 6 0 6", round: true }, +]; + +const handleChange = ( + _event: MouseEvent, + value: Types.LineStyle | null +) => { + if (value !== null) CONFIG.lineStyle = value; +}; + +const LineStyleSelector = ({ + selected, + disabled, +}: LineStyleSelectorProps): ReactElement => { + return ( + + {STYLES.map(({ value, label, dash, round }) => ( + + + + ))} + + ); +}; + +export default LineStyleSelector; diff --git a/packages/showcase/src/components/Tools/index.tsx b/packages/showcase/src/components/Tools/index.tsx index 3901a42..6009793 100644 --- a/packages/showcase/src/components/Tools/index.tsx +++ b/packages/showcase/src/components/Tools/index.tsx @@ -9,6 +9,7 @@ import CanvasSize from "./CanvasSize"; import ColorSelector from "./ColorSelector"; import ImageControls from "./ImageControls"; import LabeledSlider from "./LabeledSlider"; +import LineStyleSelector from "./LineStyleSelector"; import Overlay from "./Overlay"; import Section from "./Section"; import TextControls from "./TextControls"; @@ -51,6 +52,13 @@ const handleSelectTool = (event: MouseEvent, value: string | null) CONFIG.selectedTool = value; }; +/** The tools which draw no line style: their line is a brush, or is not kept */ +const NO_LINE_STYLE: string[] = [ + Types.Tool.HIGHLIGHTER, + Types.Tool.ERASER, + Types.Tool.LASER_POINTER, +]; + const SketchTools = (props: Config): ReactElement => { return ( @@ -58,7 +66,7 @@ const SketchTools = (props: Config): ReactElement => {
{ endIcon={} onChange={(value) => (CONFIG.lineWidth = value)} /> +
Date: Wed, 28 Oct 2026 19:48:11 +0200 Subject: [PATCH 25/33] feat: pinch zoom Two fingers on the canvas zoom and pan its view (#84): the pinchZoom, minZoom and maxZoom properties, the onViewChange callback and resetView(). The view is the viewport of the canvas, the objects stay where they are, so it is not saved, not an undo step, and onChange is not called for it. toDataURL() draws the whole drawing, whatever the view. When the second finger comes, what the first one started is dropped: the objects it added are taken off the canvas and out of the history, the free stroke stops, and the tool forgets the press (new cancel() hook of the tools, cancel() of the eraser and laser brushes). The eraser brush follows the first finger only, as the other brushes. --- packages/eraser/src/EraserBrush.ts | 15 + packages/laserpointer/src/LaserBrush.ts | 5 + .../components/SketchField/SketchField.tsx | 85 ++- .../src/components/SketchField/events.ts | 8 +- .../SketchField/history/SketchHistory.ts | 15 + .../components/SketchField/history/index.ts | 11 + .../src/components/SketchField/pinch.spec.tsx | 658 ++++++++++++++++++ .../src/components/SketchField/pinch.ts | 190 +++++ .../SketchField/tools/Arrow/Arrow.ts | 5 + .../SketchField/tools/Circle/Circle.ts | 4 + .../components/SketchField/tools/Line/Line.ts | 4 + .../components/SketchField/tools/Pan/Pan.ts | 4 + .../tools/Polygon/Polygon.spec.tsx | 8 +- .../SketchField/tools/Polygon/Polygon.ts | 7 + .../SketchField/tools/Rectangle/Rectangle.ts | 4 + .../tools/RectangleLabel/RectangleLabel.ts | 4 + .../src/components/SketchField/tools/index.ts | 7 + packages/react-sketch/src/types.ts | 22 + 18 files changed, 1046 insertions(+), 10 deletions(-) create mode 100644 packages/react-sketch/src/components/SketchField/pinch.spec.tsx create mode 100644 packages/react-sketch/src/components/SketchField/pinch.ts diff --git a/packages/eraser/src/EraserBrush.ts b/packages/eraser/src/EraserBrush.ts index 68d998e..28cdbb7 100644 --- a/packages/eraser/src/EraserBrush.ts +++ b/packages/eraser/src/EraserBrush.ts @@ -371,6 +371,8 @@ export class EraserBrush extends fabric.PencilBrush { pointer: fabric.Point, context: fabric.TEvent ): void { + // A second finger does not start a stroke of its own + if (!this.canvas._isMainEvent(context.e)) return; if ( !this.eventEmitter.dispatchEvent( new CustomEvent("start", { detail: context, cancelable: true }) @@ -553,6 +555,19 @@ export class EraserBrush extends fabric.PencilBrush { this._resetShadow(); } + /** + * Drop the stroke in progress, nothing is erased. The canvas stops + * drawing it, and the brush is ready for the next one. + */ + cancel(): void { + this.active = false; + this._disposer?.(); + delete this._disposer; + this.canvas.clearContext(this.canvas.contextTop); + this.canvas.requestRenderAll(); + this._resetShadow(); + } + dispose() { const { canvas } = this.effectContext; // prompt GC diff --git a/packages/laserpointer/src/LaserBrush.ts b/packages/laserpointer/src/LaserBrush.ts index 89f7961..d3f6577 100644 --- a/packages/laserpointer/src/LaserBrush.ts +++ b/packages/laserpointer/src/LaserBrush.ts @@ -58,6 +58,11 @@ export class LaserBrush extends fabric.BaseBrush { return false; } + /** The line which is drawn stays on its own, as when the pointer is released */ + cancel(): void { + this.trails.forEach((trail) => (trail.held = false)); + } + /** * Draw the lines on the top canvas. The canvas calls it when it renders the * top canvas while a line is drawn, the animation calls it on every frame. diff --git a/packages/react-sketch/src/components/SketchField/SketchField.tsx b/packages/react-sketch/src/components/SketchField/SketchField.tsx index b40c287..7fdc610 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.tsx @@ -13,6 +13,7 @@ import { } from "../../types"; import { CanvasEvent, FabricCanvasEventsManager, SketchEventAware } from "./events"; import { SketchHistory } from "./history/SketchHistory"; +import { IDENTITY_VIEW, PinchZoom } from "./pinch"; import { autoresize, scaleContent } from "./resize"; import { FabricCanvasTool } from "./tools"; import { create_tool } from "./tools/factory"; @@ -37,6 +38,9 @@ type DefaultedProps = Required< | "heightCorrection" | "forceValue" | "autoresize" + | "pinchZoom" + | "minZoom" + | "maxZoom" | "onObjectAdded" | "onObjectModified" | "onObjectRemoved" @@ -62,6 +66,9 @@ const defaultProps: DefaultedProps = { heightCorrection: 0, forceValue: false, autoresize: true, + pinchZoom: true, + minZoom: 0.5, + maxZoom: 8, onObjectAdded: () => undefined, onObjectModified: () => undefined, onObjectRemoved: () => undefined, @@ -84,6 +91,14 @@ class SketchCanvas extends fabric.Canvas { /** Called after the canvas is cleared, also when a value is loaded, before its objects are added */ onClear?: () => void; + /** Stop the free drawing stroke in progress, nothing is drawn of it */ + abortDrawing(): void { + if (!this._isCurrentlyDrawing) return; + (this.freeDrawingBrush as { cancel?: () => void } | undefined)?.cancel?.(); + this._isCurrentlyDrawing = false; + this.clearContext(this.contextTop); + } + clear(): void { this.clearing = true; try { @@ -127,6 +142,9 @@ class SketchField extends PureComponent { private canvas!: SketchCanvas; private tool!: FabricCanvasTool; private history!: SketchHistory; + private pinch?: PinchZoom; + /** The objects of the canvas when the last touch began, to know the ones it added */ + private objectsBeforeTouch: fabric.FabricObject[] = []; private mounted = false; /** Counts the images asked for by layer, so that only the last one of a layer is set */ private readonly imageLoads: Record = { @@ -231,7 +249,25 @@ class SketchField extends PureComponent { // Bind Events to Actions this._bind_canvas_events( - new FabricCanvasEventsManager(this.history, this.schedule) + new FabricCanvasEventsManager( + this.history, + this.schedule, + () => this.pinch?.gesturing ?? false + ) + ); + + this.pinch = new PinchZoom( + this.canvas, + { + enabled: () => this.props.pinchZoom, + minZoom: () => this.props.minZoom, + maxZoom: () => this.props.maxZoom, + }, + { + onTouch: () => (this.objectsBeforeTouch = this.canvas.getObjects().slice()), + onGesture: () => this.cancelInteraction(), + onChange: (view) => this.props.onViewChange?.(view), + } ); window.addEventListener("resize", this._resize_listener); @@ -283,6 +319,7 @@ class SketchField extends PureComponent { window.removeEventListener("resize", this._resize_listener); this.timers.forEach((id) => window.clearTimeout(id)); this.timers.clear(); + this.pinch?.dispose(); 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. @@ -426,6 +463,34 @@ class SketchField extends PureComponent { .catch(() => undefined); }; + /** + * Put the view of the canvas back to the whole canvas, as it is before it + * is zoomed or panned with two fingers + */ + resetView = (): void => { + this.canvas.setViewportTransform([...IDENTITY_VIEW]); + this.props.onViewChange?.({ zoom: 1, x: 0, y: 0 }); + }; + + /** + * What the first finger started is dropped, as the second one makes them a + * gesture of the view: the objects it added are taken off the canvas (they + * are not an undo step), the stroke it was drawing stops, and the tool forgets + * the press + */ + private cancelInteraction(): void { + const { canvas } = this; + const added = canvas + .getObjects() + .filter((object) => !this.objectsBeforeTouch.includes(object)); + if (added.length) { + this.history.discard(added); + canvas.remove(...added); + } + canvas.abortDrawing(); + this.tool.cancel(); + } + /** * Zoom the drawing by the factor specified * @@ -494,11 +559,19 @@ class SketchField extends PureComponent { * * @returns URL containing a representation of the object in the format specified by options.format */ - toDataURL = (options?: DataUrlOptions): string => - this.canvas.toDataURL({ - format: this.props.imageFormat, - ...options, - } as fabric.TDataUrlOptions); + toDataURL = (options?: DataUrlOptions): string => { + // The image is of the drawing, not of the part which the view shows + const view = this.canvas.viewportTransform; + this.canvas.viewportTransform = [...IDENTITY_VIEW]; + try { + return this.canvas.toDataURL({ + format: this.props.imageFormat, + ...options, + } as fabric.TDataUrlOptions); + } finally { + this.canvas.viewportTransform = view; + } + }; /** * Returns JSON representation of canvas diff --git a/packages/react-sketch/src/components/SketchField/events.ts b/packages/react-sketch/src/components/SketchField/events.ts index 07ac3d5..7ebf4dd 100644 --- a/packages/react-sketch/src/components/SketchField/events.ts +++ b/packages/react-sketch/src/components/SketchField/events.ts @@ -46,18 +46,22 @@ export class FabricCanvasEventsManager { private readonly history: SketchHistory; private readonly schedule: (callback: () => void, delay: number) => void; + private readonly gesturing: () => boolean; /** * @param history the history of the sketch * @param schedule runs the callback after the delay (in milliseconds) + * @param gesturing whether the fingers on the screen are a gesture of the view, which draws nothing */ constructor( history: SketchHistory, - schedule: (callback: () => void, delay: number) => void + schedule: (callback: () => void, delay: number) => void, + gesturing: () => boolean = () => false ) { super(); this.history = history; this.schedule = schedule; + this.gesturing = gesturing; } /** @@ -129,7 +133,7 @@ export class FabricCanvasEventsManager * the mouse was released or left the canvas. */ private notifyChange({ event, tool, props }: EventInput): void { - if (!tool.notifiesChange()) return; + if (!tool.notifiesChange() || this.gesturing()) return; const { onChange } = props; if (onChange) this.schedule(() => onChange(event.e), 10); } diff --git a/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts b/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts index e71bc16..0be7ae0 100644 --- a/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts +++ b/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts @@ -60,6 +60,21 @@ export class SketchHistory { this.entries.keep({ action: "add", object }); } + /** + * Forget the objects which were added, and which are taken off the canvas + * before the drawing of them was finished. They were the last actions. + */ + discard(objects: fabric.FabricObject[]): void { + for ( + let last = this.entries.getCurrent(); + last?.action === "add" && objects.includes(last.object); + last = this.entries.getCurrent() + ) { + this.entries.pop(); + } + if (this.pending && objects.includes(this.pending)) this.pending = null; + } + /** * Record an object modified on the canvas. Objects which were not added * through {@link onObjectAdded} (for example a multiple selection) are ignored. diff --git a/packages/react-sketch/src/components/SketchField/history/index.ts b/packages/react-sketch/src/components/SketchField/history/index.ts index 1b819e5..507f252 100644 --- a/packages/react-sketch/src/components/SketchField/history/index.ts +++ b/packages/react-sketch/src/components/SketchField/history/index.ts @@ -48,6 +48,17 @@ export class UndoHistory { this.current = obj; } + /** + * Forget the last object kept, as if it was never kept + * + * @returns the object forgotten, or null when the history is empty + */ + pop(): T | null { + const last = this.current; + this.current = this.undoList.pop() ?? null; + return last; + } + /** * Undo the last object, this operation will set the current object to one step back in time * diff --git a/packages/react-sketch/src/components/SketchField/pinch.spec.tsx b/packages/react-sketch/src/components/SketchField/pinch.spec.tsx new file mode 100644 index 0000000..b8fdf34 --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/pinch.spec.tsx @@ -0,0 +1,658 @@ +import { createRef } from "react"; +import { act, render } from "@testing-library/react"; +import * as fabric from "fabric"; +import { LaserBrush } from "@react-sketch/laserpointer"; +import SketchField from "./SketchField"; +import { SketchProperties, Tool } from "../../types"; + +type Point = [number, number]; +interface Finger { + id: number; + at: Point; +} + +/** What the browser would lay out for the container of the sketch, so the canvas is 800 by 600 */ +const layouts: jest.SpyInstance[] = []; +const layout = (width: number, height: number) => + layouts.push( + jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(width), + jest.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(height) + ); + +const mount = (props: SketchProperties = {}) => { + layout(800, 600); + const ref = createRef(); + const view = render(); + const sketch = ref.current as SketchField; + return { ...view, sketch, canvas: sketch["canvas"] as fabric.Canvas }; +}; + +/** Let the time pass, so the next tap is not the second of a double tap */ +const wait = (ms = 500) => act(() => jest.advanceTimersByTime(ms)); + +/** A touch event of the page: jsdom cannot build one with touches in it */ +const touch = (type: string, changed: Finger[], others: Finger[] = []): Event => { + const list = (fingers: Finger[]) => + fingers.map(({ id, at: [clientX, clientY] }) => ({ + identifier: id, + clientX, + clientY, + })); + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperties(event, { + touches: { value: list(type === "touchend" ? others : [...others, ...changed]) }, + changedTouches: { value: list(changed) }, + }); + return event; +}; + +const finger = (id: number, at: Point): Finger => ({ id, at }); + +/** The fingers touch the canvas one after the other */ +const touchDown = (canvas: fabric.Canvas, fingers: Finger[]) => + fingers.forEach((one, i) => + canvas.upperCanvasEl.dispatchEvent( + touch("touchstart", [one], fingers.slice(0, i)) + ) + ); + +const touchMove = (fingers: Finger[]) => + document.dispatchEvent(touch("touchmove", fingers)); + +/** The fingers leave the screen one after the other */ +const touchUp = (fingers: Finger[]) => + fingers.forEach((one, i) => + document.dispatchEvent(touch("touchend", [one], fingers.slice(i + 1))) + ); + +/** Two fingers go from the first places to the second ones, and leave */ +const pinch = (canvas: fabric.Canvas, from: [Point, Point], to: [Point, Point]) => { + const start = from.map((at, id) => finger(id, at)); + const end = to.map((at, id) => finger(id, at)); + touchDown(canvas, start); + touchMove(end); + touchUp(end); +}; + +const tap = (canvas: fabric.Canvas, at: Point) => { + wait(); + touchDown(canvas, [finger(0, at)]); + touchUp([finger(0, at)]); +}; + +const view = (canvas: fabric.Canvas) => [...canvas.viewportTransform]; + +const keydown = (key: string) => + document.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); + +const vertices = (polygon: fabric.Polygon): Point[] => { + const matrix = polygon.calcTransformMatrix(); + return polygon.points.map(({ x, y }) => { + const p = fabric.util.transformPoint( + new fabric.Point(x - polygon.pathOffset.x, y - polygon.pathOffset.y), + matrix + ); + return [Math.round(p.x), Math.round(p.y)]; + }); +}; + +describe("pinch zoom", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + layouts.splice(0).forEach((spy) => spy.mockRestore()); + }); + + describe("the view", () => { + it("zooms about the point between the fingers, which stays under them", () => { + const { canvas } = mount(); + + // The fingers go from 100 apart to 200 apart, around (350, 300) + pinch( + canvas, + [ + [300, 300], + [400, 300], + ], + [ + [250, 300], + [450, 300], + ] + ); + + expect(view(canvas)).toEqual([2, 0, 0, 2, -350, -300]); + }); + + it("pans when the fingers move together", () => { + const { canvas } = mount(); + + pinch( + canvas, + [ + [300, 300], + [400, 300], + ], + [ + [350, 330], + [450, 330], + ] + ); + + expect(view(canvas)).toEqual([1, 0, 0, 1, 50, 30]); + }); + + it("zooms and pans in the same gesture", () => { + const { canvas } = mount(); + + // 100 apart to 200 apart, and the middle goes from (350, 300) to (400, 300) + pinch( + canvas, + [ + [300, 300], + [400, 300], + ], + [ + [300, 300], + [500, 300], + ] + ); + + expect(view(canvas)).toEqual([2, 0, 0, 2, -300, -300]); + }); + + it("goes on from the view it has, with the next gesture", () => { + const { canvas } = mount(); + pinch( + canvas, + [ + [300, 300], + [400, 300], + ], + [ + [250, 300], + [450, 300], + ] + ); + + pinch( + canvas, + [ + [300, 300], + [400, 300], + ], + [ + [250, 300], + [450, 300], + ] + ); + + // About the same point of the screen again, so the point of the canvas under it stays + expect(view(canvas)).toEqual([4, 0, 0, 4, -1050, -900]); + }); + + it("keeps the zoom between minZoom and maxZoom, which are 0.5 and 8 by default", () => { + const { canvas } = mount(); + + pinch( + canvas, + [ + [390, 300], + [410, 300], + ], + [ + [100, 300], + [700, 300], + ] + ); + expect(canvas.getZoom()).toEqual(8); + + canvas.setViewportTransform([1, 0, 0, 1, 0, 0]); + pinch( + canvas, + [ + [100, 300], + [700, 300], + ], + [ + [395, 300], + [405, 300], + ] + ); + expect(canvas.getZoom()).toEqual(0.5); + }); + + it("keeps the zoom between the minZoom and maxZoom which are given", () => { + const { canvas } = mount({ minZoom: 1, maxZoom: 3 }); + + pinch( + canvas, + [ + [390, 300], + [410, 300], + ], + [ + [100, 300], + [700, 300], + ] + ); + expect(canvas.getZoom()).toEqual(3); + + pinch( + canvas, + [ + [100, 300], + [700, 300], + ], + [ + [395, 300], + [405, 300], + ] + ); + expect(canvas.getZoom()).toEqual(1); + }); + + it("cannot take the canvas out of sight", () => { + const { canvas } = mount(); + + // The fingers go far to the right and down: a quarter of the 600 pixels stays + pinch( + canvas, + [ + [300, 300], + [400, 300], + ], + [ + [1000, 800], + [1100, 800], + ] + ); + + expect(view(canvas)).toEqual([1, 0, 0, 1, 650, 450]); + }); + + it("does nothing with one finger, nor when pinchZoom is off", () => { + const first = mount(); + touchDown(first.canvas, [finger(0, [300, 300])]); + touchMove([finger(0, [400, 400])]); + touchUp([finger(0, [400, 400])]); + expect(view(first.canvas)).toEqual([1, 0, 0, 1, 0, 0]); + first.unmount(); + + const { canvas } = mount({ pinchZoom: false }); + pinch( + canvas, + [ + [300, 300], + [400, 300], + ], + [ + [250, 300], + [450, 300], + ] + ); + expect(view(canvas)).toEqual([1, 0, 0, 1, 0, 0]); + }); + + it("is told to onViewChange as it changes, and by resetView()", () => { + const onViewChange = jest.fn(); + const { sketch, canvas } = mount({ onViewChange }); + + pinch( + canvas, + [ + [300, 300], + [400, 300], + ], + [ + [250, 300], + [450, 300], + ] + ); + expect(onViewChange).toHaveBeenLastCalledWith({ zoom: 2, x: -350, y: -300 }); + + sketch.resetView(); + expect(view(canvas)).toEqual([1, 0, 0, 1, 0, 0]); + expect(onViewChange).toHaveBeenLastCalledWith({ zoom: 1, x: 0, y: 0 }); + }); + + it("is not saved, not undone, and does not change what the sketch tells", () => { + const onChange = jest.fn(); + const onObjectAdded = jest.fn(); + const onObjectModified = jest.fn(); + const { sketch, canvas } = mount({ + onChange, + onObjectAdded, + onObjectModified, + }); + canvas.add(new fabric.Rect({ left: 10, top: 20, width: 100, height: 50 })); + const before = JSON.stringify(sketch.toJSON()); + const undoable = sketch.canUndo(); + + pinch( + canvas, + [ + [300, 300], + [400, 300], + ], + [ + [250, 300], + [450, 300], + ] + ); + wait(); + + expect(JSON.stringify(sketch.toJSON())).toEqual(before); + expect(sketch.canUndo()).toBe(undoable); + expect(onChange).not.toHaveBeenCalled(); + expect(onObjectModified).not.toHaveBeenCalled(); + expect(canvas.getObjects()[0].left).toEqual(10); + }); + + it("is not in the image of toDataURL, and is there again after it", () => { + const { sketch, canvas } = mount(); + pinch( + canvas, + [ + [300, 300], + [400, 300], + ], + [ + [250, 300], + [450, 300], + ] + ); + const seen: number[][] = []; + const spy = jest + .spyOn(HTMLCanvasElement.prototype, "toDataURL") + .mockImplementation(() => { + seen.push([...canvas.viewportTransform]); + return "data:,"; + }); + + sketch.toDataURL(); + spy.mockRestore(); + + expect(seen).toEqual([[1, 0, 0, 1, 0, 0]]); + expect(view(canvas)).toEqual([2, 0, 0, 2, -350, -300]); + }); + + it("stops listening when the component is unmounted", () => { + const { canvas, unmount } = mount(); + const upper = canvas.upperCanvasEl; + unmount(); + + upper.dispatchEvent(touch("touchstart", [finger(0, [300, 300])])); + upper.dispatchEvent(touch("touchstart", [finger(1, [400, 300])])); + touchMove([finger(0, [250, 300]), finger(1, [450, 300])]); + touchUp([finger(0, [250, 300]), finger(1, [450, 300])]); + + expect(view(canvas)).toEqual([1, 0, 0, 1, 0, 0]); + }); + }); + + describe("a stroke which the first finger started", () => { + it("is dropped when the second finger comes: a line leaves no object nor undo step", () => { + const { sketch, canvas } = mount({ tool: Tool.LINE }); + touchDown(canvas, [finger(0, [100, 100])]); + touchMove([finger(0, [160, 140])]); + expect(canvas.getObjects()).toHaveLength(1); + + touchDown(canvas, [finger(1, [300, 300])]); + touchMove([finger(0, [170, 150]), finger(1, [320, 320])]); + touchUp([finger(0, [170, 150]), finger(1, [320, 320])]); + + expect(canvas.getObjects()).toHaveLength(0); + expect(sketch.canUndo()).toBe(false); + }); + + it.each([ + ["an arrow", Tool.ARROW], + ["a rectangle", Tool.RECTANGLE], + ["a circle", Tool.CIRCLE], + ["a rectangle with a label", Tool.RECTANGLE_LABEL], + ])("is dropped for %s too", (_name, tool) => { + const { sketch, canvas } = mount({ tool }); + touchDown(canvas, [finger(0, [100, 100])]); + touchMove([finger(0, [160, 140])]); + + touchDown(canvas, [finger(1, [300, 300])]); + touchMove([finger(0, [170, 150]), finger(1, [320, 320])]); + touchUp([finger(0, [170, 150]), finger(1, [320, 320])]); + + expect(canvas.getObjects()).toHaveLength(0); + expect(sketch.canUndo()).toBe(false); + }); + + it("is dropped for a free line of the pencil, and nothing is drawn after it", () => { + const { sketch, canvas } = mount({ tool: Tool.PENCIL }); + touchDown(canvas, [finger(0, [100, 100])]); + touchMove([finger(0, [120, 120])]); + touchMove([finger(0, [140, 150])]); + + touchDown(canvas, [finger(1, [300, 300])]); + touchMove([finger(0, [170, 150]), finger(1, [320, 320])]); + touchUp([finger(0, [170, 150]), finger(1, [320, 320])]); + + expect(canvas.getObjects()).toHaveLength(0); + expect(sketch.canUndo()).toBe(false); + }); + + it("does not erase with the eraser", async () => { + const { canvas } = mount({ tool: Tool.ERASER, lineWidth: 20 }); + const rect = new fabric.Rect({ + left: 50, + top: 50, + width: 200, + height: 100, + fill: "red", + }); + canvas.add(rect); + touchDown(canvas, [finger(0, [60, 100])]); + touchMove([finger(0, [150, 100])]); + + touchDown(canvas, [finger(1, [300, 300])]); + touchMove([finger(0, [240, 100]), finger(1, [320, 320])]); + touchUp([finger(0, [240, 100]), finger(1, [320, 320])]); + await act(async () => { + await jest.advanceTimersByTimeAsync(50); + }); + + expect(rect.clipPath).toBeUndefined(); + }); + + it("does not hold the laser line, which fades", () => { + const { canvas } = mount({ tool: Tool.LASER_POINTER }); + touchDown(canvas, [finger(0, [100, 100])]); + touchMove([finger(0, [160, 140])]); + const brush = canvas.freeDrawingBrush as LaserBrush; + const trails = () => + (brush as unknown as { trails: { held: boolean }[] }).trails; + expect(trails().some((trail) => trail.held)).toBe(true); + + touchDown(canvas, [finger(1, [300, 300])]); + + expect(trails().some((trail) => trail.held)).toBe(false); + }); + + it("is kept when it was finished before: the lines drawn are objects of the sketch", () => { + const { sketch, canvas } = mount({ tool: Tool.LINE }); + touchDown(canvas, [finger(0, [100, 100])]); + touchMove([finger(0, [160, 140])]); + touchUp([finger(0, [160, 140])]); + + pinch( + canvas, + [ + [300, 300], + [400, 300], + ], + [ + [250, 300], + [450, 300], + ] + ); + + expect(canvas.getObjects()).toHaveLength(1); + expect(sketch.canUndo()).toBe(true); + sketch.undo(); + expect(canvas.getObjects()).toHaveLength(0); + }); + + it("does not leave a stroke on the next one", () => { + const { sketch, canvas } = mount({ tool: Tool.LINE }); + touchDown(canvas, [finger(0, [100, 100])]); + touchDown(canvas, [finger(1, [300, 300])]); + touchUp([finger(0, [100, 100]), finger(1, [300, 300])]); + + wait(); + touchDown(canvas, [finger(0, [200, 200])]); + touchMove([finger(0, [260, 240])]); + touchUp([finger(0, [260, 240])]); + + expect(canvas.getObjects()).toHaveLength(1); + expect(sketch.canUndo()).toBe(true); + }); + }); + + /** + * The vertices of the polygon in progress are in canvas coordinates, so the + * view can change while it is drawn. + */ + describe("in the middle of a polygon", () => { + const polygons = (canvas: fabric.Canvas) => + canvas + .getObjects() + .filter((o): o is fabric.Polygon => o instanceof fabric.Polygon); + + /** Two vertices placed, the tool is the polygon */ + const started = () => { + const onObjectAdded = jest.fn(); + const onChange = jest.fn(); + const view = mount({ tool: Tool.POLYGON, onObjectAdded, onChange }); + tap(view.canvas, [100, 100]); + tap(view.canvas, [300, 100]); + return { ...view, onObjectAdded, onChange }; + }; + + it("keeps the vertices placed, and the next ones are placed in the new view", () => { + const { canvas } = started(); + + // A zoom of 2 about (350, 300), the middle of the fingers + pinch( + canvas, + [ + [300, 300], + [400, 300], + ], + [ + [250, 300], + [450, 300], + ] + ); + expect(view(canvas)).toEqual([2, 0, 0, 2, -350, -300]); + tap(canvas, [300, 400]); + keydown("Enter"); + + // The screen point (300, 400) is the canvas point ((300 + 350) / 2, (400 + 300) / 2) + expect(vertices(polygons(canvas)[0])).toEqual([ + [100, 100], + [300, 100], + [325, 350], + ]); + }); + + it("does not place a vertex where the fingers were, nor when they leave", () => { + const { canvas, onObjectAdded } = started(); + + pinch( + canvas, + [ + [300, 300], + [400, 300], + ], + [ + [250, 300], + [450, 300], + ] + ); + keydown("Enter"); + + // Two vertices only: Enter closes nothing + expect(polygons(canvas)).toHaveLength(0); + expect(onObjectAdded).not.toHaveBeenCalled(); + }); + + it("draws the preview in the new view, from the same vertices", () => { + const { canvas } = started(); + (canvas.contextTop.lineTo as jest.Mock).mockClear(); + + pinch( + canvas, + [ + [300, 300], + [400, 300], + ], + [ + [250, 300], + [450, 300], + ] + ); + // The canvas renders on the next frame + wait(50); + + expect((canvas.contextTop.lineTo as jest.Mock).mock.calls.at(-1)).toEqual([ + 300, 100, + ]); + expect((canvas.contextTop.transform as jest.Mock).mock.calls.at(-1)).toEqual([ + 2, 0, 0, 2, -350, -300, + ]); + }); + + it("goes on when the fingers come while one of them was about to place a vertex", () => { + const { canvas } = started(); + wait(); + + // The first finger is down where a vertex would be placed, the second finger comes + touchDown(canvas, [finger(0, [200, 250])]); + touchDown(canvas, [finger(1, [260, 250])]); + touchMove([finger(0, [180, 250]), finger(1, [280, 250])]); + touchUp([finger(0, [180, 250]), finger(1, [280, 250])]); + tap(canvas, [200, 200]); + keydown("Enter"); + + const [polygon] = polygons(canvas); + expect(polygon.points).toHaveLength(3); + expect(vertices(polygon).slice(0, 2)).toEqual([ + [100, 100], + [300, 100], + ]); + }); + + it("is one undo step and one onChange when it is closed after the view changed", () => { + const { sketch, canvas, onObjectAdded, onChange } = started(); + + pinch( + canvas, + [ + [300, 300], + [400, 300], + ], + [ + [250, 300], + [450, 300], + ] + ); + tap(canvas, [300, 400]); + keydown("Enter"); + wait(); + + expect(onObjectAdded).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledTimes(1); + sketch.undo(); + expect(canvas.getObjects()).toHaveLength(0); + }); + }); +}); diff --git a/packages/react-sketch/src/components/SketchField/pinch.ts b/packages/react-sketch/src/components/SketchField/pinch.ts new file mode 100644 index 0000000..a21f30e --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/pinch.ts @@ -0,0 +1,190 @@ +import * as fabric from "fabric"; +import { SketchView } from "../../types"; + +/** The view with nothing zoomed and nothing panned */ +export const IDENTITY_VIEW: fabric.TMat2D = [1, 0, 0, 1, 0, 0]; + +/** What the gesture asks of the component it is part of */ +export interface PinchHooks { + /** A finger touches the canvas while no other does, before the canvas handles it */ + onTouch(): void; + /** A second finger touches the canvas: the first one is no stroke any more */ + onGesture(): void; + /** The view changed */ + onChange(view: SketchView): void; +} + +/** What the gesture reads from the properties of the component, as they are now */ +export interface PinchOptions { + enabled(): boolean; + minZoom(): number; + maxZoom(): number; +} + +/** The view of the canvas as it is: the zoom and where its origin is on the canvas */ +export const viewOf = (canvas: fabric.Canvas): SketchView => { + const [zoom, , , , x, y] = canvas.viewportTransform; + return { zoom, x, y }; +}; + +/** + * Zoom and pan of the view of the canvas with two fingers: the point between + * the fingers stays under them while the distance between them sets the zoom, + * and moving them together pans. + * + * It changes the viewport transform of the canvas only. The objects stay where + * they are, so nothing the sketch saves (`toJSON()`) or records (undo) changes. + * fabric does not do this: its gestures need a library it no longer has, and it + * ignores the second finger, so the touch events of the canvas are read here. + */ +export class PinchZoom { + /** The fingers on the canvas, by identifier, in canvas pixels */ + private readonly fingers = new Map(); + /** The two fingers of the gesture in progress */ + private pair?: [number, number]; + /** True from the second finger until every finger left, while what the fingers do is no drawing */ + private gesture = false; + /** The distance between the fingers, the zoom, and the point of the canvas under their middle, when the gesture began */ + private anchor?: { distance: number; zoom: number; point: fabric.Point }; + + /** The element of the canvas and its document are kept: the canvas can be disposed before this is */ + private readonly upper: HTMLCanvasElement; + private readonly doc: Document; + + constructor( + private readonly canvas: fabric.Canvas, + private readonly options: PinchOptions, + private readonly hooks: PinchHooks + ) { + this.upper = canvas.upperCanvasEl; + this.doc = this.upper.ownerDocument; + this.upper.addEventListener("touchstart", this.onFirst, { capture: true }); + // The canvas handles a touch first (it listens before this does), as it starts a stroke + // for the second finger too: that one is dropped when the gesture begins + this.upper.addEventListener("touchstart", this.onStart); + // The listeners of the canvas for these are on the document, the gesture goes first + this.doc.addEventListener("touchmove", this.onMove, { + capture: true, + passive: false, + }); + this.doc.addEventListener("touchend", this.onEnd, { capture: true }); + this.doc.addEventListener("touchcancel", this.onEnd, { capture: true }); + } + + get gesturing(): boolean { + return this.gesture; + } + + dispose(): void { + this.upper.removeEventListener("touchstart", this.onFirst, { capture: true }); + this.upper.removeEventListener("touchstart", this.onStart); + this.doc.removeEventListener("touchmove", this.onMove, { capture: true }); + this.doc.removeEventListener("touchend", this.onEnd, { capture: true }); + this.doc.removeEventListener("touchcancel", this.onEnd, { capture: true }); + } + + private readonly onFirst = (): void => { + if (this.options.enabled() && !this.fingers.size) this.hooks.onTouch(); + }; + + private readonly onStart = (event: TouchEvent): void => { + if (!this.options.enabled()) return; + Array.from(event.changedTouches).forEach((touch) => + this.fingers.set(touch.identifier, this.point(touch)) + ); + if (!this.pair && this.fingers.size >= 2) this.begin(); + }; + + private readonly onMove = (event: TouchEvent): void => { + if (!this.pair || !this.anchor) return; + Array.from(event.changedTouches).forEach((touch) => { + if (this.fingers.has(touch.identifier)) { + this.fingers.set(touch.identifier, this.point(touch)); + } + }); + // The tools do not see the fingers while they are a gesture + event.stopImmediatePropagation(); + if (event.cancelable) event.preventDefault(); + this.follow(); + }; + + private readonly onEnd = (event: TouchEvent): void => { + Array.from(event.changedTouches).forEach((touch) => + this.fingers.delete(touch.identifier) + ); + if (this.pair && !this.pair.every((id) => this.fingers.has(id))) { + this.pair = this.anchor = undefined; + } + // The canvas handles this event after this, and it is still the gesture which ended + if (!this.fingers.size) setTimeout(() => (this.gesture = false)); + }; + + /** The second finger is down: the gesture starts from how the view is now */ + private begin(): void { + const [first, second] = [...this.fingers.keys()]; + const [a, b] = [this.fingers.get(first)!, this.fingers.get(second)!]; + this.pair = [first, second]; + this.gesture = true; + this.anchor = { + distance: Math.max(a.distanceFrom(b), 1), + zoom: this.canvas.getZoom(), + point: a + .midPointFrom(b) + .transform(fabric.util.invertTransform(this.canvas.viewportTransform)), + }; + this.hooks.onGesture(); + } + + /** Put the point of the canvas which was under the fingers under them again, at the zoom they ask for */ + private follow(): void { + const { pair, anchor } = this; + if (!pair || !anchor) return; + const [a, b] = pair.map((id) => this.fingers.get(id)) as [ + fabric.Point | undefined, + fabric.Point | undefined, + ]; + if (!a || !b) return; + const zoom = Math.min( + Math.max( + (anchor.zoom * a.distanceFrom(b)) / anchor.distance, + this.options.minZoom() + ), + this.options.maxZoom() + ); + const middle = a.midPointFrom(b); + this.show([ + zoom, + 0, + 0, + zoom, + middle.x - zoom * anchor.point.x, + middle.y - zoom * anchor.point.y, + ]); + } + + /** + * Set the view, which cannot take the canvas out of sight: a quarter of its + * smaller side stays in the window + */ + private show(view: fabric.TMat2D): void { + const { canvas } = this; + const [zoom] = view; + const [width, height] = [canvas.getWidth(), canvas.getHeight()]; + const keep = Math.min(width, height) / 4; + view[4] = Math.min(Math.max(view[4], keep - width * zoom), width - keep); + view[5] = Math.min(Math.max(view[5], keep - height * zoom), height - keep); + canvas.setViewportTransform(view); + this.hooks.onChange(viewOf(canvas)); + } + + /** The place of the finger on the canvas, in canvas pixels */ + private point({ clientX, clientY }: Touch): fabric.Point { + const { canvas } = this; + const box = canvas.upperCanvasEl.getBoundingClientRect(); + const [sx, sy] = [ + box.width ? canvas.getWidth() / box.width : 1, + box.height ? canvas.getHeight() / box.height : 1, + ]; + return new fabric.Point((clientX - box.left) * sx, (clientY - box.top) * sy); + } +} diff --git a/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts b/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts index 02acc06..092b60d 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Arrow/Arrow.ts @@ -109,6 +109,11 @@ export class Arrow extends FabricCanvasTool { return object === this.line || object === this.head; } + cancel(): void { + this.isDown = false; + this.line = this.head = null; + } + type(): Tool { return Tool.ARROW; } diff --git a/packages/react-sketch/src/components/SketchField/tools/Circle/Circle.ts b/packages/react-sketch/src/components/SketchField/tools/Circle/Circle.ts index 61ada03..db354a2 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Circle/Circle.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Circle/Circle.ts @@ -70,6 +70,10 @@ export class Circle extends FabricCanvasTool { this.isDown = false; } + cancel(): void { + this.isDown = false; + } + type(): Tool { return Tool.CIRCLE; } diff --git a/packages/react-sketch/src/components/SketchField/tools/Line/Line.ts b/packages/react-sketch/src/components/SketchField/tools/Line/Line.ts index c4fef20..a617bd8 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Line/Line.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Line/Line.ts @@ -62,6 +62,10 @@ export class Line extends FabricCanvasTool { this.isDown = false; } + cancel(): void { + this.isDown = false; + } + type(): Tool { return Tool.LINE; } diff --git a/packages/react-sketch/src/components/SketchField/tools/Pan/Pan.ts b/packages/react-sketch/src/components/SketchField/tools/Pan/Pan.ts index 17439a8..b8f839e 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Pan/Pan.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Pan/Pan.ts @@ -41,6 +41,10 @@ export class Pan extends FabricCanvasTool { this.isDown = false; } + cancel(): void { + this.isDown = false; + } + type(): Tool { return Tool.PAN; } diff --git a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx index 879e096..18f415c 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx +++ b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.spec.tsx @@ -1262,8 +1262,9 @@ describe("Polygon", () => { }); describe("a second finger", () => { - // The canvas does not pass its events on, so it is not a vertex nor a pointer - it("is not a pointer of the polygon", () => { + // The two fingers are a gesture of the view (pinch zoom), so the first one + // places no vertex, and the polygon goes on from the vertices it had + it("makes a gesture of the view, which places no vertex", () => { const { canvas } = mount(); tap(canvas, [100, 100]); tap(canvas, [200, 100]); @@ -1279,7 +1280,10 @@ describe("Polygon", () => { ); document.dispatchEvent(touchEvent("touchend", [150, 180])); keydown("Enter"); + expect(polygons(canvas)).toHaveLength(0); + tap(canvas, [150, 180]); + keydown("Enter"); expect(vertices(polygons(canvas)[0])).toEqual([ [100, 100], [200, 100], diff --git a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts index 47bfd91..1cd69c1 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Polygon/Polygon.ts @@ -90,6 +90,13 @@ export class Polygon extends FabricCanvasTool { this.paint(); } + /** The press which was going on places nothing, and the pointer is not there any more */ + cancel(): void { + this.isDown = false; + this.pointerAt = null; + this.paint(); + } + /** A press which leaves the canvas places nothing */ doMouseOut(): void { this.isDown = false; diff --git a/packages/react-sketch/src/components/SketchField/tools/Rectangle/Rectangle.ts b/packages/react-sketch/src/components/SketchField/tools/Rectangle/Rectangle.ts index 3f053f4..68f627f 100644 --- a/packages/react-sketch/src/components/SketchField/tools/Rectangle/Rectangle.ts +++ b/packages/react-sketch/src/components/SketchField/tools/Rectangle/Rectangle.ts @@ -73,6 +73,10 @@ export class Rectangle extends FabricCanvasTool { this.isDown = false; } + cancel(): void { + this.isDown = false; + } + type(): Tool { return Tool.RECTANGLE; } diff --git a/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabel.ts b/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabel.ts index 050c725..5cfc5cc 100644 --- a/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabel.ts +++ b/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabel.ts @@ -118,6 +118,10 @@ export class RectangleLabel extends FabricCanvasTool { this.canvas.renderAll(); } + cancel(): void { + this.isDown = false; + } + type(): Tool { return Tool.RECTANGLE_LABEL; } diff --git a/packages/react-sketch/src/components/SketchField/tools/index.ts b/packages/react-sketch/src/components/SketchField/tools/index.ts index 2352771..a5928d9 100644 --- a/packages/react-sketch/src/components/SketchField/tools/index.ts +++ b/packages/react-sketch/src/components/SketchField/tools/index.ts @@ -78,6 +78,13 @@ export abstract class FabricCanvasTool { abstract configureCanvas(props: SketchProperties): void; + /** + * Called when what the pointer started, with the mouse down, must not + * go on or end in something drawn (two fingers on the screen are a gesture + * of the view, not a stroke). The objects it added are taken off the canvas. + */ + cancel(): void {} + /** * Called when another tool takes over, before `dispose()`, for the tools * which have something unfinished to complete (it is not called when the diff --git a/packages/react-sketch/src/types.ts b/packages/react-sketch/src/types.ts index bbd7b12..2379ec3 100644 --- a/packages/react-sketch/src/types.ts +++ b/packages/react-sketch/src/types.ts @@ -52,6 +52,16 @@ export interface SketchObject { [property: string]: unknown; } +/** + * The view of the canvas: how much it is zoomed (1 is the whole canvas), and + * where the origin of the canvas is, in pixels + */ +export interface SketchView { + zoom: number; + x: number; + y: number; +} + /** * Event passed to the object and mouse callbacks of {@link SketchProperties} */ @@ -203,6 +213,18 @@ export interface SketchProperties { * and when `width` or `height` change. Defaults to true */ autoresize?: boolean; + /** + * Zoom and pan the view of the canvas with two fingers, on a touch screen. The + * objects stay where they are, so the view is not saved nor undone, and it is + * not in the image of `toDataURL`. Defaults to true + */ + pinchZoom?: boolean; + /** The smallest zoom of the view, with 1 being the whole canvas. Defaults to 0.5 */ + minZoom?: number; + /** The largest zoom of the view. Defaults to 8 */ + maxZoom?: number; + /** Called each time the view changes, by the fingers or by `resetView()` */ + onViewChange?: (view: SketchView) => void; /** Width of the sketch container, in pixels */ width?: number; /** Height of the sketch container, in pixels. Defaults to 512 */ From 4b1348fb2419264edacc95a1deb0b7125915212f Mon Sep 17 00:00:00 2001 From: tbolis Date: Wed, 28 Oct 2026 19:48:14 +0200 Subject: [PATCH 26/33] docs: write the pinch zoom in the README and the 1.1.0 entry of the CHANGELOG --- CHANGELOG.md | 10 +++++++++- README.md | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5999cb7..83db767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.1.0 (unreleased) -The eraser, laser pointer and polygon tools, and features that forks of react-sketch had, written again for the TypeScript code and fabric 7. +The eraser, laser pointer and polygon tools, pinch zoom, and features that forks of react-sketch had, written again for the TypeScript code and fabric 7. ### Added @@ -53,11 +53,19 @@ The eraser, laser pointer and polygon tools, and features that forks of react-sk change is for the next drawing. It is saved by `toJSON()` and loaded by `fromJSON()` with the object. The highlighter, the eraser and the laser pointer do not draw it. - The showcase has the line styles in the Line section. +- Pinch zoom (#84): two fingers on the canvas zoom and pan its view, about the point between the fingers. The + `pinchZoom` (default `true`), `minZoom` (0.5) and `maxZoom` (8) properties, the `onViewChange` callback and + the `resetView()` method. The view is the viewport of the canvas, the objects stay where they are: it is not + in `toJSON()`, not an undo step, and `onChange` is not called for it. The canvas cannot be taken out of sight. + When the second finger comes, what the first one started (a line, a shape, a free stroke, the eraser, a vertex + of a polygon) is dropped and leaves no object nor undo step, so a polygon in progress is not disturbed. ### Changed - Each tool starts from the default cursor of free drawing (the crosshair), so the cursor of the pencil or of the eraser is not kept by the highlighter. +- `toDataURL()` draws the whole drawing, whatever the zoom and pan of the view of the canvas (pinch zoom, and + the pan tool, which moved what the image showed). - `clear()` also removes the overlay image. - When several images are asked for the overlay (or the background), only the last request is set. diff --git a/README.md b/README.md index 37d3bf9..8bac666 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,9 @@ All properties are optional. | `widthCorrection` | `number` | `0` | Pixels subtracted from the width of the container when the canvas is resized. Compensates a border or padding of the container. | | `heightCorrection` | `number` | `0` | Pixels subtracted from the height of the container when the canvas is resized. | | `autoresize` | `boolean` | `true` | Resize the canvas, and scale its objects, when the window is resized. The canvas is always fitted to its container on mount and when `width` or `height` change. | +| `pinchZoom` | `boolean` | `true` | Zoom and pan the view of the canvas with two fingers on a touch screen. See [Pinch zoom](#pinch-zoom). | +| `minZoom` | `number` | `0.5` | The smallest zoom of the view, 1 being the whole canvas. | +| `maxZoom` | `number` | `8` | The largest zoom of the view. | | `width` | `number` | undefined | Width of the sketch in pixels. When not set, the sketch takes the width of its parent element. | | `height` | `number` | `512` | Height of the sketch in pixels. | | `className` | `string` | | Class name of the container element of the canvas. | @@ -246,6 +249,7 @@ All properties are optional. | `onMouseMove` | `(event: Types.SketchEvent) => void` | no-op | Mouse move on the canvas. | | `onMouseUp` | `(event: Types.SketchEvent) => void` | no-op | Mouse up on the canvas. | | `onMouseOut` | `(event: Types.SketchEvent) => void` | no-op | The mouse left the canvas. | +| `onViewChange` | `(view: Types.SketchView) => void` | | The view changed (two fingers, or `resetView()`): `zoom` (1 is the whole canvas) and the origin `x`, `y` of the canvas, in pixels. | `Types.SketchEvent` is `{ e?: Event; target?: Types.SketchObject; [key: string]: unknown }`: `e` is the DOM event, `target` is the object the event is about (object events only). `Types.SketchObject` is @@ -274,6 +278,7 @@ throws. | `setOverlayFromDataUrl(dataUrl, options?)` | `void` | Use an image as overlay, above the objects. It takes no mouse events, `toJSON` and `toDataURL` include it, and it is not part of the undo history. The options are those of `setBackgroundFromDataUrl`. | | `removeOverlayImage()` | `void` | Remove the overlay image, also one that is still loading. The objects, the background image and the `backgroundColor` are kept. | | `zoom(factor)` | `void` | Scale the objects, the background image and the overlay image by a factor: 2 doubles their size, 0.5 halves it. | +| `resetView()` | `void` | Put the view back to the whole canvas, after it was zoomed or panned with two fingers. | | `hasSelection()` | `boolean` | Whether an object is selected. | | `clearSelection()` | `void` | Deselect the selected object or objects. | | `removeSelected()` | `void` | Remove the selected object or objects. It can be undone. | @@ -362,6 +367,23 @@ const vertices = polygon.points.map(({ x, y }) => Moving the vertices of a polygon which is closed is not supported. +### Pinch zoom + +On a touch screen, two fingers on the canvas zoom and pan its view: the point between the fingers stays under +them while the distance between them sets the zoom (between `minZoom` and `maxZoom`), and moving them together +pans. The canvas cannot be taken out of sight, a quarter of its smaller side stays in the window. Set +`pinchZoom` to `false` to turn it off, call `resetView()` to go back to the whole canvas, and use `onViewChange` +to show the zoom. + +The view is the viewport of the canvas, the objects stay where they are. So it is not saved by `toJSON()`, not +an undo step, `onChange` is not called for it, and `toDataURL()` draws the whole drawing, not the part which +the view shows. This is different from `zoom(factor)`, which scales the objects. + +The second finger makes the first one a gesture of the view: what the first finger started is dropped, a line, +a shape or a free stroke is not drawn and leaves no undo step, and a polygon in progress keeps its vertices +and places none. Drawing with two fingers at once is not possible. The mouse is not used for this: on a +desktop the Pan tool moves the view. + ### Eraser The eraser cuts the strokes it draws out of the objects it crosses: a line, a shape, an image or a text. From f33dfae76baaba0616e3b1a859ea93f1e57d681d Mon Sep 17 00:00:00 2001 From: tbolis Date: Fri, 30 Oct 2026 17:45:05 +0200 Subject: [PATCH 27/33] showcase: a View section for the pinch zoom, and a reset view button The View section has the pinchZoom switch, the smallest and the largest zoom, and the view as onViewChange reports it (also the chip of the section). The reset view button is next to the zoom buttons of the canvas. --- CHANGELOG.md | 2 + .../components/Canvas/CanvasActions/index.tsx | 6 ++ .../src/components/Canvas/Scenarios/index.tsx | 8 ++- .../showcase/src/components/Canvas/index.tsx | 13 +++- .../components/Tools/ViewControls/index.tsx | 60 +++++++++++++++++++ .../showcase/src/components/Tools/index.tsx | 7 +++ packages/showcase/src/stores/config.ts | 12 ++++ 7 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 packages/showcase/src/components/Tools/ViewControls/index.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 83db767..dc58103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,8 @@ The eraser, laser pointer and polygon tools, pinch zoom, and features that forks in `toJSON()`, not an undo step, and `onChange` is not called for it. The canvas cannot be taken out of sight. When the second finger comes, what the first one started (a line, a shape, a free stroke, the eraser, a vertex of a polygon) is dropped and leaves no object nor undo step, so a polygon in progress is not disturbed. +- The showcase has a View section for the pinch zoom (the switch, the smallest and the largest zoom, and the + view as `onViewChange` reports it) and a reset view button above the canvas. ### Changed diff --git a/packages/showcase/src/components/Canvas/CanvasActions/index.tsx b/packages/showcase/src/components/Canvas/CanvasActions/index.tsx index 5eef2fe..f6bdc63 100644 --- a/packages/showcase/src/components/Canvas/CanvasActions/index.tsx +++ b/packages/showcase/src/components/Canvas/CanvasActions/index.tsx @@ -7,6 +7,7 @@ import PasteIcon from "@mui/icons-material/ContentPaste"; import RemoveSelectedIcon from "@mui/icons-material/Delete"; import ZoomInIcon from "@mui/icons-material/ZoomIn"; import ZoomOutIcon from "@mui/icons-material/ZoomOut"; +import ResetViewIcon from "@mui/icons-material/CenterFocusStrong"; import SaveIcon from "@mui/icons-material/Save"; import DownloadIcon from "@mui/icons-material/Download"; import ClearIcon from "@mui/icons-material/DeleteSweep"; @@ -140,6 +141,11 @@ const CanvasActions = ({ onClick={() => withSketch((sketch) => sketch.zoom(0.8))}> + withSketch((sketch) => sketch.resetView())}> + + diff --git a/packages/showcase/src/components/Canvas/Scenarios/index.tsx b/packages/showcase/src/components/Canvas/Scenarios/index.tsx index cce139e..02e2be0 100644 --- a/packages/showcase/src/components/Canvas/Scenarios/index.tsx +++ b/packages/showcase/src/components/Canvas/Scenarios/index.tsx @@ -9,7 +9,12 @@ import Tabs from "@mui/material/Tabs"; import Typography from "@mui/material/Typography"; import controlledValue from "../../../data/controlledValue"; import imageDataUrl from "../../../data/imageDataUrl"; -import { CONFIG, type Config, type Scenario } from "../../../stores/config"; +import { + CONFIG, + INITIAL_VIEW, + type Config, + type Scenario, +} from "../../../stores/config"; import { withSketch } from "../sketchRef"; const SCENARIOS: { value: Scenario; label: string }[] = [ @@ -29,6 +34,7 @@ const selectScenario = (scenario: Scenario) => { CONFIG.canRedo = false; CONFIG.hasSelection = false; CONFIG.canPaste = false; + CONFIG.view = INITIAL_VIEW; }; const ScenarioTabs = ({ scenario }: Pick): ReactElement => ( diff --git a/packages/showcase/src/components/Canvas/index.tsx b/packages/showcase/src/components/Canvas/index.tsx index 27e3214..c1717d6 100644 --- a/packages/showcase/src/components/Canvas/index.tsx +++ b/packages/showcase/src/components/Canvas/index.tsx @@ -1,9 +1,9 @@ import type { ReactElement } from "react"; -import { SketchField } from "react-sketch"; +import { SketchField, type Types } from "react-sketch"; import Paper from "@mui/material/Paper"; import { teal } from "@mui/material/colors"; import defaultValue from "../../data/defaultValue"; -import { Config } from "../../stores/config"; +import { CONFIG, type Config } from "../../stores/config"; import CanvasActions from "./CanvasActions"; import Drawings from "./Drawings"; import { ScenarioControls, ScenarioTabs } from "./Scenarios"; @@ -11,6 +11,11 @@ import { sketchRef, syncState } from "./sketchRef"; const style = { backgroundColor: teal[50] }; +/** Stable, so that the canvas does not render again for it */ +const showView = (view: Types.SketchView) => { + CONFIG.view = view; +}; + const SketchCanvas = (props: Config): ReactElement => { const controlled = props.scenario === "controlled-value"; @@ -43,6 +48,10 @@ const SketchCanvas = (props: Config): ReactElement => { lineColor={props.lineColor} lineWidth={props.lineWidth} lineStyle={props.lineStyle} + pinchZoom={props.pinchZoom} + minZoom={props.minZoom} + maxZoom={props.maxZoom} + onViewChange={showView} fillColor={props.fillWithColor ? props.fillColor : "transparent"} backgroundColor={ props.fillWithBackgroundColor ? props.backgroundColor : "transparent" diff --git a/packages/showcase/src/components/Tools/ViewControls/index.tsx b/packages/showcase/src/components/Tools/ViewControls/index.tsx new file mode 100644 index 0000000..ddd1f5b --- /dev/null +++ b/packages/showcase/src/components/Tools/ViewControls/index.tsx @@ -0,0 +1,60 @@ +import type { ReactElement } from "react"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Switch from "@mui/material/Switch"; +import Typography from "@mui/material/Typography"; +import { CONFIG, type Config } from "../../../stores/config"; +import LabeledSlider from "../LabeledSlider"; +import Section from "../Section"; + +type ViewControlsProps = Pick; + +/** + * Try the pinch zoom: the switch and the limits of the view, and the view as the + * canvas reports it. The view is reset by the button of the canvas. + */ +const ViewControls = (props: ViewControlsProps): ReactElement => { + const { pinchZoom, minZoom, maxZoom, view } = props; + + return ( +
+ + Two fingers on the canvas zoom and pan its view. The drawing is not changed: + the view is not saved, not an undo step, and not in the download. The reset + view button is above the canvas. + + (CONFIG.pinchZoom = event.target.checked)} + /> + } + /> + (CONFIG.minZoom = value)} + /> + (CONFIG.maxZoom = value)} + /> + + onViewChange: zoom {view.zoom.toFixed(2)}, x {Math.round(view.x)}, y{" "} + {Math.round(view.y)} + +
+ ); +}; + +export default ViewControls; diff --git a/packages/showcase/src/components/Tools/index.tsx b/packages/showcase/src/components/Tools/index.tsx index 6009793..27c1f06 100644 --- a/packages/showcase/src/components/Tools/index.tsx +++ b/packages/showcase/src/components/Tools/index.tsx @@ -13,6 +13,7 @@ import LineStyleSelector from "./LineStyleSelector"; import Overlay from "./Overlay"; import Section from "./Section"; import TextControls from "./TextControls"; +import ViewControls from "./ViewControls"; import ToolSelector from "./ToolSelector"; interface LineSettings { @@ -99,6 +100,12 @@ const SketchTools = (props: Config): ReactElement => { stretchedX={props.stretchedX} stretchedY={props.stretchedY} /> + diff --git a/packages/showcase/src/stores/config.ts b/packages/showcase/src/stores/config.ts index 962a8c2..67fc562 100644 --- a/packages/showcase/src/stores/config.ts +++ b/packages/showcase/src/stores/config.ts @@ -12,12 +12,20 @@ export type Scenario = | "add-img" | "background-image"; +/** The view of a canvas which was not zoomed nor panned */ +export const INITIAL_VIEW: Types.SketchView = { zoom: 1, x: 0, y: 0 }; + export interface Config { scenario: Scenario; selectedTool: Types.Tool | string; lineColor: string; lineWidth: number; lineStyle: Types.LineStyle; + pinchZoom: boolean; + minZoom: number; + maxZoom: number; + /** The view of the canvas, as it reports it */ + view: Types.SketchView; fillWithColor: boolean; fillColor: string; fillWithBackgroundColor: boolean; @@ -67,6 +75,10 @@ export class ConfigStore implements Config { public lineColor = "#000000"; public lineWidth = 1; public lineStyle: Types.LineStyle = "solid"; + public pinchZoom = true; + public minZoom = 0.5; + public maxZoom = 8; + public view: Types.SketchView = INITIAL_VIEW; public fillWithColor = false; public fillColor = "#68ccca"; public fillWithBackgroundColor = false; From 9fade4c48116826ea954f4d5d045dd9ffa965518 Mon Sep 17 00:00:00 2001 From: tbolis Date: Fri, 30 Oct 2026 17:58:52 +0200 Subject: [PATCH 28/33] showcase: the reset button also takes back the zoom in and zoom out buttons The buttons scale the drawing with zoom(), which is not the view of the fingers that resetView() puts back, so on a desktop the reset did nothing. The showcase keeps the factor the buttons scaled by, and the reset scales by its inverse before it resets the view. The factor starts again when the canvas is cleared, a value is loaded, or the scenario changes. --- CHANGELOG.md | 2 +- .../components/Canvas/CanvasActions/index.tsx | 29 +++++++++++++------ .../src/components/Canvas/Scenarios/index.tsx | 7 ++++- .../components/Tools/ViewControls/index.tsx | 2 +- packages/showcase/src/stores/config.ts | 3 ++ 5 files changed, 31 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc58103..7452521 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,7 @@ The eraser, laser pointer and polygon tools, pinch zoom, and features that forks When the second finger comes, what the first one started (a line, a shape, a free stroke, the eraser, a vertex of a polygon) is dropped and leaves no object nor undo step, so a polygon in progress is not disturbed. - The showcase has a View section for the pinch zoom (the switch, the smallest and the largest zoom, and the - view as `onViewChange` reports it) and a reset view button above the canvas. + view as `onViewChange` reports it) and a reset button above the canvas, which also takes back the zoom in and zoom out buttons. ### Changed diff --git a/packages/showcase/src/components/Canvas/CanvasActions/index.tsx b/packages/showcase/src/components/Canvas/CanvasActions/index.tsx index f6bdc63..8900af0 100644 --- a/packages/showcase/src/components/Canvas/CanvasActions/index.tsx +++ b/packages/showcase/src/components/Canvas/CanvasActions/index.tsx @@ -56,10 +56,27 @@ const clear = () => { withSketch((sketch) => sketch.clear()); CONFIG.controlledValue = undefined; CONFIG.fillWithBackgroundColor = false; + // What was scaled is gone + CONFIG.zoomFactor = 1; // The canvas does not announce it syncState(); }; +/** The buttons scale the drawing (`zoom()`), which is not the view of the fingers */ +const zoomBy = (factor: number) => + withSketch((sketch) => { + sketch.zoom(factor); + CONFIG.zoomFactor *= factor; + }); + +/** Back to the whole canvas: what the buttons scaled, and what the fingers zoomed and panned */ +const resetView = () => + withSketch((sketch) => { + if (CONFIG.zoomFactor !== 1) sketch.zoom(1 / CONFIG.zoomFactor); + CONFIG.zoomFactor = 1; + sketch.resetView(); + }); + const copy = () => withSketch((sketch) => { sketch.copy(); @@ -131,19 +148,13 @@ const CanvasActions = ({
- withSketch((sketch) => sketch.zoom(1.25))}> + zoomBy(1.25)}> - withSketch((sketch) => sketch.zoom(0.8))}> + zoomBy(0.8)}> - withSketch((sketch) => sketch.resetView())}> + diff --git a/packages/showcase/src/components/Canvas/Scenarios/index.tsx b/packages/showcase/src/components/Canvas/Scenarios/index.tsx index 02e2be0..7539a61 100644 --- a/packages/showcase/src/components/Canvas/Scenarios/index.tsx +++ b/packages/showcase/src/components/Canvas/Scenarios/index.tsx @@ -35,6 +35,7 @@ const selectScenario = (scenario: Scenario) => { CONFIG.hasSelection = false; CONFIG.canPaste = false; CONFIG.view = INITIAL_VIEW; + CONFIG.zoomFactor = 1; }; const ScenarioTabs = ({ scenario }: Pick): ReactElement => ( @@ -78,7 +79,11 @@ const ScenarioControls = ({ { Two fingers on the canvas zoom and pan its view. The drawing is not changed: the view is not saved, not an undo step, and not in the download. The reset - view button is above the canvas. + button above the canvas also takes back the zoom in and zoom out buttons. Date: Fri, 30 Oct 2026 18:13:49 +0200 Subject: [PATCH 29/33] showcase: the pencil is the tool selected at the start It was the line. --- CHANGELOG.md | 1 + packages/showcase/src/stores/config.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7452521..ab87072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ The eraser, laser pointer and polygon tools, pinch zoom, and features that forks - `toDataURL()` draws the whole drawing, whatever the zoom and pan of the view of the canvas (pinch zoom, and the pan tool, which moved what the image showed). - `clear()` also removes the overlay image. +- The showcase starts with the pencil tool selected, it was the line. - When several images are asked for the overlay (or the background), only the last request is set. ### Fixed diff --git a/packages/showcase/src/stores/config.ts b/packages/showcase/src/stores/config.ts index 6679efe..01e7c10 100644 --- a/packages/showcase/src/stores/config.ts +++ b/packages/showcase/src/stores/config.ts @@ -73,7 +73,7 @@ export const createDrawing = (src: string): Drawing => ({ export class ConfigStore implements Config { public scenario: Scenario = "playground"; - public selectedTool = "line"; + public selectedTool = Types.Tool.PENCIL; public lineColor = "#000000"; public lineWidth = 1; public lineStyle: Types.LineStyle = "solid"; From ab448df2e4bbd3c649b83aa367b8afb09a99a0a1 Mon Sep 17 00:00:00 2001 From: tbolis Date: Fri, 30 Oct 2026 18:50:26 +0200 Subject: [PATCH 30/33] fix: onChange is called when the sketch changed (#80) onChange was called 10 ms after every mouse up and mouse out, whether or not the drawing changed: a click which selects, a drag of the pan tool and a pointer which comes and goes all called it. The events manager now notes that an object was added, modified or removed since the mouse was pressed, and tells the application only then, once: the release which follows a mouse out in the middle of a drawing has nothing more to tell. undo, redo and the load of a value call onChange as before. The README no longer lists the limitation, nor the one of the arrow's undo steps, which #201 fixed. --- CHANGELOG.md | 4 + README.md | 78 ++++++----- .../SketchField/SketchField.spec.tsx | 124 +++++++++++++++--- .../src/components/SketchField/events.ts | 18 ++- .../components/SketchField/onChange.spec.tsx | 122 +++++++++++++++++ packages/react-sketch/src/types.ts | 6 +- 6 files changed, 292 insertions(+), 60 deletions(-) create mode 100644 packages/react-sketch/src/components/SketchField/onChange.spec.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index ab87072..6ab7d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,10 @@ The eraser, laser pointer and polygon tools, pinch zoom, and features that forks - `clear()` also removes the overlay image. - The showcase starts with the pencil tool selected, it was the line. - When several images are asked for the overlay (or the background), only the last request is set. +- `onChange` is called when the sketch changed, and not after every mouse up and mouse out (#80): an object + was added, changed or removed since the mouse was pressed. A click which only selects, a drag of the pan + tool and a pointer which comes and goes do not call it, and a drawing which the mouse leaves the canvas + in the middle of calls it once. `undo`, `redo` and the load of a value call it as before. ### Fixed diff --git a/README.md b/README.md index 8bac666..d74fbbf 100644 --- a/README.md +++ b/README.md @@ -212,44 +212,44 @@ sketch.current?.addImg(photoUrl, { crossOrigin: "anonymous" }); // the canvas ca All properties are optional. -| Property | Type | Default | Description | -| ------------------------ | ------------------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `tool` | `Types.Tool` or `string` | `"select"` | The tool to use, see [Tools](#tools). `null` is treated as `"select"`, an unknown name selects `"pencil"`. | -| `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). | -| `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`. | -| `backgroundImageOptions` | `Types.BackgroundOptions` | | Options of `backgroundImage`, those of `setBackgroundFromDataUrl`. Options written inline in a render do not load the image again when their content is the same. | -| `opacity` | `number` | `1.0` | Accepted and not applied: it has no effect. It had no effect in 0.5.x either. | -| `undoSteps` | `number` | `25` | Number of undo/redo steps to keep. Read when the component mounts, a later change has no effect. | -| `imageFormat` | `string` | | Default format of `toDataURL`, `png` or `jpeg`, used when the options of the call have no `format`. Without it, `png`. | -| `value` | `object` or `string` | | Drawing to load, loaded again when it changes. See [Initial, loaded and saved drawings](#initial-loaded-and-saved-drawings). | -| `forceValue` | `boolean` | `false` | Load `value` on every update of the component, even when it did not change. | -| `defaultValue` | `object` or `string` | | Drawing loaded when the component mounts, unless `value` is set. | -| `widthCorrection` | `number` | `0` | Pixels subtracted from the width of the container when the canvas is resized. Compensates a border or padding of the container. | -| `heightCorrection` | `number` | `0` | Pixels subtracted from the height of the container when the canvas is resized. | -| `autoresize` | `boolean` | `true` | Resize the canvas, and scale its objects, when the window is resized. The canvas is always fitted to its container on mount and when `width` or `height` change. | -| `pinchZoom` | `boolean` | `true` | Zoom and pan the view of the canvas with two fingers on a touch screen. See [Pinch zoom](#pinch-zoom). | -| `minZoom` | `number` | `0.5` | The smallest zoom of the view, 1 being the whole canvas. | -| `maxZoom` | `number` | `8` | The largest zoom of the view. | -| `width` | `number` | undefined | Width of the sketch in pixels. When not set, the sketch takes the width of its parent element. | -| `height` | `number` | `512` | Height of the sketch in pixels. | -| `className` | `string` | | Class name of the container element of the canvas. | -| `style` | `CSS.Properties` | | Style of the container element of the canvas. | -| `onChange` | `(event?: Event) => void` | | Called 10 ms after every mouse up and mouse out on the canvas, whether or not the drawing changed (see [Known limitations](#known-limitations)), and after `undo`, `redo` and the load of a value. The argument is the DOM event after a mouse event, nothing otherwise. | -| `onObjectAdded` | `(event: Types.SketchEvent) => void` | no-op | An object was added: drawn, pasted, or loaded with a value. Not called when `undo` or `redo` add an object back. | -| `onObjectModified` | `(event: Types.SketchEvent) => void` | no-op | An object was modified by the user (moved, scaled, rotated). | -| `onObjectRemoved` | `(event: Types.SketchEvent) => void` | no-op | An object was removed from the sketch. | -| `onObjectMoving` | `(event: Types.SketchEvent) => void` | no-op | An object is being moved. | -| `onObjectScaling` | `(event: Types.SketchEvent) => void` | no-op | An object is being scaled. | -| `onObjectRotating` | `(event: Types.SketchEvent) => void` | no-op | An object is being rotated. | -| `onMouseDown` | `(event: Types.SketchEvent) => void` | no-op | Mouse down on the canvas. | -| `onMouseMove` | `(event: Types.SketchEvent) => void` | no-op | Mouse move on the canvas. | -| `onMouseUp` | `(event: Types.SketchEvent) => void` | no-op | Mouse up on the canvas. | -| `onMouseOut` | `(event: Types.SketchEvent) => void` | no-op | The mouse left the canvas. | -| `onViewChange` | `(view: Types.SketchView) => void` | | The view changed (two fingers, or `resetView()`): `zoom` (1 is the whole canvas) and the origin `x`, `y` of the canvas, in pixels. | +| Property | Type | Default | Description | +| ------------------------ | ------------------------------------ | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tool` | `Types.Tool` or `string` | `"select"` | The tool to use, see [Tools](#tools). `null` is treated as `"select"`, an unknown name selects `"pencil"`. | +| `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). | +| `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`. | +| `backgroundImageOptions` | `Types.BackgroundOptions` | | Options of `backgroundImage`, those of `setBackgroundFromDataUrl`. Options written inline in a render do not load the image again when their content is the same. | +| `opacity` | `number` | `1.0` | Accepted and not applied: it has no effect. It had no effect in 0.5.x either. | +| `undoSteps` | `number` | `25` | Number of undo/redo steps to keep. Read when the component mounts, a later change has no effect. | +| `imageFormat` | `string` | | Default format of `toDataURL`, `png` or `jpeg`, used when the options of the call have no `format`. Without it, `png`. | +| `value` | `object` or `string` | | Drawing to load, loaded again when it changes. See [Initial, loaded and saved drawings](#initial-loaded-and-saved-drawings). | +| `forceValue` | `boolean` | `false` | Load `value` on every update of the component, even when it did not change. | +| `defaultValue` | `object` or `string` | | Drawing loaded when the component mounts, unless `value` is set. | +| `widthCorrection` | `number` | `0` | Pixels subtracted from the width of the container when the canvas is resized. Compensates a border or padding of the container. | +| `heightCorrection` | `number` | `0` | Pixels subtracted from the height of the container when the canvas is resized. | +| `autoresize` | `boolean` | `true` | Resize the canvas, and scale its objects, when the window is resized. The canvas is always fitted to its container on mount and when `width` or `height` change. | +| `pinchZoom` | `boolean` | `true` | Zoom and pan the view of the canvas with two fingers on a touch screen. See [Pinch zoom](#pinch-zoom). | +| `minZoom` | `number` | `0.5` | The smallest zoom of the view, 1 being the whole canvas. | +| `maxZoom` | `number` | `8` | The largest zoom of the view. | +| `width` | `number` | undefined | Width of the sketch in pixels. When not set, the sketch takes the width of its parent element. | +| `height` | `number` | `512` | Height of the sketch in pixels. | +| `className` | `string` | | Class name of the container element of the canvas. | +| `style` | `CSS.Properties` | | Style of the container element of the canvas. | +| `onChange` | `(event?: Event) => void` | | Called 10 ms after the mouse is released on the canvas, or leaves it, when an object was added, changed or removed since it was pressed, and after `undo`, `redo` and the load of a value. It is not called for a click which only selects, a drag of the pan tool, or a pointer which comes and goes. The argument is the DOM event after a mouse event, nothing otherwise. | +| `onObjectAdded` | `(event: Types.SketchEvent) => void` | no-op | An object was added: drawn, pasted, or loaded with a value. Not called when `undo` or `redo` add an object back. | +| `onObjectModified` | `(event: Types.SketchEvent) => void` | no-op | An object was modified by the user (moved, scaled, rotated). | +| `onObjectRemoved` | `(event: Types.SketchEvent) => void` | no-op | An object was removed from the sketch. | +| `onObjectMoving` | `(event: Types.SketchEvent) => void` | no-op | An object is being moved. | +| `onObjectScaling` | `(event: Types.SketchEvent) => void` | no-op | An object is being scaled. | +| `onObjectRotating` | `(event: Types.SketchEvent) => void` | no-op | An object is being rotated. | +| `onMouseDown` | `(event: Types.SketchEvent) => void` | no-op | Mouse down on the canvas. | +| `onMouseMove` | `(event: Types.SketchEvent) => void` | no-op | Mouse move on the canvas. | +| `onMouseUp` | `(event: Types.SketchEvent) => void` | no-op | Mouse up on the canvas. | +| `onMouseOut` | `(event: Types.SketchEvent) => void` | no-op | The mouse left the canvas. | +| `onViewChange` | `(view: Types.SketchView) => void` | | The view changed (two fingers, or `resetView()`): `zoom` (1 is the whole canvas) and the origin `x`, `y` of the canvas, in pixels. | `Types.SketchEvent` is `{ e?: Event; target?: Types.SketchObject; [key: string]: unknown }`: `e` is the DOM event, `target` is the object the event is about (object events only). `Types.SketchObject` is @@ -413,9 +413,7 @@ not called when it is drawn. Switching to another tool clears what is left of th ## Known limitations - `opacity` is accepted and not applied. -- `onChange` is called after every mouse up and mouse out, whether or not the drawing changed (issue [#80](https://github.com/tbolis/react-sketch/issues/80)). - `undoSteps` keeps one step more than its value: `undoSteps + 1` actions can be undone. -- Drawing an arrow records undo steps which have no visible effect. - With `forceValue`, every update of the component (for example each re-render of the parent with new properties) loads `value` again. ## Support diff --git a/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx b/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx index 05bdb4f..9dd6b9f 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx @@ -769,23 +769,117 @@ describe("SketchField", () => { }); describe("callbacks", () => { - it("calls onChange with the DOM event, a moment after mouse up and mouse out", () => { - jest.useFakeTimers(); - const onChange = jest.fn(); - const { canvas } = mount({ tool: Tool.RECTANGLE, onChange }); + describe("onChange", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); - const up = mouseEvent("mouseup", END); - fire(canvas, "mouse:up", up); - expect(onChange).not.toHaveBeenCalled(); - jest.advanceTimersByTime(10); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenLastCalledWith(up.e); + afterEach(() => { + jest.useRealTimers(); + }); + + it("is called with the DOM event, a moment after the mouse is released on a drawing", () => { + const onChange = jest.fn(); + const { canvas } = mount({ tool: Tool.RECTANGLE, onChange }); + + fire(canvas, "mouse:down", mouseEvent("mousedown", START)); + fire(canvas, "mouse:move", mouseEvent("mousemove", END)); + const up = mouseEvent("mouseup", END); + fire(canvas, "mouse:up", up); + expect(onChange).not.toHaveBeenCalled(); + jest.advanceTimersByTime(10); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith(up.e); + }); + + it("is called with the DOM event when the mouse leaves the canvas in the middle of a drawing, and once", () => { + const onChange = jest.fn(); + const { canvas } = mount({ tool: Tool.RECTANGLE, onChange }); + + fire(canvas, "mouse:down", mouseEvent("mousedown", START)); + fire(canvas, "mouse:move", mouseEvent("mousemove", END)); + const out = mouseEvent("mouseout", END); + fire(canvas, "mouse:out", out); + jest.advanceTimersByTime(10); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith(out.e); + + // The release outside of the canvas has nothing more to tell + fire(canvas, "mouse:up", mouseEvent("mouseup", END)); + jest.advanceTimersByTime(10); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("is called once for each drawing", () => { + const onChange = jest.fn(); + const { canvas } = mount({ tool: Tool.RECTANGLE, onChange }); + + objectFromDrag(canvas, START, END); + jest.advanceTimersByTime(10); + objectFromDrag(canvas, START, END); + jest.advanceTimersByTime(10); - const out = mouseEvent("mouseout", END); - fire(canvas, "mouse:out", out); - jest.advanceTimersByTime(10); - expect(onChange).toHaveBeenCalledTimes(2); - expect(onChange).toHaveBeenLastCalledWith(out.e); + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it("is not called when the mouse is released or leaves the canvas without a change", () => { + const onChange = jest.fn(); + const { canvas } = mount({ tool: Tool.SELECT, onChange }); + canvas.add(new fabric.Rect({ left: 0, top: 0, width: 50, height: 50 })); + + // A click which only selects, and a pointer which comes and goes + fire(canvas, "mouse:down", mouseEvent("mousedown", START)); + fire(canvas, "mouse:up", mouseEvent("mouseup", START)); + fire(canvas, "mouse:out", mouseEvent("mouseout", END)); + jest.advanceTimersByTime(1000); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("is not called for the mouse of the pan tool", () => { + const onChange = jest.fn(); + const { canvas } = mount({ tool: Tool.PAN, onChange }); + + fire(canvas, "mouse:down", mouseEvent("mousedown", START)); + fire(canvas, "mouse:move", mouseEvent("mousemove", END)); + fire(canvas, "mouse:up", mouseEvent("mouseup", END)); + jest.advanceTimersByTime(1000); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("is called when an object is moved, and when it is removed", () => { + const onChange = jest.fn(); + const { canvas } = mount({ tool: Tool.SELECT, onChange }); + const rect = new fabric.Rect({ left: 0, top: 0, width: 50, height: 50 }); + canvas.add(rect); + + fire(canvas, "mouse:down", mouseEvent("mousedown", START)); + rect.set({ left: 20 }); + fire(canvas, "object:modified", { target: rect }); + fire(canvas, "mouse:up", mouseEvent("mouseup", END)); + jest.advanceTimersByTime(10); + expect(onChange).toHaveBeenCalledTimes(1); + + fire(canvas, "mouse:down", mouseEvent("mousedown", START)); + canvas.remove(rect); + fire(canvas, "mouse:up", mouseEvent("mouseup", END)); + jest.advanceTimersByTime(10); + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it("is not called for a change made before the press, like an object added by code", () => { + const onChange = jest.fn(); + const { canvas } = mount({ tool: Tool.SELECT, onChange }); + canvas.add(new fabric.Rect({ left: 0, top: 0, width: 50, height: 50 })); + + fire(canvas, "mouse:down", mouseEvent("mousedown", START)); + fire(canvas, "mouse:up", mouseEvent("mouseup", START)); + jest.advanceTimersByTime(1000); + + expect(onChange).not.toHaveBeenCalled(); + }); }); it("does not call onChange after the component is unmounted", () => { diff --git a/packages/react-sketch/src/components/SketchField/events.ts b/packages/react-sketch/src/components/SketchField/events.ts index 7ebf4dd..7e42de9 100644 --- a/packages/react-sketch/src/components/SketchField/events.ts +++ b/packages/react-sketch/src/components/SketchField/events.ts @@ -47,6 +47,12 @@ export class FabricCanvasEventsManager private readonly history: SketchHistory; private readonly schedule: (callback: () => void, delay: number) => void; private readonly gesturing: () => boolean; + /** + * Whether an object was added, modified or removed since the mouse was + * pressed. Only then the application is told of a change, so a click which + * selects an object, or a pointer which leaves the canvas, is not one. + */ + private changed = false; /** * @param history the history of the sketch @@ -73,17 +79,20 @@ export class FabricCanvasEventsManager // Only erasable objects are affected by the eraser if (event.target) (event.target as { erasable?: boolean }).erasable = true; if (this.history.replaying) return; + this.changed = true; if (event.target) this.history.onObjectAdded(event.target); props.onObjectAdded?.(sketchEvent(event)); } public onObjectModified({ event, props }: EventInput): void { + this.changed = true; if (event.target) this.history.onObjectModified(event.target); props.onObjectModified?.(sketchEvent(event)); } public onObjectRemoved({ event, tool, props }: EventInput): void { if (this.isPreview(event, tool)) return; + this.changed = true; props.onObjectRemoved?.(sketchEvent(event)); } @@ -100,6 +109,7 @@ export class FabricCanvasEventsManager } public onMouseDown({ event, tool, props }: EventInput): void { + this.changed = false; tool.doMouseDown(event); props.onMouseDown?.(sketchEvent(event)); } @@ -129,11 +139,13 @@ export class FabricCanvasEventsManager } /** - * Tell the application that the sketch may have changed, shortly after - * the mouse was released or left the canvas. + * Tell the application that the sketch changed, shortly after the mouse was + * released or left the canvas. Nothing is told when it did not change, and + * a change is told once: the release which follows a mouse out has none. */ private notifyChange({ event, tool, props }: EventInput): void { - if (!tool.notifiesChange() || this.gesturing()) return; + if (!tool.notifiesChange() || this.gesturing() || !this.changed) return; + this.changed = false; const { onChange } = props; if (onChange) this.schedule(() => onChange(event.e), 10); } diff --git a/packages/react-sketch/src/components/SketchField/onChange.spec.tsx b/packages/react-sketch/src/components/SketchField/onChange.spec.tsx new file mode 100644 index 0000000..a0958bf --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/onChange.spec.tsx @@ -0,0 +1,122 @@ +import { act, render } from "@testing-library/react"; +import SketchField from "./SketchField"; +import { SketchProperties, Tool } from "../../types"; + +type Point = [number, number]; + +const mount = (props: SketchProperties = {}) => { + let current = props; + const view = render(); + const canvasEl = view.container.querySelector( + "canvas.upper-canvas" + ) as HTMLElement; + return { + ...view, + upper: canvasEl, + /** The same sketch, with other properties */ + use: (next: SketchProperties) => { + current = { ...current, ...next }; + view.rerender(); + }, + }; +}; + +const mouseEvent = (type: string, [x, y]: Point): MouseEvent => + new MouseEvent(type, { + clientX: x, + clientY: y, + buttons: type === "mouseup" ? 0 : 1, + bubbles: true, + cancelable: true, + }); + +const wait = (ms = 20) => act(() => jest.advanceTimersByTime(ms)); + +/** Press the mouse at `from`, drag it to `to` and release it, as the browser tells the canvas */ +const drag = (upper: HTMLElement, from: Point, to: Point) => { + upper.dispatchEvent(mouseEvent("mousedown", from)); + document.dispatchEvent( + mouseEvent("mousemove", [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]) + ); + document.dispatchEvent(mouseEvent("mousemove", to)); + document.dispatchEvent(mouseEvent("mouseup", to)); +}; + +describe("onChange, with the events of the browser", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("is called for a drawing, and for the move of it with the select tool", () => { + const onChange = jest.fn(); + const { upper, use } = mount({ tool: Tool.RECTANGLE, onChange }); + + drag(upper, [100, 100], [200, 160]); + wait(); + expect(onChange).toHaveBeenCalledTimes(1); + + use({ tool: Tool.SELECT }); + drag(upper, [150, 130], [170, 150]); + wait(); + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it("is not called for a click which selects, for a drag in the empty part, or for a pointer which leaves", () => { + const onChange = jest.fn(); + const { upper, use } = mount({ tool: Tool.RECTANGLE, onChange }); + drag(upper, [100, 100], [200, 160]); + wait(); + onChange.mockClear(); + use({ tool: Tool.SELECT }); + + // Selects the rectangle, and moves nothing + upper.dispatchEvent(mouseEvent("mousedown", [150, 130])); + document.dispatchEvent(mouseEvent("mouseup", [150, 130])); + // A drag in the part with nothing in it + drag(upper, [400, 400], [450, 450]); + // The pointer comes and goes + upper.dispatchEvent(mouseEvent("mouseout", [450, 450])); + wait(1000); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("is called once for a drawing which the mouse leaves the canvas in the middle of", () => { + const onChange = jest.fn(); + const { upper } = mount({ tool: Tool.LINE, onChange }); + + upper.dispatchEvent(mouseEvent("mousedown", [100, 100])); + document.dispatchEvent(mouseEvent("mousemove", [150, 120])); + upper.dispatchEvent(mouseEvent("mouseout", [150, 120])); + wait(); + expect(onChange).toHaveBeenCalledTimes(1); + + document.dispatchEvent(mouseEvent("mouseup", [300, 300])); + wait(1000); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("is called for a free stroke of the pencil", () => { + const onChange = jest.fn(); + const { upper } = mount({ tool: Tool.PENCIL, onChange }); + + drag(upper, [100, 100], [200, 160]); + wait(); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("is not called for the press and release of a tool which draws nothing, the pan tool", () => { + const onChange = jest.fn(); + const { upper } = mount({ tool: Tool.PAN, onChange }); + + drag(upper, [100, 100], [200, 160]); + wait(1000); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-sketch/src/types.ts b/packages/react-sketch/src/types.ts index 2379ec3..4ab12ae 100644 --- a/packages/react-sketch/src/types.ts +++ b/packages/react-sketch/src/types.ts @@ -234,8 +234,10 @@ export interface SketchProperties { /** Style of the container div of the canvas */ style?: CSS.Properties; /** - * Called after a drawing was finished (mouse up, mouse out) and after undo, redo and - * loading a value. The argument is the DOM event of the former, nothing in the latter + * Called when the mouse was released on the canvas, or left it, after an object was + * added, changed or removed since it was pressed, and after undo, redo and loading a + * value. Not called for a click which only selects, or for a pointer which comes and + * goes. The argument is the DOM event of the former, nothing in the latter */ onChange?: (event?: Event) => void; /** Called when an object is added by the user, not when undo or redo add it back */ From 72df4b2ca2c62db14e99f541e146dc815ce4d20e Mon Sep 17 00:00:00 2001 From: tbolis Date: Mon, 2 Nov 2026 10:09:38 +0200 Subject: [PATCH 31/33] fix: the objects added while a drawing tool is in use are frozen The tools which draw a shape or pan freeze the objects of the canvas when they take over, but not the ones added afterwards: an image from addImg(), a text from addText(), a pasted object, and one which undo brings back were selectable. A press on an image with the arrow tool selected it, and a drag moved it instead of drawing the arrow. The tools get isInteractive(), and the events manager freezes what is added while the tool in use is not interactive. The select tool unfreezes all of them when it takes over, as before. --- CHANGELOG.md | 5 + README.md | 30 ++-- .../SketchField/addedObjects.spec.tsx | 164 ++++++++++++++++++ .../src/components/SketchField/events.ts | 6 + .../src/components/SketchField/tools/index.ts | 13 ++ 5 files changed, 203 insertions(+), 15 deletions(-) create mode 100644 packages/react-sketch/src/components/SketchField/addedObjects.spec.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ab7d23..eb401ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,11 @@ The eraser, laser pointer and polygon tools, pinch zoom, and features that forks - The arrow tool: an arrow is one undo step, and `onObjectAdded` is called once with it, and `onObjectRemoved` is not called. The line and the head which are shown while the arrow is drawn were recorded and announced too, so an arrow took three undos, and `onObjectAdded` was called three times. +- The tools which draw a shape or pan (line, arrow, rectangle, rectangle label, circle, polygon, pan and + the default tool) freeze the objects which are added while they are in use too: an image from `addImg()`, + a text from `addText()`, a pasted object, and one which undo brings back. They were selectable, so a press + on an image drew nothing and moved the image instead, with the arrow or any other of these tools. They are + selectable again when the select tool takes over. ### Packaging and tooling diff --git a/README.md b/README.md index d74fbbf..9be974f 100644 --- a/README.md +++ b/README.md @@ -292,21 +292,21 @@ throws. Use the tools by name (`"pencil"`), with `Tools` (`Tools.Pencil`) or with the enumeration `Types.Tool` (`Types.Tool.PENCIL`). -| Tool | `Tools` | Value | Description | -| --------------- | ---------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Select | `Tools.Select` | `select` | Disables drawing. Objects can be selected, moved, scaled and rotated. | -| Pencil | `Tools.Pencil` | `pencil` | Free drawing. | -| Line | `Tools.Line` | `line` | Straight lines. | -| Arrow | `Tools.Arrow` | `arrow` | Lines with an arrow head. | -| Rectangle | `Tools.Rectangle` | `rectangle` | Rectangles, filled with `fillColor`. | -| Rectangle label | `Tools.RectangleLabel` | `rectangle-label` | Rectangles with a text box above them. The text box follows the rectangle when it is moved or scaled. | -| Polygon | `Tools.Polygon` | `polygon` | Closed shapes of straight sides, filled with `fillColor`. Each click or tap is a vertex. See [Polygon](#polygon). | -| Circle | `Tools.Circle` | `circle` | Circles, filled with `fillColor`. The drag from the first to the second point is the diameter. | -| Pan | `Tools.Pan` | `pan` | Disables drawing. Dragging moves the whole canvas, useful after zooming (contributed by [wmaillard](https://github.com/wmaillard)). | -| Highlighter | `Tools.Highlighter` | `highlighter` | Free drawing in a semi-transparent color. `lineColor` must be a hex code or a color name; any other value falls back to the default color. | -| Eraser | `Tools.Eraser` | `eraser` | Erases parts of the objects, with the width of `lineWidth`. The cursor shows the size of the brush. The background color and the background image are not erased. See [Eraser](#eraser). | -| Laser pointer | `Tools.LaserPointer` | `laser-pointer` | A line that follows the pointer, fades out and disappears. It is not part of the drawing. See [Laser pointer](#laser-pointer). | -| Default tool | `Tools.DefaultTool` | `default-tool` | Disables drawing and selection. Use it to show a drawing that is not edited. | +| Tool | `Tools` | Value | Description | +| --------------- | ---------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Select | `Tools.Select` | `select` | Disables drawing. Objects can be selected, moved, scaled and rotated. The tools which draw a shape, and the pan tool, freeze the objects (also the ones added while they are in use), so a drag draws or pans. | +| Pencil | `Tools.Pencil` | `pencil` | Free drawing. | +| Line | `Tools.Line` | `line` | Straight lines. | +| Arrow | `Tools.Arrow` | `arrow` | Lines with an arrow head. | +| Rectangle | `Tools.Rectangle` | `rectangle` | Rectangles, filled with `fillColor`. | +| Rectangle label | `Tools.RectangleLabel` | `rectangle-label` | Rectangles with a text box above them. The text box follows the rectangle when it is moved or scaled. | +| Polygon | `Tools.Polygon` | `polygon` | Closed shapes of straight sides, filled with `fillColor`. Each click or tap is a vertex. See [Polygon](#polygon). | +| Circle | `Tools.Circle` | `circle` | Circles, filled with `fillColor`. The drag from the first to the second point is the diameter. | +| Pan | `Tools.Pan` | `pan` | Disables drawing. Dragging moves the whole canvas, useful after zooming (contributed by [wmaillard](https://github.com/wmaillard)). | +| Highlighter | `Tools.Highlighter` | `highlighter` | Free drawing in a semi-transparent color. `lineColor` must be a hex code or a color name; any other value falls back to the default color. | +| Eraser | `Tools.Eraser` | `eraser` | Erases parts of the objects, with the width of `lineWidth`. The cursor shows the size of the brush. The background color and the background image are not erased. See [Eraser](#eraser). | +| Laser pointer | `Tools.LaserPointer` | `laser-pointer` | A line that follows the pointer, fades out and disappears. It is not part of the drawing. See [Laser pointer](#laser-pointer). | +| Default tool | `Tools.DefaultTool` | `default-tool` | Disables drawing and selection. Use it to show a drawing that is not edited. | ### Line style diff --git a/packages/react-sketch/src/components/SketchField/addedObjects.spec.tsx b/packages/react-sketch/src/components/SketchField/addedObjects.spec.tsx new file mode 100644 index 0000000..9a3786a --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/addedObjects.spec.tsx @@ -0,0 +1,164 @@ +import { createRef } from "react"; +import { act, render } from "@testing-library/react"; +import * as fabric from "fabric"; +import SketchField from "./SketchField"; +import { SketchProperties, Tool } from "../../types"; + +type Point = [number, number]; + +const mount = (props: SketchProperties = {}) => { + const ref = createRef(); + const view = render(); + const sketch = ref.current as SketchField; + return { + ...view, + sketch, + canvas: sketch["canvas"] as fabric.Canvas, + /** The same sketch, with another tool */ + use: (tool: Tool) => + view.rerender(), + }; +}; + +/** The image which `addImg` adds: jsdom loads no image */ +const stubImages = () => + jest.spyOn(fabric.FabricImage, "fromURL").mockImplementation(() => + Promise.resolve( + new fabric.FabricImage(document.createElement("img"), { + width: 100, + height: 60, + }) + ) + ); + +const settle = () => act(async () => void (await jest.advanceTimersByTimeAsync(50))); + +const mouseEvent = (type: string, [x, y]: Point): MouseEvent => + new MouseEvent(type, { + clientX: x, + clientY: y, + buttons: type === "mouseup" ? 0 : 1, + bubbles: true, + cancelable: true, + }); + +/** Press the mouse at `from`, drag it to `to` and release it, as the browser tells the canvas */ +const drag = (canvas: fabric.Canvas, from: Point, to: Point) => { + canvas.upperCanvasEl.dispatchEvent(mouseEvent("mousedown", from)); + document.dispatchEvent( + mouseEvent("mousemove", [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]) + ); + document.dispatchEvent(mouseEvent("mousemove", to)); + document.dispatchEvent(mouseEvent("mouseup", to)); +}; + +/** The tools which freeze the objects, as a drag with them always draws, or pans */ +const FROZEN = [ + Tool.LINE, + Tool.ARROW, + Tool.RECTANGLE, + Tool.RECTANGLE_LABEL, + Tool.CIRCLE, + Tool.POLYGON, + Tool.PAN, + Tool.DEFAULT_TOOL, +]; + +describe("objects added while a tool draws", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + describe.each(FROZEN)("with the %s tool", (tool) => { + it("are not selectable: an image, a text", async () => { + stubImages(); + const { sketch, canvas } = mount({ tool }); + + sketch.addImg("data:image/png;base64,00", { left: 10, top: 10, scale: 1 }); + sketch.addText("Hello"); + await settle(); + + const objects = canvas.getObjects(); + // The text is added at once, the image when it is loaded + expect(objects.map((o) => o.type).sort()).toEqual(["i-text", "image"]); + objects.forEach((object) => { + expect(object.selectable).toBe(false); + expect(object.evented).toBe(false); + }); + }); + }); + + it("are selectable with the select tool, and with the tools which draw free hand", async () => { + stubImages(); + for (const tool of [Tool.SELECT, Tool.PENCIL, Tool.HIGHLIGHTER, Tool.ERASER]) { + const { sketch, canvas, unmount } = mount({ tool }); + + sketch.addImg("data:image/png;base64,00"); + sketch.addText("Hello"); + await settle(); + + canvas.getObjects().forEach((object) => { + expect(object.selectable).toBe(true); + expect(object.evented).toBe(true); + }); + unmount(); + } + }); + + it("are selectable again when the select tool takes over", async () => { + stubImages(); + const { sketch, canvas, use } = mount({ tool: Tool.ARROW }); + sketch.addImg("data:image/png;base64,00"); + await settle(); + const [image] = canvas.getObjects(); + expect(image.selectable).toBe(false); + + use(Tool.SELECT); + + expect(image.selectable).toBe(true); + expect(image.evented).toBe(true); + }); + + it("do not move when an arrow is drawn from them, and the arrow is drawn", async () => { + stubImages(); + const { sketch, canvas } = mount({ tool: Tool.ARROW }); + sketch.addImg("data:image/png;base64,00", { left: 100, top: 100, scale: 1 }); + await settle(); + const [image] = canvas.getObjects(); + + drag(canvas, [120, 120], [300, 200]); + + expect(canvas.getActiveObject()).toBeUndefined(); + expect([image.left, image.top]).toEqual([100, 100]); + const objects = canvas.getObjects(); + expect(objects).toHaveLength(2); + expect(objects[1]).toBeInstanceOf(fabric.Group); + }); + + it("are not selectable when a paste adds them, or an undo brings them back", async () => { + const { sketch, canvas, use } = mount({ tool: Tool.SELECT }); + const rect = new fabric.Rect({ left: 10, top: 10, width: 50, height: 50 }); + canvas.add(rect); + canvas.setActiveObject(rect); + sketch.copy(); + sketch.removeSelected(); + expect(canvas.getObjects()).toHaveLength(0); + + use(Tool.ARROW); + sketch.undo(); + sketch.paste(); + await settle(); + + const objects = canvas.getObjects(); + expect(objects).toHaveLength(2); + objects.forEach((object) => { + expect(object.selectable).toBe(false); + expect(object.evented).toBe(false); + }); + }); +}); diff --git a/packages/react-sketch/src/components/SketchField/events.ts b/packages/react-sketch/src/components/SketchField/events.ts index 7e42de9..37a8c71 100644 --- a/packages/react-sketch/src/components/SketchField/events.ts +++ b/packages/react-sketch/src/components/SketchField/events.ts @@ -76,6 +76,12 @@ export class FabricCanvasEventsManager */ public onObjectAdded({ event, tool, props }: EventInput): void { if (this.isPreview(event, tool)) return; + // What is added while a tool draws (an image, a text, a pasted object, one which + // undo brings back) is frozen like the rest: a press on it would select it and + // a drag would move it, instead of drawing + if (event.target && !tool.isInteractive()) { + event.target.set({ selectable: false, evented: false }); + } // Only erasable objects are affected by the eraser if (event.target) (event.target as { erasable?: boolean }).erasable = true; if (this.history.replaying) return; diff --git a/packages/react-sketch/src/components/SketchField/tools/index.ts b/packages/react-sketch/src/components/SketchField/tools/index.ts index a5928d9..b7fa1db 100644 --- a/packages/react-sketch/src/components/SketchField/tools/index.ts +++ b/packages/react-sketch/src/components/SketchField/tools/index.ts @@ -69,6 +69,7 @@ export abstract class AbstractEventAware implements CanvasEventAware { */ export abstract class FabricCanvasTool { private readonly _canvas: fabric.Canvas; + private interactive = true; protected constructor(canvas: fabric.Canvas) { this._canvas = canvas; @@ -98,6 +99,17 @@ export abstract class FabricCanvasTool { */ dispose(): void {} + /** + * Whether the objects can be selected and moved with the tool. It is not for + * the tools which freeze them, so a press on an object draws instead of + * moving it: the objects which are added while the tool is in use are frozen + * too. The tools which draw free hand leave them as they are, as fabric + * does not select an object in drawing mode. + */ + isInteractive(): boolean { + return this.interactive; + } + /** * Called when the content of the canvas is replaced (it is cleared, a value * is loaded, an undo or a redo is done), for the tools which hold something @@ -178,6 +190,7 @@ export abstract class FabricCanvasTool { * Free drawing is turned off in both. */ protected setInteractive(interactive: boolean): void { + this.interactive = interactive; this.canvas.isDrawingMode = false; this.canvas.selection = interactive; this.canvas.forEachObject((o) => (o.selectable = o.evented = interactive)); From fc2459f574df59f68e594591d616bdd6ddcbe4ee Mon Sep 17 00:00:00 2001 From: tbolis Date: Fri, 30 Oct 2026 19:03:16 +0200 Subject: [PATCH 32/33] showcase: a preview of the drawing with a frame for the view (#150) The Preview section has a small copy of the whole drawing, drawn again after each change, undo, redo, zoom and clear with toDataURL({ multiplier }). When the view is zoomed or panned, a frame shows the part which the canvas shows, from onViewChange. The README has the recipe in "A preview of the drawing". --- CHANGELOG.md | 3 + README.md | 37 +++++++++ .../components/Canvas/CanvasActions/index.tsx | 5 +- .../src/components/Canvas/Scenarios/index.tsx | 1 + .../showcase/src/components/Canvas/index.tsx | 8 +- .../src/components/Canvas/sketchRef.ts | 27 +++++++ .../src/components/Tools/Preview/index.tsx | 78 +++++++++++++++++++ .../showcase/src/components/Tools/index.tsx | 2 + packages/showcase/src/stores/config.ts | 3 + 9 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 packages/showcase/src/components/Tools/Preview/index.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index eb401ad..df4a586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,9 @@ The eraser, laser pointer and polygon tools, pinch zoom, and features that forks in `toJSON()`, not an undo step, and `onChange` is not called for it. The canvas cannot be taken out of sight. When the second finger comes, what the first one started (a line, a shape, a free stroke, the eraser, a vertex of a polygon) is dropped and leaves no object nor undo step, so a polygon in progress is not disturbed. +- The showcase has a Preview section (#150): a small copy of the whole drawing, drawn again after each + change with `toDataURL({ multiplier })`, with a frame on it for the part which the canvas shows when the + view is zoomed or panned. The README has the same in "A preview of the drawing". - The showcase has a View section for the pinch zoom (the switch, the smallest and the largest zoom, and the view as `onViewChange` reports it) and a reset button above the canvas, which also takes back the zoom in and zoom out buttons. diff --git a/README.md b/README.md index 9be974f..ac013c6 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,43 @@ export const Editor = ({ imageUrl }: { imageUrl: string }) => { }; ``` +### A preview of the drawing + +`toDataURL()` draws the whole drawing, whatever the zoom and the pan of the view, so a small copy of it, +drawn again after each change, is a preview (or a minimap) of the sketch: + +```tsx +import { useRef, useState } from "react"; +import { SketchField } from "react-sketch"; + +export const WithPreview = () => { + const sketch = useRef(null); + const [preview, setPreview] = useState(""); + const refresh = () => + setPreview(sketch.current?.toDataURL({ multiplier: 0.25 }) ?? ""); + + return ( + <> + + {preview && Preview of the drawing} + + ); +}; +``` + +`onChange` is called for a drawing, `undo`, `redo` and the load of a value. `onObjectAdded` and +`onObjectRemoved` also tell of the objects which code adds or removes (`addText()`, `addImg()`, `paste()`, +`removeSelected()`). `zoom()` and `clear()` tell nothing, so draw the preview again after them. With +`onViewChange`, a frame on the preview can show what the canvas shows: the visible part starts at +`-x / zoom`, `-y / zoom` in canvas pixels, and is the size of the canvas divided by `zoom`. The showcase has +this in its Preview section. `toDataURL()` throws for a canvas with an image of another site which does not +allow it (CORS), so catch it where that can happen. + ### Background and overlay images - The background image is drawn under the objects. Set it with the `backgroundImage` property (and `backgroundImageOptions`), or with `setBackgroundFromDataUrl` and `removeBackgroundImage`. The property takes the place of the background image of a `value`. diff --git a/packages/showcase/src/components/Canvas/CanvasActions/index.tsx b/packages/showcase/src/components/Canvas/CanvasActions/index.tsx index 8900af0..b7ea774 100644 --- a/packages/showcase/src/components/Canvas/CanvasActions/index.tsx +++ b/packages/showcase/src/components/Canvas/CanvasActions/index.tsx @@ -16,7 +16,7 @@ import IconButton from "@mui/material/IconButton"; import Stack from "@mui/material/Stack"; import { CONFIG, createDrawing, type Config } from "../../../stores/config"; import { downloadText, downloadUrl } from "../../../utils/files"; -import { syncState, withSketch } from "../sketchRef"; +import { syncPreview, syncState, withSketch } from "../sketchRef"; type CanvasActionsProps = Pick< Config, @@ -60,6 +60,7 @@ const clear = () => { CONFIG.zoomFactor = 1; // The canvas does not announce it syncState(); + syncPreview(); }; /** The buttons scale the drawing (`zoom()`), which is not the view of the fingers */ @@ -67,6 +68,7 @@ const zoomBy = (factor: number) => withSketch((sketch) => { sketch.zoom(factor); CONFIG.zoomFactor *= factor; + syncPreview(); }); /** Back to the whole canvas: what the buttons scaled, and what the fingers zoomed and panned */ @@ -75,6 +77,7 @@ const resetView = () => if (CONFIG.zoomFactor !== 1) sketch.zoom(1 / CONFIG.zoomFactor); CONFIG.zoomFactor = 1; sketch.resetView(); + syncPreview(); }); const copy = () => diff --git a/packages/showcase/src/components/Canvas/Scenarios/index.tsx b/packages/showcase/src/components/Canvas/Scenarios/index.tsx index 7539a61..0a7f036 100644 --- a/packages/showcase/src/components/Canvas/Scenarios/index.tsx +++ b/packages/showcase/src/components/Canvas/Scenarios/index.tsx @@ -36,6 +36,7 @@ const selectScenario = (scenario: Scenario) => { CONFIG.canPaste = false; CONFIG.view = INITIAL_VIEW; CONFIG.zoomFactor = 1; + CONFIG.preview = ""; }; const ScenarioTabs = ({ scenario }: Pick): ReactElement => ( diff --git a/packages/showcase/src/components/Canvas/index.tsx b/packages/showcase/src/components/Canvas/index.tsx index c1717d6..81fe1fe 100644 --- a/packages/showcase/src/components/Canvas/index.tsx +++ b/packages/showcase/src/components/Canvas/index.tsx @@ -7,7 +7,7 @@ import { CONFIG, type Config } from "../../stores/config"; import CanvasActions from "./CanvasActions"; import Drawings from "./Drawings"; import { ScenarioControls, ScenarioTabs } from "./Scenarios"; -import { sketchRef, syncState } from "./sketchRef"; +import { sketchRef, syncChange, syncState } from "./sketchRef"; const style = { backgroundColor: teal[50] }; @@ -73,11 +73,11 @@ const SketchCanvas = (props: Config): ReactElement => { value={controlled ? props.controlledValue : undefined} forceValue={controlled && props.forceValue} // The canvas announces a change after drawing, undo, redo and loading a value - onChange={syncState} + onChange={syncChange} // The selection changes with the mouse, and the objects with the actions onMouseUp={syncState} - onObjectAdded={syncState} - onObjectRemoved={syncState} + onObjectAdded={syncChange} + onObjectRemoved={syncChange} />
diff --git a/packages/showcase/src/components/Canvas/sketchRef.ts b/packages/showcase/src/components/Canvas/sketchRef.ts index 266c188..c414167 100644 --- a/packages/showcase/src/components/Canvas/sketchRef.ts +++ b/packages/showcase/src/components/Canvas/sketchRef.ts @@ -18,6 +18,25 @@ export const withSketch = (action: (sketch: SketchField) => void): void => { } }; +/** The size of the preview, as a fraction of the canvas */ +export const PREVIEW_MULTIPLIER = 0.25; + +/** + * Draw the preview again: a small copy of the whole drawing, whatever the view of the + * canvas shows. A canvas with an image of another site which does not allow it (CORS) + * cannot be exported, so there is no preview then. + */ +export const syncPreview = (): void => + queueMicrotask(() => + withSketch((sketch) => { + try { + CONFIG.preview = sketch.toDataURL({ multiplier: PREVIEW_MULTIPLIER }); + } catch { + CONFIG.preview = ""; + } + }) + ); + /** * Copy the state of the canvas which enables or disables the actions (undo, redo, * copy, delete) into the store. The canvas does not announce all of its changes, so @@ -34,3 +53,11 @@ export const syncState = (): void => CONFIG.hasSelection = sketch.hasSelection(); }) ); + +/** + * What the changes of the drawing update: the state of the actions, and the preview + */ +export const syncChange = (): void => { + syncState(); + syncPreview(); +}; diff --git a/packages/showcase/src/components/Tools/Preview/index.tsx b/packages/showcase/src/components/Tools/Preview/index.tsx new file mode 100644 index 0000000..597b74a --- /dev/null +++ b/packages/showcase/src/components/Tools/Preview/index.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import type { ReactElement } from "react"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { teal } from "@mui/material/colors"; +import { type Config } from "../../../stores/config"; +import { PREVIEW_MULTIPLIER } from "../../Canvas/sketchRef"; +import Section from "../Section"; + +type PreviewProps = Pick; + +/** + * A small copy of the whole drawing, drawn again after each change with + * `toDataURL({ multiplier })`. When the view is zoomed or panned, a frame shows + * the part of the drawing which the canvas shows, as a minimap does. + */ +const Preview = ({ preview, view }: PreviewProps): ReactElement => { + // The size of the image, which is the size of the canvas times the multiplier + const [size, setSize] = useState({ width: 0, height: 0 }); + const moved = view.zoom !== 1 || view.x !== 0 || view.y !== 0; + // The canvas is size / multiplier, and the view puts its origin at x, y + const frame = { + left: `${((-view.x * PREVIEW_MULTIPLIER) / (view.zoom * size.width)) * 100}%`, + top: `${((-view.y * PREVIEW_MULTIPLIER) / (view.zoom * size.height)) * 100}%`, + width: `${100 / view.zoom}%`, + height: `${100 / view.zoom}%`, + }; + + return ( +
+ + The whole drawing, a quarter of its size, drawn again after each change. With + the view zoomed or panned, the frame is what the canvas shows. + + {preview ? ( + + Preview of the drawing + setSize({ + width: event.currentTarget.naturalWidth, + height: event.currentTarget.naturalHeight, + }) + } + /> + {moved && size.width > 0 && size.height > 0 && ( + + )} + + ) : ( + + Nothing drawn yet. + + )} +
+ ); +}; + +export default Preview; diff --git a/packages/showcase/src/components/Tools/index.tsx b/packages/showcase/src/components/Tools/index.tsx index 27c1f06..75da1a1 100644 --- a/packages/showcase/src/components/Tools/index.tsx +++ b/packages/showcase/src/components/Tools/index.tsx @@ -11,6 +11,7 @@ import ImageControls from "./ImageControls"; import LabeledSlider from "./LabeledSlider"; import LineStyleSelector from "./LineStyleSelector"; import Overlay from "./Overlay"; +import Preview from "./Preview"; import Section from "./Section"; import TextControls from "./TextControls"; import ViewControls from "./ViewControls"; @@ -100,6 +101,7 @@ const SketchTools = (props: Config): ReactElement => { stretchedX={props.stretchedX} stretchedY={props.stretchedY} /> + Date: Mon, 2 Nov 2026 12:52:54 +0200 Subject: [PATCH 33/33] fix: the rectangle label listens to the canvas once for all its labels Each label subscribed to object:scaling and object:moving on the canvas when it was drawn, and nothing unsubscribed. The listeners stayed after a label was deleted or the canvas was cleared, so a canvas drawn on for long kept more and more of them. The canvas has the two listeners once, and they look the label up by the target of the event. They are not removed with a label, as undo and redo take its rectangle off the canvas and put it back, and it follows again. --- CHANGELOG.md | 4 ++ .../RectangleLabel/RectangleLabelObject.ts | 30 +++++++-- .../SketchField/tools/tools.spec.ts | 62 +++++++++++++++++++ 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df4a586..ae5d7c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,10 @@ The eraser, laser pointer and polygon tools, pinch zoom, and features that forks a text from `addText()`, a pasted object, and one which undo brings back. They were selectable, so a press on an image drew nothing and moved the image instead, with the arrow or any other of these tools. They are selectable again when the select tool takes over. +- The rectangle label tool: the canvas has two listeners for all the labels, not two more for each label + that is drawn. They were never removed, so they stayed after a label was deleted or the canvas was + cleared, and a canvas that is drawn on for long kept more of them. A label still follows its rectangle + when undo and redo take it off the canvas and put it back. ### Packaging and tooling diff --git a/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabelObject.ts b/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabelObject.ts index a1db611..162f1b5 100644 --- a/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabelObject.ts +++ b/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabelObject.ts @@ -1,6 +1,27 @@ import * as fabric from "fabric"; import { CanvasEvent } from "../../events"; +/** The label of each rectangle, so that the listeners of a canvas serve all of its labels */ +const labels = new WeakMap(); + +/** The canvases which already have those listeners */ +const following = new WeakSet(); + +/** + * Make the labels of the canvas follow their rectangles. A label stays with its + * rectangle when it is taken off the canvas and put back (undo, redo), so the + * listeners are not removed with it: there are two for each canvas, not for each label. + */ +const follow = (canvas: fabric.Canvas): void => { + if (following.has(canvas)) return; + following.add(canvas); + const update = ({ target }: CanvasEvent): void => { + if (target) labels.get(target)?.update(); + }; + canvas.on("object:scaling", update); + canvas.on("object:moving", update); +}; + /** * A rectangle with a text box on top of it. The text box follows the * rectangle when it is moved or scaled. @@ -17,12 +38,11 @@ export class RectangleLabelObject { ) { this.rect = new fabric.Rect(rectProps); this.textbox = new fabric.Textbox(text, textProps); - canvas.on("object:scaling", this.update); - canvas.on("object:moving", this.update); + labels.set(this.rect, this); + follow(canvas); } - update = (event: CanvasEvent): void => { - if (event.target !== this.rect) return; + update(): void { this.textbox.set({ width: this.rect.getScaledWidth(), scaleX: 1, @@ -30,7 +50,7 @@ export class RectangleLabelObject { top: (this.rect.top ?? 0) - this.textbox.getScaledHeight(), left: this.rect.left, }); - }; + } setText(text: string): void { this.textbox.set({ text }); diff --git a/packages/react-sketch/src/components/SketchField/tools/tools.spec.ts b/packages/react-sketch/src/components/SketchField/tools/tools.spec.ts index d2d7654..6ed6d32 100644 --- a/packages/react-sketch/src/components/SketchField/tools/tools.spec.ts +++ b/packages/react-sketch/src/components/SketchField/tools/tools.spec.ts @@ -215,6 +215,68 @@ describe("Tools", () => { expect(text.left).toEqual(200); expect(text.top).toEqual(150 - text.getScaledHeight()); }); + + it("listens to the canvas once, whatever the number of labels", () => { + const on = jest.spyOn(canvas, "on"); + const tool = create_tool(canvas, { tool: Tool.RECTANGLE_LABEL }); + [100, 200, 300].forEach((y) => drag(tool, [100, y], [180, y + 60])); + + const listened = (name: string) => + on.mock.calls.filter(([event]) => event === name); + expect(canvas.getObjects()).toHaveLength(6); + expect(listened("object:moving")).toHaveLength(1); + expect(listened("object:scaling")).toHaveLength(1); + }); + + it("moves each label along with its own rectangle", () => { + const tool = create_tool(canvas, { tool: Tool.RECTANGLE_LABEL }); + drag(tool, [100, 100], [180, 160]); + drag(tool, [100, 300], [180, 360]); + + const [, first, second, other] = canvas.getObjects() as fabric.FabricObject[]; + const firstTop = first.top; + second.set({ left: 250, top: 320 }); + fire(canvas, "object:moving", { target: second }); + + expect(other.left).toEqual(250); + expect(other.top).toEqual(320 - other.getScaledHeight()); + expect(first.top).toEqual(firstTop); + }); + + it("moves the label of a canvas which is not the first one", () => { + const another = new fabric.Canvas(document.createElement("canvas")); + try { + drag( + create_tool(canvas, { tool: Tool.RECTANGLE_LABEL }), + [10, 10], + [50, 50] + ); + const tool = create_tool(another, { tool: Tool.RECTANGLE_LABEL }); + drag(tool, [100, 100], [180, 160]); + + const [rect, text] = another.getObjects() as fabric.FabricObject[]; + rect.set({ left: 200, top: 150 }); + fire(another, "object:moving", { target: rect }); + + expect(text.left).toEqual(200); + expect(text.top).toEqual(150 - text.getScaledHeight()); + } finally { + another.dispose(); + } + }); + + it("moves the label of a rectangle which undo and redo took off and put back", () => { + const tool = create_tool(canvas, { tool: Tool.RECTANGLE_LABEL }); + drag(tool, [100, 100], [180, 160]); + + const [rect, text] = canvas.getObjects() as fabric.FabricObject[]; + canvas.remove(rect); + canvas.add(rect); + rect.set({ left: 200, top: 150 }); + fire(canvas, "object:moving", { target: rect }); + + expect(text.left).toEqual(200); + }); }); describe("Pan", () => {