From 3686aebc078fda599f7809e1b135fbfcf257f34d Mon Sep 17 00:00:00 2001 From: tbolis Date: Fri, 6 Nov 2026 18:54:55 +0200 Subject: [PATCH] fix: undo and redo of a move put the objects back where they were Undo set all the properties of the saved state of an object. 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 a line went to the top left corner. Undo and redo now set the properties which the modification changed, and no others. A modification of a selection of many objects was not recorded, so undo removed the last object drawn. It is one action for all the objects now. The state an object goes back to is the one it has when the pointer is pressed on it. The snapshot of a rectangle with a label was the one of the press which drew it (the size of 0), as only the last object added was refreshed on release, so undo made the rectangle disappear, and its text box, which follows it, was left behind. The text box is recorded with it. --- CHANGELOG.md | 18 + .../src/components/SketchField/events.ts | 2 + .../SketchField/history/SketchHistory.ts | 179 +++++-- .../history/modifications.spec.tsx | 466 ++++++++++++++++++ .../RectangleLabel/RectangleLabelObject.ts | 11 + 5 files changed, 640 insertions(+), 36 deletions(-) create mode 100644 packages/react-sketch/src/components/SketchField/history/modifications.spec.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d1b6d4a..0018376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 1.2.0 + +### 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. + ## 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/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/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.