Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
101 changes: 76 additions & 25 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/react-sketch/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
187 changes: 187 additions & 0 deletions packages/react-sketch/src/components/SketchField/SketchField.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });
Expand Down
52 changes: 35 additions & 17 deletions packages/react-sketch/src/components/SketchField/SketchField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ImageOptions,
SketchData,
SketchProperties,
SketchSize,
SketchValue,
TextOptions,
Tool,
Expand Down Expand Up @@ -541,38 +542,55 @@ class SketchField extends PureComponent<SketchProperties & DefaultedProps> {
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
* <table style="width:100%">
*
* <tr><td><b>Name</b></td><td><b>Type</b></td><td><b>Argument</b></td><td><b>Default</b></td><td><b>Description</b></td></tr>
* <tr><td>format</td> <td>String</td> <td><optional></td><td>the imageFormat property, then png</td><td>The format of the output image. Either "jpeg" or "png"</td></tr>
* <tr><td>quality</td><td>Number</td><td><optional></td><td>1</td><td>Quality level (0..1). Only used for jpeg.</td></tr>
* <tr><td>multiplier</td><td>Number</td><td><optional></td><td>1</td><td>Multiplier to scale by</td></tr>
* <tr><td>left</td><td>Number</td><td><optional></td><td></td><td>Cropping left offset. Introduced in v1.2.14</td></tr>
* <tr><td>top</td><td>Number</td><td><optional></td><td></td><td>Cropping top offset. Introduced in v1.2.14</td></tr>
* <tr><td>width</td><td>Number</td><td><optional></td><td></td><td>Cropping width. Introduced in v1.2.14</td></tr>
* <tr><td>height</td><td>Number</td><td><optional></td><td></td><td>Cropping height. Introduced in v1.2.14</td></tr>
*
* </table>
*
* @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
*
Expand Down
2 changes: 2 additions & 0 deletions packages/react-sketch/src/components/SketchField/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
Loading
Loading