Skip to content

Text editing

Text is edited through Session.editing, a TextEdit that is separate from the pointer gesture. The shell draws a native text box over the shape and calls setText on every input event. This package does no DOM work. It keeps the shape’s frame in step with the text, using EditorEnv.measureText.

type TextEdit = {
readonly id: ShapeId;
readonly live: Editor;
readonly created: boolean;
};
  • id is the shape being edited.
  • live is the preview: the committed editor plus the text typed so far. liveEditor(session) returns it while editing.
  • created is true when the edit made the shape, or, for a shape that holds text, when it had no text yet. A created text shape does not exist in session.editor until the edit commits.

Opening

  • startText(session, pointer, env) ends any open edit, then opens one at the pointer. A press on a text shape edits that shape. A press on a rectangle, ellipse or diamond edits the text inside it, see below. Any other press creates an empty text shape centred vertically on the point, with its left edge on the point. The tool returns to Select.
  • pointerDown with the Text tool calls startText. With a text edit already open, a press commits it and starts nothing else.
  • doubleClick(session, pointer, env) opens an edit on a text shape, on a rectangle, ellipse or diamond, or on empty canvas, and does nothing on any other shape. A double click on a member of a group enters the group first, as before, and the next one edits. The shell calls it from the dblclick event.

Text inside a shape

A rectangle, an ellipse or a diamond holds its text as a label, see Text inside shapes. The edit works on the shape itself, so TextEdit.id is the shape’s id and the shape is selected. setText sets the label and grows the shape taller when the text no longer fits. endText commits once. Blank text removes the label and keeps the shape, and a shape with no label that ends blank leaves no history entry. Move, resize, rotate, copy, duplicate, group and delete need no text code, because the label is part of the shape.

Text style

type TextStyle = {
readonly fontSize: number;
readonly fontFamily: FontFamily;
readonly align: TextAlign;
};

Session.textStyle is the style for new text. DEFAULT_TEXT_STYLE is the starting value: FONT_SIZES.medium (20), "hand-drawn" and TextAlign.left.

  • A new text shape takes all three fields.
  • A new label takes fontSize and fontFamily, but its alignment is always TextAlign.center, whatever textStyle.align says. Text is left aligned by default and text inside a shape is centred, as in Excalidraw, and one session field cannot hold both. The alignment of an existing label is changed with restyleText, which edits that label and nothing else.
  • A label that already exists keeps its own style when it is edited again.

restyleText(session, change, env) and restyleTextSelection(editor, change, env) are described below. The session command also stores change(session.textStyle) as the new textStyle.

Restyling the selection

restyleTextSelection(editor, change, env) calls change once per selected text shape and once per selected container that has a label, with that shape’s own TextStyle, and writes the result back. The whole selection is one history entry. Each shape is fitted again:

  • An auto-sized text measures its frame again. The top-left corner stays at its page position, as in editText, in a rotated frame too.
  • A fixed-width text keeps its width, wraps again and changes height.
  • A rectangle, ellipse or diamond grows to fit its label, as in fitLabel. It never shrinks, so a smaller font leaves the frame as it was.
  • Any other shape, and a container without a label, is untouched and keeps its identity.

The commit goes through settle, so arrows bound to a shape that grew follow it. A change that alters nothing pushes no entry and leaves the document as it was.

docs/examples/text-style.ts
import {
createEditor,
createSession,
perform,
putShapes,
restyleText,
select,
undo,
EMPTY_DOC,
} from "@draw/editor";
import type { EditorEnv, Session } from "@draw/editor";
import {
bindingId,
DEFAULT_STYLE,
FONT_SIZES,
groupId,
parseZIndex,
radians,
seed,
shapeId,
} from "@draw/shapes";
import type { Shape } from "@draw/shapes";
const env: EditorEnv = {
measureText: (line, font) => line.length * font.size,
newShapeId: () => shapeId("unused"),
newBindingId: () => bindingId("unused-binding"),
newSeed: () => seed(0),
newGroupId: () => groupId("unused-group"),
};
const index = parseZIndex("a0");
if (!index.ok) throw new Error("bad index");
const note: Shape = {
id: shapeId("note"),
frame: { x: 100, y: 100, width: 40, height: 25, rotation: radians(0) },
drawable: {
kind: "text",
text: "ab",
sizing: "auto",
fontSize: FONT_SIZES.medium,
fontFamily: "hand-drawn",
align: "left",
},
index: index.value,
style: DEFAULT_STYLE,
groupIds: [],
};
const start: Session = createSession(
select(createEditor(putShapes(EMPTY_DOC, [note])), new Set([note.id])),
);
export const restyled: Session = restyleText(
start,
(style) => ({ ...style, fontSize: FONT_SIZES.extraLarge, align: "right" }),
env,
);
export const undone: Session = perform(restyled, undo);

The text ab is 40 by 25 at size 20. At the extra large size it is 72 by 45, with its corner still at (100, 100), and it is right aligned. One undo restores the 40 by 25 frame.

Typing and committing

  • setText(session, text, env) replaces the whole text and re-lays it out. The frame grows to fit with the top-left corner fixed, at the font size of Session.textStyle and a line height of 1.25. It adds no history.
  • editedText(edit) reads the text typed so far.
  • endText(session) commits once. Text with any non-space character becomes one history entry and is selected, so creating a shape and typing its first text is a single undo step, and a later edit is another. Blank text removes the shape. A created shape that ends blank leaves no history entry at all. An edit that changes nothing adds none.
  • perform ends an open edit first, so Undo and Redo during an edit commit it, then run.

Example

docs/examples/text-edit.ts
import {
createEditor,
createSession,
editedText,
endText,
perform,
pointerDown,
setText,
Tool,
undo,
} from "@draw/editor";
import type { EditorEnv, Pointer, Session } from "@draw/editor";
import { bindingId, groupId, pagePoint, seed, shapeId } from "@draw/shapes";
const env: EditorEnv = {
measureText: (line, font) => line.length * font.size,
newShapeId: () => shapeId("note"),
newBindingId: () => bindingId("bound"),
newSeed: () => seed(7),
newGroupId: () => groupId("unused-group"),
};
const at = (x: number, y: number): Pointer => ({
point: pagePoint(x, y),
pressure: 0.5,
zoom: 1,
shiftKey: false,
altKey: false,
});
const start: Session = { ...createSession(createEditor()), tool: Tool.text };
export const opened: Session = pointerDown(start, at(100, 100), env);
export const typing: Session = setText(setText(opened, "hi", env), "hi there", env);
export const committed: Session = endText(typing);
export const undone: Session = perform(committed, undo);
export const typed: string = typing.editing === undefined ? "" : editedText(typing.editing);

After pointerDown the shape exists only in the preview and the history is empty. endText commits one entry. One undo removes the shape.