RichText

Formatted text people edit as it looks, saved as Markdown.

AlphaSince 1.0.0
On this page

Import

import {
  createRichTextExtensions,
  RichText,
} from '@envsemble/envui/rich-text';

Usage

import { RichText, RichTextEditor } from '@envsemble/envui/rich-text';

<RichTextEditor label="Meeting notes" value={notes} onValueChange={setNotes} />
<RichText>{notes}</RichText>

RichTextEditor and RichText come from the optional @envsemble/envui/rich-text entry, which needs Tiptap and marked, installed by the application. The editor is a field with its label, description and error, and its value is Markdown: leave it uncontrolled, with defaultValue, or control it with value and onValueChange, which receives the Markdown after every edit, normalized by the editor's schema. placeholder shows in an empty document; readOnly shows it without editing, and disabled turns it off and leaves its value out of a form.

Show saved content

RichText renders the Markdown in its children, also on a server. Pass it the editor's extensions. onReferenceClick and onLinkClick open references and links; renderReference renders a reference's content, such as an avatar beside the name, in both.

Toolbar and variants

toolbar takes the commands to show, in order, or false for none; Undo and Redo, and Attach files with onUpload, follow them. variant is outline, the default, or ghost. The document is at least 10 rem tall; minRows makes it as tall as that many lines instead, such as minRows={2} for a short note in a card, and it grows with its text either way.

References and slash commands

// At module scope, so the sources stay the same between renders.
const references: RichTextReferenceSource[] = [
  { trigger: '@', label: 'People', search: searchPeople },
  { trigger: '#', label: 'Topics', search: searchTopics },
];

<RichTextEditor label="Comment" references={references} slashCommands />;

Each source has its trigger, a label and a search lookup, which receives the query and a signal that aborts when it changes. The Markdown keeps each reference's ID. slashCommands is true for heading, the lists, quote, code block and table, or an array that adds the application's commands after them. Keep references, extensions and slashCommands the same between renders: a new array starts a new session and loses undo.

onUpload adds Attach files to the toolbar and uploads each chosen file, returning where it lives, null to skip it, or a promise of either; accept and multiple set what it offers. pickLink replaces the link dialog with the application's picker: it receives a MarkdownLinkRequest and returns a MarkdownLink or null, types @envsemble/envui/rich-text exports.

Commands from outside

const editorRef = useRef<RichTextEditorHandle>(null);

<RichTextEditor editorRef={editorRef} label="Reply" />
<Button
  onClick={() =>
    editorRef.current?.editor?.chain().focus('end').insertContent(' — Alex Morgan').run()
  }
>
  Add signature
</Button>

editorRef.current.editor is the Tiptap editor, and ref reaches the root element, so the application can change the document with its commands.

In a form

<form onSubmit={publish} noValidate>
  <RichTextEditor name="notes" label="Release notes" error={error} />
  <Button type="submit">Publish</Button>
</form>

With name, a native form submits the Markdown through a hidden input, and form names a form the editor sits outside of. error says what to fix.

Composition

extensions adds Tiptap extensions to the schema the editor and RichText share, such as a custom mark, which must parse and write Markdown; pass the same ones to both. createRichTextExtensions returns that shared set, with options.extensions after it, for another Tiptap integration that needs the same schema. The toolbar is the Toolbar MarkdownEditor shares; the suggestions are env-rich-text-suggestions, and the status env-editor-status.

Customize

Its popup is frosted, as every popup is: --env-popup-opacity, --env-popup-blur and --env-popup-shadow, set on a theme, change it, as Popover describes.

Accessibility

You provide

  • A label that names the document, and one for each reference source.
  • Labels for image attachments.
  • Lookups, uploads, navigation and saving.
  • Translated words through EnvUIProvider's messages.

envUI provides

  • The document as a multiline textbox, named by the label and described by the description and the error.
  • A named toolbar with one tab stop, a tooltip on each button and aria-pressed for formatting at the caret.
  • Suggestions as a listbox named by the source's label, with the highlighted option announced.
  • A status for uploads, failures and refused links.
  • Raw HTML kept as text, and unsafe URLs refused.

