Editor

A rich text editor with a floating toolbar, a fixed toolbar, a slash menu and mentions, built on ProseMirror.

1(function EditorPreview() {
2 const people = [
3 { id: "u1", label: "Maya Chen", type: "user" },
4 { id: "u2", label: "Arjun Rao", type: "user" },
5 { id: "u3", label: "Dana Whitfield", type: "user" },
6 ];
7
8 return (
9 <div style={{ width: 560 }}>
10 <Editor
11 defaultValue={{
12 type: "doc",
13 content: [
14 {
15 type: "heading",

Anatomy

Import and assemble the editor. The root owns the editor state, so a toolbar can sit anywhere inside it, above or below the content.

1import { Editor, Toolbar } from '@raystack/apsara'
2
3<Editor>
4 <Editor.Toolbar>
5 <Editor.HistoryButton action="undo" />
6 <Editor.HeadingMenu />
7 <Editor.ListMenu />
8 <Editor.BlockButton block="blockquote" />
9 <Toolbar.Separator />
10 <Editor.MarkButton mark="bold" />
11 <Editor.LinkButton />
12 </Editor.Toolbar>
13 <Editor.Content />
14 <Editor.FloatingToolbar>
15 <Editor.MarkButton mark="bold" />
16 </Editor.FloatingToolbar>
17 <Editor.SlashMenu />
18 <Editor.Mentions />
19</Editor>

The controls work the same in Editor.Toolbar and Editor.FloatingToolbar, so you build either toolbar from the same parts. Use Toolbar.Group and Toolbar.Separator to group them.

Playground

API Reference

Root

Groups all parts and owns the editor state. Renders a div with data-focused, data-empty, data-disabled and data-readonly.

Prop

Type

Content

The editable document. It renders the ProseMirror view with role="textbox" and aria-multiline. Give it an accessible name with aria-label.

Prop

Type

Toolbar

A toolbar that stays in place. It renders an Apsara Toolbar and takes its props. It does not render when the editor is read only.

Prop

Type

FloatingToolbar

A toolbar that shows above a text selection while the editor has focus. It waits for the mouse button to come up, and it shows at once for a keyboard selection. Escape hides it until the selection changes.

Prop

Type

MarkButton

Toggles a mark. The button is pressed when the selection has the mark, and it is disabled where the mark is not allowed, for example in a code block.

Prop

Type

BlockButton

Toggles a quote, a code block or a list, or inserts a divider.

Prop

Type

HeadingMenu

A menu with regular text and the headings. The trigger shows the current style, and each row shows its shortcut.

Prop

Type

ListMenu

A menu with the list types. The trigger icon follows the active list.

Prop

Type

LinkButton

Adds, edits or removes a link. In the floating toolbar the URL field replaces the buttons. In the fixed toolbar it opens in a popover. Mod-k opens the same field. Only http, https, mailto and relative links are allowed.

Prop

Type

HistoryButton

Undoes or redoes. It is disabled when there is nothing to undo or redo.

Prop

Type

SlashMenu

A menu of block commands that opens on / at the start of a block or after a space. It filters on the label and the keywords, and a space in the query closes it. Picking a command removes the typed /query, and one undo brings it back. A built-in command that cannot run at the caret is disabled, for example a heading in the first paragraph of a list item.

Prop

Type

Prop

Type

Mentions

A menu that inserts a mention chip. It takes the same props as PromptInput.Mentions. Mount one per trigger, for example @ for people and # for issues.

Prop

Type

Prop

Type

Hooks

useEditor() returns the editor API inside <Editor>. useEditorState(selector, isEqual?) selects a value from the ProseMirror state and re-renders only when that value changes. actionsRef gives you the same API from outside the editor.

editorCommands has the same commands as ProseMirror commands, for code that works on an EditorState directly. For example, editorCommands.setHeading(2)(state, dispatch) runs what editor.commands.setHeading(2) runs. commands.insertMention inserts the chip at the selection.

Prop

Type

Prop

Type

Change details

The second argument of onValueChange. The methods convert the doc from that change each time you call them, so call a method only when you need its result.

Prop

Type

Markdown

MarkdownAdapter.create(options) returns an adapter for the markdown prop. MarkdownAdapter.toEditor(markdown) and MarkdownAdapter.fromEditor(value) convert without an editor, for example on the server. An app that never imports MarkdownAdapter ships no Markdown code.

Markdown has no form for some content. A hard break at the end of a block is dropped, a hard break in a heading becomes a space, and an empty paragraph is dropped. A controlled Markdown value that the current doc converts to does not reload the doc, so these do not change while the user types.

Prop

Type

Examples

Fixed toolbar

A toolbar above the content, like a document editor.

1<div style={{ width: 600 }}>
2 <Editor placeholder="Start writing…">
3 <Editor.Toolbar>
4 <Toolbar.Group>
5 <Editor.HistoryButton action="undo" />
6 <Editor.HistoryButton action="redo" />
7 </Toolbar.Group>
8 <Toolbar.Separator />
9 <Toolbar.Group>
10 <Editor.HeadingMenu levels={[1, 2, 3]} />
11 <Editor.ListMenu />
12 <Editor.BlockButton block="blockquote" />
13 <Editor.BlockButton block="codeBlock" />
14 </Toolbar.Group>
15 <Toolbar.Separator />

Comment box

formats limits the nodes and marks, and input rules and paste follow it. details.empty gates the submit button, and actionsRef reads and clears the editor.

1(function CommentBox() {
2 const editor = React.useRef(null);
3 const [empty, setEmpty] = React.useState(true);
4 const [comments, setComments] = React.useState([]);
5 const people = [
6 { id: "u1", label: "Maya Chen", type: "user" },
7 { id: "u2", label: "Arjun Rao", type: "user" },
8 { id: "u3", label: "Dana Whitfield", type: "user" },
9 ];
10
11 const send = () => {
12 const html = editor.current.getHTML();
13 setComments((current) => [...current, html]);
14 editor.current.commands.clear();
15 setEmpty(true);

Mentions

Two triggers: @ filters a list, and # searches asynchronously. onSearch gets an AbortSignal for requests that a newer query replaces.

1(function MentionsDemo() {
2 const people = [
3 { id: "u1", label: "Maya Chen", type: "user" },
4 { id: "u2", label: "Arjun Rao", type: "user" },
5 { id: "u3", label: "Dana Whitfield", type: "user" },
6 ];
7 const issues = [
8 { id: "ENG-214", label: "ENG-214 Improve onboarding", type: "issue" },
9 { id: "ENG-230", label: "ENG-230 Calendar range", type: "issue" },
10 ];
11
12 const searchIssues = (query, { signal }) =>
13 new Promise((resolve, reject) => {
14 const timer = setTimeout(
15 () =>

Custom slash commands

Spread defaultSlashItems and add your own. run gets the editor API after the menu removes the typed query.

1(function SlashItems() {
2 const items = [
3 ...defaultSlashItems,
4 {
5 id: "date",
6 label: "Today's date",
7 group: "Insert",
8 keywords: ["today", "time"],
9 icon: <CalendarIcon />,
10 run: (editor) =>
11 editor.commands.insertText(new Date().toLocaleDateString()),
12 },
13 ];
14
15 return (

Controlled

onValueChange emits editor JSON, and details.getHTML() converts it to HTML. Pass the emitted value back to value, and the editor keeps its selection. A different value replaces the doc and starts a new undo history.

1(function ControlledEditor() {
2 const [value, setValue] = React.useState({
3 type: "doc",
4 content: [
5 {
6 type: "heading",
7 attrs: { level: 2 },
8 content: [{ type: "text", text: "Release notes" }],
9 },
10 {
11 type: "paragraph",
12 content: [
13 { type: "text", text: "Select this text to format it, type " },
14 { type: "text", marks: [{ type: "code" }], text: "/" },
15 { type: "text", text: " for commands, or " },

Markdown

With the markdown prop, value can be a Markdown string, pasted plain-text Markdown turns into rich content, and details.getMarkdown() returns Markdown. The editor still emits JSON.

1(function MarkdownEditor() {
2 const adapter = React.useMemo(() => MarkdownAdapter.create(), []);
3 const [markdown, setMarkdown] = React.useState(
4 "## Notes\n\nPaste **Markdown** here, or use _shortcuts_ like `- ` and `## `.\n\n- [x] Load Markdown\n- [ ] Save Markdown"
5 );
6
7 return (
8 <Flex direction="column" gap={4} style={{ width: 520 }}>
9 <Editor
10 markdown={adapter}
11 value={markdown}
12 onValueChange={(_, details) => setMarkdown(details.getMarkdown())}
13 >
14 <Editor.Content
15 style={{

Read only

readOnly renders the content and hides the toolbars and menus. Use editorToHTML(value) to render stored content without an editor, for example on the server, and editorToText(value) for plain text.

1<div style={{ width: 520 }}>
2 <Editor
3 readOnly
4 defaultValue={{
5 type: "doc",
6 content: [
7 {
8 type: "heading",
9 attrs: { level: 2 },
10 content: [{ type: "text", text: "Release notes" }],
11 },
12 {
13 type: "paragraph",
14 content: [
15 { type: "text", text: "Select this text to format it, type " },

Custom control

Build your own control with useEditor and useEditorState. Prevent the default on mouse down, so a click does not move the selection.

1function ClearFormattingButton() {
2 const editor = useEditor();
3 const canClear = useEditorState(() => editor.can.clearFormatting());
4
5 return (
6 <Toolbar.Button
7 disabled={!canClear}
8 onMouseDown={(event) => event.preventDefault()}
9 onClick={() => editor.commands.clearFormatting()}
10 >
11 Clear
12 </Toolbar.Button>
13 );
14}
15

Data model

The value is ProseMirror JSON, the output of doc.toJSON(). Node and mark names match Tiptap, so content from Tiptap loads as is.

NameKindAttributesMarkdown
paragraphblocktext
headingblocklevel: 1 to 4# to ####
blockquoteblock>
codeBlockblocklanguagefenced code
bulletList, listItemblock-
orderedListblockstart1.
taskList, taskItemblockchecked- [ ]
horizontalRuleblock---
hardBreakinlinetrailing \
mentioninlineid, label, type, trigger@[label](type:id)
bold, italic, strike, codemark**, *, ~~, `
underlinemark<u>
linkmarkhref[text](href)

On load, a node the schema does not have unwraps into its blocks, or becomes a paragraph when it holds only text. A mark the schema or the parent node does not allow is dropped, and a node in the wrong place is wrapped, for example a list item outside a list.

Typing shortcuts: # to #### and a space make a heading, - or * a bulleted list, 1. a numbered list, [] a checklist, > a quote, ``` a code block, and ---, or *** or ___ and a space, a divider. **bold**, _italic_, `code` and ~~strike~~ apply marks. Backspace right after a shortcut undoes it.

Keyboard shortcuts

Mod is Cmd on macOS and Ctrl elsewhere. Override or turn off a key with the shortcuts prop. Tooltips and menu rows show the key you set. defaultShortcuts holds the default keys.

ActionKey
BoldMod-b
ItalicMod-i
UnderlineMod-u
StrikethroughMod-Shift-x
Inline codeMod-e
LinkMod-k
TextMod-Alt-0
Heading 1 to 4Mod-Alt-1 to Mod-Alt-4
Bulleted listMod-Shift-8
Numbered listMod-Shift-9
ChecklistMod-Shift-7
QuoteAlt-Shift-.
Code blockMod-Shift-\
Undo, redoMod-z, Mod-Shift-z or Mod-y
Focus the toolbarAlt-F10

In a list, Enter splits the item, and Tab and Shift+Tab indent and outdent it. Shift+Enter adds a line break.

Slots

Every rendered part carries a stable data-slot attribute for styling and testing:

SlotElement
editorThe root element
editor-contentEditor.Content
editor-toolbarEditor.Toolbar
editor-floating-toolbarThe toolbar inside Editor.FloatingToolbar
editor-mark-buttonEditor.MarkButton
editor-block-buttonEditor.BlockButton
editor-heading-menuThe Editor.HeadingMenu trigger
editor-list-menuThe Editor.ListMenu trigger
editor-link-buttonEditor.LinkButton
editor-link-formThe URL form of Editor.LinkButton
editor-link-inputThe URL field
editor-history-buttonEditor.HistoryButton
editor-slash-menuThe Editor.SlashMenu listbox
editor-mention-menuThe Editor.Mentions listbox

Accessibility

  • The content is a textbox with aria-multiline. While a menu is open it also has aria-controls and aria-activedescendant, and focus stays in the text.
  • The toolbars have role="toolbar" and move focus with the arrow keys. Alt-F10 moves focus from the text to the toolbar. In the floating toolbar, Escape, Tab past either end, and a command that hides the toolbar return focus to the text.
  • Toggle buttons use aria-pressed. Each control's accessible name is its label, and the tooltip adds the shortcut.
  • Heading and list menu rows use menuitemradio with aria-checked.
  • The slash and mention menus use listbox and option roles.
  • Checklist checkboxes are real input elements. They are disabled while the editor is read only or disabled.