Course progress Course outline 18 of 18 lessons available
Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates
Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools
- 05 Chapter 5: Give the Pixel World a Registry available now
- 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
- 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
- 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
- 09 Chapter 9: Tools Are Traffic Lights, Not a Bag of Booleans available now
Part III: From “It Drags” to “It Is Trustworthy”—Interaction, Text, Assets, and Recovery
- 10 Chapter 10: Make the Editor Feel Right available now
- 11 Chapter 11: Drawn Text Is Not Editable Text Current lesson
- 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules available now
- 13 Chapter 13: Time Machines and Old Boxes available now
- 14 Chapter 14: Looking Correct Is Not Being Correct available now
Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI
Start with a game a five-year-old can understand
The one truth in this chapter: Text Rendering and Text Editing are two completely different systems.
Write “Hello 👨👩👧👦” on a sheet of paper, take a photograph, and give both the original paper and the photo to a child. Both show the words. Ask the child to put a caret between “e” and “l,” select “Hello,” convert a reading into kanji with a Japanese input method, and have a reader speak the text aloud.
Predict what happens first. Which of those tasks can the photograph perform? If a program appends one character to the end of a string for every keydown, what happens during Chinese Pinyin composition? When Backspace deletes the family Emoji, should it remove one person or the whole family symbol?
- View the photo normallyfast and moves with the picture
- Switch to the original paper to editcaret, selection, language input
- Align the original paper exactlyposition, scale, rotation
- Take a new photo when finishedcommit or cancel
Canvas fillText() is like photographing letters into an image. A textarea or contenteditable element is like the original paper the browser understands. The Renderer draws a Text Shape when it is not being edited. A DOM Overlay takes over after entering Editing State. On commit, the Document updates and the Renderer redraws. On cancel, the Overlay is discarded and the original value stays unchanged.
Translate the toys into Canvas
| Toy world | Text system | Owner |
|---|---|---|
| Photograph of text | Canvas Preview | Fast display rendered with the Scene |
| Writable original paper | textarea / contenteditable | Caret, Selection, Clipboard, IME, Spellcheck |
| Box of printing type | FontFace / document.fonts | Font loading and readiness notifications |
| Ruler for letters | TextMetrics / DOM Measurement | Width, baseline, actual bounds, and wrapping |
| One “visible character” | Grapheme Cluster | The user-operation unit for Emoji and combining characters |
| Right-to-left book | Bidi / RTL | Text algorithms handle visual and logical order |
| Input-method candidate card | Composition Events | The value during composition is not the final Document value |
| Clear tape over the paper | DOM Overlay Transform | Align with Shape/Camera and compensate for Zoom |
| Finish writing or tear it up | Commit / Cancel | One History Transaction or zero writes |
The analogy has limits. The DOM Overlay and Canvas are not the same layout engine. Even with an identical font string, wrapping, fallback, hinting, and baseline can differ by pixels. A Grapheme is a unit perceived by users, not necessarily a linguistic character or word. contenteditable supplies rich browser behavior, but it is not a safe Rich Text data model; pasted HTML still requires sanitization and normalization.
Kill the wrong instincts first
- “Canvas draws text, so we can draw a caret too.” You would still have to rebuild Selection, IME, Bidi, Clipboard, virtual keyboards, assistive technology, and platform conventions. The cost is far beyond a caret.
- “Appending
event.keyonkeydowngives maximum control.” Composition emits intermediate keys, an Emoji can contain multiple code points, and Selection is not necessarily at the end. - “
text.lengthis the character count.” UTF-16 code units, Unicode code points, and Grapheme Clusters differ. A family Emoji has a length far greater than 1. - “Once the font URL loads, text is ready to measure.” The font may not be decoded, and fallback may already have participated in layout. Wait for FontFaceSet and invalidate after font changes.
- “Canvas
measureTextwraps automatically.” It measures only the supplied inline text. Paragraph layout or DOM measurement must perform wrapping. - “Placing a textarea at the Shape’s x/y aligns it.” Parent, World, and Camera matrices, rotation, transform origin, padding, line height, and Zoom compensation are still missing.
- “Store Rich Text as
innerHTML.” That mixes untrusted HTML, browser-private markup, and the business model.
Production backpack
Prerequisite contract
Chapter 4 supplies the Shape Local→World→Screen matrix. Chapter 6 provides a DOMOverlayLayer Portal with symmetric cleanup. Chapter 9 has an editing State and Commit/Cancel. Chapter 10 prevents Selection and Resize from stealing input during Text Editing. A Document Text Shape stores text, width, style tokens, and direction—not a DOM node.
Formal knowledge
Font Loading is more than downloading a file. FontFace represents a loadable font, and document.fonts is a FontFaceSet. Before layout or measurement, you can call await document.fonts.load('16px "Canvas Sans"', sample) and invalidate after loadingdone. On failure, use an explicit Font Fallback chain and record the failure without blocking editing. When fallback changes to the target font, Text Metrics, wrapping, Shape height, and Connector anchors may all change. Recompute derived layout instead of silently changing Document content.
Canvas measureText() returns TextMetrics. In addition to width, some browsers expose actual bounding boxes, font bounding boxes, and baseline-related values. textBaseline selects a drawing anchor line; it is not a CSS line box. Primitive Text Rendering explicitly sets font, direction, textAlign, and baseline, then calls fillText() for each laid-out line. Canvas has no paragraph-wrapping API. A simple Text Shape can break lines at a deterministic width. Production Rich Text is better served by authoritative measurement in a hidden DOM or a mature layout engine, with the measurement version included in the cache key.
Line Breaking is not split on spaces. CJK scripts have no required spaces, and punctuation restrictions, soft hyphens, and long words need rules. Wrapping distinguishes hard breaks \n from soft wraps. Intl.Segmenter(...,{granularity:'grapheme'}) avoids splitting most Grapheme Clusters. If the target Browser Matrix lacks it, bundle a segmentation library verified against Unicode fixtures; Array.from(string) separates code points and is not a Grapheme fallback. Complete Unicode line breaking also requires a line-breaking library for the product’s supported languages or DOM layout. Test Emoji, combining marks, skin-tone modifiers, and ZWJ sequences as user-perceived units.
The Bidi algorithm determines the visual arrangement of mixed LTR and RTL text. The Document stores the logical string and does not reorder characters into screen order. A Shape has direction: auto|ltr|rtl; the Overlay uses dir and CSS direction, while Canvas sets context direction. Never reverse Arabic manually. Font Fallback can occur per Grapheme, so measurement must use the same CSS font shorthand as rendering.
IME coordinates compositionstart/update/end with beforeinput/input. The DOM value can change during composition, but intermediate steps must not each create History. compositionend is not the only possible commit moment either; when the editing transaction ends, Commit uses the Overlay’s current value. keydown handles only control intentions such as Escape and explicit shortcuts, checking event.isComposing during composition. Leave Caret, Selection, the local undo stack, and Clipboard to the native control. Define the boundary between Text Shape Undo and the control’s internal Undo: for example, Ctrl/Cmd+Z goes to the control while Editing and to Document History only after exit.
textarea is suitable for plain text: it is stable and natively supports Selection, IME, and mobile keyboards. contenteditable suits Rich Text, but requires a normalized model, Paste Sanitization, Selection mapping, and browser-matrix tests. Choose spellcheck, autocorrect, and autocomplete based on product and privacy requirements; never send user strings to your own telemetry. A Screen Reader needs an accessible name, editing state, and focus. Outside editing, provide a DOM Inspector or Fallback Semantics rather than exposing only “Canvas.”
When Textarea Overlay opens, freeze the original value, create the DOM, set value/dir/lang/spellcheck, apply the complete screen matrix, focus, and restore Selection. Update the matrix whenever the Camera or a Parent Transform changes; aligning only at construction is insufficient. A Zoom-Compensated Editor can increase the DOM font size by screen scale or transform its outer element with a matrix. In either case, DOM wrap width must correspond to the Canvas layout’s world width. Apply Rotation with a CSS matrix, not by rotating only the position. Commit reads the control value, validates maximum length and the Rich Text schema, then emits one UpdateText Command. Do not secretly apply NFC/NFKC normalization here unless the Document Schema specifies it and the product explains it to users, because normalization changes the actual code-point sequence. Cancel emits no Command. Exit removes listeners and the node, then maps focus back to Canvas or the Inspector.
Evidence and compatibility (verified 2026-08-29)
- MDN CSS Font Loading API,
FontFace, andDocument.fontsdefine loading and readiness boundaries. - MDN TextMetrics,
measureText(), andtextBaselinedocument Canvas primitive measurement, not paragraph editing. - MDN
Intl.Segmenterprovides Grapheme, word, and sentence segmentation; it is not a complete Rich Text layout engine. - MDN CompositionEvent,
InputEvent.isComposing, and the UI Events composition section define the IME lifecycle. Never build text from keydown. - MDN
<textarea>,contenteditable,dir, andspellcheckare the basis for native editing, Bidi, and spell checking. - WHATWG HTML’s editing APIs and the
textareaelement define the standards boundary. Features and mobile keyboards still need verification across real browsers.
The engineering increment for this chapter
Starting point: A Text Label uses only Canvas fillText(), with no Caret, IME, or Clipboard. Finish line: Canvas Preview when not editing; a DOM Overlay exactly covers the Text Shape during editing. Commit updates the Document once, while Cancel performs zero writes.
Add these files:
src/engine/text/TextLayout.ts: font cache, Grapheme-safe wrapping, and baseline;src/engine/text/FontManager.ts: FontFaceSet readiness and invalidation;src/ui/overlays/TextEditorOverlay.ts: textarea lifecycle and matrix;src/engine/shapes/TextShapeRenderer.ts: draw Preview from layout lines;src/engine/text/__tests__/layout.test.ts: CJK, Emoji, RTL, and fallback;tests/browser/text-ime.spec.ts: composition, selection, clipboard, rotation, and Zoom.
Start with a complete pure function for testable primitive layout. It respects hard breaks, Graphemes, and maximum width without using split('') to tear Emoji apart:
export type Measure = (text: string) => number;
export type TextLine = Readonly<{ text: string; width: number }>;
export function wrapGraphemes(
value: string,
maxWidth: number,
measure: Measure,
locale = 'und',
): readonly TextLine[] {
if (!(maxWidth > 0)) throw new Error('TEXT_WIDTH_MUST_BE_POSITIVE');
const segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
const output: TextLine[] = [];
for (const paragraph of value.split('\n')) {
const graphemes = Array.from(segmenter.segment(paragraph), (part) => part.segment);
if (graphemes.length === 0) {
output.push({ text: '', width: 0 });
continue;
}
let line = '',
width = 0;
for (const grapheme of graphemes) {
const candidate = line + grapheme;
const candidateWidth = measure(candidate);
if (line && candidateWidth > maxWidth) {
output.push({ text: line, width });
line = grapheme;
width = measure(grapheme);
} else {
line = candidate;
width = candidateWidth;
}
}
output.push({ text: line, width });
}
return output;
}
export async function layoutCanvasText(
context: CanvasRenderingContext2D,
value: string,
cssFont: string,
width: number,
locale: string,
onFontFailure: (reason: unknown) => void = () => undefined,
): Promise<readonly TextLine[]> {
try {
const faces = await document.fonts.load(cssFont, value || 'M');
if (faces.length === 0) onFontFailure(new Error(`no face matched ${cssFont}`));
} catch (cause) {
onFontFailure(cause);
}
context.save();
try {
context.font = cssFont;
return wrapGraphemes(value, width, (text) => context.measureText(text).width, locale);
} finally {
context.restore();
}
}
The complete plain-text Overlay below does not listen for ordinary characters on keydown, does not write the Document during composition, and passes the Shape→Screen matrix directly to CSS:
export type TextShape = Readonly<{
id: string;
text: string;
width: number;
minHeight: number;
font: string;
fontSize: number;
lineHeight: number;
color: string;
direction: 'auto' | 'ltr' | 'rtl';
language: string;
}>;
export type Matrix2D = Readonly<{
a: number;
b: number;
c: number;
d: number;
e: number;
f: number;
}>;
export interface TextEditPort {
commit(id: string, before: string, after: string): void;
focusCanvas(): void;
}
export class TextEditorOverlay {
private readonly textarea: HTMLTextAreaElement;
private composing = false;
private blurPending = false;
private closed = false;
constructor(
host: HTMLElement,
private readonly shape: TextShape,
screenMatrix: Matrix2D,
private readonly port: TextEditPort,
) {
const area = document.createElement('textarea');
this.textarea = area;
area.value = shape.text;
area.dir = shape.direction;
area.lang = shape.language;
area.spellcheck = true;
area.setAttribute('aria-label', 'Edit canvas text');
area.style.position = 'absolute';
area.style.left = '0';
area.style.top = '0';
area.style.width = `${shape.width}px`;
area.style.minHeight = `${shape.minHeight}px`;
area.style.margin = '0';
area.style.padding = '0';
area.style.border = '1px solid currentColor';
area.style.background = 'Canvas';
area.style.color = shape.color;
area.style.font = shape.font;
area.style.lineHeight = String(shape.lineHeight);
area.style.resize = 'none';
area.style.transformOrigin = '0 0';
this.updateScreenMatrix(screenMatrix);
area.addEventListener('compositionstart', this.onCompositionStart);
area.addEventListener('compositionend', this.onCompositionEnd);
area.addEventListener('keydown', this.onKeyDown);
area.addEventListener('blur', this.onBlur);
host.append(area);
area.focus({ preventScroll: true });
area.setSelectionRange(area.value.length, area.value.length);
}
private onCompositionStart = () => {
this.composing = true;
};
private onCompositionEnd = () => {
this.composing = false;
if (this.blurPending) this.commit();
};
private onKeyDown = (event: KeyboardEvent) => {
if (event.isComposing || this.composing) return;
if (event.key === 'Escape') {
event.preventDefault();
this.cancel();
}
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
this.commit();
}
};
private onBlur = () => {
if (this.closed) return;
if (this.composing) {
this.blurPending = true;
return;
}
this.commit();
};
updateScreenMatrix(matrix: Matrix2D) {
if (this.closed) return;
this.textarea.style.transform = `matrix(${matrix.a},${matrix.b},${matrix.c},${matrix.d},${matrix.e},${matrix.f})`;
}
commit() {
if (this.closed) return;
if (this.composing) {
this.blurPending = true;
return;
}
this.blurPending = false;
const after = this.textarea.value;
if (new TextEncoder().encode(after).byteLength > 100_000) {
this.textarea.setCustomValidity('Text is too long');
this.textarea.reportValidity();
this.textarea.focus({ preventScroll: true });
return;
}
this.textarea.setCustomValidity('');
this.closed = true;
this.cleanup();
if (after !== this.shape.text) this.port.commit(this.shape.id, this.shape.text, after);
this.port.focusCanvas();
}
cancel() {
if (this.closed) return;
this.closed = true;
this.cleanup();
this.port.focusCanvas();
}
private cleanup() {
this.textarea.removeEventListener('compositionstart', this.onCompositionStart);
this.textarea.removeEventListener('compositionend', this.onCompositionEnd);
this.textarea.removeEventListener('keydown', this.onKeyDown);
this.textarea.removeEventListener('blur', this.onBlur);
this.textarea.remove();
}
}
Canvas Preview uses the same font/lineHeight and cached lines, with an explicit baseline. direction:auto cannot be assigned directly to Canvas direction, which accepts only ltr/rtl/inherit. Authoritative DOM measurement and layout first resolves a resolvedDirection for the paragraph, then Preview reuses that value. fontSize must also be an independently validated number. Calling parseFloat() on "600 16px Canvas Sans" would mistake font weight 600 for the font size:
export function renderTextPreview(
context: CanvasRenderingContext2D,
shape: TextShape,
lines: readonly TextLine[],
resolvedDirection: 'ltr' | 'rtl',
) {
context.save();
try {
context.font = shape.font;
context.fillStyle = shape.color;
context.textBaseline = 'alphabetic';
context.direction = resolvedDirection;
const fontSize = shape.fontSize;
if (!(fontSize > 0) || !Number.isFinite(fontSize)) throw new Error('INVALID_FONT_SIZE');
lines.forEach((line, index) =>
context.fillText(line.text, 0, fontSize + index * fontSize * shape.lineHeight),
);
} finally {
context.restore();
}
}
The unit tests prove that a Grapheme never breaks inside a ZWJ sequence. Real-browser tests provide the final evidence for IME:
import { describe, expect, it } from 'vitest';
import { wrapGraphemes } from '../TextLayout';
describe('text layout', () => {
it('treats a family emoji as one wrapping unit', () => {
const family = '👨👩👧👦';
const lines = wrapGraphemes(
`A${family}B`,
2,
(text) =>
Array.from(new Intl.Segmenter('und', { granularity: 'grapheme' }).segment(text)).length,
);
expect(lines.map((line) => line.text)).toEqual([`A${family}`, 'B']);
expect(lines.flatMap((line) => [...line.text]).join('')).toBe(`A${family}B`);
});
it('preserves hard line breaks, including empty lines', () => {
expect(wrapGraphemes('あ\n\nい', 10, (text) => text.length).map((line) => line.text)).toEqual([
'あ',
'',
'い',
]);
});
});
Run pnpm vitest run src/engine/text. The CJK, Emoji, empty-line, width-boundary, and font-cache tests should pass. Run pnpm playwright test tests/browser/text-ime.spec.ts --project=chromium. During composition, the History revision should remain unchanged; ending composition and committing should increase it by exactly 1. Then run Clipboard, Selection, and RTL tests in Safari/WebKit and Firefox. On mobile, manually verify that the virtual keyboard does not push the Overlay permanently outside the viewport.
Return to the photograph game. Renderer displays the new photograph, the DOM control is the original paper temporarily laid over it, FontManager is the box of printing type, and Composition is a candidate card not yet chosen. Only when the child says “finished” do we take a new photo and update the ledger. Cancel simply removes the original paper.
Break it on purpose
| Injection | Symptom | Evidence | Fix | Regression test | Recovery |
|---|---|---|---|---|---|
| Chinese Pinyin composition | Candidate repeats; one Undo per key | Composition/input/History trace | Native control owns intermediate values; Commit once on exit | Chromium IME event sequence | Cancel Overlay; Document unchanged |
| Japanese conversion | Enter exits editing too early | isComposing=true keydown | Ignore commit shortcuts during composition | WebKit/Chromium fixture | Restore Selection |
| Family Emoji | Backspace leaves broken symbol or wrap splits it | Grapheme segments | Intl.Segmenter or DOM layout | ZWJ/skin-tone/flag fixtures | Rebuild layout cache |
| Arabic RTL | Characters reverse and Caret is misplaced | Logical value and visual screenshot | Do not reverse string; set dir/direction | RTL mixed-number test | Restore logical text |
| Font loads late | Text jumps and Connector anchor is wrong | fonts.status and before/after metrics | Invalidate on loadingdone | Delayed-font route | Clear layout cache |
| Target font replaces fallback | Line count changes but Bounds do not | Line-count and metrics revision | Recalculate derived height and anchors | Font-swap test | One deterministic redraw |
| 400% Zoom + Rotation | Overlay separates from text | Compare CSS and Shape screen matrices | Full matrix and transform-origin | Multi-Zoom screenshots at 0/37/90° | Rebuild Overlay |
| Paste Rich HTML | Script or private styles enter model | Clipboard MIME/schema log | Plain-text policy or sanitizer plus typed rich model | Malicious-HTML fixture | Reject Paste atomically |
| Mobile Virtual Keyboard | Input is covered or focus is lost | VisualViewport/focus trace | Visible-region scrolling and focus policy | Real iOS/Android devices | Preserve editing session and reposition |
Pass with evidence
| Claim to prove | Automated evidence | Required manual/device evidence |
|---|---|---|
| Keydown construction does not break IME | Event replay and one Command | Real Chinese/Japanese input methods |
| Graphemes do not split | Segmenter fixtures | Delete with the system Emoji keyboard |
| RTL/Bidi preserves logical value | Value snapshot | Screen Reader and Caret direction |
| Font changes recover | Delayed load and cache invalidation | Fallback on different operating systems |
| Overlay aligns | Screenshot diff across Zoom and rotation | Selection at 400% Zoom |
| Commit/Cancel boundary | Revision assertions of 1/0 | Blur, Escape, and virtual keyboard |
- FontFace,
document.fonts, fallback, loading failure, and remeasurement have a protocol. - Text Metrics, Line Breaking/Wrapping, Baseline, and Canvas Preview consume explicit layout data.
- Grapheme, Emoji, Bidi, RTL, IME, Composition, Caret, and Selection are owned by the correct layers.
- The textarea/contenteditable choice has an ADR; Rich Text, Spellcheck, and Paste have security policies.
- DOM Measurement and Overlay remain aligned after Zoom, Rotation, and Parent Transform changes.
- Commit is one Command; Cancel is zero Document writes.
- Ordinary text is not assembled manually from
keydown, so IME, Selection, Clipboard, Composition, and Screen Reader behavior remains intact.
Explain it to a five-year-old
Answer without saying “DOM,” “Canvas,” “input method,” “grapheme,” or “bidirectional text”:
- Why can you see words in a photograph but not place a little vertical line between two letters?
- While the child is still choosing a candidate character, why can every key press not update the shared ledger?
- Why does the family picture contain many tiny parts but usually need to act as one whole?
- New question: the box of printing type arrives one minute late and a line of text becomes two lines. What must be measured again?