Composites
Rich Text Editor
The editor for content that gets published — articles, news pages, product descriptions, email templates. It stores HTML, its schema holds what Markdown cannot (underline, table cells with lists and images), and its toolbar sits pinned on top, because the people writing here expect one. Underneath it is Markdown Editor's core: the same / menu, @ mentions, images, links and shortcuts.
Same entry, same peers: @comitor/ui/editor, with the @tiptap/* packages installed at one version — the list is on the Markdown Editor page. It does not use @tiptap/markdown, but the two editors share one schema function, so the entry imports it and the list stays the same.
Markdown Editor or Rich Text Editor
Choose by what the column stores. Both take toolbar="bubble" | "fixed", so the look is never the reason. One component with a format prop was considered and rejected: the format decides the schema, the schema is built once at mount, and a prop that can change at runtime without effect is a prop that lies.
| Aspect | Markdown Editor | Rich Text Editor |
|---|---|---|
| Stores | A Markdown string (GFM) | An HTML string — "" when empty |
| For | Task descriptions, comments, internal docs — read by MCP tools and emails as-is | Published content: articles, news pages, product descriptions, email templates |
| Default toolbar | bubble — beside the selection, like Linear | fixed — pinned to the top, like a CMS |
| Underline | No — Markdown has no syntax for it | Yes — ⌘U |
| Text alignment (3.14.0) | Paragraphs and headings at the top level or in a quote — stored as a trailing {align=center} | Every paragraph and heading — stored as style="text-align: …;" |
| Table cells | One paragraph each — a GFM cell is one line | Any blocks — lists, code, images, several paragraphs |
| Shared | The / menu, @ mentions, images, links, shortcuts, read mode, FormField wiring | The same — one core underneath |
An article
Live, with the stored HTML underneath. Put the caret in the right-hand table cell: the block buttons stay on — a list, a code block or an image goes into the cell — while Table locks, because tables do not nest. Undo and redo light up as history builds. Drop or paste an image and it uploads for real to /api/demo-upload, which keeps nothing.
The value — what onChange hands you, and what the column stores
Building the editor…
What is written is not what is drawn, in two places. The cover shows from a signed address, yet the HTML keeps src="media/coast-town.png" — a signed URL written to a column is a value that expires inside your database. The mention reads “@Wei Chen” on screen, from resolveLabel, yet the HTML keeps “@Wei”, the name captured when the mention was made — stored content does not rewrite itself from the directory just because someone opened and saved it. Both the address and the live name are drawn by a node view; only renderHTML writes, and the package's gate checks it.
HTML in, HTML out — through the schema
The schema is an allow-list. Whatever HTML arrives — a value, setHTML, a paste — keeps only what the schema knows: the script, the iframe, the inline style and the event handler below are gone, and the javascript: link is left as plain text. The one style that survives, from 3.14.0, is text-align on a paragraph or heading.
HTML in
<h3 style="color:red">Imported from an old CMS</h3> <p onclick="steal()">A paragraph with an event handler.</p> <script>steal()</script> <iframe src="https://example.com/ad"></iframe> <p><a href="javascript:steal()">A javascript: link</a> and a <a href="https://comitor.ai">real one</a>.</p>
The editor
getHTML() out
Building the editor…
| Content | HTML written |
|---|---|
| Paragraph · headings | <p> · <h1>…<h6> (the UI offers 1–3) |
| Bold · italic · underline · strike · code | <strong> · <em> · <u> · <s> · <code> |
| Link | <a target="_blank" rel="noopener noreferrer nofollow" href="…"> |
| Lists · checklist | <ul> / <ol> · <ul data-type="taskList"><li data-type="taskItem" data-checked> |
| Quote · code block · divider | <blockquote> · <pre><code class="language-ts"> · <hr> |
| Table | <table style="min-width:…"><colgroup>…<tbody> — cells hold blocks |
| Image | <img src alt> — src is what onImageUpload returned |
| Alignment (3.14.0) | <p style="text-align: center;"> · <h2 style="text-align: right;"> — center and right only |
| Mention | <span data-type="mention" data-id data-label>@Name</span> |
| Mention with a kind (3.7.0) | <span data-type="mention" data-id data-label data-kind="task">@KEY-12</span> |
Showing stored HTML
The schema filters what the editor writes — not what your column holds. Anyone calling your API directly can store any string at all. So render stored HTML through the same schema, in read mode, and keep dangerouslySetInnerHTML for HTML your server has sanitised itself. A server-side sanitiser is the app's job: which tags a public page allows belongs to the page that shows them, not to the editor.
The toolbar
- Undo · redo | marks + link | blocks | align left · center · right | table · divider · image. The image button appears only with
onImageUpload. The alignment buttons (3.14.0) act on every paragraph and heading in the selection, as one undo step; there are no shortcuts for them, because the usual ⌘⇧R is the browser's hard reload. Link opens the address field in the bubble beside the selection — the field has to stand next to the words it links. - It sticks to the top while a long article scrolls, on an opaque background so the text passes underneath rather than through. On a phone it wraps onto a second row instead of scrolling sideways.
- It is drawn before the editor exists — every button disabled — so the page is not pushed down when the editor finishes building after hydration.
- Or keep the Linear bubble:
toolbar="bubble".
Not in the box
Colours, font sizes, embedded video. Each is one more @tiptap/* package; pass it through extensions, at the same version, until a second module needs it in the package. Rendering on the server — HTML in the first response, for a public page's SEO — is the next step the package documents, not yet a component.
Props
Everything not listed here works exactly as in Markdown Editor.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | HTML, controlled. A value equal to the one the editor just emitted is ignored; any other value reloads the document — through the schema. |
defaultValue | string | — | HTML to start from when uncontrolled. |
onChange | (html: string) => void | — | Every change, as the HTML the schema writes (editor.getHTML()). An empty document is "", not <p></p>, so a required check is value === "" with either editor. |
toolbar | "fixed" | "bubble" | "fixed" | fixed pins the toolbar to the top of the frame and keeps the bubble for links only; bubble is Markdown Editor's default, formatting beside the selection. |
variant | "outline" | "ghost" | "outline" | outline is framed like a Textarea; ghost puts the document straight on the page — the read-mode rendering above. |
editable | boolean | true | false is read mode: no toolbar, no caret, links open on click. The way to show stored HTML. |
mentions | { items, resolveLabel?, getHref? } | — | Same as Markdown Editor, including kind on an item and getHref (3.7.0). resolveLabel draws the live name; the HTML keeps the name captured when the mention was made, plus data-kind for a typed mention — never the address getHref returns. |
onImageUpload · resolveImageSrc · onImageUploadError | see Markdown Editor | — | Return the src to store; map it to a display URL at draw time. getHTML() writes the stored src, never the signed one. |
onSubmit · submitOnEnter · onFocus · onBlur · placeholder · autoFocus | — | — | As in Markdown Editor. |
name | string | — | Adds a hidden input carrying the HTML, for a real <form>. |
id · aria-* | string | — | Land on the editing surface; a <label for> with the id becomes its name. |
extensions | AnyExtension[] | — | Tiptap extensions of your own — colours, embeds (alignment is built in from 3.14.0; do not add @tiptap/extension-text-align). Read once at mount; the same @tiptap/* version as the package. |
labels | Partial<MarkdownEditorLabels> | from provider | The same markdownEditor table as Markdown Editor — both editors share it, including underline, undo and redo. |
className · contentClassName | string | — | contentClassName lands on the editing surface: max-h-96 overflow-y-auto makes a scrolling box with the toolbar outside it. |
The handle
Through ref — RichTextEditorHandle.
| Prop | Type | Default | Description |
|---|---|---|---|
getHTML() | () => string | — | The same string onChange last received; "" when empty. |
setHTML(html) | (html: string) => void | — | Replaces the document — through the schema. Calls onChange. |
clear() · focus() · isEmpty() · isUploading() · editor | — | — | As in Markdown Editor. |
Accessibility
- The toolbar is a named toolbar (
role="toolbar", “Formatting”). Formatting buttons are toggles that reportaria-pressed; commands such as Undo are plain buttons. Each names itself and shows its shortcut in a tooltip that also appears on keyboard focus. - Pressing a button keeps focus in the document, so the selection it formats is still there — and every formatting button has a shortcut (⌘B, ⌘U, ⌘K…) for working without the pointer at all.
- Locked is visible. Buttons that cannot act where the caret is — Table inside a table, Undo with nothing to undo — are disabled rather than silently doing nothing.
- Read mode has no toolbar and says
role="none", so a published article is read as text, not announced as a read-only input. - Everything else is Markdown Editor's — the named textbox, the label wiring, the keyboard menus, the named checkboxes, the 16 px text on phones.