Props

RichText

Saved rich text, read-only: Markdown rendered with the editor's own schema and parser, so it shows what RichTextEditor wrote, references included, with envUI's links, code and checkboxes. It renders on the server too.

PropTypeDescription
childrenrequired
string
The Markdown to show, such as what RichTextEditor saved.
extensionsDefault []
AnyExtension[]
Tiptap extensions added to the shared schema, such as a custom mark. They must parse and write Markdown, and the array must stay the same between renders, such as at module scope; a new array starts a new editing session. Pass the same extensions to RichText.
renderReferenceDefault the trigger and the label, such as #Launch
(reference: RichTextReference, context: { readOnly: boolean; selected: boolean }) => ReactNode
Renders a reference's content, such as an avatar beside the name; it receives whether the document is read-only and whether the reference is selected.
onReferenceClick
(reference: RichTextReference, event: MouseEvent<HTMLElement>) => void
Called when people activate a reference, with the reference and the event, such as to open the record. A reference with an href is a link, which the editor never follows while it is editable; without an href, this makes the reference a button.
onLinkClick
(href: string, event: RichTextLinkEvent) => void
Called when people activate a link in the document, with its href and the event: a native MouseEvent in the editor, a React mouse event in RichText. The editor never follows links while editable; call preventDefault to keep RichText or a read-only editor from following it.

Also accepts the native props of <div>, such as ref, className and aria-* attributes.

RichTextEditor

A labelled editor for formatted text that people edit as it looks, built on Tiptap, with a formatting toolbar, references typed with a trigger such as

PropTypeDescription
editorRef
Ref<RichTextEditorHandle>
Gives the Tiptap editor, as editorRef.current.editor, for commands.
value
string
The Markdown of a controlled editor; update it from onValueChange. A new value from outside, such as another draft, replaces the document and keeps the caret where it can.
defaultValueDefault ''
string
The Markdown an uncontrolled editor starts with, and returns to when its form resets.
onValueChange
(markdown: string) => void
Receives the document as Markdown after every edit, normalized by the editor's schema, so it can differ from the Markdown it was given, such as * for _ around italics.
name
string
Submits the Markdown with a native form under this name, through a hidden input.
form
string
The ID of the form the hidden input belongs to, where the editor sits outside it.
placeholder
string
Text shown in an empty document, such as what to write.
disabledDefault false
boolean
Makes the editor unavailable: it cannot be edited or focused, the toolbar is disabled, its value is left out of form submission and it takes the shared disabled opacity.
readOnlyDefault false
boolean
Shows the document without editing it: the toolbar is disabled and the document cannot be focused, but its links and references can, and the form still submits its value.
toolbarDefault every command, from heading to table
readonly RichTextCommand[]false
The toolbar's commands, in order, or false for no toolbar; Undo and Redo, and Attach files with onUpload, follow them.
referencesDefault []
readonly RichTextReferenceSource[]
Kinds of references people insert by typing a trigger, such as @ for people and # for topics, each with its own lookup. Triggers must be unique, and the array must stay the same between renders, such as at module scope.
slashCommandsDefault false
booleanreadonly RichTextSlashCommand[]
Typing / opens a menu of block commands: true offers heading, the lists, quote, code block and table; an array adds the application's commands after them. A / reference source can't be used beside it.
onUpload
(file: File, request: { signal: AbortSignal }) => RichTextAttachment | null | Promise<RichTextAttachment | null>
Uploads a file people chose with Attach files, which shows in the toolbar only with this. Return where it lives as an attachment, null to skip it, or a promise of either; the signal aborts when the editor becomes read-only or disabled, or when a controlled value changes.
accept
string
The file types Attach files offers, as in a file input's accept, such as "image/*,.pdf".
multipleDefault true
boolean
Lets people choose several files at once with Attach files.
variantDefault 'outline'
'outline''ghost'
The document's look, as a text area's: outline, in a bordered box on what it sits on; or ghost, only its text at rest, with a soft fill under the pointer and while it has focus. The document is always tall, with its label above it.
minRows
number
Lines of text the document is tall enough for while it holds fewer, such as 2 for a short note in a card; it grows with its text. Without it the document is at least 10 rem tall.
extensionsDefault []
AnyExtension[]
Tiptap extensions added to the shared schema, such as a custom mark. They must parse and write Markdown, and the array must stay the same between renders, such as at module scope; a new array starts a new editing session. Pass the same extensions to RichText.
renderReferenceDefault the trigger and the label, such as #Launch
(reference: RichTextReference, context: { readOnly: boolean; selected: boolean }) => ReactNode
Renders a reference's content, such as an avatar beside the name; it receives whether the document is read-only and whether the reference is selected.
onReferenceClick
(reference: RichTextReference, event: MouseEvent<HTMLElement>) => void
Called when people activate a reference, with the reference and the event, such as to open the record. A reference with an href is a link, which the editor never follows while it is editable; without an href, this makes the reference a button.
onLinkClick
(href: string, event: RichTextLinkEvent) => void
Called when people activate a link in the document, with its href and the event: a native MouseEvent in the editor, a React mouse event in RichText. The editor never follows links while editable; call preventDefault to keep RichText or a read-only editor from following it.
label
ReactNode
The field's visible name, such as Project name; it also names the control for assistive technology.
description
ReactNode
Lasting guidance under the field, such as a format or a limit, linked to the control as its description.
error
ReactNode
A message under the field that says how to correct the value. While it is set, the control is invalid (aria-invalid) and the message joins its description, after the description.

