diff --git a/CHANGELOG.md b/CHANGELOG.md index d1b6d4a..82ef779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 1.2.0 + +### Added + +- `toDataURL({ multiplier: "original" })` (#75): the drawing at the resolution of the background image, one + pixel of the picture for each pixel of the image, at any size of the canvas. Without a background image the + multiplier is 1. An image which is scaled differently in the two directions is exported at the larger + scale, so no pixel of it is lost. +- `getOriginalSize()`: the size of the background image in pixels, as the file has it, and the new + `Types.SketchSize`. A canvas of that size shows the image pixel for pixel. +- The showcase has an Original size button in the Canvas tab, which gives the canvas the size of the + background image, and its download has the resolution of the image. + +### Fixed + +- Undo and redo of a move, scale or rotation of a straight line (the line tool) put the line at the top left + corner of the canvas, where it was not seen. A line has its points relative to its center, and setting them + again moved it to the origin. Undo and redo now set the properties which the modification changed, and no + others. +- Undo after the move, scale or rotation of a selection of many objects removed the last object which was drawn, + as the modification of a selection was not recorded. It is now one action, which undo and redo apply to all + the objects of the selection, and the selection is left when they do. +- Undo of the move of a rectangle with a label made the rectangle disappear, as it went back to the size it had + when the pointer was pressed to draw it, and left its text box behind. They go back together, to where they + were. +- Undo of a modification goes back to the state of the object when the pointer was pressed on it, and not to the + one it had when it was added or last modified by the history. A modification which changes nothing is not an + action to undo. +- The drawing is scaled by exact factors when the canvas is resized. The factors were rounded to two + decimals, which left the drawing and the background image a little off their size, and the error added up + with every resize, so a canvas could not go back to the size it had. + ## 1.1.0 (2026-11-04) 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. diff --git a/README.md b/README.md index c013398..0f8c314 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,55 @@ export const Editor = ({ imageUrl }: { imageUrl: string }) => { }; ``` +### Keeping the resolution of an image + +The canvas has the size of its container, so `toDataURL()` draws the drawing at that size, and a photo which is shown +smaller than it is loses pixels in the file. `multiplier: "original"` draws it at the resolution of the background +image, one pixel of the picture for each pixel of the image, at any size of the canvas (without a background image it +is 1). `getOriginalSize()` returns the size of the image in pixels, and a canvas of that size shows the image pixel for +pixel, so it can be restored with it: + +```tsx +import { useRef, useState } from "react"; +import { SketchField, Types } from "react-sketch"; + +export const OnPhoto = ({ photo }: { photo: string }) => { + const sketch = useRef(null); + const [size, setSize] = useState({ width: 800, height: 600 }); + + const download = () => { + const link = document.createElement("a"); + // The resolution of the photo, whatever the size of the canvas + link.href = sketch.current?.toDataURL({ multiplier: "original" }) ?? ""; + link.download = "photo.png"; + link.click(); + }; + + // The drawing is scaled with the canvas, so it stays on the same part of the photo + const restoreSize = () => setSize(sketch.current?.getOriginalSize() ?? size); + + return ( + <> + + + + + ); +}; +``` + +To keep the resolution of the image, keep the image as the background and the drawing as objects (`toJSON()` saves +them), and export once, at the end. Exporting the drawing and using it as the next background draws it again at the +size of the canvas each time. Browsers limit the area of a canvas (a phone about 16 million pixels, for example), and +an export over the limit is empty. + ### 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, @@ -300,31 +349,33 @@ The methods are properties of the component instance, access them through a `ref the moment the component is mounted until it is unmounted. Calling one before the component is mounted throws. -| Method | Returns | Description | -| --------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `undo()` | `void` | Undo the last action. Does nothing when there is nothing to undo. Calls `onChange` when it undid something. | -| `redo()` | `void` | Redo the last undone action. Does nothing when there is nothing to redo. Calls `onChange` when it redid something. | -| `canUndo()` | `boolean` | Whether `undo()` has something to undo. | -| `canRedo()` | `boolean` | Whether `redo()` has something to redo. | -| `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, 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 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. | -| `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. | -| `copy()` | `void` | Copy the selected object. Does nothing without a selection. | -| `paste()` | `void` | Paste the copied object, 10 pixels further down and to the right of the previous one. Does nothing when nothing was copied. | -| `enableTouchScroll()` | `void` | Allow the page to scroll on touch devices when touching the canvas. | -| `disableTouchScroll()` | `void` | Do not scroll the page when touching the canvas. | +| Method | Returns | Description | +| --------------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `undo()` | `void` | Undo the last action. Does nothing when there is nothing to undo. Calls `onChange` when it undid something. | +| `redo()` | `void` | Redo the last undone action. Does nothing when there is nothing to redo. Calls `onChange` when it redid something. | +| `canUndo()` | `boolean` | Whether `undo()` has something to undo. | +| `canRedo()` | `boolean` | Whether `redo()` has something to redo. | +| `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` (a number, default 1, or `"original"` for the resolution of the background image), and the crop area `left`, `top`, `width`, `height`. | +| `getOriginalSize()` | `{ width, height } \| undefined` | The size in pixels of the background image, as the file has it, not as it is shown. Undefined without a background image, or while it loads. | +| `getOriginalSize()` | `{ width, height } \| undefined` | The size in pixels of the background image, as the file has it, not as it is shown. Undefined without a background image, or while it loads. | +| `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, 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 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. | +| `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. | +| `copy()` | `void` | Copy the selected object. Does nothing without a selection. | +| `paste()` | `void` | Paste the copied object, 10 pixels further down and to the right of the previous one. Does nothing when nothing was copied. | +| `enableTouchScroll()` | `void` | Allow the page to scroll on touch devices when touching the canvas. | +| `disableTouchScroll()` | `void` | Do not scroll the page when touching the canvas. | ## Tools diff --git a/packages/react-sketch/package.json b/packages/react-sketch/package.json index 9b9ad48..63a08a5 100644 --- a/packages/react-sketch/package.json +++ b/packages/react-sketch/package.json @@ -1,6 +1,6 @@ { "name": "react-sketch", - "version": "1.1.0", + "version": "1.2.0", "description": "React component for drawing on an HTML canvas with pencil, line, arrow, rectangle and circle tools, built on fabric.js, with undo/redo, JSON import/export and TypeScript types", "keywords": [ "react", diff --git a/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx b/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx index 9dd6b9f..2e91884 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.spec.tsx @@ -514,6 +514,70 @@ describe("SketchField", () => { expect([overlay.scaleX, overlay.scaleY]).toEqual([4, 6]); }); + it("takes the drawing back to its size, as many times as the canvas is resized", () => { + stubImageLoads(); + layout(400, 300); + const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); + sketch.setBackgroundFromDataUrl("data:image/png;base64,00", { + stretched: true, + }); + const image = canvas.backgroundImage as fabric.FabricImage; + + // Sizes which are not a multiple of the one before: a factor rounded to two decimals is off by a pixel or more + for (const [width, height] of [ + [333, 211], + [1013, 777], + [271, 189], + [400, 300], + ]) { + layout(width, height); + window.dispatchEvent(new Event("resize")); + } + + // 200 by 100 pixels, stretched to 400 by 300 + expect(image.scaleX).toBeCloseTo(2, 10); + expect(image.scaleY).toBeCloseTo(3, 10); + }); + + it("shows the background image pixel for pixel when the canvas has the size of the image", () => { + stubImageLoads(); + layout(997, 613); + const { sketch, canvas } = mount(); + sketch.setBackgroundFromDataUrl("data:image/png;base64,00", { + stretched: true, + }); + + const size = sketch.getOriginalSize() as { width: number; height: number }; + layout(size.width, size.height); + window.dispatchEvent(new Event("resize")); + + const image = canvas.backgroundImage as fabric.FabricImage; + expect(image.scaleX).toBeCloseTo(1, 10); + expect(image.scaleY).toBeCloseTo(1, 10); + }); + + it("goes to the size of the image through the width and height properties", () => { + stubImageLoads(); + layout(400, 300); + const { sketch, canvas, update } = mount({ + width: 400, + height: 300, + autoresize: false, + }); + sketch.setBackgroundFromDataUrl("data:image/png;base64,00", { + stretched: true, + }); + const image = canvas.backgroundImage as fabric.FabricImage; + const size = sketch.getOriginalSize() as { width: number; height: number }; + + layout(size.width, size.height); + update({ width: size.width, height: size.height, autoresize: false }); + + expect([canvas.getWidth(), canvas.getHeight()]).toEqual([200, 100]); + expect(image.scaleX).toBeCloseTo(1, 10); + expect(image.scaleY).toBeCloseTo(1, 10); + }); + it("does not follow the window when autoresize is off", () => { layout(400, 300); const { canvas } = mount({ autoresize: false }); @@ -1365,6 +1429,129 @@ describe("SketchField", () => { }); }); + describe("toDataURL with the original resolution", () => { + const exportedAt = (sketch: SketchField, canvas: fabric.Canvas): unknown => { + const toDataURL = jest.spyOn(canvas, "toDataURL"); + sketch.toDataURL({ multiplier: "original" }); + return (toDataURL.mock.calls[0][0] as { multiplier?: number }).multiplier; + }; + + it("scales by what gives the background image a pixel for each of its own", () => { + stubImageLoads(); + layout(400, 300); + const { sketch, canvas } = mount(); + // 200 by 100 pixels, shown at 400 by 200 + sketch.setBackgroundFromDataUrl("data:image/png;base64,00", { + scaleX: 2, + scaleY: 2, + }); + + expect(exportedAt(sketch, canvas)).toEqual(0.5); + }); + + it("follows the scale of the image, when the drawing is zoomed", () => { + stubImageLoads(); + layout(400, 300); + const { sketch, canvas } = mount(); + sketch.setBackgroundFromDataUrl("data:image/png;base64,00", { + scaleX: 2, + scaleY: 2, + }); + + sketch.zoom(2); + + expect(exportedAt(sketch, canvas)).toEqual(0.25); + }); + + it("is larger than 1 for an image which is shown smaller than it is", () => { + stubImageLoads(); + layout(100, 50); + const { sketch, canvas } = mount(); + // 200 by 100 pixels, stretched to 100 by 50 + sketch.setBackgroundFromDataUrl("data:image/png;base64,00", { + stretched: true, + }); + + expect(exportedAt(sketch, canvas)).toEqual(2); + }); + + it("keeps every pixel of an image which is scaled differently in the two directions", () => { + stubImageLoads(); + layout(400, 300); + const { sketch, canvas } = mount(); + // 200 by 100 pixels, stretched to 400 by 300: the scales are 2 and 3 + sketch.setBackgroundFromDataUrl("data:image/png;base64,00", { + stretched: true, + }); + + expect(exportedAt(sketch, canvas)).toEqual(0.5); + }); + + it("is not thrown off by a flipped image", () => { + stubImageLoads(); + layout(400, 300); + const { sketch, canvas } = mount(); + sketch.setBackgroundFromDataUrl("data:image/png;base64,00", { + scaleX: -2, + scaleY: 2, + }); + + expect(exportedAt(sketch, canvas)).toEqual(0.5); + }); + + it("is 1 without a background image", () => { + const { sketch, canvas } = mount(); + + expect(exportedAt(sketch, canvas)).toEqual(1); + }); + + it("leaves the other options as they are", () => { + const { sketch, canvas } = mount(); + const toDataURL = jest.spyOn(canvas, "toDataURL"); + + sketch.toDataURL({ multiplier: "original", format: "jpeg", left: 1 }); + + expect(toDataURL).toHaveBeenCalledWith( + expect.objectContaining({ multiplier: 1, format: "jpeg", left: 1 }) + ); + }); + }); + + describe("getOriginalSize", () => { + it("is the size of the image in pixels, not the one it is shown at", () => { + stubImageLoads(); + layout(400, 300); + const { sketch } = mount(); + sketch.setBackgroundFromDataUrl("data:image/png;base64,00", { + stretched: true, + }); + + expect(sketch.getOriginalSize()).toEqual({ width: 200, height: 100 }); + + sketch.zoom(3); + expect(sketch.getOriginalSize()).toEqual({ width: 200, height: 100 }); + }); + + it("is undefined without a background image", () => { + const { sketch } = mount(); + + expect(sketch.getOriginalSize()).toBeUndefined(); + }); + + it("is undefined while the image loads, and after it is removed", () => { + const loads = deferImageLoads(); + const { sketch } = mount(); + sketch.setBackgroundFromDataUrl("data:image/png;base64,00"); + expect(sketch.getOriginalSize()).toBeUndefined(); + + loads[0](); + expect(sketch.getOriginalSize()).toEqual({ width: 200, height: 100 }); + + sketch.removeBackgroundImage(); + expect(sketch.getOriginalSize()).toBeUndefined(); + }); + }); + describe("toJSON", () => { it("returns the canvas as an object", () => { const { sketch, canvas } = mount({ tool: Tool.RECTANGLE }); diff --git a/packages/react-sketch/src/components/SketchField/SketchField.tsx b/packages/react-sketch/src/components/SketchField/SketchField.tsx index 7fdc610..84f67ba 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.tsx @@ -7,6 +7,7 @@ import { ImageOptions, SketchData, SketchProperties, + SketchSize, SketchValue, TextOptions, Tool, @@ -541,38 +542,55 @@ class SketchField extends PureComponent { canRedo = (): boolean => this.history.canRedo(); /** - * Exports canvas element to a dataurl image. Note that when multiplier is used, cropping is scaled appropriately + * Exports the canvas as a data URL. The image is of the whole drawing, whatever the view + * shows. With a multiplier the crop area is scaled by it too. * - * Available Options are - * - * - * - * - * - * - * - * - * - * - * - *
NameTypeArgumentDefaultDescription
format String the imageFormat property, then pngThe format of the output image. Either "jpeg" or "png"
qualityNumber1Quality level (0..1). Only used for jpeg.
multiplierNumber1Multiplier to scale by
leftNumberCropping left offset. Introduced in v1.2.14
topNumberCropping top offset. Introduced in v1.2.14
widthNumberCropping width. Introduced in v1.2.14
heightNumberCropping height. Introduced in v1.2.14
- * - * @returns URL containing a representation of the object in the format specified by options.format + * @param options see {@link DataUrlOptions} + * @returns URL containing a representation of the drawing in the format of the options */ toDataURL = (options?: DataUrlOptions): string => { + const { multiplier, ...rest } = options ?? {}; // 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, + ...rest, + multiplier: + multiplier === "original" ? this.originalMultiplier() : multiplier, } as fabric.TDataUrlOptions); } finally { this.canvas.viewportTransform = view; } }; + /** + * The size of the background image in pixels, as the file it was loaded from has it, and + * not as it is shown (the canvas scales it to its size). It is the size of the canvas at + * which the drawing is on the image pixel for pixel. Undefined when there is no + * background image, or it is still loading. + */ + getOriginalSize = (): SketchSize | undefined => { + const image = this.canvas.backgroundImage; + return image?.width && image.height + ? { width: image.width, height: image.height } + : undefined; + }; + + /** + * The multiplier of an export which gives the background image one pixel for each of + * its own. An image which is scaled differently in the two directions is exported at + * the multiplier of the larger scale, so no pixel of it is lost. 1 without an image. + */ + private originalMultiplier(): number { + const image = this.canvas.backgroundImage; + const scale = image + ? Math.min(Math.abs(image.scaleX ?? 1), Math.abs(image.scaleY ?? 1)) + : 0; + return scale > 0 ? 1 / scale : 1; + } + /** * 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 37a8c71..6f65f18 100644 --- a/packages/react-sketch/src/components/SketchField/events.ts +++ b/packages/react-sketch/src/components/SketchField/events.ts @@ -116,6 +116,8 @@ export class FabricCanvasEventsManager public onMouseDown({ event, tool, props }: EventInput): void { this.changed = false; + // What is pressed is what the press may move, scale or rotate: its state now is the one undo goes back to + if (event.target) this.history.onMouseDown(event.target); tool.doMouseDown(event); props.onMouseDown?.(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 0be7ae0..5b48f14 100644 --- a/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts +++ b/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts @@ -1,14 +1,25 @@ import * as fabric from "fabric"; import { UndoHistory } from "./index"; import type { Erasure } from "../tools/Eraser"; +import { linkedObjects } from "../tools/RectangleLabel/RectangleLabelObject"; /** - * One undoable action of the sketch + * What a modification did to an object: its state (as JSON) before and after it + */ +interface Change { + object: fabric.FabricObject; + before: string; + after: string; +} + +/** + * One undoable action of the sketch. A modification can be of many objects, as + * the ones of a selection which is moved. */ type HistoryEntry = | { action: "add"; object: fabric.FabricObject } | { action: "remove"; object: fabric.FabricObject } - | { action: "modify"; object: fabric.FabricObject; before: string; after: string } + | { action: "modify"; changes: Change[] } | { action: "erase"; erasures: Erasure[] }; /** @@ -21,6 +32,68 @@ const NOT_RESTORED = ["type", "version", "objects", "layoutManager", "clipPath"] const stateOf = (object: fabric.FabricObject): string => JSON.stringify(object.toJSON()); +/** The properties which say where an object is, and how it is turned and scaled */ +const PLACEMENT = [ + "left", + "top", + "scaleX", + "scaleY", + "angle", + "skewX", + "skewY", + "flipX", + "flipY", +] as const; + +/** + * State of an object as it is on the canvas on its own. The object of a + * selection has its place relative to the selection (its `left` and `top` are + * not the ones of the canvas), so the transform of the selection is applied to + * it for the state, and then it is put back as it was. + */ +const stateOutsideGroup = (object: fabric.FabricObject): string => { + const { group } = object; + if (!group) return stateOf(object); + const own = Object.fromEntries(PLACEMENT.map((key) => [key, object[key]])); + fabric.util.addTransformToObject(object, group.calcTransformMatrix()); + try { + return stateOf(object); + } finally { + object.set(own); + object.setCoords(); + } +}; + +/** + * The objects which a press or a modification is about: the ones of a selection, or the + * one object, and the objects which follow them (the pointer moves the text box of a + * label with its rectangle, and nothing tells the history) + */ +const objectsOf = (target: fabric.FabricObject): fabric.FabricObject[] => { + const objects = + target instanceof fabric.ActiveSelection ? target.getObjects() : [target]; + return [ + ...new Set(objects.flatMap((object) => [object, ...linkedObjects(object)])), + ]; +}; + +/** + * The properties of `state` which are not the ones of `other`: what a modification + * changed, as undo and redo set nothing else. Setting all of them is not harmless: + * a line has its points relative to its center, and the canvas takes them for the + * ones of the canvas when they are set, so the line is moved to the origin. + */ +const changedProps = (state: string, other: string): Record => { + const props = JSON.parse(state); + const others = JSON.parse(other); + NOT_RESTORED.forEach((key) => delete props[key]); + Object.keys(props).forEach((key) => { + if (JSON.stringify(props[key]) === JSON.stringify(others[key])) + delete props[key]; + }); + return props; +}; + /** * Records what happens to the objects of a canvas (added, modified, removed) * and applies undo and redo on them. @@ -30,8 +103,10 @@ export class SketchHistory { private readonly entries: UndoHistory; /** Last known state of each object, it is the "before" of its next modification */ private readonly snapshots = new WeakMap(); - /** The object added since the last mouse up, whose final state is not known yet */ - private pending: fabric.FabricObject | null = null; + /** The objects added since the last mouse up, whose final state is not known yet */ + private readonly pending = new Set(); + /** The state the objects pressed had when they were, which is the "before" of the modification the press makes */ + private readonly pressed = new Map(); private applying = false; /** @@ -56,7 +131,7 @@ export class SketchHistory { */ onObjectAdded(object: fabric.FabricObject): void { this.snapshots.set(object, stateOf(object)); - this.pending = object; + this.pending.add(object); this.entries.keep({ action: "add", object }); } @@ -72,19 +147,39 @@ export class SketchHistory { ) { this.entries.pop(); } - if (this.pending && objects.includes(this.pending)) this.pending = null; + objects.forEach((object) => this.pending.delete(object)); + } + + /** + * The state of what is pressed, before the press changes it. A modification goes back + * to it, whatever the objects were before the press: the snapshots are the state they + * had when they were added, which is not the one of an object which something else + * changed since. + */ + onMouseDown(target: fabric.FabricObject): void { + this.pressed.clear(); + objectsOf(target).forEach((object) => + this.pressed.set(object, stateOutsideGroup(object)) + ); } /** - * Record an object modified on the canvas. Objects which were not added - * through {@link onObjectAdded} (for example a multiple selection) are ignored. + * Record a modification on the canvas: of an object, or of the objects of a selection + * (one action for all of them). The objects which the history does not know, as they + * were not added through {@link onObjectAdded}, are ignored, and so is a modification + * which changed nothing. */ - onObjectModified(object: fabric.FabricObject): void { - const before = this.snapshots.get(object); - if (before === undefined) return; - const after = stateOf(object); - this.snapshots.set(object, after); - this.entries.keep({ action: "modify", object, before, after }); + onObjectModified(target: fabric.FabricObject): void { + const changes: Change[] = []; + objectsOf(target).forEach((object) => { + const before = this.pressed.get(object) ?? this.snapshots.get(object); + if (before === undefined) return; + const after = stateOutsideGroup(object); + this.snapshots.set(object, after); + if (after !== before) changes.push({ object, before, after }); + }); + this.pressed.clear(); + if (changes.length) this.entries.keep({ action: "modify", changes }); } /** @@ -100,10 +195,8 @@ export class SketchHistory { * the mouse), so its state is only final on mouse up. */ onMouseUp(): void { - if (this.pending) { - this.snapshots.set(this.pending, stateOf(this.pending)); - this.pending = null; - } + this.pending.forEach((object) => this.snapshots.set(object, stateOf(object))); + this.pending.clear(); } /** @@ -154,7 +247,8 @@ export class SketchHistory { */ clear(): void { this.entries.clear(); - this.pending = null; + this.pending.clear(); + this.pressed.clear(); } /** @@ -177,34 +271,47 @@ export class SketchHistory { object.set("dirty", true); } + /** + * Set the objects of a modification to their state before it (undo) or after it + * (redo): the properties it changed, and no others + */ + private applyChanges(changes: Change[], forward: boolean): void { + // The objects of a selection have a place relative to it: they are given the ones of the canvas + const active = this.canvas.getActiveObject(); + if ( + active instanceof fabric.ActiveSelection && + changes.some(({ object }) => active.contains(object)) + ) { + this.canvas.discardActiveObject(); + } + changes.forEach(({ object, before, after }) => { + const [state, other] = forward ? [after, before] : [before, after]; + object.set(changedProps(state, other)); + object.setCoords(); + this.snapshots.set(object, state); + }); + this.pressed.clear(); + } + private apply(entry: HistoryEntry, forward: boolean): void { 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 "erase": + entry.erasures.forEach((erasure) => this.applyErasure(erasure, forward)); + break; + case "modify": + this.applyChanges(entry.changes, forward); + break; case "add": case "remove": // redo of an add, and undo of a remove, put the object back if ((entry.action === "add") === forward) { - this.canvas.add(object); + this.canvas.add(entry.object); } else { - this.canvas.remove(object); + this.canvas.remove(entry.object); } break; - case "modify": { - const state = forward ? entry.after : entry.before; - const props = JSON.parse(state); - NOT_RESTORED.forEach((key) => delete props[key]); - object.set(props); - object.setCoords(); - this.snapshots.set(object, state); - break; - } } this.canvas.renderAll(); } finally { diff --git a/packages/react-sketch/src/components/SketchField/history/modifications.spec.tsx b/packages/react-sketch/src/components/SketchField/history/modifications.spec.tsx new file mode 100644 index 0000000..4ad3cce --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/history/modifications.spec.tsx @@ -0,0 +1,466 @@ +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, + update: (next: SketchProperties) => + view.rerender(), + }; +}; + +const fire = (canvas: fabric.Canvas, name: string, event: object): void => + (canvas as unknown as fabric.Observable>).fire(name, event); + +const mouseEvent = (type: string, [x, y]: Point = [0, 0]): { e: MouseEvent } => ({ + e: new MouseEvent(type, { clientX: x, clientY: y }), +}); + +/** Draw with the tool of the sketch: press, move, release. Returns the objects on the canvas */ +const draw = ( + canvas: fabric.Canvas, + from: Point, + to: Point +): fabric.FabricObject[] => { + fire(canvas, "mouse:down", mouseEvent("mousedown", from)); + fire(canvas, "mouse:move", mouseEvent("mousemove", to)); + fire(canvas, "mouse:up", mouseEvent("mouseup", to)); + return canvas.getObjects(); +}; + +/** + * What the select tool does when the pointer moves the target: it is pressed, it + * changes (the canvas tells that it moves, and then that it is modified), and the + * pointer is released + */ +const change = ( + canvas: fabric.Canvas, + target: fabric.FabricObject, + props: Partial +): void => { + fire(canvas, "mouse:down", { ...mouseEvent("mousedown", [5, 5]), target }); + target.set(props); + target.setCoords(); + fire(canvas, "object:moving", { target }); + fire(canvas, "object:modified", { target }); + fire(canvas, "mouse:up", mouseEvent("mouseup", [5, 5])); +}; + +const moveBy = ( + canvas: fabric.Canvas, + target: fabric.FabricObject, + dx: number, + dy: number +) => + change(canvas, target, { + left: (target.left ?? 0) + dx, + top: (target.top ?? 0) + dy, + }); + +const stateOf = (object: fabric.FabricObject): string => + JSON.stringify(object.toJSON()); + +const centers = (objects: fabric.FabricObject[]): Point[] => + objects.map((object) => { + const { x, y } = object.getCenterPoint(); + return [x, y]; + }); + +const expectCenters = (objects: fabric.FabricObject[], expected: Point[]): void => + objects.forEach((object, i) => { + const [x, y] = centers([object])[0]; + expect(x).toBeCloseTo(expected[i][0], 3); + expect(y).toBeCloseTo(expected[i][1], 3); + }); + +describe("undo and redo of a modification", () => { + describe("of an object of any tool", () => { + const line = (...points: [number, number, number, number]) => + new fabric.Line(points, { stroke: "black", strokeWidth: 3 }); + + const objects: [string, () => fabric.FabricObject][] = [ + ["a line going down to the right", () => line(50, 50, 150, 110)], + ["a line going up to the right", () => line(50, 110, 150, 50)], + ["a line going down to the left", () => line(150, 50, 50, 110)], + ["a horizontal line", () => line(50, 80, 150, 80)], + ["a vertical line", () => line(80, 50, 80, 150)], + [ + "a rectangle", + () => + new fabric.Rect({ + left: 40, + top: 30, + width: 90, + height: 60, + stroke: "red", + }), + ], + ["a circle", () => new fabric.Circle({ left: 40, top: 30, radius: 40 })], + [ + "a polygon", + () => + new fabric.Polygon( + [ + { x: 20, y: 90 }, + { x: 70, y: 10 }, + { x: 120, y: 90 }, + ], + { stroke: "black" } + ), + ], + [ + "a stroke of the pencil", + () => + new fabric.Path("M 10 10 C 40 80, 90 -20, 140 60", { + fill: "", + stroke: "black", + }), + ], + ["a text", () => new fabric.IText("some text", { left: 30, top: 40 })], + ]; + + describe.each(objects)("%s", (_name, create) => { + const setup = () => { + const view = mount({ tool: Tool.SELECT }); + // Another object, drawn after it, which undoing the move must not remove + const object = create(); + view.canvas.add(object); + view.canvas.add( + new fabric.Rect({ left: 200, top: 200, width: 20, height: 20 }) + ); + return { ...view, object, original: stateOf(object) }; + }; + + it("goes back to where it was", () => { + const { sketch, canvas, object, original } = setup(); + + moveBy(canvas, object, 120, 70); + expect(stateOf(object)).not.toEqual(original); + sketch.undo(); + + expect(stateOf(object)).toEqual(original); + expect(canvas.getObjects()).toHaveLength(2); + }); + + it("is moved again by redo", () => { + const { sketch, canvas, object } = setup(); + moveBy(canvas, object, 120, 70); + const moved = stateOf(object); + + sketch.undo(); + sketch.redo(); + + expect(stateOf(object)).toEqual(moved); + expect(canvas.getObjects()).toHaveLength(2); + }); + + it("is one step: the other object stays", () => { + const { sketch, canvas, object } = setup(); + moveBy(canvas, object, 120, 70); + + sketch.undo(); + + expect(canvas.getObjects()).toContain(object); + expect(canvas.getObjects()[1].type).toBe("rect"); + expect(sketch.canUndo()).toBe(true); + }); + + it("is scaled and rotated back, and forth", () => { + const { sketch, canvas, object, original } = setup(); + + change(canvas, object, { scaleX: 1.5, scaleY: 0.5, angle: 30 }); + const turned = stateOf(object); + sketch.undo(); + expect(stateOf(object)).toEqual(original); + sketch.redo(); + + expect(stateOf(object)).toEqual(turned); + }); + }); + + it("keeps a line where the user left it, not at the origin of the canvas", () => { + const { sketch, canvas, update } = mount({ tool: Tool.LINE }); + const [drawn] = draw(canvas, [50, 50], [150, 110]); + update({ tool: Tool.SELECT }); + const center = centers([drawn])[0]; + + moveBy(canvas, drawn, 300, 200); + sketch.undo(); + + expectCenters([drawn], [center]); + expect(center[0]).toBeGreaterThan(50); + sketch.redo(); + expectCenters([drawn], [[center[0] + 300, center[1] + 200]]); + }); + + it("keeps an arrow where the user left it", () => { + const { sketch, canvas, update } = mount({ tool: Tool.ARROW }); + const [drawn] = draw(canvas, [50, 50], [150, 110]); + update({ tool: Tool.SELECT }); + const original = stateOf(drawn); + + moveBy(canvas, drawn, 120, 70); + expect(stateOf(drawn)).not.toEqual(original); + sketch.undo(); + + expect(stateOf(drawn)).toEqual(original); + expect(canvas.getObjects()).toEqual([drawn]); + }); + }); + + describe("of an object which is not as it was when it was added", () => { + it("goes back to the state before the press, and not to the one it had when it was added", () => { + const { sketch, canvas } = mount({ tool: Tool.SELECT }); + const rect = new fabric.Rect({ left: 10, top: 10, width: 30, height: 30 }); + canvas.add(rect); + // Changed by something which the history does not see + rect.set({ left: 100 }); + + moveBy(canvas, rect, 50, 0); + sketch.undo(); + + expect(rect.left).toBe(100); + }); + + it("is not an action when it changed nothing", () => { + const { sketch, canvas } = mount({ tool: Tool.SELECT }); + const rect = new fabric.Rect({ left: 10, top: 10, width: 30, height: 30 }); + canvas.add(rect); + + change(canvas, rect, {}); + sketch.undo(); + + // The only action is the addition of the rectangle + expect(canvas.getObjects()).toHaveLength(0); + expect(sketch.canUndo()).toBe(false); + }); + + it("is undone when it was made by code, without the press of the pointer", () => { + const { sketch, canvas } = mount({ tool: Tool.SELECT }); + const rect = new fabric.Rect({ left: 10, top: 10, width: 30, height: 30 }); + canvas.add(rect); + + rect.set({ left: 90 }); + fire(canvas, "object:modified", { target: rect }); + sketch.undo(); + + expect(rect.left).toBe(10); + }); + + it("is undone for an object which was loaded with the value", async () => { + jest.useFakeTimers(); + const loaded = new fabric.Line([50, 50, 150, 110], { stroke: "black" }); + const { sketch, canvas } = mount({ + tool: Tool.SELECT, + defaultValue: { objects: [loaded.toObject()] }, + }); + await jest.advanceTimersByTimeAsync(100); + const [line] = canvas.getObjects(); + const original = stateOf(line); + + moveBy(canvas, line, 120, 70); + expect(stateOf(line)).not.toEqual(original); + sketch.undo(); + + expect(stateOf(line)).toEqual(original); + expect(canvas.getObjects()).toHaveLength(1); + jest.useRealTimers(); + }); + }); + + describe("of a rectangle with a label", () => { + const setup = () => { + const view = mount({ tool: Tool.RECTANGLE_LABEL }); + const [rect, textbox] = draw(view.canvas, [50, 80], [250, 200]); + view.update({ tool: Tool.SELECT }); + return { ...view, rect, textbox, original: [stateOf(rect), stateOf(textbox)] }; + }; + + it("is a rectangle which has its size, and not the one of the press which drew it", () => { + const { sketch, rect, canvas } = setup(); + + moveBy(canvas, rect, 100, 40); + sketch.undo(); + + expect(rect.width).toBe(200); + expect(rect.height).toBe(120); + }); + + it("puts the rectangle and its text box back where they were", () => { + const { sketch, canvas, rect, textbox, original } = setup(); + + moveBy(canvas, rect, 100, 40); + // The text box follows the rectangle when it is moved + expect(stateOf(textbox)).not.toEqual(original[1]); + sketch.undo(); + + expect([stateOf(rect), stateOf(textbox)]).toEqual(original); + }); + + it("moves them again with redo", () => { + const { sketch, canvas, rect, textbox } = setup(); + moveBy(canvas, rect, 100, 40); + const moved = [stateOf(rect), stateOf(textbox)]; + + sketch.undo(); + sketch.redo(); + + expect([stateOf(rect), stateOf(textbox)]).toEqual(moved); + }); + + it("puts the text box back when it was moved on its own", () => { + const { sketch, canvas, textbox, original } = setup(); + + moveBy(canvas, textbox, 0, -30); + sketch.undo(); + + expect(stateOf(textbox)).toEqual(original[1]); + }); + }); + + describe("of a selection of many objects", () => { + const setup = () => { + const view = mount({ tool: Tool.SELECT }); + const line = new fabric.Line([50, 50, 150, 110], { + stroke: "black", + strokeWidth: 3, + }); + const rect = new fabric.Rect({ + left: 80, + top: 160, + width: 100, + height: 50, + stroke: "red", + }); + const last = new fabric.Circle({ left: 300, top: 40, radius: 20 }); + view.canvas.add(line, rect, last); + const selection = new fabric.ActiveSelection([line, rect], { + canvas: view.canvas, + }); + view.canvas.setActiveObject(selection); + return { + ...view, + line, + rect, + last, + selection, + members: [line, rect], + at: centers([line, rect]), + }; + }; + + it("puts all of them back where they were with one undo, and removes none", () => { + const { sketch, canvas, selection, members, last, at } = setup(); + + moveBy(canvas, selection, 150, 90); + expectCenters( + members, + at.map(([x, y]) => [x + 150, y + 90]) + ); + sketch.undo(); + + expectCenters(members, at); + expect(canvas.getObjects()).toHaveLength(3); + expect(canvas.getObjects()).toContain(last); + }); + + it("moves all of them again with redo", () => { + const { sketch, canvas, selection, members, at } = setup(); + moveBy(canvas, selection, 150, 90); + + sketch.undo(); + sketch.redo(); + + expectCenters( + members, + at.map(([x, y]) => [x + 150, y + 90]) + ); + }); + + it("is one action, so the next undo goes to the one before", () => { + const { sketch, canvas, selection, members, at, last } = setup(); + moveBy(canvas, selection, 150, 90); + + sketch.undo(); + sketch.undo(); + + // The circle was the last object added + expect(canvas.getObjects()).not.toContain(last); + expectCenters(members, at); + }); + + it("gives the objects the position of the canvas when it goes back, and leaves the selection", () => { + const { sketch, canvas, selection, members } = setup(); + moveBy(canvas, selection, 150, 90); + + sketch.undo(); + + expect(canvas.getActiveObject()).toBeUndefined(); + members.forEach((member) => expect(member.group).toBeUndefined()); + }); + + it("is scaled and rotated back, and forth", () => { + const { sketch, canvas, selection, members, at } = setup(); + + change(canvas, selection, { scaleX: 1.6, scaleY: 0.7, angle: 35 }); + const turned = members.map((member) => { + const { x, y } = member.getCenterPoint(); + return [x, y, member.getTotalAngle()]; + }); + sketch.undo(); + expectCenters(members, at); + members.forEach((member) => expect(member.getTotalAngle()).toBeCloseTo(0, 3)); + sketch.redo(); + + members.forEach((member, i) => { + const { x, y } = member.getCenterPoint(); + expect([x, y, member.getTotalAngle()]).toEqual( + turned[i].map((value) => expect.closeTo(value, 3)) + ); + }); + }); + + it("tells the application that the objects changed, once", () => { + const onObjectModified = jest.fn(); + const view = mount({ tool: Tool.SELECT, onObjectModified }); + const a = new fabric.Rect({ left: 10, top: 10, width: 20, height: 20 }); + const b = new fabric.Rect({ left: 60, top: 10, width: 20, height: 20 }); + view.canvas.add(a, b); + const selection = new fabric.ActiveSelection([a, b], { canvas: view.canvas }); + view.canvas.setActiveObject(selection); + + moveBy(view.canvas, selection, 20, 20); + + expect(onObjectModified).toHaveBeenCalledTimes(1); + expect(onObjectModified.mock.calls[0][0].target).toBe(selection); + }); + + it("is recorded for the objects it knows only", () => { + const view = mount({ tool: Tool.SELECT }); + const known = new fabric.Rect({ left: 10, top: 10, width: 20, height: 20 }); + view.canvas.add(known); + const stranger = new fabric.Rect({ left: 60, top: 10, width: 20, height: 20 }); + // Not on the canvas, so the history never saw it + const selection = new fabric.ActiveSelection([known, stranger], { + canvas: view.canvas, + }); + view.canvas.setActiveObject(selection); + const at = centers([known]); + + moveBy(view.canvas, selection, 30, 30); + view.sketch.undo(); + + expectCenters([known], at); + }); + }); +}); diff --git a/packages/react-sketch/src/components/SketchField/resize.ts b/packages/react-sketch/src/components/SketchField/resize.ts index e9065cd..f91b48e 100644 --- a/packages/react-sketch/src/components/SketchField/resize.ts +++ b/packages/react-sketch/src/components/SketchField/resize.ts @@ -49,8 +49,8 @@ export const autoresize = ( const prevHeight = canvas.getHeight(); if (width <= 0 || height <= 0 || !prevWidth || !prevHeight) return undefined; - const wfactor = parseFloat((width / prevWidth).toFixed(2)); - const hfactor = parseFloat((height / prevHeight).toFixed(2)); + const wfactor = width / prevWidth; + const hfactor = height / prevHeight; canvas.setDimensions({ width, height }); scaleContent(canvas, wfactor, hfactor); 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 162f1b5..6ac6cd4 100644 --- a/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabelObject.ts +++ b/packages/react-sketch/src/components/SketchField/tools/RectangleLabel/RectangleLabelObject.ts @@ -22,6 +22,17 @@ const follow = (canvas: fabric.Canvas): void => { canvas.on("object:moving", update); }; +/** + * The objects which the pointer moves along with the object: the text box of the + * rectangle of a label, which follows it. None for any other object. + */ +export const linkedObjects = ( + object: fabric.FabricObject +): fabric.FabricObject[] => { + const label = labels.get(object); + return label ? [label.textbox] : []; +}; + /** * A rectangle with a text box on top of it. The text box follows the * rectangle when it is moved or scaled. diff --git a/packages/react-sketch/src/types.ts b/packages/react-sketch/src/types.ts index 4ab12ae..29afd06 100644 --- a/packages/react-sketch/src/types.ts +++ b/packages/react-sketch/src/types.ts @@ -74,6 +74,14 @@ export interface SketchEvent { [property: string]: unknown; } +/** + * A size in pixels, for example the one of the image of the background + */ +export interface SketchSize { + width: number; + height: number; +} + /** * Content of a sketch, as returned by `toJSON` and accepted by `value`, * `defaultValue` and `fromJSON`: an object, or the same object as JSON string. @@ -93,8 +101,13 @@ export interface DataUrlOptions { format?: string; /** Quality level (0..1). Only used for jpeg. Defaults to 1 */ quality?: number; - /** Multiplier to scale by. Defaults to 1 */ - multiplier?: number; + /** + * Multiplier to scale by. Defaults to 1. "original" scales by what gives the + * background image one pixel of the picture for each pixel of the image it was + * loaded from, so a drawing on a photo is exported at the resolution of the photo. + * Without a background image it is 1 + */ + multiplier?: number | "original"; /** Cropping left offset */ left?: number; /** Cropping top offset */ diff --git a/packages/showcase/README.md b/packages/showcase/README.md index 390f733..709be8b 100644 --- a/packages/showcase/README.md +++ b/packages/showcase/README.md @@ -10,7 +10,8 @@ The canvas fills the window, and the panels float over it: - **Properties** (left): line color, fill color, line weight and line style of the tool, and copy, paste and delete of the selected objects. - **Settings** (right, the gear): the **Scenario** tab (what the scenario shows and its actions), the **Canvas** tab - (size, background color and image, pinch zoom, view, grid) and the **Insert** tab (text, image from a URL, + (size, with a button for the original size of the background image, background color and image, pinch zoom, + view, grid) and the **Insert** tab (text, image from a URL, overlay). - **Top left**: the menu, and undo and redo, which fade out until the first change. - **Bottom**: zoom (the percentage resets the view), and a hint for the tool in use. The **preview** is at the diff --git a/packages/showcase/package.json b/packages/showcase/package.json index b8ffccb..15f5393 100644 --- a/packages/showcase/package.json +++ b/packages/showcase/package.json @@ -1,6 +1,6 @@ { "name": "showcase", - "version": "1.1.0", + "version": "1.2.0", "private": true, "description": "Demo application for react-sketch: a canvas with floating tools, properties and settings, and the defaultValue, controlled value, addImg and backgroundImage usage scenarios", "homepage": "https://github.com/tbolis/react-sketch#readme", @@ -41,6 +41,6 @@ "mobx-react-lite": "^5.1.0", "react": "^19.3.1", "react-dom": "^19.3.1", - "react-sketch": "^1.1.0" + "react-sketch": "^1.2.0" } } diff --git a/packages/showcase/src/actions.ts b/packages/showcase/src/actions.ts index 816e6d6..5c428af 100644 --- a/packages/showcase/src/actions.ts +++ b/packages/showcase/src/actions.ts @@ -119,6 +119,24 @@ export const resetView = (): void => syncPreview(); }); +/** + * Give the canvas the size of the background image, so the drawing is on the image pixel + * for pixel. The canvas is a sheet of that size, which scrolls when the window is smaller. + * The zoom is taken back first: the size is the one of the drawing, not of the view. + */ +export const restoreOriginalSize = (): void => + withSketch((sketch) => { + const size = sketch.getOriginalSize(); + if (!size) { + CONFIG.notice = "The canvas has no background image."; + return; + } + resetView(); + CONFIG.sketchWidth = size.width; + CONFIG.sketchHeight = size.height; + CONFIG.controlledSize = true; + }); + export const copy = (): void => withSketch((sketch) => { if (!sketch.hasSelection()) return; @@ -138,9 +156,12 @@ export const removeSelected = (): void => * The canvas as a data URL. A canvas with an image of another site that does not allow * it (CORS) cannot be exported: the user is told, and there is no URL. */ -const exportPng = (sketch: SketchField): string | undefined => { +const exportPng = ( + sketch: SketchField, + options?: Types.DataUrlOptions +): string | undefined => { try { - return sketch.toDataURL(); + return sketch.toDataURL(options); } catch { CONFIG.notice = "The canvas cannot be exported: an image on it comes from another site that does not allow it (CORS)."; @@ -158,7 +179,8 @@ export const save = (): void => export const download = (): void => withSketch((sketch) => { - const src = exportPng(sketch); + // The file has the resolution of the background image, whatever the size of the canvas + const src = exportPng(sketch, { multiplier: "original" }); if (!src) return; downloadUrl(src, "toPNG.png"); downloadText(JSON.stringify(sketch.toJSON()), "toDataJSON.json"); diff --git a/packages/showcase/src/components/Settings/CanvasTab.tsx b/packages/showcase/src/components/Settings/CanvasTab.tsx index 0da574c..a3c4cb0 100644 --- a/packages/showcase/src/components/Settings/CanvasTab.tsx +++ b/packages/showcase/src/components/Settings/CanvasTab.tsx @@ -8,6 +8,7 @@ import Stack from "@mui/material/Stack"; import Switch from "@mui/material/Switch"; import Typography from "@mui/material/Typography"; import { observer } from "mobx-react-lite"; +import { restoreOriginalSize } from "../../actions"; import { withSketch } from "../../sketchRef"; import { CONFIG } from "../../stores/config"; import { readAsDataUrl } from "../../utils/files"; @@ -170,11 +171,19 @@ const CanvasTab = observer((): ReactElement => { checked={controlledSize} onChange={(checked) => (CONFIG.controlledSize = checked)} /> + + Original size gives the canvas the size of the background image, so the + drawing is on the image pixel for pixel. The download has the resolution of + the image, at any size of the canvas. + + (CONFIG.sketchWidth = value)} /> @@ -182,7 +191,7 @@ const CanvasTab = observer((): ReactElement => { label="Change Canvas Height" value={sketchHeight} min={10} - max={1000} + max={Math.max(1000, sketchHeight)} disabled={!controlledSize} onChange={(value) => (CONFIG.sketchHeight = value)} />