Markdown Editor

A Linear-style editor that stores Markdown. You write on the formatted document — there is no preview pane: type ## for a heading, [] for a checklist, / for any block, @ to mention someone, and select text for the formatting bubble. What goes in and out is a Markdown string (GFM) — what a task's description column holds, what an MCP tool reads and writes, and what an email can show without an editor.

Storing HTML for content that gets published — an article, a product page? That is Rich Text Editor: the same core, a wider schema, a toolbar pinned on top. Pick by what the column stores, not by the look — both take toolbar="bubble" | "fixed".

Its own entry, Tiptap as optional peers

Both editors live at @comitor/ui/editor and run on Tiptap 3 — ProseMirror, the same foundation as Linear's editor. The @tiptap/* packages are optional peers: an app that never imports the entry pays nothing. Install them when you do, all at one version:

terminal
pnpm add @tiptap/core @tiptap/pm @tiptap/react @tiptap/starter-kit @tiptap/markdown \
  @tiptap/extensions @tiptap/extension-list @tiptap/extension-table @tiptap/extension-image \
  @tiptap/extension-mention @tiptap/suggestion @tiptap/extension-paragraph \
  @tiptap/extension-code-block-lowlight lowlight highlight.js
  • One version for every @tiptap package. They pin @tiptap/core and @tiptap/pm to the exact number, and an extension you write yourself must use that same copy — two copies of ProseMirror on a page and a node built by one does not fit a document of the other.
  • About 234 KB gzipped — ProseMirror, Tiptap and the menus around 184 KB, the syntax highlighter (37 languages) around 53 KB. A page that does not need an editor the moment it opens can load it with next/dynamic — under webpack too (next dev --webpack) from 3.17.2, which gave every entry of the exports map a default condition.
  • Server rendering: the editor builds on the client after hydration, holding its minimum height until then — read mode included.

A task description

variant="ghost": no frame, no padding, the document straight on the card. Everything below is live — edit the text and watch the stored Markdown change; tick a checkbox; select a word for the bubble; type / on an empty line, or @. Paste or drop an image and it uploads for real to /api/demo-upload, which keeps nothing — look at what lands in the value.

The value — what onChange hands you, and what the column stores

## Sign in with a one-time code

