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;};idis the shape being edited.liveis the preview: the committed editor plus the text typed so far.liveEditor(session)returns it while editing.createdis 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 insession.editoruntil 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.pointerDownwith the Text tool callsstartText. 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 thedblclickevent.
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
fontSizeandfontFamily, but its alignment is alwaysTextAlign.center, whatevertextStyle.alignsays. 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 withrestyleText, 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.
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 ofSession.textStyleand 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.performends an open edit first, so Undo and Redo during an edit commit it, then run.
Example
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.