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;