Also accepts the native props of <div>, such as ref, className and aria-* attributes.

createRichTextExtensions

The Tiptap extensions RichTextEditor and RichText share: formatting, lists and task lists, tables, safe links and images, Markdown with raw HTML kept as text, a placeholder and references. options.extensions are added after them, for other Tiptap integrations that need the same schema.

createRichTextExtensions(options?: RichTextRenderOptions & { placeholder?: string; }): AnyExtension[]

Part names

Each part's class and state attributes are public and versioned, for an application's CSS to style.

RichText

env-rich-text
The rendered rich text.
env-rich-text-document
The editable document.
env-rich-text-reference
A reference to a person or an item.
env-rich-text-suggestions
The list of suggestions while typing a reference.
env-rich-text-suggestions-positioner
The suggestions' positioner.
env-rich-text-task
A task item.
env-rich-text-task-check
A task's checkbox.
env-rich-text-task-content
A task's text.
data-checked
false · true on env-rich-text-task
data-reference-id
Any value, or present on env-rich-text-reference

RichTextEditor

env-rich-text-editor
The rich text editor.
env-rich-text-editor-content
The editable region.
env-rich-text-editor-control
The well around the toolbar and the document.
data-disabled
Present on env-rich-text-editor, env-rich-text-editor-control
data-invalid
Present on env-rich-text-editor-control
data-readonly
Present

Strings

EnvUIProvider's messages overrides each, and a component's own label prop wins over it.

