Skip to content

Editing features

Each feature lives in its own file in src/features/. A pointer feature has a gesture record and two functions: one begins the gesture, and one returns the next Doc for a pointer position. The next document is always computed from the editor the gesture started from, so a preview frame never builds on the previous frame. The session routes pointer events to these functions. Commands such as delete and restyle return the next Editor and push one history entry.

Inputs

type Tool = "select" | "rectangle" | "ellipse" | "diamond" | "line" | "arrow" | "freehand";
type Pointer = {
readonly point: PagePoint;
readonly pressure: number;
readonly zoom: number;
readonly shiftKey: boolean;
readonly altKey: boolean;
};
type EditorEnv = AdapterEnv & {
readonly newShapeId: () => ShapeId;
readonly newBindingId: () => BindingId;
readonly newGroupId: () => GroupId;
readonly newSeed: () => Seed;
};
  • Tool is a string union with a same-named const object, so compare against Tool.select, never "select".
  • Pointer is a pointer event already converted to page space with screenToPage. pressure feeds freehand strokes. zoom is the camera zoom at the event. The camera is not part of the editor, so the shell passes it with each pointer, and features turn screen-pixel tolerances into page units by dividing by it. Shift locks the aspect ratio during a resize, and Alt resizes from the centre.
  • EditorEnv holds the ports that are not pure. The app passes createShapeId, a binding id generator, createGroupId and a random seed. Tests pass counters, so every result is exact.

Creating

type DragTool = Exclude<Tool, "select" | "freehand">;
type PageSample = { readonly point: PagePoint; readonly pressure: number };
type CreateGesture = {
readonly kind: "create";
readonly tool: DragTool;
readonly origin: PagePoint;
readonly zoom: number;
readonly id: ShapeId;
readonly index: ZIndex;
readonly style: Style;
};
type InkGesture = {
readonly kind: "ink";
readonly samples: readonly [PageSample, ...PageSample[]];
readonly id: ShapeId;
readonly index: ZIndex;
readonly style: Style;
};
  • A create gesture takes its id, its z-index above every shape, and a fresh seed once, when it begins. Every preview frame reuses them, so the shape keeps one id and one look.
  • A drag gives a shape that fills the dragged box in any direction. A line or an arrow runs from the press point to the pointer. The arrow gets the arrow adapter’s default heads.
  • When the pointer is at the press point, nothing is created. A click without a drag adds nothing and pushes no entry.
  • On release, the shape is committed as one entry and selected.
  • A freehand gesture adds a sample on every move. A press alone draws a dot. The new stroke is not selected.

Moving

type MoveGesture = {
readonly kind: "move";
readonly origin: PagePoint;
readonly deselect?: ReadonlySet<ShapeId>;
};

A move shifts every selected shape by the pointer’s movement since origin. deselect is set by a Shift press on an already selected shape. It holds what that press picked, the shape or its whole group. If the pointer is released exactly at origin, those shapes leave the selection, and any drag moves the whole set instead. A move back to the origin changes nothing and pushes no entry. A bound arrow follows a moved target through settle. An arrow moved without its target is detached.

Resizing

type ResizeGesture = {
readonly kind: "resize";
readonly handle: ResizeHandle;
readonly box: Frame;
};
  • selectionFrame(editor) is the frame the resize handles sit on. For one shape it is that shape’s own frame, so a rotated shape keeps its rotated handles. For a group it is the unrotated bounds of every selected shape. With nothing selected it is undefined.
  • A resize runs resizeFrame once on box. A single shape then goes through its adapter’s resize with that change, exactly like @draw/shapes resizes one shape.
  • In a group, each shape’s frame is scaled within the box about the same anchor, and its adapter’s resize fits its drawable. Dragging past the opposite edge mirrors the group, including the points of lines and arrows. A shape keeps its rotation. A rotated shape inside a group is placed by its centre, so it is exact only for unrotated shapes.

Rotating

type RotateGesture = { readonly kind: "rotate"; readonly box: Frame };
  • rotationHandlePoint(box, zoom) is where the rotation handle sits: above the middle of the top edge of box, ROTATION_HANDLE_OFFSET (24) screen pixels away, so 24 / zoom page units. It turns with the frame. The grab radius is the usual 6 screen pixels.
  • A lone line or arrow has point handles and no rotation handle. Every other selection has one, on selectionFrame.
  • rotateTo points the handle at the pointer. The new angle of box is the angle from its centre to the pointer plus a quarter turn, in the range 0 up to a full turn. With Shift it snaps to a multiple of ROTATION_SNAP (15 degrees).
  • A single shape turns about its own centre. Several shapes all turn by the same angle about the centre of box, which is the axis-aligned bounds of the group. Each shape’s centre moves along a circle and its rotation changes by that angle. The bounds box stays axis-aligned and is recomputed from the rotated shapes.
  • Bound arrows follow through settle, because the target’s record changed.
  • An arrow binds its ends as it is drawn. See Binding arrows below.
  • A whole drag is one history entry. A rotation is part of the shape’s frame, so it survives toShapeDocument and load.
