richtext
September 2, 2026 · View on GitHub
import {
registerRichText,
RICHTEXT_ELEMENT,
useSelectionMenu,
} from '@react-x11/components/richtext';
Styled text that a document can select across: the <richtext> element —
wrapped runs, per-run decoration, and the four text accessors core's
selection asks for — plus the right-click menu a read-only surface offers.
<Markdown> and <Code> are compositions over
this. It has a subpath of its own because an app building its own text
surface needs the same parts.
A shared module, not a component. Importing this barrel registers
nothing: a component that renders <richtext> calls registerRichText() at
its own module scope, so an app that imports neither <Markdown> nor
<Code> registers nothing at all.
The element
interface RichTextProps {
runs: TextRun[];
wrap?: boolean;
style?: Style | Style[];
}
Give runs a stable array identity — the layout cache and the streaming
path both key off it. wrap: false lays the text out at its natural width,
unwrapped, which is what a code line wants.
interface TextRun {
text: string;
family?: string;
size?: number;
weight?: number | 'normal' | 'bold';
style?: 'normal' | 'italic';
color?: string;
bg?: string; // fill painted behind the run — the inline-code chip
bgFill?: 'chip' | 'line'; // how `bg` is painted; default 'chip'
underline?: string; // rule under the baseline, in this colour — links
underlineStyle?: 'single' | 'double' | 'curly' | 'dotted' | 'dashed';
strike?: string; // 1px rule through the x-height — ~~del~~
href?: string | null; // link target; null is a link still streaming in
}
text, family, size, weight, style and color are ntk-span
vocabulary and pass straight through to fonts.layout. The rest — bg,
bgFill, underline, underlineStyle, strike, href — are this
element's, painted by it.
href: null is deliberate and is what makes a streamed [text](partial-url
render as link-styled text that is not yet clickable.
bgFill is the difference between a chip and a cell. 'chip' insets the
fill to the run's ink and pads it a little, which is what an inline-code
background wants. 'line' fills the run's exact width and the line's full
height, so adjacent runs abut with no seam and no bleed — which is what a
captured terminal session needs, where a two-pixel overhang paints over the
neighbouring cell and a fill that stops at the descender leaves a gap between
rows. <TerminalOutput> is why the field exists.
underlineStyle names SGR 4's sub-parameters. All five are drawn from 1px
rectangles rather than a stroked path, because the mock backend has no path
API and a hairline on a text baseline does not need one.
The decorations are read off what the engine hands back. ntk returns
every laid-out run with the span it came from, markers and all, which is how
the painter finds the chips, the rules and the link targets. react-x11's
Cocoa text engine (2.3.x) returns a run's geometry and nothing else, so on
that backend the text still shapes, draws and selects, but bg,
underline, strike and hrefAtPoint have nothing to read and degrade
to none rather than throwing; a development build says so once on the
console. The gap is the engine's to close, and nothing here changes when it
does.
Selection is core's
Since react-x11#291, selection lives in core: a selectable prop on a box
makes it a surface, everything under it that answers for its own text joins
the selection, and the anchor/focus, granularity, PRIMARY, Ctrl+A / Ctrl+C
and the one-visible-selection rule all come with it.
This module used to carry all of that — a TextSelection controller and a
useSelectionGestures hook. Both are gone, and so are the
order/registry/joiner props the element took to feed them: a copy's
separators now come from the layout.
What is left is the element answering core's four accessors —
textContent, textIndexAt, textCaretRect, textRangeRects — which is
all an element of your own has to do to join a document.
useSelectionMenu(enabled?)
The read-only edit menu a surface offers on right-click. Spread the result
onto the same box the selectable prop is on — the menu's verbs are read
off that node, which is the surface:
const menu = useSelectionMenu(selectable);
<box selectable {...menu}>
…
</box>;
interface SelectionMenuHandlers {
onContextMenu: (ev: X11MouseEvent<DrawnNode>) => void;
}
Core selects, copies and takes PRIMARY by itself, and opens the standard edit
menu by itself for <textinput> — but a selectable surface has no default
context menu, because which verbs a surface offers is the surface's to say. A
read-only document offers two: Copy and Select all. A verb left out
is a row that is not there rather than a greyed one, so this menu simply
never mentions Cut or Paste.
Everything else about the menu — which rows are enabled, the arrow keys,
Escape, the pointer grab that dismisses it, handing the keyboard back
afterwards — is core's openEditMenu and shared with the field.
useLinkClicks(onLink?)
Following a link. Spread the handlers onto the document root; they return
handlers that do nothing when onLink is absent, so a document with no
handler simply has inert link text and this module never navigates by itself.
const links = useLinkClicks(onLink);
<box selectable {...links}>
…
</box>;
The whole problem it solves is that a press on a link is also the start of
a drag, and the two are told apart only at the release: a pointer that
barely moved and left nothing selected was a click, anything else was a
selection that happened to begin on a link. The handlers never call
preventDefault, so core's own selection still runs after them.
It lived in <Markdown> until <TerminalOutput> needed it for OSC 8
hyperlinks, and moved here rather than being copied — there is no markdown in
it, only TextRun.href and RichTextNode.hrefAtPoint, both of which are this
module's.
Also exported
RichTextNode— the node class, for a component that wants to subclass or to type a ref.hrefAtPoint(x, y)is whatuseLinkClicksreads; it takes the logical window point a mouse event carries, whatever the display scale, while the four selection accessors keep core's device-pixel contract.NtkApp,TextLayoutLike— the structural types the node speaks. ntk is deliberately loose in react-x11's declarations, so an element says what it needs rather than importing a wide type.
tint(color, amount) — the shade helper these surfaces share — used to be
exported from here as well. It is core's now: import it from
react-x11/style, where readableInk and interpolate sit beside it.
Registering
import { registerRichText } from '@react-x11/components/richtext';
registerRichText(); // at module scope, in the component's own index.ts
Idempotent on purpose: registerElement throws on a second registration
without override, which is the right default for two packages fighting
over a name — but a lockfile skew that puts two copies of this package in one
app should not fail to boot over it.