Skip to content

Bindings

A binding attaches one end of an arrow to another shape, its target. When the target moves, resizes or rotates, the bound end follows.

The records

A Binding is its own record, not a field on the arrow:

Field Meaning
id A BindingId
arrowId The ShapeId of the arrow
terminal The bound end, a Terminal: "start" or "end"
targetId The ShapeId of the shape the end is attached to
anchor A NormalizedPoint, where in the target’s frame it attaches

Separate records keep the shapes plain. Deleting either side is a filter over the bindings, and the arrow never holds an id that may have gone stale.

BindingId is a branded string, like ShapeId. bindingId(value) brands a string you already have. The package does not generate binding ids. The caller passes them in, so every function here stays pure.

NormalizedPoint is a branded point in (0..1, 0..1) inside the target’s frame, in its local space. (0, 0) is the frame’s local top-left and (1, 1) its bottom-right. normalizedPoint(x, y) clamps both values to 0..1, so the brand always means in range. A fraction of the frame survives the target being moved, resized or rotated, which page units would not.

ArrowShape is a Shape whose drawable is an arrow. isArrowShape(shape) is its type guard. TypeScript cannot narrow a Shape by checking shape.drawable.kind, so the functions that need an arrow take an ArrowShape.

Choosing a target

bindingTarget(shapes, point, { tolerance, exclude }) returns the shape a terminal dropped at point attaches to, or undefined. tolerance is in page units. bindingDistance(zoom) is the tolerance Excalidraw uses, in page units: 15 at 100% zoom and above, then 15 / (zoom * 1.5) as the zoom drops, clamped to at most 30. At zoom 0.5 it is 20, and at 0.25 or less it is 30. BINDING_DISTANCE (15) is its base.

  • Candidates: every shape except arrows, which are never targets, and the shape whose id is exclude. Pass the arrow being edited as exclude.
  • Closed shapes qualify when their outline is within tolerance of the point, or when the point is inside the outline. fill makes no difference.
  • Open shapes (line, freehand) qualify only near their stroke. They have no inside.
  • Among several, the smallest area wins. An open shape has an area of 0, so a line near the point beats the rectangle around it. Ties go to the shape higher in z-order.

Smallest-first lets you bind to a small shape inside a large one. This differs from picking, where a filled shape hides what is below it. A terminal targets what you aim at, not what you would select.

The corner of an ellipse’s frame is outside the ellipse, so a point there finds no target.

Binding a terminal

bindTerminal({ arrow, terminal, target, point, id }) returns the Binding for arrow’s terminal attached to target. The anchor is point in the target’s local space, divided by its width and height, then clamped to the frame. A point outside a closed target’s outline, near it or in a corner of its frame, anchors at the centre (0.5, 0.5), so the arrow aims at the shape and stops at its outline with the gap. A point inside the outline keeps its place. A point near an open target, such as a line, is clamped to the frame. A target axis of size 0 anchors at 0.5 on that axis.

Resolving an arrow

resolveArrow(arrow, bindings, shapesById) returns the arrow’s new frame and drawable as a Fitted<"arrow">. shapesById is a ReadonlyMap from ShapeId to Shape. Only the bindings whose arrowId is the arrow’s id count.

  1. Ends. A bound end moves to its anchor’s page point: the anchor times the target’s width and height, taken from local to page space. An unbound end keeps its page position. A binding whose target is not in shapesById is ignored, not an error, and that end counts as unbound.
  2. Curve. The unclipped curve is the arc through the two ends with the arrow’s current bend, flattened as on Arrows.
  3. Clipping. A bound end is clipped back along that curve to the target’s outline, so the arrow stops at the edge instead of reaching the anchor. The clip point is the first place the flattened curve crosses the outline, walking from that end toward the other. The end then steps back along the curve by bindingGap(target), BINDING_GAP (5) plus half the target’s stroke width, like Excalidraw, so the arrowhead does not touch the outline. The step is at most half of what is left of the curve, so a very short arrow keeps a positive length.
  4. Fit. The result is the arc through the clipped ends, with the same bend, refitted to the arrow’s frame. The frame keeps its rotation.

