From e57fcf397ddde5669b6e8e666e8a6526a13fdcda Mon Sep 17 00:00:00 2001 From: tbolis Date: Tue, 10 Nov 2026 16:38:47 +0200 Subject: [PATCH] feat: group and ungroup the selected objects group() makes one object of the selected objects, and ungroup() takes it apart. canGroup() and canUngroup() tell whether they can be used. A group is a class of its own (SketchGroup), so that it is saved and loaded as a group, and the group of an arrow is not taken apart. Both are one undo step. The history puts the group back as the same object, so the modifications made on it can still be undone. The group takes the place of the topmost of its objects, and the text box of a rectangle label goes into the group with its rectangle. The showcase has Group and Ungroup buttons in the selection panel, with Ctrl G and Ctrl Shift G. The buttons of the panel are small, so that the row of five fits in the panel when it scrolls. --- CHANGELOG.md | 7 + README.md | 21 + .../components/SketchField/SketchField.tsx | 54 +++ .../src/components/SketchField/SketchGroup.ts | 12 + .../SketchField/history/SketchHistory.ts | 114 ++++- .../SketchField/history/grouping.spec.tsx | 456 ++++++++++++++++++ packages/react-sketch/src/types.ts | 4 +- packages/showcase/README.md | 4 +- packages/showcase/src/actions.ts | 7 + .../showcase/src/components/Properties.tsx | 28 +- packages/showcase/src/shortcuts.ts | 9 + packages/showcase/src/sketchRef.ts | 2 + packages/showcase/src/stores/config.ts | 4 + 13 files changed, 715 insertions(+), 7 deletions(-) create mode 100644 packages/react-sketch/src/components/SketchField/SketchGroup.ts create mode 100644 packages/react-sketch/src/components/SketchField/history/grouping.spec.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 82ef779..ce913e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ ### Added +- Group and ungroup (#42): `group()` makes one object of the selected objects, `ungroup()` takes it apart, + and `canGroup()` and `canUngroup()` tell whether they can be used. A group is moved, scaled, rotated, + copied and removed as one object, and `toJSON()` saves it (as a `SketchGroup`) and `value`, + `defaultValue` and `fromJSON()` load it. Each is one undo step, and the group is put back as the same + object, so the modifications of it can still be undone. The text box of a rectangle label goes into the + group with its rectangle. The showcase has Group and Ungroup buttons in the selection panel, with Ctrl G + and Ctrl Shift G. - `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 diff --git a/README.md b/README.md index 0f8c314..09c0adf 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,10 @@ throws. | `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. | +| `canGroup()` | `boolean` | Whether two or more objects are selected, which `group()` makes a group of. | +| `canUngroup()` | `boolean` | Whether the selected object is a group which `group()` made. | +| `group()` | `void` | Make a group of the selected objects, and select it. One undo step. See [Groups](#groups). | +| `ungroup()` | `void` | Take the selected group apart, and select its objects. One undo step. | | `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. | @@ -491,6 +495,23 @@ The line is drawn on the top canvas of fabric only, it is never an object of the `toJSON()` or `toDataURL()`, it is not an undo step, `onObjectAdded` is not called for it, and `onChange` is not called when it is drawn. Switching to another tool clears what is left of the line. +### Groups + +`group()` makes one object of the selected objects (two or more) and selects it: the group is moved, scaled, +rotated, copied and removed as one object, `toJSON()` saves it and `value`, `defaultValue` and `fromJSON()` load +it. `ungroup()` takes the selected group apart and selects its objects, which keep where they are on the canvas, +with the move, the scale and the turn which the group gave them. Both are one undo step, and both call +`onChange` (and, as undo and redo do, not `onObjectAdded`). `canGroup()` and `canUngroup()` tell whether they +will do something, for example to enable a button. + +- The group takes the place of the topmost of its objects among the others, so what is above it stays above it. + Undo puts every object back in its own place. +- The text box of a rectangle label goes into the group with its rectangle. +- A group can be in another group, and taking that one apart gives back the inner group. +- A group is one object to the select tool: its objects cannot be selected or changed until it is taken apart. +- The group of an arrow is not one which can be taken apart. +- The eraser cuts a group as a whole, so taking the group apart gives back what was erased from it. + ## Exports | Export | Description | diff --git a/packages/react-sketch/src/components/SketchField/SketchField.tsx b/packages/react-sketch/src/components/SketchField/SketchField.tsx index 84f67ba..0f906f8 100644 --- a/packages/react-sketch/src/components/SketchField/SketchField.tsx +++ b/packages/react-sketch/src/components/SketchField/SketchField.tsx @@ -16,6 +16,7 @@ import { CanvasEvent, FabricCanvasEventsManager, SketchEventAware } from "./even import { SketchHistory } from "./history/SketchHistory"; import { IDENTITY_VIEW, PinchZoom } from "./pinch"; import { autoresize, scaleContent } from "./resize"; +import { SketchGroup } from "./SketchGroup"; import { FabricCanvasTool } from "./tools"; import { create_tool } from "./tools/factory"; import { Eraser } from "./tools/Eraser"; @@ -735,6 +736,59 @@ class SketchField extends PureComponent { .catch(() => undefined); }; + /** + * Whether the selection can be made a group: two or more objects are selected + */ + canGroup = (): boolean => { + const selected = this.canvas.getActiveObject(); + return ( + selected instanceof fabric.ActiveSelection && selected.getObjects().length > 1 + ); + }; + + /** + * Whether the selected object is a group which `group()` made. The group of an arrow is not. + */ + canUngroup = (): boolean => this.canvas.getActiveObject() instanceof SketchGroup; + + /** + * Make a group of the selected objects, which is selected then. The group is one object: + * it is moved, scaled, rotated, copied and removed as one, and it is saved by `toJSON()`. + * It takes the place of the topmost of the objects, and the text box of a rectangle label + * goes into the group with its rectangle. It is one undo step. Like undo and redo, it + * calls `onChange` and not `onObjectAdded`. Nothing happens when `canGroup()` is false. + */ + group = (): void => { + const canvas = this.canvas; + const selected = canvas.getActiveObject(); + if (!this.canGroup() || !(selected instanceof fabric.ActiveSelection)) return; + const objects = selected.getObjects(); + // Leaving the selection first gives the objects back their own position + canvas.discardActiveObject(); + const group = this.history.group(objects); + if (group) canvas.setActiveObject(group); + canvas.requestRenderAll(); + this.props.onChange?.(); + }; + + /** + * Take the selected group apart: its objects are selected then, where they were in the + * drawing, with the position, the scale and the turn which the group gave them. It is one + * undo step. Nothing happens when `canUngroup()` is false. + */ + ungroup = (): void => { + const canvas = this.canvas; + const group = canvas.getActiveObject(); + if (!(group instanceof SketchGroup)) return; + canvas.discardActiveObject(); + const objects = this.history.ungroup(group); + if (objects.length) { + canvas.setActiveObject(new fabric.ActiveSelection(objects, { canvas })); + } + canvas.requestRenderAll(); + this.props.onChange?.(); + }; + /** * Sets an image on a layer of the canvas, in place of the one it has, and * fits it as the options say. Only the last image asked for a layer is set, diff --git a/packages/react-sketch/src/components/SketchField/SketchGroup.ts b/packages/react-sketch/src/components/SketchField/SketchGroup.ts new file mode 100644 index 0000000..2fd85ac --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/SketchGroup.ts @@ -0,0 +1,12 @@ +import * as fabric from "fabric"; + +/** + * A group which the user made from a selection. It is a class of its own, so that + * it is known among the objects: the group of an arrow is a plain group, and is not + * taken apart. It is saved with its type, and loaded as one. + */ +export class SketchGroup extends fabric.Group { + static type = "SketchGroup"; +} + +fabric.classRegistry.setClass(SketchGroup); diff --git a/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts b/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts index 5b48f14..0f8d6ef 100644 --- a/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts +++ b/packages/react-sketch/src/components/SketchField/history/SketchHistory.ts @@ -2,6 +2,7 @@ import * as fabric from "fabric"; import { UndoHistory } from "./index"; import type { Erasure } from "../tools/Eraser"; import { linkedObjects } from "../tools/RectangleLabel/RectangleLabelObject"; +import { SketchGroup } from "../SketchGroup"; /** * What a modification did to an object: its state (as JSON) before and after it @@ -12,6 +13,18 @@ interface Change { after: string; } +/** + * A group and its objects, for making the group and for taking it apart. `index` is where + * the group is among the objects of the canvas, and `indices` where each of the `members` + * is when it is not in the group (the members are in the order of the canvas). + */ +interface Grouping { + group: SketchGroup; + members: fabric.FabricObject[]; + indices: number[]; + index: number; +} + /** * One undoable action of the sketch. A modification can be of many objects, as * the ones of a selection which is moved. @@ -20,7 +33,8 @@ type HistoryEntry = | { action: "add"; object: fabric.FabricObject } | { action: "remove"; object: fabric.FabricObject } | { action: "modify"; changes: Change[] } - | { action: "erase"; erasures: Erasure[] }; + | { action: "erase"; erasures: Erasure[] } + | ({ action: "group" | "ungroup" } & Grouping); /** * Properties of the serialized state which are not restored: the type cannot @@ -199,6 +213,56 @@ export class SketchHistory { this.pending.clear(); } + /** + * Make a group of the objects, and of the ones which follow them (the text box of a + * label). The group takes the place of the topmost of them, and it is one action. + * + * @returns the group, or undefined when there are not two objects of the canvas to group + */ + group(objects: fabric.FabricObject[]): SketchGroup | undefined { + const onCanvas = this.canvas.getObjects(); + const members = [ + ...new Set(objects.flatMap((object) => [object, ...linkedObjects(object)])), + ] + .filter((object) => onCanvas.includes(object)) + .sort((a, b) => onCanvas.indexOf(a) - onCanvas.indexOf(b)); + if (members.length < 2) return undefined; + const indices = members.map((object) => onCanvas.indexOf(object)); + const index = indices[indices.length - 1] - (members.length - 1); + const group = this.silently(() => { + this.canvas.remove(...members); + const made = new SketchGroup(members); + this.canvas.insertAt(index, made); + return made; + }); + this.refresh(group, ...members); + this.entries.keep({ action: "group", group, members, indices, index }); + return group; + } + + /** + * Take a group apart: its objects take its place on the canvas, with the position, the + * scale and the turn which the group gave them. It is one action. + * + * @returns the objects of the group, which are not in it any more + */ + ungroup(group: SketchGroup): fabric.FabricObject[] { + const index = this.canvas.getObjects().indexOf(group); + if (index === -1) return []; + const members = group.getObjects(); + const entry: HistoryEntry = { + action: "ungroup", + group, + members, + indices: members.map((_, place) => index + place), + index, + }; + this.silently(() => this.leaveGroup(entry)); + this.refresh(group, ...members); + this.entries.keep(entry); + return members; + } + /** * Record the removal of the objects and remove them from the canvas */ @@ -293,6 +357,45 @@ export class SketchHistory { this.pressed.clear(); } + /** + * Run a change of the canvas which is not an action of its own, so the objects it adds are + * not recorded, nor announced + */ + private silently(change: () => T): T { + this.applying = true; + try { + return change(); + } finally { + this.applying = false; + } + } + + /** The state of the objects is the one they have now, which is the "before" of their next modification */ + private refresh(...objects: fabric.FabricObject[]): void { + objects.forEach((object) => this.snapshots.set(object, stateOf(object))); + this.pressed.clear(); + } + + /** + * The objects leave the group, which leaves the canvas: they take its place, as it + * turned and scaled them + */ + private leaveGroup({ group, members, indices }: Grouping): void { + group.removeAll(); + this.canvas.remove(group); + members.forEach((member, place) => this.canvas.insertAt(indices[place], member)); + } + + /** + * The objects, which are on the canvas, go into the group, which takes their place. It + * is the group they left, so what refers to it (the modifications of it) still does. + */ + private enterGroup({ group, members, index }: Grouping): void { + this.canvas.remove(...members); + group.add(...members); + this.canvas.insertAt(index, group); + } + private apply(entry: HistoryEntry, forward: boolean): void { this.applying = true; try { @@ -303,6 +406,15 @@ export class SketchHistory { case "modify": this.applyChanges(entry.changes, forward); break; + case "group": + case "ungroup": + // The group, or its objects, may be selected: they are not any more + this.canvas.discardActiveObject(); + // redo of a group, and undo of an ungroup, put the objects in the group + if ((entry.action === "group") === forward) this.enterGroup(entry); + else this.leaveGroup(entry); + this.refresh(entry.group, ...entry.members); + break; case "add": case "remove": // redo of an add, and undo of a remove, put the object back diff --git a/packages/react-sketch/src/components/SketchField/history/grouping.spec.tsx b/packages/react-sketch/src/components/SketchField/history/grouping.spec.tsx new file mode 100644 index 0000000..562a97a --- /dev/null +++ b/packages/react-sketch/src/components/SketchField/history/grouping.spec.tsx @@ -0,0 +1,456 @@ +import { createRef } from "react"; +import { render } from "@testing-library/react"; +import * as fabric from "fabric"; +import SketchField from "../SketchField"; +import { SketchGroup } from "../SketchGroup"; +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 changes the target */ +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, + }); + +/** Where the center of the object is on the canvas, also for the object of a group */ +const centerOf = (object: fabric.FabricObject): Point => { + const { x, y } = object.group + ? fabric.util.transformPoint( + object.getRelativeCenterPoint(), + object.group.calcTransformMatrix() + ) + : object.getCenterPoint(); + return [x, y]; +}; + +const expectCenters = (objects: fabric.FabricObject[], expected: Point[]): void => + objects.forEach((object, i) => { + const [x, y] = centerOf(object); + expect(x).toBeCloseTo(expected[i][0], 3); + expect(y).toBeCloseTo(expected[i][1], 3); + }); + +/** Four objects drawn in this order, and the ones of the second and the fourth selected */ +const setup = (props: SketchProperties = {}) => { + const view = mount({ tool: Tool.SELECT, ...props }); + const a = new fabric.Line([50, 50, 150, 110], { stroke: "black", strokeWidth: 3 }); + const b = new fabric.Rect({ left: 300, top: 20, width: 40, height: 40 }); + const c = new fabric.Rect({ + left: 80, + top: 160, + width: 100, + height: 50, + stroke: "red", + }); + const d = new fabric.Circle({ left: 300, top: 200, radius: 20 }); + view.canvas.add(a, b, c, d); + const select = (...objects: fabric.FabricObject[]) => { + view.canvas.setActiveObject( + new fabric.ActiveSelection(objects, { canvas: view.canvas }) + ); + }; + select(a, c); + return { ...view, a, b, c, d, select, at: [a, b, c, d].map(centerOf) }; +}; + +describe("group and ungroup", () => { + describe("canGroup and canUngroup", () => { + it("need two objects selected to group, and a group to ungroup", () => { + const { sketch, canvas, a, select } = setup(); + expect([sketch.canGroup(), sketch.canUngroup()]).toEqual([true, false]); + + canvas.discardActiveObject(); + expect(sketch.canGroup()).toBe(false); + + canvas.setActiveObject(a); + expect([sketch.canGroup(), sketch.canUngroup()]).toEqual([false, false]); + + select(a, canvas.getObjects()[1]); + sketch.group(); + expect([sketch.canGroup(), sketch.canUngroup()]).toEqual([false, true]); + }); + + it("do not take the group of an arrow for one which can be taken apart", () => { + const { sketch, canvas, a, d } = setup(); + const arrow = new fabric.Group([a, d]); + canvas.add(arrow); + canvas.setActiveObject(arrow); + + expect(sketch.canUngroup()).toBe(false); + sketch.ungroup(); + expect(canvas.getObjects()).toContain(arrow); + expect(arrow.getObjects()).toHaveLength(2); + }); + }); + + describe("group", () => { + it("makes one group of the selected objects, which is selected", () => { + const { sketch, canvas, a, c } = setup(); + + sketch.group(); + + const group = canvas.getActiveObject(); + expect(group).toBeInstanceOf(SketchGroup); + expect((group as SketchGroup).getObjects()).toEqual([a, c]); + }); + + it("keeps the objects where they are on the canvas", () => { + const { sketch, a, c, at } = setup(); + + sketch.group(); + + expectCenters([a, c], [at[0], at[2]]); + }); + + it("takes the place of the topmost object, so what is above it stays above", () => { + const { sketch, canvas, b, d } = setup(); + + sketch.group(); + + const objects = canvas.getObjects(); + expect(objects).toHaveLength(3); + expect(objects[0]).toBe(b); + expect(objects[1]).toBeInstanceOf(SketchGroup); + expect(objects[2]).toBe(d); + }); + + it("keeps the order of the canvas for the objects of the group, whatever the order of the selection", () => { + const { sketch, a, c, select } = setup(); + select(c, a); + + sketch.group(); + + expect( + (sketch["canvas"].getActiveObject() as SketchGroup).getObjects() + ).toEqual([a, c]); + }); + + it("is one undo step, which puts the objects back, in their places, and selects nothing", () => { + const { sketch, canvas, a, b, c, d, at } = setup(); + + sketch.group(); + sketch.undo(); + + expect(canvas.getObjects()).toEqual([a, b, c, d]); + expectCenters([a, b, c, d], at); + expect(canvas.getActiveObject()).toBeUndefined(); + expect(a.group).toBeUndefined(); + }); + + it("is put back by redo, as the same group", () => { + const { sketch, canvas, a, c, at } = setup(); + + sketch.group(); + const group = canvas.getActiveObject() as SketchGroup; + const state = JSON.stringify(group.toJSON()); + sketch.undo(); + sketch.redo(); + + expect(canvas.getObjects()[1]).toBe(group); + expect(group.getObjects()).toEqual([a, c]); + expectCenters([a, c], [at[0], at[2]]); + expect(JSON.parse(JSON.stringify(group.toJSON()))).toMatchObject({ + left: expect.closeTo(JSON.parse(state).left, 3), + top: expect.closeTo(JSON.parse(state).top, 3), + width: expect.closeTo(JSON.parse(state).width, 3), + height: expect.closeTo(JSON.parse(state).height, 3), + }); + }); + + it("does nothing without two objects selected", () => { + const onChange = jest.fn(); + const { sketch, canvas, a, b, c, d } = setup({ onChange }); + canvas.discardActiveObject(); + + sketch.group(); + canvas.setActiveObject(a); + sketch.group(); + + expect(canvas.getObjects()).toEqual([a, b, c, d]); + expect(onChange).not.toHaveBeenCalled(); + expect(sketch.canUndo()).toBe(true); + sketch.undo(); + expect(canvas.getObjects()).toEqual([a, b, c]); + }); + + it("calls onChange, and does not announce the group as an object added by the user", () => { + const onChange = jest.fn(); + const onObjectAdded = jest.fn(); + const { sketch } = setup({ onChange, onObjectAdded }); + onObjectAdded.mockClear(); + + sketch.group(); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onObjectAdded).not.toHaveBeenCalled(); + }); + + it("is not an action of the history when the objects were not drawn on it", () => { + const { sketch, canvas } = setup(); + + sketch.group(); + sketch.undo(); + sketch.undo(); + + // Only the last object drawn went: the group was one step + expect(canvas.getObjects()).toHaveLength(3); + }); + }); + + describe("a group which is changed", () => { + it("is moved, undone and redone as one object", () => { + const { sketch, canvas, a, c, at } = setup(); + sketch.group(); + const group = canvas.getActiveObject() as SketchGroup; + + moveBy(canvas, group, 120, 80); + expectCenters( + [a, c], + [at[0], at[2]].map(([x, y]) => [x + 120, y + 80]) + ); + sketch.undo(); + expectCenters([a, c], [at[0], at[2]]); + sketch.redo(); + expectCenters( + [a, c], + [at[0], at[2]].map(([x, y]) => [x + 120, y + 80]) + ); + }); + + it("keeps its modifications when it is taken apart and put together again, by undo", () => { + const { sketch, canvas, a, c, at } = setup(); + sketch.group(); + const group = canvas.getActiveObject() as SketchGroup; + moveBy(canvas, group, 120, 80); + const moved = [a, c].map(centerOf); + + sketch.ungroup(); + sketch.undo(); + + expect(canvas.getObjects()).toContain(group); + expectCenters([a, c], moved); + // The move is still undone by the group it was made on + sketch.undo(); + expectCenters([a, c], [at[0], at[2]]); + expect(canvas.getObjects()[1]).toBe(group); + // And the group itself + sketch.undo(); + expectCenters([a, c], [at[0], at[2]]); + expect(a.group).toBeUndefined(); + }); + + it("keeps its modifications when it is put together again by redo", () => { + const { sketch, canvas, a, c, at } = setup(); + sketch.group(); + const group = canvas.getActiveObject() as SketchGroup; + moveBy(canvas, group, 120, 80); + const moved = [a, c].map(centerOf); + + sketch.undo(); + sketch.undo(); + expectCenters([a, c], [at[0], at[2]]); + sketch.redo(); + sketch.redo(); + + expect(canvas.getObjects()[1]).toBe(group); + expectCenters([a, c], moved); + }); + }); + + describe("ungroup", () => { + it("takes the group apart, where its objects were, and selects them", () => { + const { sketch, canvas, a, b, c, d, at } = setup(); + sketch.group(); + + sketch.ungroup(); + + expect(canvas.getObjects()).toEqual([b, a, c, d]); + expectCenters([a, c], [at[0], at[2]]); + const selected = canvas.getActiveObject(); + expect(selected).toBeInstanceOf(fabric.ActiveSelection); + expect((selected as fabric.ActiveSelection).getObjects()).toEqual([a, c]); + expect(sketch.canGroup()).toBe(true); + }); + + it("gives the objects the move, the scale and the turn of the group", () => { + const { sketch, canvas, a, c } = setup(); + sketch.group(); + const group = canvas.getActiveObject() as SketchGroup; + change(canvas, group, { + left: 400, + top: 300, + scaleX: 1.5, + scaleY: 0.8, + angle: 30, + }); + const shown = [a, c].map(centerOf); + + sketch.ungroup(); + + expectCenters([a, c], shown); + // Leaving the selection gives the objects their own place + canvas.discardActiveObject(); + expect(a.group).toBeUndefined(); + expect(a.angle).toBeCloseTo(30, 3); + expect(a.scaleX).toBeCloseTo(1.5, 3); + expectCenters([a, c], shown); + }); + + it("is one undo step, which makes the group again, and redo takes it apart again", () => { + const { sketch, canvas, a, b, c, d, at } = setup(); + sketch.group(); + const group = canvas.getActiveObject() as SketchGroup; + + sketch.ungroup(); + sketch.undo(); + expect(canvas.getObjects()).toEqual([b, group, d]); + expect(group.getObjects()).toEqual([a, c]); + expectCenters([a, c], [at[0], at[2]]); + + sketch.redo(); + expect(canvas.getObjects()).toEqual([b, a, c, d]); + expectCenters([a, c], [at[0], at[2]]); + }); + + it("takes only the group apart, when it holds another one", () => { + const { sketch, canvas, a, b, c, d, select } = setup(); + sketch.group(); + const inner = canvas.getActiveObject() as SketchGroup; + select(inner, b); + sketch.group(); + const outer = canvas.getActiveObject() as SketchGroup; + expect(outer.getObjects()).toEqual([b, inner]); + + sketch.ungroup(); + + expect(canvas.getObjects()).toEqual([b, inner, d]); + expect(inner.getObjects()).toEqual([a, c]); + }); + + it("calls onChange", () => { + const onChange = jest.fn(); + const { sketch } = setup({ onChange }); + sketch.group(); + onChange.mockClear(); + + sketch.ungroup(); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + }); + + describe("a group among the objects of the sketch", () => { + it("is saved with its type, and loaded as a group which can be taken apart", async () => { + const { sketch, canvas, a, c } = setup(); + sketch.group(); + const group = canvas.getActiveObject() as SketchGroup; + + const json = JSON.parse(JSON.stringify(group.toObject())); + expect(json.type).toBe("SketchGroup"); + const [loaded] = await fabric.util.enlivenObjects([json]); + + expect(loaded).toBeInstanceOf(SketchGroup); + expect((loaded as SketchGroup).getObjects()).toHaveLength(2); + expect((loaded as SketchGroup).getObjects()[0]).toBeInstanceOf(fabric.Line); + expect([a, c]).toHaveLength(2); + }); + + it("is copied as a group", async () => { + const { sketch, canvas } = setup(); + sketch.group(); + + const copy = await (canvas.getActiveObject() as SketchGroup).clone(); + + expect(copy).toBeInstanceOf(SketchGroup); + }); + + it("is removed as one object, and comes back with undo", () => { + const { sketch, canvas, b, d } = setup(); + sketch.group(); + const group = canvas.getActiveObject() as SketchGroup; + + sketch.removeSelected(); + expect(canvas.getObjects()).toEqual([b, d]); + sketch.undo(); + + expect(canvas.getObjects()).toContain(group); + expect(group.getObjects()).toHaveLength(2); + }); + }); + + describe("a rectangle with a label", () => { + it("is grouped with its text box, which follows it", () => { + const view = mount({ tool: Tool.RECTANGLE_LABEL }); + const [rect, textbox] = draw(view.canvas, [50, 80], [250, 200]); + view.update({ tool: Tool.SELECT }); + const other = new fabric.Circle({ left: 400, top: 300, radius: 20 }); + view.canvas.add(other); + const at = [rect, textbox, other].map(centerOf); + view.canvas.setActiveObject( + new fabric.ActiveSelection([rect, other], { canvas: view.canvas }) + ); + + view.sketch.group(); + + const group = view.canvas.getActiveObject() as SketchGroup; + expect(group.getObjects()).toEqual([rect, textbox, other]); + expectCenters([rect, textbox, other], at); + + view.sketch.undo(); + expect(view.canvas.getObjects()).toEqual([rect, textbox, other]); + expectCenters([rect, textbox, other], at); + }); + }); +}); diff --git a/packages/react-sketch/src/types.ts b/packages/react-sketch/src/types.ts index 29afd06..b9b2fe1 100644 --- a/packages/react-sketch/src/types.ts +++ b/packages/react-sketch/src/types.ts @@ -248,8 +248,8 @@ export interface SketchProperties { style?: CSS.Properties; /** * Called when the mouse was released on the canvas, or left it, after an object was - * added, changed or removed since it was pressed, and after undo, redo and loading a - * value. Not called for a click which only selects, or for a pointer which comes and + * added, changed or removed since it was pressed, and after undo, redo, group, ungroup + * and loading a value. Not called for a click which only selects, or for a pointer which comes and * goes. The argument is the DOM event of the former, nothing in the latter */ onChange?: (event?: Event) => void; diff --git a/packages/showcase/README.md b/packages/showcase/README.md index 709be8b..b6a7672 100644 --- a/packages/showcase/README.md +++ b/packages/showcase/README.md @@ -7,8 +7,8 @@ The canvas fills the window, and the panels float over it: - **Toolbar** (top): select, pan, rectangle, rectangle label, circle, polygon, line, arrow, pencil, highlighter, eraser and laser pointer. The keys of the tools are on the buttons and in the shortcuts dialog. -- **Properties** (left): line color, fill color, line weight and line style of the tool, and copy, paste and delete - of the selected objects. +- **Properties** (left): line color, fill color, line weight and line style of the tool, and copy, paste, group, ungroup + and delete of the selected objects (Ctrl G and Ctrl Shift G group and ungroup). - **Settings** (right, the gear): the **Scenario** tab (what the scenario shows and its actions), the **Canvas** tab (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, diff --git a/packages/showcase/src/actions.ts b/packages/showcase/src/actions.ts index 5c428af..ae4c05c 100644 --- a/packages/showcase/src/actions.ts +++ b/packages/showcase/src/actions.ts @@ -69,6 +69,8 @@ export const selectScenario = (scenario: Scenario): void => { CONFIG.canUndo = false; CONFIG.canRedo = false; CONFIG.hasSelection = false; + CONFIG.canGroup = false; + CONFIG.canUngroup = false; CONFIG.canPaste = false; CONFIG.empty = true; CONFIG.view = INITIAL_VIEW; @@ -137,6 +139,11 @@ export const restoreOriginalSize = (): void => CONFIG.controlledSize = true; }); +/** The canvas announces the change, which updates the state of the actions */ +export const groupSelected = (): void => withSketch((sketch) => sketch.group()); + +export const ungroupSelected = (): void => withSketch((sketch) => sketch.ungroup()); + export const copy = (): void => withSketch((sketch) => { if (!sketch.hasSelection()) return; diff --git a/packages/showcase/src/components/Properties.tsx b/packages/showcase/src/components/Properties.tsx index 00517c1..aef1e9f 100644 --- a/packages/showcase/src/components/Properties.tsx +++ b/packages/showcase/src/components/Properties.tsx @@ -6,6 +6,8 @@ import DeleteIcon from "@mui/icons-material/Delete"; import PasteIcon from "@mui/icons-material/ContentPaste"; import RemoveIcon from "@mui/icons-material/Remove"; import StyleIcon from "@mui/icons-material/Palette"; +import GroupIcon from "@mui/icons-material/Workspaces"; +import UngroupIcon from "@mui/icons-material/WorkspacesOutlined"; import Box from "@mui/material/Box"; import Divider from "@mui/material/Divider"; import IconButton from "@mui/material/IconButton"; @@ -19,8 +21,10 @@ import { NO_LINE_STYLE, NO_PROPERTIES, copy, + groupSelected, paste, removeSelected, + ungroupSelected, } from "../actions"; import { CONFIG } from "../stores/config"; import { toolByValue } from "../tools"; @@ -46,7 +50,11 @@ interface ActionProps { const Action = ({ label, disabled, onClick, children }: ActionProps) => ( - + {children} @@ -59,7 +67,14 @@ const Action = ({ label, disabled, onClick, children }: ActionProps) => ( * nothing leave it with the actions only. */ const Properties = observer((): ReactElement => { - const { selectedTool, propertiesOpen, hasSelection, canPaste } = CONFIG; + const { + selectedTool, + propertiesOpen, + hasSelection, + canGroup, + canUngroup, + canPaste, + } = CONFIG; const drawing = !NO_PROPERTIES.includes(selectedTool); const tool = toolByValue(selectedTool); @@ -184,6 +199,15 @@ const Properties = observer((): ReactElement => { + + + + + + { if (!CONFIG.canPaste) return false; paste(); return true; + case "g": + // The browser finds the next match with it, which is not wanted with objects selected + if (!(event.shiftKey ? CONFIG.canUngroup : CONFIG.canGroup)) return false; + (event.shiftKey ? ungroupSelected : groupSelected)(); + return true; case "s": save(); return true; diff --git a/packages/showcase/src/sketchRef.ts b/packages/showcase/src/sketchRef.ts index 7b807ed..7ddcc39 100644 --- a/packages/showcase/src/sketchRef.ts +++ b/packages/showcase/src/sketchRef.ts @@ -54,6 +54,8 @@ export const syncState = (): void => CONFIG.canUndo = sketch.canUndo(); CONFIG.canRedo = sketch.canRedo(); CONFIG.hasSelection = sketch.hasSelection(); + CONFIG.canGroup = sketch.canGroup(); + CONFIG.canUngroup = sketch.canUngroup(); }) ); diff --git a/packages/showcase/src/stores/config.ts b/packages/showcase/src/stores/config.ts index a50a389..38167d8 100644 --- a/packages/showcase/src/stores/config.ts +++ b/packages/showcase/src/stores/config.ts @@ -65,6 +65,8 @@ export interface Config { canUndo: boolean; canRedo: boolean; hasSelection: boolean; + canGroup: boolean; + canUngroup: boolean; canPaste: boolean; /** Whether nothing is drawn on the canvas */ empty: boolean; @@ -133,6 +135,8 @@ export class ConfigStore implements Config { public canUndo = false; public canRedo = false; public hasSelection = false; + public canGroup = false; + public canUngroup = false; public canPaste = false; public empty = true; public grid = false;