Skip to content

Text inside shapes

A rectangle, an ellipse or a diamond can carry text. The text is the optional label field of the drawable, { text, fontSize, fontFamily, align }, so it lives and dies with its shape. ADR 0010 (docs/decisions/0010-text-inside-shapes.md) explains why it is a field and not a second shape.

A Container is a drawable that may have a label, and isContainer(drawable) narrows to it. ContainerKind is its kind.

Layout

The text block is centred in the frame, horizontally and vertically. Each line sits in the block by the label’s align, one of TextAlign (see Text). A new label is DEFAULT_LABEL_ALIGN, "center". It wraps at the inner width, with LABEL_PADDING (5) on each side, as in Excalidraw.

Kind Inner width
Rectangle width - 10
Ellipse round(width / 2 * sqrt(2)) - 10, the widest box inside the ellipse
Diamond round(width / 2) - 10, the widest box inside the diamond

labelWidth(kind, frameWidth) returns that width, and never less than 1. labelLayout(frame, drawable, env) returns the TextLayout of the label at that width, or undefined when there is no label. The layout’s align is the label’s, and its x is the line anchor in frame coordinates, so a renderer draws each line at x with the canvas textAlign of align. For a rectangle 100 wide that is 5 for left, 50 for centre and 95 for right. The block is narrower than the frame for an ellipse or a diamond, so x stays inside the inscribed box. The measurer comes from AdapterEnv, as for text.

Growing

A frame is never shorter than its text. fitLabel(frame, drawable, env) returns a Labelled pair, { frame, drawable }. When the laid-out text plus padding is taller than the frame, the frame grows to the height that holds it. For an ellipse that is the height of the ellipse around the text box, and for a diamond it is twice the text height plus padding. The top edge stays where it is on the page, including in a rotated frame. A frame that already fits is returned as it was, so a shape never shrinks to fit shorter text.

The rectangle, ellipse and diamond adapters call fitLabel from resize, so narrowing a shape re-wraps its text and makes it taller.

Editing

editLabel(frame, drawable, text, env) sets the text and fits the frame. A shape with no label gets one at DEFAULT_LABEL_FONT_SIZE (20) in "hand-drawn" at DEFAULT_LABEL_ALIGN. A shape with a label keeps its font and alignment. clearLabel(drawable) returns the drawable without a label.

docs/examples/label-grow.ts
import { adapterFor, editLabel, radians } from "@draw/shapes";
import type { AdapterEnv, DrawableOf, Frame, Labelled } from "@draw/shapes";
const env: AdapterEnv = { measureText: (line, font) => line.length * (font.size * 0.6) };
const box: Frame = { x: 0, y: 0, width: 100, height: 50, rotation: radians(0) };
const rectangle: DrawableOf<"rectangle"> = adapterFor("rectangle").create(box, env);
export const grown: Labelled<DrawableOf<"rectangle">> = editLabel(
box,
rectangle,
"aaaaaaa bbbbbbb ccccccc",
env,
);

A 100 by 50 rectangle holds aaaaaaa bbbbbbb ccccccc. Each word is 84 wide at 20, and the inner width is 90, so the text takes three lines of 25. With padding that is 85, so the frame grows from 50 to 85 and keeps its top at y = 0.