A bound end is clipped only when all of these hold:

  • The target’s outline is closed. An open target, such as a line, has no inside to leave.
  • The anchor’s page point is inside the outline, by more than 1e-6. An anchor on an ellipse’s frame corner is outside the ellipse, so that end stops at the anchor. An anchor on the outline itself, such as one clamped to a rectangle’s edge, already touches the edge and is not clipped. Without that margin, rounding decides whether such an anchor is inside, and repeated calls clip it to different points.
  • The other end is not bound to the same target, and does not lie inside it. An arrow drawn within one shape keeps both ends where they are.
  • The curve crosses the outline. If it does not, the end stays at the anchor.

Idempotency

bend is kept through resolution, and the clip is computed on the curve through the unclipped anchor points. Each call resets the bound ends to their anchors, which depend only on the targets. So resolveArrow on its own result gives the same arrow again, up to floating-point rounding. You can resolve an arrow after every change without it drifting.

The clipped arc is not the unclipped arc cut short. It is a new arc with the same bend over a shorter chord. The clip point lies on the flattened curve, within 0.5 units of the true circle.

docs/examples/bound-arrow-resize.ts
import {
DEFAULT_STYLE,
arrowsToResolve,
bindingId,
bindTerminal,
indexAbove,
localPoint,
pagePoint,
radians,
resizeFrame,
resolveArrow,
shapeId,
} from "@draw/shapes";
import type { ArrowShape, Binding, Fitted, Shape, ShapeId } from "@draw/shapes";
const box: Shape = {
id: shapeId("box"),
frame: { x: 300, y: 100, width: 100, height: 100, rotation: radians(0) },
drawable: { kind: "rectangle", cornerRadius: 0 },
index: indexAbove([]),
style: DEFAULT_STYLE,
groupIds: [],
};
const arrow: ArrowShape = {
id: shapeId("arrow"),
frame: { x: 0, y: 150, width: 350, height: 0, rotation: radians(0) },
drawable: {
kind: "arrow",
start: localPoint(0, 0),
end: localPoint(350, 0),
bend: 0,
startHead: "none",
endHead: "arrow",
},
index: indexAbove([box]),
style: DEFAULT_STYLE,
groupIds: [],
};
const binding: Binding = bindTerminal({
arrow,
terminal: "end",
target: box,
point: pagePoint(350, 150),
id: bindingId("b1"),
});
const resized: Shape = { ...box, frame: resizeFrame(box.frame, "left", pagePoint(200, 150)).frame };
export const toResolve: readonly ShapeId[] = arrowsToResolve(new Set([box.id]), [binding]);
function resolve(target: Shape): Fitted<"arrow"> {
return resolveArrow(arrow, [binding], new Map([[target.id, target]]));
}
export const before: Fitted<"arrow"> = resolve(box);
export const after: Fitted<"arrow"> = resolve(resized);

The arrow runs from (0, 150) to the centre of a 100 by 100 box at x = 300. Its end is bound at the anchor (0.5, 0.5). Resolved, the end stops 6 units short of the box’s left edge, 5 plus half the default stroke width of 2, at (294, 150). The box’s left handle then drags to x = 200, so the box is 200 wide. arrowsToResolve names the arrow. The anchor is still the box’s centre, now (300, 150), and the end clips to the new left edge and stops at (194, 150). The unbound start stays at (0, 150).

Keeping bindings current

arrowsToResolve(changedIds, bindings) returns the ids of the arrows with an end bound to one of the changed shapes. Each arrow appears once, in the order its first binding appears in bindings. A change to the arrow itself lists nothing, because only targets move bound ends.

removeBindingsFor(bindings, deletedIds) returns the bindings that remain after a delete. It drops every binding whose arrow or target is in deletedIds.

What the app decides

The package leaves two policies to the app:

  • Unbinding on an arrow drag. Whether dragging the whole arrow detaches its bound ends, keeps them, or drags the targets along is an interaction choice.
  • Anchor snapping. Snapping an anchor to the target’s centre or to the middle of an edge is a choice made while dragging. bindTerminal stores the anchor it is given. Snap the point before you pass it.