Behive Tech Component Registry

Rich Text Editor

v0.1.3Draft

A TipTap rich text editor with a toolbar (bold, italic, headings 2 and 3, bulleted and numbered lists, quote, link, image), plus `RichText`, the renderer for the HTML it stores, with print-friendly prose styles.

pnpm add @behivetech/molecules.rich-text-editor

A TipTap rich text editor with a toolbar (bold, italic, headings 2 and 3, bulleted and numbered lists, quote, link, image), plus RichText, the renderer for the HTML it stores, with print-friendly prose styles.

Usage

Open in new tab

The jsx block above runs live on the docs site. RichText on its own renders stored HTML for readers.

A page that only shows stored HTML imports RichText from @behivetech/molecules.rich-text-editor/rich-text. That entry has no hooks, so a server component can render it and the TipTap editor stays out of the page's JavaScript.

Editor

Wrap RichTextEditor in a FormField. The label's htmlFor cannot name a contenteditable area, so also pass aria-label or aria-labelledby.

Open in new tab
  • value and onChange are HTML. An empty document is "", not <p></p>, so a required check is value === "".
  • onInsertImage shows the Image button. It resolves with { src, alt, width?, height? }, or a plain address string (then alt is empty). Resolve with nothing, or reject, to insert nothing. The button shows a loading state while it waits.
  • features limits the toolbar (and what can be typed or pasted) to "bold", "italic", "heading", "bulletList", "orderedList", "blockquote", "link", "image".
  • labels replaces the English toolbar and dialog text, for translation.
  • Links are edited in a small dialog. An address must be https://, http://, mailto:, tel: or a site path starting with /.
  • The editor is keyboard-first: the toolbar is one tab stop, and the arrow keys, Home and End move between its buttons.

Sanitising

The editor can only produce a short list of elements, but a stored value can be anything and RichText inserts HTML as-is. Sanitise on save with an allowlist of what the editor produces:

ElementAttributes
p, h2, h3, strong, em, ul, li, blockquote, brnone
olstart (a number), type
ahref (https:, http:, mailto:, tel: or a path starting with /)
imgsrc, alt, width, height

Styles

styles.css covers both the editor and the RichText prose, including @media print. RichText sets no width; the page decides the measure. In print, headings stay with the text after them, list items, quotes and images are not split across pages, images are capped at 8cm tall, and each link is followed by its address.

Properties

RichTextEditor props

PropTypeDefaultDescription
onChange*(html: string) => void—Called with the updated HTML on every edit; an empty document gives an empty string
value*string—Current value as HTML. Sanitise it on save: the editor only produces what `RichText` lists, but a stored value can be anything
aria-describedbystring—Id of the element that describes the editing area, for example the `FormField` hint
aria-labelstring—Accessible name of the editing area; defaults to `labels.editor`
aria-labelledbystring—Id of the element that names the editing area, for example the `FormField` label
classNamestring—Additional class names to merge with the component root element
featuresRichTextEditorFeature[][ "bold", "italic", "heading", "bulletList", "orderedList", "blockquote", "link", "image", ]Which formats to offer. Defaults to all of them; the Image button also needs `onInsertImage`
idstring—Id of the editing area. A `FormField` label cannot name a contenteditable, so also pass `aria-labelledby`
invalidbooleanfalseMarks the editor as invalid: adds the `--invalid` modifier and sets `aria-invalid`. `FormField` carries the message
labelsPartial<RichTextEditorLabels>—Text overrides for the toolbar and the link dialog, for translation. Missing keys fall back to English
onInsertImage(() => InsertImageResult | Promise<InsertImageResult>)—Shows the Image button and runs when it is pressed. Resolve with `{ src, alt, width?, height? }`, or a plain address string (alt is then empty), to insert the image at the caret; resolve with nothing, or reject, to insert nothing. The button shows a loading state meanwhile.

RichText props

PropTypeDefaultDescription
html*string—Stored HTML to render. It must already be sanitised: `RichText` does not filter it
classNamestring—Additional class names to merge with the component root element

Also accepts the native attributes of its root element (className, aria-*, event handlers, …).

Functions

normalizeLinkHref function

Trims a link address and checks it against what the editor may produce: `https://`, `http://`, `mailto:`, `tel:` or a site path starting with `/`. Anything else (`javascript:`, `data:`, a bare word) is rejected so the stored HTML never carries a scheme the consuming app has to filter out.

normalizeLinkHref(raw: string): string | null
ParameterTypeDefaultDescription
rawstring—- The address as typed.

Returns string | null — The trimmed address, or `null` when it is not allowed.

buildExtensions function

Builds the TipTap extensions for a feature set. The set is also the output allowlist: nothing outside the toolbar's formats (underline, strike, code, code blocks, rules, h1 and h4 to h6) can be produced, even by a keyboard shortcut or a paste, so the consuming app's sanitiser only has to allow what is listed on `RichText`.

buildExtensions(features: RichTextEditorFeature[]): Extensions
ParameterTypeDefaultDescription
featuresRichTextEditorFeature[]—- The features switched on.

Returns Extensions

getRichTextClassName function

Builds the root class of the prose styles. The editor puts it on its contenteditable area so an admin edits exactly what a reader sees.

getRichTextClassName(className?: string | undefined): string
ParameterTypeDefaultDescription
className?string | undefined—- Extra classes to merge with the prose root.

Returns string

useRichTextEditorActions hook

Owns the two toolbar actions that do more than toggle a format: the link dialog and the image callback.

useRichTextEditorActions(editor: Editor | null, onInsertImage?: (() => InsertImageResult | Promise<InsertImageResult>) | undefined): { linkDraft: LinkDraft | null; isInsertingImage: boolean; openLink: (opener?: HTMLButtonElement) => void; saveLink: ({ text, href }: { text: string; href: string; }) => void; removeLink: () => void; closeLink: () => void; insertImage: (opener?: HTMLButtonElement) => Promise<void>; }
ParameterTypeDefaultDescription
editorEditor | null—- The mounted editor, or null before TipTap mounts.
onInsertImage?(() => InsertImageResult | Promise<InsertImageResult>) | undefined—- The app's picker, if the image button is shown.

Returns { linkDraft: LinkDraft | null; isInsertingImage: boolean; openLink: (opener?: HTMLButtonElement) => void; saveLink: ({ text, href }: { text: string; href: string; }) => void; removeLink: () => void; closeLink: () => void; insertImage: (opener?: HTMLButtonElement) => Promise<void>; }

stubLayout function

stubLayout(): void

Returns void

selectText function

Puts the DOM selection across a text node, as a user dragging would.

selectText(root: HTMLElement, text: string): void
ParameterTypeDefaultDescription
rootHTMLElement——
textstring——

Returns void

renderEditor function

renderEditor(props?: Partial<import("./rich-text-editor.js").RichTextEditorProps>): Promise<{ area: HTMLElement; onChange: Mock<(html: string) => void>; }>
ParameterTypeDefaultDescription
props?Partial<import("./rich-text-editor.js").RichTextEditorProps>{}—

Returns Promise<{ area: HTMLElement; onChange: Mock<(html: string) => void>; }>