docs/examples/rotate-selection.ts
import {
createEditor,
createSession,
fromShapeDocument,
pointerDown,
pointerMove,
pointerUp,
rotationHandlePoint,
select,
} from "@draw/editor";
import type { EditorEnv, Pointer, Session } from "@draw/editor";
import {
DEFAULT_STYLE,
bindingId,
groupId,
indexAbove,
pagePoint,
radians,
seed,
shapeId,
} from "@draw/shapes";
import type { PagePoint, Shape } from "@draw/shapes";
const env: EditorEnv = {
measureText: (line, font) => line.length * font.size,
newShapeId: () => shapeId("unused"),
newBindingId: () => bindingId("unused"),
newGroupId: () => groupId("unused-group"),
newSeed: () => seed(7),
};
const at = (point: PagePoint, shiftKey = false): Pointer => ({
point,
pressure: 0.5,
zoom: 1,
shiftKey,
altKey: false,
});
const box: Shape = {
id: shapeId("box"),
frame: { x: 0, y: 0, width: 100, height: 100, rotation: radians(0) },
drawable: { kind: "rectangle", cornerRadius: 0 },
index: indexAbove([]),
style: DEFAULT_STYLE,
groupIds: [],
};
const selected = select(
createEditor(fromShapeDocument({ shapes: [box], bindings: [] })),
new Set([box.id]),
);
export const handle: PagePoint = rotationHandlePoint(box.frame, 1);
const pressed = pointerDown(createSession(selected), at(handle), env);
const aimed = pointerMove(pressed, at(pagePoint(150, 33), true), env);
export const rotated: Session = pointerUp(aimed, at(pagePoint(150, 33), true), env);
export const angle: number | undefined = rotated.editor.doc.shapes.get(box.id)?.frame.rotation;

Handles

type HandleGesture = {
readonly kind: "handle";
readonly id: ShapeId;
readonly handle: Handle;
readonly zoom: number;
};
  • shapeHandles(shape) lists a shape’s point handles: every vertex of a line, and the two terminals and the bend of an arrow. Other shapes have none and are edited through resize handles.
  • Dragging a handle calls the adapter’s dragHandle. Dragging an arrow’s terminal rebinds that terminal as described under Binding arrows below, so dropping it away from every shape detaches it. The other terminal stays bound.

Binding arrows

A terminal binds as it is placed, in features/bind.ts. bindEnd(doc, { arrowId, terminal, point, zoom }, env) drops any binding of that terminal, then looks for a target with bindingTarget at a tolerance of bindingDistance(zoom) page units: 15 at 100% zoom and above, 20 at zoom 0.5, at most 30. A hit becomes a binding from bindTerminal with a fresh id from EditorEnv.newBindingId. No hit leaves the terminal free. The arrow itself is never a candidate, and bindingTarget never returns another arrow.

  • Drawing an arrow. Every frame of a create gesture with the Arrow tool binds the start at the press point and the end at the pointer, like Excalidraw. A press inside or near a shape binds the start from the first frame.
  • Dragging a terminal. A handle drag on a terminal binds that terminal at the pointer on every frame. Dropping on a shape binds it, and dropping away from every shape leaves it unbound. A bound end dropped on a different shape moves its binding there, so a terminal never has two.
  • Anchor. A point inside the target keeps its place as the anchor. A point only near the target anchors at its centre, so the arrow aims at the shape and stops at its outline, bindingGap short of it: 5 page units plus half the target’s stroke width, so 6 by default.
  • Highlight. bindingHighlight(session) returns the shape that the arrow end being drawn or dragged is bound to in the preview, or undefined. A shell outlines it. It is the same shape the release will bind, because both read the preview’s bindings.
  • Moves. A bound arrow follows a moved, resized or rotated target through settle. Moving only the arrow detaches its ends, and deleting the target drops its bindings and leaves the arrow where it is.

Picking

A press with the Select tool resolves in this order:

  1. A handle of the selection. A lone line or arrow offers its point handles. Any other selection offers the eight resize handles of selectionFrame and the rotation handle above them. A handle within 6 screen pixels of the pointer (6 / zoom page units) starts a handle drag or a resize, even when another shape lies under the pointer.
  2. A shape. pickShape with a tolerance of 6 screen pixels (6 / zoom page units) finds the shape under the pointer. If it is already selected, the selection is kept, so a press on any selected shape moves the whole selection. Otherwise the shape becomes the selection. Either way, a move starts. With Shift held, an unselected shape is added to the selection, and a selected shape stays selected until release, when a click without movement removes it (see MoveGesture).
  3. Empty canvas. The selection is cleared and a marquee starts. With Shift held the selection is kept as the marquee’s base.

