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

Filter by extension

Filter by extension

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

Expand Down Expand Up @@ -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 |
Expand Down
54 changes: 54 additions & 0 deletions packages/react-sketch/src/components/SketchField/SketchField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -735,6 +736,59 @@ class SketchField extends PureComponent<SketchProperties & DefaultedProps> {
.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,
Expand Down
12 changes: 12 additions & 0 deletions packages/react-sketch/src/components/SketchField/SketchGroup.ts
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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<T>(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 {
Expand All @@ -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
Expand Down
Loading
Loading