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.