Text
A text is { kind: "text", text, sizing, fontSize, fontFamily, align }. sizing is "auto" or "fixed-width". The frame is the box the laid-out text fills.
FontFamily is "hand-drawn", "sans" or "mono". "hand-drawn" is Excalifont. The package knows the names only. The app maps each one to a font file and its metrics. A new text is "hand-drawn".
FONT_SIZES holds the four size presets, matching Excalidraw’s S, M, L and XL: small 16, medium 20, large 28 and extraLarge 36. A new text is medium. The drawable accepts any positive fontSize, and a corner drag scales it freely, so the presets are a menu and not a constraint.
TextAlign names the three alignments with the named values TextAlign.left, TextAlign.center and TextAlign.right (ADR 0006). A new text is "left".
Text is the one drawable whose size depends on a font. The package has no DOM and does no I/O, so it cannot measure a string. The caller passes a measurer in, and everything else is pure.
The measurer
MeasureText is (line, font) => number, where Font is { size, family }. It returns the advance width of one unbroken line in that font. The package never calls it with a newline. An app backs it with canvas measureText. A test backs it with arithmetic.
Colour is not part of the drawable. It is style.
Layout
layoutText(input, measure) returns a TextLayout: the lines, the width and height of the box they fill, the align and x. TextLayoutInput holds the text, the sizing, the font, an optional width and an optional align that defaults to "left".
TextSizing is "auto" or "fixed-width".
- Auto breaks only at
"\n". The width is the widest line. - Fixed width also wraps each line greedily at spaces so it fits
width. The returned width iswidth. A fixed-width input with nowidthdoes not wrap.
The height is the number of lines times TEXT_LINE_HEIGHT times the font size. TEXT_LINE_HEIGHT is 1.25. An empty text is one empty line, so it has a height and no width.
Wrapping rules
- A word wider than the line breaks at grapheme boundaries. Graphemes come from
Intl.Segmenter, so an emoji joined with zero-width joiners is never split. The next word joins the last piece. - A grapheme wider than the line gets a line of its own, so no line is empty except one that the text itself leaves empty.
- Trailing spaces never count toward a line’s width, so they never force a wrap. A wrap drops them. A line at the end of a paragraph keeps them.
- Leading spaces count. When they push the first word to the next line, they are dropped.
Alignment
Alignment changes where the lines sit in the box, never how they break. The lines and the box are the same for every align. x is the anchor of every line in the box: 0 for left, half the width for centre and the full width for right. A renderer draws each line at x with the matching canvas textAlign. alignedX(width, align) is the same rule for a caller that has a width and no layout.
An auto text is as wide as its widest line, so its anchor is on that line’s edge or middle. A fixed-width text anchors at the fixed width.
import { layoutText } from "@draw/shapes";import type { Font, TextLayout } from "@draw/shapes";
const font: Font = { size: 10, family: "sans" };
const measureText = (line: string, measured: Font): number => line.length * (measured.size * 0.6);
const input = { text: "aa bb cc dd", sizing: "fixed-width", font, width: 30 } as const;
export const left: TextLayout = layoutText(input, measureText);
export const center: TextLayout = layoutText({ ...input, align: "center" }, measureText);
export const right: TextLayout = layoutText({ ...input, align: "right" }, measureText);The text aa bb cc dd wraps at 30 into aa bb and cc dd for every alignment. The anchor is 0, 15 and 30.
Narrowest width
minTextWidth(text, font, measure) returns the widest single grapheme in the text, and never less than 1. A fixed width below it cannot hold every grapheme on a line.
The adapter
The text adapter follows the shared interface in Drawables and geometry. Layout needs the measurer, so every adapter call takes an AdapterEnv, { measureText }.
create(frame, env)returns an empty auto-sized text with a font size of20, the"hand-drawn"family and"left"alignment. It returns a drawable and no frame, so it cannot fit the frame to the text. CalleditTextto lay the frame out.geometry(frame, drawable)is the frame rectangle, closed. A hit test against it hits anywhere in the frame and not only on the glyphs.handlesis empty. A text has no handles of its own.dragHandlereturns the supplied frame and the drawable as they were.resize(drawable, change, env)is described below.
Editing
editText(frame, drawable, text, env) returns a Fitted pair for new content. It lays the text out and sizes the frame to the result. An auto text takes the width of its widest line. A fixed-width text keeps the frame’s width and re-wraps. The frame’s top-left corner stays at its page position, including in a rotated frame, and the rotation does not change.
Resizing
resize reads FrameChange.handle to tell a corner from a side, and FrameChange.anchor to find the point that stays fixed. The text keeps that point at its page position and is never mirrored. When a drag crosses the anchor, the text moves to the other side of it. This holds in rotated frames and with every ResizeOptions value.
| Handle | Result |
|---|---|
| Corner | The font size scales by the larger of the absolute x and y scales. Sizing and line breaks are kept. A fixed width scales too. |
| Left and right | Sizing becomes "fixed-width" at the dragged width, and the text re-wraps. The top edge stays put. |
| Top and bottom | Nothing changes. The result is the start frame. |
A side drag never makes the text narrower than minTextWidth, so the widest grapheme always fits on a line.
With fromCenter, the fixed point is the frame’s centre, so a corner drag scales the font about the centre and a side drag re-wraps about the vertical centre line. A side drag keeps the top edge even under lockAspect, because the height comes from the layout, not from the drag.
import { adapterFor, editText, pagePoint, radians, resizeFrame } from "@draw/shapes";import type { AdapterEnv, Fitted, Frame } from "@draw/shapes";
const env: AdapterEnv = { measureText: (line, font) => line.length * (font.size * 0.6) };
const adapter = adapterFor("text");
const seed: Frame = { x: 100, y: 100, width: 0, height: 0, rotation: radians(0) };
const typed = editText(seed, adapter.create(seed, env), "aa bb cc", env);
const change = resizeFrame(typed.frame, "right", pagePoint(160, 112));
export const rewrapped: Fitted<"text"> = adapter.resize(typed.drawable, change, env);An auto text, aa bb cc, is 96 wide. Dragging the right handle to x = 160 makes it fixed-width at 60. The text wraps after aa bb, so the frame is 60 by 50.
import { adapterFor, editText, pagePoint, radians, resizeFrame } from "@draw/shapes";import type { AdapterEnv, Fitted, Frame } from "@draw/shapes";
const env: AdapterEnv = { measureText: (line, font) => line.length * (font.size * 0.6) };
const adapter = adapterFor("text");
const seed: Frame = { x: 100, y: 100, width: 0, height: 0, rotation: radians(0) };
const typed = editText(seed, adapter.create(seed, env), "ab", env);
const change = resizeFrame(typed.frame, "bottom-right", pagePoint(148, 150));
export const scaled: Fitted<"text"> = adapter.resize(typed.drawable, change, env);The bottom-right corner moves to (148, 150), which doubles both axes. The font size goes from 20 to 40, and the frame stays anchored at (100, 100).