Appearance
Rich text
A <text> element takes either a string or a list of runs. The runs are what make prose possible: a word in bold, a phrase in italic, a snippet in a monospaced face, a link the reader can press, all inside one paragraph that breaks its lines from all of them together.
Everything on that card is four <text> elements. There is no rich text component, and no node per word.
Runs
spans replaces text. Each run carries its own text and the style fields it overrides; anything it does not set it inherits from the element, and the element from its environment, exactly as it always did.
tsx
<text
spans={[
{ text: 'Read the ' },
{ text: 'guide', color: 'primary', textDecoration: 'underline', link: { href: '/guide' } },
{ text: ' before you ' },
{ text: 'ship', fontWeight: 700 },
{ text: '.' }
]}
/>| Field | What it changes |
|---|---|
fontFamily, fontSize, fontWeight | The face the run is measured and drawn in |
fontStyle | normal, italic or oblique |
fontStretch, fontVariant, fontKerning | The width axis, small caps, and the font's kerning |
letterSpacing | Tracking, for this run only |
color, backgroundColor | A value or a theme token, resolved per run |
textDecoration | underline, line-through, or both |
link | Makes the run pressable; see below |
The first seven change how wide the run measures, so a paragraph is laid out again when one of them changes. The last three do not: a run that changed only its colour, its background, its underline or its handler is repainted from the lines the layout already found.
One string, whatever the runs
This is the rule everything else follows from. The runs' texts concatenated are the paragraph, so an offset into it means the same thing to every layer:
- the line breaker measures each run in its own font and breaks the line from all of them,
- a selection crosses a run boundary without noticing one,
- find-in-page matches text that starts in one run and ends in another,
- and the accessibility mirror reads the paragraph as prose.
A run is a shape of a paragraph rather than a node inside it, which is why none of that needed a second tree.
Inline links
A run with a link is pressable. It shows a pointer cursor, lights up under the pointer with a wash of its own colour and an underline, and runs onClick when a press is released where it landed. A press that drags is a selection instead, as it is on a page.
tsx
{ text: 'the reference', color: 'primary', link: { href: '/reference', onClick: open, label: 'the API reference' } }label names the run for a screen reader when its own words would not: "here" and "read more" are links nobody can use out of context. href is carried for the application's own use; the runtime never navigates to it.
In the accessibility mirror the paragraph becomes a paragraph whose children are the prose and the links in reading order, so a screen reader reaches the link the way it reaches one in a page rather than hearing its words go past inside a sentence.
A markdown document
The example above is a markdown reader in forty lines. Blocks become elements and inline markers become runs:
tsx
/** A block of the document: a heading at some level, or a paragraph. */
export interface MarkdownBlock {
/** 0 for a paragraph, 1 or 2 for a heading. */
readonly level: number;
readonly spans: readonly UiTextSpan[];
}
/**
* A deliberately small markdown reader: blank lines separate blocks,
* a leading `#` or `##` makes a heading, and inside a block `**bold**`,
* `*italic*`, `` `code` `` and `[label](href)` become runs.
*
* It is short because the interesting part is not the parsing. What
* matters is what it produces: a flat list of runs per block, which is
* exactly what a `<text>` element takes.
*/
export function markdownBlocks(source: string, onLink: (href: string) => void): MarkdownBlock[] {
return source
.split(/\n{2,}/)
.map(block => block.trim())
.filter(block => block.length > 0)
.map(block => {
const heading = /^(#{1,2})\s+/.exec(block);
const level = heading === null ? 0 : heading[1].length;
// A wrapped source line is one paragraph, so the newlines inside
// a block are spaces rather than breaks, as markdown has them.
const text = block.slice(heading?.[0].length ?? 0).replace(/\s*\n\s*/g, ' ');
return { level, spans: inlineSpans(text, onLink) };
});
}
/** The four inline forms, in one pass, longest marker first. */
const INLINE = /\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)/g;
function inlineSpans(text: string, onLink: (href: string) => void): UiTextSpan[] {
const spans: UiTextSpan[] = [];
let at = 0;
INLINE.lastIndex = 0;
for (let match = INLINE.exec(text); match !== null; match = INLINE.exec(text)) {
if (match.index > at) {
spans.push({ text: text.slice(at, match.index) });
}
const [, bold, italic, code, label, href] = match;
if (bold !== undefined) {
spans.push({ text: bold, fontWeight: 700 });
} else if (italic !== undefined) {
spans.push({ text: italic, fontStyle: 'italic' });
} else if (code !== undefined) {
spans.push({ text: code, fontFamily: 'monospace', color: 'text', backgroundColor: 'background' });
} else {
spans.push({
text: label,
color: 'primary',
textDecoration: 'underline',
link: { href, onClick: () => onLink(href) }
});
}
at = match.index + match[0].length;
}
if (at < text.length) {
spans.push({ text: text.slice(at) });
}
return spans;
}And the document is one element per block, with a heading differing from a paragraph only in size and weight:
tsx
/**
* One `<text>` per block, and nothing else.
*
* A heading is the same element as a paragraph with a larger size and
* a heavier weight; the runs inside it are the runs inside any other
* block. There is no rich-text component here, because there does not
* need to be one.
*/
function Document(inputs: Inputs<{ blocks: MarkdownBlock[] }>, _ctx: ComponentContext) {
return (
<column gap={12} width={percent(100)}>
{inputs.blocks.pipe(
map(blocks =>
blocks.map((block, index) => (
<text
key={`block-${index}`}
spans={block.spans}
fontSize={block.level === 1 ? 22 : block.level === 2 ? 17 : 13}
fontWeight={block.level === 0 ? 400 : 600}
color="text"
width={percent(100)}
/>
))
)
)}
</column>
);
}The line box
A run inherits the paragraph's line height as a length, so a run smaller than the paragraph does not change the line and a run larger than it grows the line around its own box, which is what CSS does with the same two inline boxes.
Where Gesso differs from a browser is that every line of a paragraph gets the tallest line's box. A paragraph is its line count times one line height here, and both renderers step by that. Keep a run's size close to its paragraph's and the two agree exactly; the fixture that pins the difference is runs/a-taller-run-grows-only-its-own-line.
What is not here
Justified text and hyphenation are still deferred. Font features beyond the ones in the table above are not reachable: a canvas font string carries style, variant, weight, size and family and nothing else, so tnum and liga cannot be asked for from here. A variable font's wght is reached through a numeric fontWeight and its wdth through fontStretch.