Skip to main content

richtext

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.

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.

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 what useLinkClicks reads.
  • 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.