People who **forgot their password** need a fast way back in. See the [Account docs](https://comitor.ai/docs) and ask [@Wei Chen](mention:u5) about rate limits.

- [x] Design the code entry screen
- [ ] Send the code by email
  - [ ] At most 5 codes an hour
- [ ] Write the tests

> Codes expire after 10 minutes. Never store the raw code.

```ts
export function verifyCode(input: string, hash: string): boolean {
  // constant-time comparison
  return timingSafeEqual(sha256(input), hash)
}
```

| Step | Owner | Due |
| --- | --- | --- |
| Design | Priya Raman | Oct 5 |
| Backend | Tomas Berg | Oct 12 |

The image you drop shows at once, from the file itself, but it is not in the value until the upload returns — and then what lands is the key onImageUpload returned, not the address you see. Undo removes the image in one step, and redo brings back the real one: the half-uploaded picture is a decoration, never a node, so history cannot resurrect it.

What you can type

The / menu lists every block with its shortcut beside it, greyed — so the menu is also where the syntax is learned, and next time the person types it straight. It filters accent-insensitively, with synonyms per language.

TypeMakesKeys
# · ## · ###Heading 1 · 2 · 3⌘⌥1 · 2 · 3
- or *Bulleted list⌘⇧8
1.Numbered list⌘⇧7
[] or [x]Checklist — ⌘↵ ticks the item⌘⇧9
>Quote⌘⇧B
```Code block — Tab indents, ↓ on the last line leaves⌘⌥C
---Divider—
**bold** · *italic*Bold · italic⌘B · ⌘I
~~strike~~ · `code`Strikethrough · inline code⌘⇧S · ⌘E
[text](url)Link — or paste a URL over selected text⌘K
/The block menu, at the start of a line or after a space—
@Mention someone (when mentions is set)—

⌘K inside the editor opens the link field, not the command palette — the shell's listener ignores the key when it comes from a contenteditable. Keys shown for macOS; elsewhere ⌘ is Ctrl and ⌥ is Alt.

A comment box

The default outline variant, framed like a Textarea. onSubmit takes ⌘↵, the handle clears the box, and each posted comment is the same editor with editable={false}.

Mentioning more than people

New in 3.7.0. An @ item with a kind — "task", "project" — is stored with that kind in front of its id, [@DS-388](mention:task/t388), while a person keeps the 3.5.0 form, so content saved before reads exactly as it did. icon stands in for the avatar in the menu, resolveLabel receives the kind as its second argument because ids of two kinds can collide, and getHref turns a mention into a link — here the task opens the Tasks pattern. Type @ds to find the tasks.

The value

Blocked by [@DS-388](mention:task/t388) until [@Wei Chen](mention:u5) signs off the copy.

The address is drawn, never stored. getHref runs when the mention is painted, so moving tasks to another path changes no saved content. It is a plain <a href>: in a Next app a click is a full page load, not a router transition — the price of not wiring a listener into the editor.

In a form

Spread FormField's control into it like any input. Click the label: the caret goes in. Turn the error on: the frame turns --destructive-ink and the surface carries aria-invalid.

A pinned toolbar instead of the bubble

toolbar="fixed" suits people who do not know the shortcuts — long internal documents, say. The bar sticks to the top while the document scrolls, wraps onto a second row on a phone, and inside a table it locks the block and insert buttons: a GFM cell holds a single line, so a block button would do nothing there and an insert button would split the table to make room. Underline never appears here; Markdown has no syntax for it. From 3.14.0 the bar also aligns paragraphs and headings left, center or right — but not inside a list item or a table cell, where the buttons lock: a GFM table aligns whole columns, and a list item's trailing attribute would not survive the next open.

In Vietnamese

Every string follows ComitorLocaleProvider — placeholder Write something, or type “/” for blocks… in English, Viết nội dung, hoặc gõ “/” để chèn khối… in Vietnamese. Type /tieu: the menu finds “Tiêu đề” without the diacritics, and /h2 still works in every language.

The stored format is a contract

This is the entry's real public API: an app stores these strings, and changing how any of them is written means old content reads back differently.

ContentMarkdownNote
Headings# … ######The UI offers 1–3; 4–6 from Markdown still round-trip.
Bold · italic · strike · code**a** · *a* · ~~a~~ · `a`_a_ reads in, *a* writes out.
Link[text](https://…)http(s), mailto:, tel: and in-app paths only — javascript: is refused.
Lists · checklist- a · 1. a · - [ ] a / - [x] aNest freely.
Quote · code · divider> a · ```ts · ---The fence grows longer than any run of backticks inside.
TableGFMEach cell holds exactly one paragraph — a GFM cell is one line.
Image![alt](src)src is what onImageUpload returned — never a signed URL.
Mention[@Name](mention:<id>)An ordinary Markdown link; the name is a snapshot.
Mention with a kind[@KEY-12](mention:task/<id>)Since 3.7.0. Ids are URI-encoded, so a bare / always means a kind.
Hard line breaktwo spaces, then a newlineShift+Enter.
Center · rightText {align=center} · Text {align=right}Since 3.14.0. A trailing attribute after one space; left is the default and writes nothing.
  • A mention is an ordinary Markdown link, not Tiptap's own [@ id="…"] syntax: a notification email, an agent reading over MCP or any other Markdown viewer prints “@Name” instead of a bracket soup. The name inside is a snapshot from the moment of mentioning; the live name is drawn through resolveLabel, for the same reason a Comitor module keeps no column of people's names.
  • No underline, on purpose. Markdown has no syntax for it, and Tiptap would write ++text++ — which every other viewer prints raw.
  • Alignment is a trailing attribute (3.14.0) — {align=center} or {align=right} after one space, the markdown-it-attrs convention. Another viewer prints it at the end of the line, which still reads. Text a person types that ends in {…} is saved as \{…} so it stays text. A page that renders the Markdown itself — an email, a channel's HTML — must understand the attribute or strip it.
  • Five upstream round-trip bugs are patched and guarded by the package's test:editor-markdown gate: a paragraph starting # reopening as a heading, a code block cut short by its own backticks, table cells decaying into &lt;br&gt; one save at a time, mentions in a private syntax, and a parser that got fifteen times slower over a working day. Each was silent — the screen was right; the document was different the next time it opened.

Props

PropTypeDefaultDescription
valuestring—Markdown, controlled. A value equal to the one the editor just emitted is ignored — otherwise every keystroke would reload the document and throw the caret to the start. Any other value reloads it.
defaultValuestring—Markdown to start from when uncontrolled.
onChange(markdown: string) => void—Every change, as normalised Markdown. Normalisation settles after one pass: saving twice changes nothing.
variant"outline" | "ghost""outline"outline is framed like a Textarea — forms and comment boxes. ghost has no frame and no padding: the document sits on the page, the way Linear draws a task description.
toolbar"bubble" | "fixed""bubble"bubble shows formatting where you select. fixed pins a toolbar to the top of the frame — undo/redo, marks, link, blocks, align left/center/right (3.14.0), table, divider, image — and keeps the bubble for links only.
editablebooleantruefalse is read mode: no caret, no menus, links open on click, checkboxes ignore the pointer. Can change at runtime.
mentions{ items, resolveLabel?, getHref? }—Turns @ on. items is an array (filtered for you, accent-insensitive, at most 50 shown) or (query, { signal }) => items — the signal aborts when the user keeps typing, and the call waits 150 ms after the last key. An item is { id, label, description?, avatarUrl?, kind?, icon? }; kind (3.7.0) marks something other than a person, and icon replaces the avatar in the menu. resolveLabel(id, kind) returns the live name to draw; the stored snapshot is the fallback. getHref(id, kind) (3.7.0) makes the mention a link — a same-origin /path or http(s):// only, never written into the content.
onImageUpload(file: File) => Promise<string | { src, alt?, title? }>—Turns images on — paste, drop, and the Image block of the / menu (and the toolbar's image button). Return the src to STORE: a storage key or a stable route of your app. Never a signed, expiring URL.
resolveImageSrc(src: string) => string—Maps a stored src to a display URL (a freshly signed one, say) at draw time. The value keeps the stored src.
onImageUploadError(error: unknown, file: File) => void—Default: a destructive toast — which needs a Toaster mounted at the root. This site has none, so its demos pass this.
onSubmit() => void—⌘/Ctrl+Enter, for a comment box. Inside a checklist the key ticks the item instead. Without it the key keeps its default meaning.
submitOnEnterbooleanfalseNew in 3.12.0, for a chat-style input. A bare Enter calls onSubmit too and Shift+Enter breaks the line — except while the @ or / menu is open (Enter picks the item) or an IME is still composing (Enter commits the word). Needs onSubmit; an image still uploading is not in the Markdown yet, so check isUploading() before sending. ChatComposer in @comitor/ui/chat turns it on for you.
placeholderstringfrom providerShown while the document is empty.
autoFocusbooleanfalsePuts the caret at the end once the editor exists.
namestring—Adds a hidden input carrying the Markdown, so the editor works inside a real <form>.
id · aria-*string—Land on the editing surface. With an id, a <label for> anywhere on the page becomes the accessible name — FormField relies on this.
extensionsAnyExtension[]—Tiptap extensions of your own — a KEY-123 reference node, say. Read once at mount; change the component's key to rebuild. Must be the same @tiptap/* version as the package.
labelsPartial<MarkdownEditorLabels>from providerMerged over the markdownEditor table of ComitorLocaleProvider (English without one). DEFAULT_MARKDOWN_EDITOR_LABELS is that English table; the Vietnamese one is comitorMessagesVi.markdownEditor.
className · contentClassNamestring—className lands on the frame; contentClassName on the editing surface — min-h-40, or max-h-96 overflow-y-auto for a scrolling box.

The handle

Through ref — MarkdownEditorHandle.

PropTypeDefaultDescription
getMarkdown()() => string—The same string onChange last received.
setMarkdown(markdown)(markdown: string) => void—Replaces the document. Calls onChange.
clear()() => void—Empties the document — after sending a comment. Calls onChange('').
focus(position?)("start" | "end") => void—Defaults to the end.
isEmpty()() => boolean—True for an empty document.
isUploading()() => boolean—An image is still uploading — it is not in the Markdown yet, so a Send button should wait.
editorEditor | null—The Tiptap editor underneath, for what the package does not cover. Null before the first client render. The handle object itself is stable for the component's whole life.

Accessibility

  • The editing surface is a named textbox — role="textbox", aria-multiline, named by your aria-label, by a <label for> it finds through its id, or by a generic “Editor” as the last resort. In read mode it says role="none": a list of comments should not be announced as a column of read-only inputs.
  • Both menus are listboxes driven from the keyboard — ↑ ↓ to move, Enter to pick, Escape to close. Inside a dialog, Escape closes the menu first and the dialog only on the next press, and clicking a menu item does not dismiss the dialog.
  • Every toolbar button names itself and shows its shortcut in a tooltip that also appears on keyboard focus. Pressing one keeps focus in the document, so the selection it formats survives.
  • Checkboxes are named after their item — “Task: Send the code by email”, not a bare “checkbox” — and a parent item's name stops at its own text, before its children's.
  • An image that is still uploading is announced as an image named “Uploading image…”, and a rejected link address answers with a role="alert" line instead of silently doing nothing.
  • 16 px text on phones. Below 16 px, Safari on iOS zooms the whole page when the surface is tapped; from md up the editor drops to 14 px, like Textarea and — since 3.13.4 — every other field in the package.