pickAt(editor, point, zoom, options) takes additive from Pointer.shiftKey and the entered scope from the session. A picked shape is turned into the ids to select by one function, expandPick(editor, id, scope), which is where groups (below) change selection.

Marquee

type MarqueeGesture = {
readonly kind: "marquee";
readonly origin: PagePoint;
readonly current: PagePoint;
readonly base: Selection;
};

A drag on empty canvas selects every shape whose bounds lie fully inside the box between origin and current, in any drag direction. A shape in a group counts only when every member of its outermost group is inside, and then the whole group is selected. Lines and arrows count by their bounds, as in Excalidraw. The result is base plus the shapes inside, and the preview shows it while the pointer moves. The gesture changes only the selection, so it pushes no history entry. marqueeBox(gesture) returns the normalised box for drawing the rectangle.

selectAll(editor) selects every shape in the document. It is not an edit either.

A resize of several shapes scales each one within the selection box. Shift locks the box’s aspect ratio and Alt scales from its centre, as for one shape.

Selecting is not an edit, so it pushes no entry.

Commands

  • restyleSelection(editor, change) applies change to the style of every selected shape in one entry. A change that leaves every style equal pushes no entry.
  • deleteSelection(editor) removes the selected shapes and clears the selection in one entry. Bindings to or from a removed shape are dropped by settle. One undo restores the shapes, the bindings and the selection.
  • reorderSelection(editor, order) changes the stacking order of every selected shape in one entry. order is Order.forward, Order.backward, Order.front or Order.back. One step forward or backward jumps over the nearest shape that overlaps the selection. Several selected shapes keep their order relative to each other. A reorder that moves nothing, such as sending the bottom shape backward or an empty selection, pushes no entry. The selection is kept. If two shapes share a z-index, the document is returned unchanged.

Groups

A group is a GroupId that several shapes list in Shape.groupIds, innermost first (ADR 0009, docs/decisions/0009-groups.md). There is no group record. The selection stays a set of shape ids, so move, resize, rotate, delete, restyle, restack, copy and duplicate act on a group with no group code, because a pick selects every member.

  • pickedGroup(shape, scope) is the group a click on shape selects. With no scope it is the outermost group. With scope, the group the user has entered, it is the next group inside it, or nothing when the shape is a direct member. A shape that is not in scope falls back to its outermost group. undefined means the shape is picked alone.
  • membersOf(doc, group) is the ids of every shape that lists group.
  • expandPick(editor, id, scope) is the ids a click on shape id selects: the members of pickedGroup, or the shape alone.
  • groupSelection(editor, scope, env) appends a fresh group id from env.newGroupId to every selected shape, in one entry. It needs at least two units, where a unit is a group at the current level or a lone shape, so grouping a selection that is already one group changes nothing. Inside an entered group the new id goes below the entered one, so the entered group stays outermost.
  • ungroupSelection(editor, scope) removes pickedGroup from every selected shape, in one entry. It removes only that level. Shapes in no group are left alone.

The entered group is input state, so it lives in the session as enteredGroup. A double-click enters the group, and a pick or a selection that leaves it clears it. Paste and duplicate give each distinct group in the clip a fresh id, so a copy is a new group.

Copy, paste and duplicate

type ShapeDocument = {
readonly shapes: readonly Shape[];
readonly bindings: readonly Binding[];
};
  • copySelection(editor) returns the selected shapes in stacking order as a ShapeDocument, the clip. It holds the bindings whose arrow and target are both selected, and drops the rest. The clip is plain data, so the app can JSON.stringify it onto the system clipboard.
  • parseClip(text) parses clipboard text back into a clip. Text that is not JSON, or not a shape document, is an error value.
  • pasteClip(editor, clip, at, env) inserts the clip with its bounds centred on at. Every shape gets a fresh id from env.newShapeId and a fresh seed from env.newSeed, so a copy looks hand-drawn differently. Each distinct group id in the clip is replaced by one fresh id from env.newGroupId, so the copies form a new group. Each kept binding gets a fresh id from env.newBindingId and points at the copies. A binding to a shape the clip does not hold is dropped. The copies take new indices above every shape, in their original order, and become the selection. An empty clip changes nothing.
  • duplicateSelection(editor, env) is a copy and a paste at an offset of DUPLICATE_OFFSET (10) page units down and right.

A paste and a duplicate are each one history entry. One undo removes the copies and brings back the old selection. A copied arrow is re-resolved by settle, so it follows the copy of its target.