Rich Text Editor
v0.1.3DraftA 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.
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
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.
valueandonChangeare HTML. An empty document is"", not<p></p>, so a required check isvalue === "".onInsertImageshows the Image button. It resolves with{ src, alt, width?, height? }, or a plain address string (thenaltis empty). Resolve with nothing, or reject, to insert nothing. The button shows a loading state while it waits.featureslimits the toolbar (and what can be typed or pasted) to"bold","italic","heading","bulletList","orderedList","blockquote","link","image".labelsreplaces 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:
| Element | Attributes |
|---|---|
p, h2, h3, strong, em, ul, li, blockquote, br | none |
ol | start (a number), type |
a | href (https:, http:, mailto:, tel: or a path starting with /) |
img | src, 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.
Examples
Open all 4 →Properties
RichTextEditor props
| Prop | Type | Default | Description |
|---|---|---|---|
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-describedby | string | — | Id of the element that describes the editing area, for example the `FormField` hint |
aria-label | string | — | Accessible name of the editing area; defaults to `labels.editor` |
aria-labelledby | string | — | Id of the element that names the editing area, for example the `FormField` label |
className | string | — | Additional class names to merge with the component root element |
features | RichTextEditorFeature[] | [
"bold",
"italic",
"heading",
"bulletList",
"orderedList",
"blockquote",
"link",
"image",
] | Which formats to offer. Defaults to all of them; the Image button also needs `onInsertImage` |
id | string | — | Id of the editing area. A `FormField` label cannot name a contenteditable, so also pass `aria-labelledby` |
invalid | boolean | false | Marks the editor as invalid: adds the `--invalid` modifier and sets `aria-invalid`. `FormField` carries the message |
labels | Partial<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
| Prop | Type | Default | Description |
|---|---|---|---|
html* | string | — | Stored HTML to render. It must already be sanitised: `RichText` does not filter it |
className | string | — | 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| Parameter | Type | Default | Description |
|---|---|---|---|
raw | string | — | - 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| Parameter | Type | Default | Description |
|---|---|---|---|
features | RichTextEditorFeature[] | — | - 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| Parameter | Type | Default | Description |
|---|---|---|---|
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>; }| Parameter | Type | Default | Description |
|---|---|---|---|
editor | Editor | 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(): voidReturns void
selectText function
Puts the DOM selection across a text node, as a user dragging would.
selectText(root: HTMLElement, text: string): void| Parameter | Type | Default | Description |
|---|---|---|---|
root | HTMLElement | — | — |
text | string | — | — |
Returns void
renderEditor function
renderEditor(props?: Partial<import("./rich-text-editor.js").RichTextEditorProps>): Promise<{ area: HTMLElement; onChange: Mock<(html: string) => void>; }>| Parameter | Type | Default | Description |
|---|---|---|---|
props? | Partial<import("./rich-text-editor.js").RichTextEditorProps> | {} | — |
Returns Promise<{ area: HTMLElement; onChange: Mock<(html: string) => void>; }>