Skip to main content

Text Shape

Text is a first-class element type. A text element carries its string, font, and style (plus optional styled runs); the renderer measures and draws it with the bundled fonts so it looks identical across the Canvas2D, WebGL2, and offscreen backends.

The text element​

A TextElement (from @oh-just-another/scene) is a discriminated shape with type: "text". Its fields:

import { isText, type TextElement } from "@oh-just-another/scene";

// TextElement fields:
// text: string
// fontFamily: string // e.g. "sans-serif", "serif", "monospace"
// fontSize: number // world px
// maxWidth?: number // width budget for wrapping; omit = single line
// style: TextStyle // textAlign, fontWeight, fontStyle, textDecoration…
// runs?: readonly TextRun[] // optional per-range style overlay (rich text)

const labels = (els: Iterable<unknown>) =>
[...els].filter((s): s is TextElement => isText(s as never));

The style (a TextStyle) controls textAlign, textBaseline, fontWeight (bold), fontStyle (italic), and textDecoration (underline / strikethrough); it also inherits Style fields (fill = text color). runs styles character ranges within one element β€” see Rich Text.

Fonts and rendering​

The editor ships three font families via @oh-just-another/fonts: sans (Roboto), serif (PT Serif), and mono (Roboto Mono). A fontFamily stack is resolved to one of these so every backend draws the same glyphs:

import { resolveBundledFamily, FONT_SANS } from "@oh-just-another/fonts";

resolveBundledFamily("monospace"); // "Roboto Mono"
resolveBundledFamily("Georgia, serif"); // "PT Serif"
resolveBundledFamily("system-ui"); // FONT_SANS (fallback)

On WebGL2 these faces are baked into an MSDF glyph atlas (@oh-just-another/glyph-atlas) for sharp text at any zoom; the Canvas2D backend draws the same bundled web fonts, so text stays consistent rather than falling back to whatever the OS resolves.

Empty-text placeholder​

While a text element is empty (right after the Text tool drops it, before the first keystroke) the interactive renderer draws a grey prompt inside it β€” "Type something", "Place for text", … β€” picked per element from TEXT_PLACEHOLDERS in @oh-just-another/scene (weighted; pickTextPlaceholder(id) is deterministic in the element id, so the prompt never changes under the caret). The text bounder sizes an empty element by its prompt, so the selection box wraps the prompt and snaps to the real text from the first keystroke. It is drawn only when RenderSceneOptions.textPlaceholders is set β€” the editor sets it outside view mode; exports and headless rendering leave empty text blank. Colour: TEXT_PLACEHOLDER_COLOR.