messages.editor.heading
"Heading" A command.
messages.editor.bold
"Bold" A command.
messages.editor.italic
"Italic" A command.
messages.editor.strike
"Strikethrough" A command.
messages.editor.bulletList
"Bulleted list" A command.
messages.editor.numberedList
"Numbered list" A command.
messages.editor.taskList
"Task list" A command.
messages.editor.quote
"Quote" A command.
messages.editor.inlineCode
"Inline code" A command.
messages.editor.codeBlock
"Code block" A command.
messages.editor.link
"Insert link" A command.
messages.editor.table
"Insert table" A command.
messages.editor.undo
"Undo" A command.
messages.editor.redo
"Redo" A command.
messages.linkDialog.title
"Insert link" The dialog's title.
messages.linkDialog.insert
"Insert link" The confirm button's label.
messages.linkDialog.text
"Text" The text field's label.
messages.linkDialog.url
"URL or path" The URL field's label.
messages.linkDialog.urlPlaceholder
"https://example.com or /documents/example" The URL field's placeholder, an example of what it takes.
messages.linkDialog.invalidUrl
"Enter a valid URL." The error for an empty destination.
messages.linkDialog.invalidPath
"Enter a valid URL or relative path." The error for a destination that is neither a URL nor a path.
messages.richText.task
"Task" Names a task item's checkbox without text.
messages.richText.commands
"Commands" Names the slash command list.
messages.richText.toolbar
"Rich text formatting" Names the formatting toolbar.
messages.richText.attach
"Attach files" The attach button's name.
messages.richText.suggestionsLoading
"Loading suggestions…" Shown while suggestions load.
messages.richText.suggestionsEmpty
"No results" Shown when no suggestion matches.
messages.richText.suggestionsFailed
"Suggestions could not load. Try typing again." Shown when the suggestions could not load.
messages.richText.suggestions
"Suggestions" Names the suggestion list of a source without a label.
messages.richText.changedWhilePicking
"The document changed while the picker was open. Insert the link again." Said when the document changed while the application's link picker was open.
messages.richText.pickerFailed
"The link picker could not finish. Try again." Said when the application's link picker failed.
messages.richText.uploading
"Uploading attachments…" Said while attachments upload.
messages.richText.unsafeUpload
"The upload returned an unsafe URL." Said when an upload returned a URL the editor does not link to.
messages.richText.changedWhileUploading
"The document changed during upload. Attach the files again." Said when the document changed while the files uploaded.
messages.richText.uploadFailed
"The upload failed. Try again." Said when an upload failed without a message of its own.

Changelog

1.0.0

A reference source's char is trigger and its items lookup is search, and a reference's char is trigger; The Markdown saved before keeps working.

RichTextEditor takes the field vocabulary: description instead of hint, and the variants outline, underline and ghost instead of outlined, plain and quiet, which drew as outlined. The document sits in the field's box, with its border, its states and a disabled field whose label stays readable, and task items show envUI's checkboxes beside their text. The toolbar is the Toolbar MarkdownEditor shares, with tooltips, the link dialog is the shared one, and every word comes from EnvUIProvider's messages. Suggestions are rows of a listbox in the editor, above a Dialog it sits in, and close as focus leaves; a controlled editor raises no flushSync warning. The ./rich-text entry is alpha.

Tiptap, marked, Shiki and the Pierre themes are no longer peer dependencies; applications that import the rich-text entry install them, as Setup lists.

1.0.0-prerelease.6

RichTextEditor takes minRows, the lines of text its document is tall enough for, for a short editor rather than one at least 10 rem tall.

Bold text in a document is semibold rather than medium, as in Markdown, so it stands out from the body text around it.

Where the toolbar is wider than the editor, its groups wrap onto more rows rather than scrolling sideways, so Undo and Redo stay in view. The editor's label takes the size, weight and muted color of a field's floated label.

In RichText and a read-only editor, a task's checkbox is read-only rather than disabled: in full color, with aria-readonly, out of the tab order and not answering the pointer. A disabled editor's tasks stay disabled. The checkbox sits on the middle of its text's first line rather than a little above it.

RichText keeps a line break within a paragraph, a single newline in the Markdown, and runs of spaces, as the editor shows them, rather than joining the lines.

A disabled editor no longer fades as a whole: its document takes --env-color-fg-disabled and its outline the muted border, as a disabled TextField does.

1.0.0-prerelease.5

onRequestLink is pickLink: it supplies the link rather than reporting an event.

ref reaches the root element, and editorRef gives the Tiptap editor.

Suggestions are frosted: the popup's color lets a little of what is behind through, blurred, with a soft shadow and no outline edge but a faint hairline in dark, and is solid with its edge at high contrast and with reduced transparency, with the rows of Select's list.

1.0.0-prerelease.3

A task's row in RichText is as tall as in the editor. The suggestions keep the active option in view as the arrow keys move past the list's edge, and close as focus leaves the editor rather than on any press on the page.

1.0.0-prerelease.2

The underline variant is gone, for the default outline.