Gesso
Editors

Markdown editor

A block editor whose file is the markdown. Untouched blocks save back byte for byte, and only real edits reach onChange.

A Notion-style block editor for notes that live as markdown files, like an Obsidian vault. The file stays the source of truth: loading a note and saving it back changes nothing you did not touch, and opening a note never writes. It runs on Tiptap v3 and its official markdown bridge, with a fidelity layer the kit owns on top.

It reads a few extensions to CommonMark and GFM that a vault leans on: [[wikilinks]], (key:: value) fields rendered as chips, ^block-ids, #tags, callouts, ==highlights==, and --- rules as card breaks. It also reads mentions of people, [@Name](mention:<id>), and page blocks, a line holding only a link to a page by its id, which your app draws as a card.

Saved file matches what loaded, byte for byte

Saved markdown
---
kind: journal
---
# Thursday, September 24

Planning the week with [@Ana Ruiz](mention:ana) around the [[Launch checklist]] and the notes in [[Design review|review]]. Anything tagged #launch is on the critical path, and [[Vendor shortlist]] is still to write.

- [ ] Draft the release notes (deadline:: 2026-09-21) ^t-4bd9c2
- [ ] Book the venue walkthrough (due:: 2026-09-28) (repeat:: weekly) ^t-7f01aa
- [ ] Pick a launch date (decide:: Oct 6 | Oct 13 | Oct 20) ^t-2c9e11
- [ ] Summarize the survey results (ai-done:: 2026-09-23) ^t-91d0be
- [ ] Confirm the caterer (waiting:: 2026-09-22) ^t-3d8a70
- [x] Send the sponsor deck ^t-0a33f4
- [ ] Reorganize the old archive (dropped:: 2026-09-22) ^t-5e6b20
- Loose thoughts
  - Keep the landing page to one scroll
  - Ask about ==accessibility review== timing

---

## Launch notes (area:: product)

> [!tip] Keep it short
> A one-screen summary beats a long doc nobody opens.

The copy lives in https://example.com/blog/2026/launch-week-plan and the tracker in <https://example.com/tracker>. Tagged #copy and #design for the review.

The spec is in https://www.notion.so/ramson/Launch-spec-8f3a1c2b9d4e4f6a8b7c6d5e4f3a2b1c and the config in [launch.ts](cursor://file/Users/ana/launch/src/launch.ts:12).

| Owner | Task | Status |
| --- | --- | --- |
| Ana | Copy | Draft |
| Ben | Visuals | Review |

```ts
export const launch = { date: "2026-10-13", channels: ["email", "blog"] };
```

---

Three risks worth naming before Monday. (ai:: claude) (tokens:: 18,420) (cache:: 6,100) (effort:: medium)

Timing slips if review runs long, so the date stays a decision until Friday. (ai-end)

Tick a task, type / at the start of a line, type [[ or @, click a date chip, or hover a block for its handle. The panel on the right is what onChange received. The line above it reads "byte for byte" until you edit, ticking one box rewrites one line, and unticking it again puts the file back exactly as it loaded.

Usage

import { MarkdownEditor } from '@/components/ui/markdown-editor';

<MarkdownEditor
  defaultValue={note.markdown}
  documentId={note.path}
  onChange={(markdown) => save(note.path, markdown)}
  links={vaultIndex}
  onOpenLink={(entry, target) => entry && router.push(hrefFor(entry))}
/>;

The editor is uncontrolled. It takes defaultValue once and owns the document after that, because reparsing on every keystroke would throw away the node identity that the fidelity layer depends on. Change documentId to load another note, or call setMarkdown on the ref to reload one in place. Neither counts as an edit, so neither calls onChange.

onChange fires only after a real pointer or key interaction. Plugins that adjust the document on load, like the trailing empty paragraph, never cause a write. onBaseline reports the editor's reading of what it loaded, which with this editor is the input itself.

Room around the editor

The editor draws its text to its own edges, with no padding of its own. The block handle, an add button and a drag grip, hangs in the 52px to the left of the block it belongs to, and with cards on, the card rail takes the 44px to the right of the editor. Leave that room in the container around the editor, and keep overflow-hidden off the elements in between, or the handle is clipped. An invisible strip fills the left margin, so moving the pointer onto the handle keeps the block beside it.

<div className="py-4 pr-4 pl-4 sm:pl-14">
  <MarkdownEditor defaultValue={note.markdown} onChange={save} />
</div>

When the editor sits in a box of its own, like a comment composer or a tinted panel, mark the box with data-markdown-gutter-edge. The handle then hangs just outside the outermost marked ancestor, so the text inside the box needs no indent to make room for it. The handle has a bg-surface-raised fill, so it reads over a tinted page or a dialog's scrim.

<div data-markdown-gutter-edge className="rounded-lg border border-border p-3">
  <MarkdownEditor defaultValue={draft} onChange={setDraft} />
</div>

Drag a block by its grip to move it. When the block lands, or the drag is called off, the caret sits at the end of the block's text, or on the line after a block that has none, such as a page block, so you can keep typing.

Fidelity

Every ProseMirror, Slate, or Lexical editor, Milkdown included, rewrites the whole document through a serializer when it saves. That is where list markers flip, blank lines collapse, and escapes appear in lines nobody edited. This editor saves differently:

  1. Parse stamps a source. Each top-level block and each list item keeps the exact markdown it came from.
  2. Identity decides. ProseMirror keeps the same node object for every subtree an edit did not reach. A block that is still the same object writes its source back, with the blank lines that surrounded it. A list item keys on its content, so ticking a checkbox, which only changes an attribute, rewrites that one line.
  3. Edited blocks render minimally, then prove it. A block you changed renders without defensive escaping, is reparsed, and is compared with the node it came from. If the meaning differs, it renders again with full escaping.

Measured by round-tripping a real vault:

EngineUntouched notes saved byte for byte
Milkdown, with its markdown plugin194 of 361 (54%)
@tiptap/markdown, stock132 of 361 (37%)
This editor558 of 558, the 361 included

Across 14,907 blocks rendered as if edited, 100% reparse to the same content and 97% come back byte for byte. What still changes in a block you edit: redundant escapes like \~ drop, a legacy <br /> line goes, a setext heading becomes #, and a wrapped list item's continuation lines take the list's indent.

The layer reads two internals of @tiptap/markdown (its lexer and its token parser). If a future Tiptap moves them, MarkdownCodec.preserving turns false and the editor falls back to the stock serializer instead of failing. Tiptap carries each block's raw text in its marked tokens, which is what makes the layer possible, and it is React-first and MIT licensed.

Dialect

MarkdownIn the editorSaves as
[[Note]], [[Note|alias]]A chip, the alias when there is one. A link that resolves to nothing reads amber and dashed, with a broken-link icon. The brackets show while the caret is inside.Unchanged text
[[<id>]], [[<id>|Title]] alone on a line, where the id is a UUIDA page block, drawn by pageBlockIts own source
[@Ana Ruiz](mention:u_42)A mention chip with an @ glyphUnchanged text
(due:: 2026-09-28), [key:: value]A chip the caret steps over. Click or press Enter on it to edit.Its own source
^t-4bd9c2, ^block-id at a block's endNothing. The caret steps over it and Backspace does not delete it.Its own source
#tag, #area/tagA tag chipUnchanged text
> [!tip] TitleAn Alert with a title; its icon opens a menu of typesA callout
==text==A highlight==text==
--- with cards onA break between floating cards---
(ai:: model) through (ai-end)A tinted run with a byline: model, tokens, cache, effortUnchanged
(gallery:: bento) then imagesA grid: bento, masonry, or tideUnchanged
(area:: slug)Tints its card in the area's colorUnchanged
YAML frontmatter that opens on a key: lineHidden, kept verbatim. A note that opens with a --- rule and no key: line under it keeps the rule.Byte for byte

Everything in the table except callouts is drawn over unchanged text or over an atom that writes its own source, so none of it can alter the file.

Fields

A field is (key:: value) anywhere in a line. Each one draws as a ChipTrigger at sm, ghost, with the caret on hover, under the picker its kind needs: a Calendar in a Popover for dates, a Menu for choices, and a Popover with an Input for free text. A field that says what state a task is in fills with a category color, mixed the way a CategoryTag is, and plain information stays ghost. Each chip is named for the property and its value, as in "Deadline: Sep 21". Unknown keys draw key: value.

KeyChipEditor
due, startDate, filled amber once a task is past its dueCalendar with Today, Tomorrow, Next week
deadlineFlag and date, filled orange, and red once a task is past itCalendar
repeatThe ruleMenu of daily, weekdays, weekends, weekly, every 2 weeks, monthly
waiting"waiting since" and the date, filled amberCalendar
decide"decide", or "proposal" for ?, filled blueMenu of the options split on |. Picking one adds (decided:: choice · date)
decidedThe choice, filled greenText
dropped"didn't do", filled gray, and the line strikes throughMenu with Reopen task
ai-done"review", filled purpleMenu with Mark reviewed
areaThe label, with the area's color dotText
habitThe label, with a green targetText
ai, ai-end, tokens, cache, effort, galleryHidden, read by the byline and galleryNone

Add or override keys with fields, and take a click over with onFieldClick:

<MarkdownEditor
  fields={{
    estimate: {
      icon: Timer,
      editor: 'text',
      label: (v) => `${v} pts`,
      accent: (v) => (Number(v) > 8 ? { color: 'red', filled: true } : null),
    },
  }}
  onFieldClick={(field) => {
    if (field.key !== 'linear') return false;
    window.open(`https://linear.app/issue/${field.value}`);
    return true;
  }}
/>

accent returns a category color for a value, or null for a plain chip. With filled the chip takes the CategoryTag fill; without it only the icon takes the hue.

content draws the chip's words as any node, for a value that updates live, like a task's current state read from another app, and label stays the chip's accessible name. appearance: 'inline' drops the chip and draws content straight into the line, for a field that stands in for the line itself. An inline field's clicks are yours to handle.

A field typed or pasted as text turns into its chip as soon as the closing bracket lands, the same chip a loaded note draws, so the line saves as written.

Color

The editor paints with the category palette and never with status roles, so a retheme leaves the meaning of each hue alone.

WhereColor
An open task's boxBlue while it has a decide, purple while it waits on review, amber while it is waiting
==highlight==The amber CategoryTag fill
An (ai:: model) runA purple wash, with the byline in purple ink
(area:: slug)The area's own color, as a dot on the chip and a wash on the block
#tagNeutral, or the color tagColor returns

Pass tagColor={hashTagColor} to give every tag a stable hue from its name, so #design is the same color in every note, or your own function to color the tags you care about. hashTagColor leaves out gray, which reads as no color, and red, which reads as an error. It leaves out yellow too, so every tag keeps the hue it has always hashed to.

Tasks

Tasks and bullets share one list item type, with checked null for a bullet, so a list that mixes - [ ] and - stays one list in the file instead of splitting in two.

The box is the kit Checkbox, and ticking it by hand lets a small ink blot soak out behind it. Once the ink settles the task sinks below its siblings, and unticking it within five seconds puts it back where it was. Both moves are real edits, so undo walks them back. Pass sinkCompletedTasks={false} to keep tasks where they are. Loading a note full of done tasks draws nothing, because the blot only follows a click.

A tick also clears the fields that end with the task, (ai-done:: …) and (waiting:: …) by default, in the same step, so one undo brings both back. Pass clearFieldsOnDone to change the list, or [] to keep every field. Ticking with ⌘↵ leaves the task where it is, since the caret is in it. Enter on a task makes a new unchecked task, never a done one, and splits behind the line's trailing fields and id so they stay with the task they belong to.

A task can belong to your app rather than to the note, like a task that lives in a tracker. taskBox receives the line's own (key:: value) fields and returns the box to draw in place of the checkbox, such as a Work mark square, or null to keep the checkbox. The box is yours, and so is the tick: a click on it does what your box does, and ⌘↵ in that line leaves it alone.

<MarkdownEditor
  taskBox={(fields) =>
    fields.task ? (
      <TrackerTaskBox taskId={fields.task} />
    ) : null
  }
/>

Here TrackerTaskBox is your component, say a button around a WorkMark shape="square" that opens the task's status menu.

Slash commands

Type / at the start of a block, or after a space anywhere in one, list items included. Matching is a word prefix over the id, label, and keywords, so /h1, /ul, /todo, and /hr all find their command, and /h never offers something just because "h" is in the middle of a word. Commands that insert a block (divider, callout, code, table, gallery) only appear at a block's start, and the command that would do nothing to the current block is hidden.

Extend the defaults rather than replacing them:

<MarkdownEditor
  slashCommands={(defaults) => [
    ...defaults,
    {
      id: 'standup',
      label: 'Standup notes',
      group: 'Templates',
      keywords: ['meeting', 'daily'],
      icon: UsersThree,
      atStart: true,
      run: ({ editor }) =>
        editor.chain().focus().insertContent('## Yesterday\n\n## Today\n\n## Blocked').run(),
    },
  ]}
/>

turnInto(editor, kind) and blockKindOf(state) are exported for commands that re-type a block.

Every link goes through one kind registry, and a kind decides the chip, its icon, and what a click does. The first kind that claims an href wins:

KindClaimsChipA click
internal[[wikilinks]]bg-selected in text-tone-ink with a file icon, or amber and dashed with a broken-link icon when nothing resolvesCalls onOpenLink
mentionmention: linksbg-selected in text-tone-ink with an @ glyph, and no hoverNothing
YoursWhatever your linkKinds test forYour icon, or a plain linkYour open, or a new tab
codecursor://, vscode://, windsurf://, zed://Muted, with a code icon and ↗Hands off to the editor app
notionnotion.so and notion.com pagesMuted, with the Notion mark and ↗A new tab
webAny other http(s) URLMuted, with a globe and ↗A new tab

Anything no kind claims, like mailto: or a relative path, stays an underlined link. A kind that is not external gets the same brand tint as a note. A chip that opens something shows the pointer cursor, and a mention keeps the default cursor, since a click on it goes nowhere.

const linear: MarkdownLinkKind = {
  id: 'linear',
  label: 'Open in Linear',
  icon: Kanban,
  test: (href) => /^https:\/\/linear\.app\//i.test(href),
  external: true,
  open: (href) => window.open(href.replace(/^https/, 'linear'), '_self'),
};

<MarkdownEditor linkKinds={[linear]} />;

A pasted bare URL chips as a short title read from its path, so cooking.nytimes.com/recipes/1026573-marry-me-salmon reads "Marry Me Salmon", and the file keeps the URL. Since a chip hides its address, hovering one shows the host and the full URL with Copy, Edit, and Unlink. Editing a bare URL rewrites its text too, so it keeps reading as its new title. A plain click on a chip opens it; ⌥-click places the caret, and the caret inside a link shows the same actions from the keyboard.

LinkChip draws the same chip as a component, for a node view that shows a link the way the text does, like a page block's card or a host's task box. It takes the link's kind, href, and dead for the broken style, renders a real <a>, and keeps the browser from following a plain click, so the editor or your own onClick opens it, while a ⌘-click still reaches the browser.

links is the note index for wikilinks and the [[ picker. A bare name resolves to a title or file name, preferring the linking note's folder, then exact case, then the shallowest path, and an index file like AREA.md answers to its folder name.

Mentions

Pass mentions, a list of people, and typing @ opens a picker at the caret. The @ counts at the start of a block or after a space, an opening bracket, or a quote, so an email address never opens it, and it does nothing inside code or a link. The picker matches a name's start first, then the start of any word in the name, then detail, such as an email, and shows up to eight. It closes when nothing matches. Arrow keys move, Enter or Tab picks, and Escape dismisses it until you start a new @. Without mentions there is no picker, and an @ is plain text.

<MarkdownEditor
  mentions={[
    { id: 'u_42', label: 'Ana Ruiz', detail: 'ana@example.com' },
    { id: 'u_57', label: 'Ben Okafor' },
  ]}
/>

Picking a person writes an ordinary markdown link, [@Ana Ruiz](mention:u_42), and a space after it. The chip shows the name with an @ glyph, and the text's own @ shows only while the caret is in the link. A mention has no hover card and no link bar, and a click does nothing, so who was mentioned is yours to act on: read the mention: links out of the saved markdown.

MentionChip draws the same chip outside the editor, for markdown you render read-only, such as a comment thread. Pass the link's text as label, and the leading @ is dropped for the glyph.

import { MentionChip } from '@/components/ui/markdown-editor';

<MentionChip label="@Ana Ruiz" />

Page blocks

A line that holds only a wikilink to a page by its UUID, [[<id>]] or [[<id>|Title]], is a page block: one block that stands for the page, and that saves back byte for byte. Anywhere without the editor, such as another markdown tool, it still reads as a link. pageBlockLine(id, title) writes the line, for a command or a server that inserts one.

pageBlock draws the block. It receives the page's id and title, where the title is what the link was last written with, and returns your card, or null for the editor's own quiet row, the title in a bordered box.

<MarkdownEditor
  pageBlock={(page) => <PageCard id={page.id} fallbackTitle={page.title} />}
/>

PageCard is yours, such as an Item with the page's live title and its opening lines, read from your store by id.

The card is the drag handle, so grabbing it anywhere moves the block, while a link or a button inside it keeps its own click. Links inside a page block drop the editor's link style, and its paragraphs lose their margins, so a card built from kit parts looks the same in the note as anywhere else. The block is drawn in font-sans, and a selected block shows a 2px ring.

A page block line typed or pasted as text stays a wikilink until the note loads again.

Cards

With cards on, each --- rule becomes the gap between two floating cards, and hovering a card shows a rail with a drag handle, Add card below, Copy, and Delete. Dragging the handle dims the card and draws a line in the gap where it will land, and letting go moves it in one step that undo reverses; Escape cancels. ⌥⇧↑ and ⌥⇧↓ move the card at the caret the same way. A moved card keeps its blocks and its rule, so the file changes only where the cards swapped. Copy puts the card's markdown on the clipboard, with untouched blocks byte for byte. Add your own actions above them with cardActions, and call flashCard(index) on the ref to scroll to a card and highlight it. splitCards counts cards the same way, skipping runs that are only blank lines, so an ordinal from a server matches the editor's.

Images and galleries

Pass onUploadImage and images can be pasted, dropped, or added with /gallery. Each batch lands as a gallery paragraph, (gallery:: bento) ![](…) ![](…), and the bar above a gallery switches its layout between bento, masonry, and tide.

Keyboard

KeysDoes
/Slash menu. ↑ ↓ move, Enter or Tab runs, Esc dismisses.
[[Note picker, when links has entries
@Mention picker, when mentions has entries. ↑ ↓ move, Enter or Tab picks, Esc dismisses.
Backspace at a block's startA heading becomes text, a list item steps out one level, a quote's first line leaves the quote. Only a plain paragraph merges up.
Delete at a block's endPulls the next block's text up, text only, so containers never change type
Enter in a taskA new unchecked task
Tab, ⇧TabIndent and outdent the list item. ⇧Tab moves only the item under the caret.
⌥↑, ⌥↓Move the block or list item
⌥⇧↑, ⌥⇧↓Move the card, with cards on
⌘↵Tick the task, or make the block a task. Does nothing in a task whose box taskBox draws.
Enter on a selected chipOpen its editor
⌘B, ⌘I, ⌘E, ⌘⇧S, ⌘⇧HBold, italic, code, strikethrough, highlight

Ref

type MarkdownEditorHandle = {
  getMarkdown: () => string;
  setMarkdown: (markdown: string) => void;
  focus: () => void;
  flashCard: (index: number) => void;
  editor: Editor | null;
};

Props

PropTypeDefaultWhat it does
defaultValuestring""Markdown to load, frontmatter included
documentIdstringA change reloads defaultValue. Wikilinks resolve from its folder.
onChange(markdown) => voidThe document after a real edit
onBaseline(markdown) => voidWhat loaded, as the editor reads it. Never a write.
linksMarkdownLinkEntry[][]Notes for wikilinks and the [[ picker
onOpenLink(entry, target) => voidA wikilink was clicked; entry is null when it resolves to nothing
mentionsMarkdownMentionEntry[][]People @ can mention, as { id, label, detail? }. No @ picker without them.
linkKindsMarkdownLinkKind[]Extra link kinds, tried after wikilinks and mentions and before the built-in hosts
slashCommandscommands or (defaults) => commandsthe defaultsReplace or extend the slash menu
fieldsRecord<string, MarkdownFieldSpec>Field chips, merged over the defaults
onFieldClick(field) => booleanReturn true to handle a chip click yourself
areasRecord<string, { label, color }>Labels and categorical colors for (area:: slug)
tagColor(tag) => CategoryColor | nullA color per #tag. hashTagColor hashes the name to a hue.
cardsbooleanfalseDraw --- as card breaks, with the card rail
cardActionsMarkdownCardAction[]Extra actions on the card rail
sinkCompletedTasksbooleantrueA ticked task sinks below its siblings
clearFieldsOnDonestring[]["ai-done", "waiting"]Fields a tick removes from the task's line
taskBox(fields) => ReactNode | nullDraws the box for a task line your app owns, from the line's fields. Null keeps the checkbox.
pageBlock(page: { id, title }) => ReactNode | nullDraws a page block as your card. Null keeps the quiet row.
onUploadImage(file) => Promise<string>Turns on image paste, drop, and galleries
placeholderstring"Write something, or press / for commands"Shown in the empty block at the caret
readOnlybooleanfalseLinks still open; nothing edits
autoFocusbooleanfalseFocus the end of the document on load
aria-labelstring"Document"The accessible name of the textbox
classNamestringOn the editor's root, which has no horizontal padding
refRef<MarkdownEditorHandle>The handle in Ref

On a server

The dialect helpers and the schema carry no React, so a server can read and write the same markdown the editor does. Import from the module files rather than the client entry:

import { createMarkdownCodec } from '@/components/ui/markdown-editor/fidelity';
import { splitFrontmatter } from '@/components/ui/markdown-editor/dialect';

const codec = createMarkdownCodec();
const { frontmatter, body } = splitFrontmatter(file);
const doc = codec.load(body);
frontmatter + codec.serialize(doc) === file; // untouched, byte for byte

parseTaskLine, buildTaskLine, readInlineFields, splitTitle, composeBody, and headingsOf cover the rest of the dialect as plain string work. pageBlockLine in schema writes a page block's line, and links has mentionHref and isMentionHref for writing and reading mention links.

Accessibility

  • The document is a textbox with aria-multiline. While the slash menu, the note picker, or the mention picker is open it carries aria-expanded, aria-controls, and aria-activedescendant pointing at the active option, so focus never leaves the text. The mention picker is a listbox named "Mention".
  • A task's checkbox is role="checkbox" with aria-checked and a name that says what a click will do. It is not a tab stop, which keeps the document a single stop; ⌘↵ is the keyboard path.
  • A field chip is a button inside an atom. Arrow onto it and press Enter to open its editor, which takes focus and returns it to the text when it closes.
  • The block handle and the card rail are pointer-only, aria-hidden and out of the tab order. Every action they offer has a keyboard path above. They follow the mouse, so a touch screen never shows them.
  • The ink blot, the sink slide, and the card flash all respect prefers-reduced-motion.
  • The link hover card is for a mouse. Its Copy, Edit, and Unlink are also in the bar that shows when the caret is inside a link. A mention shows neither.
  • A mention reads as the person's name. MentionChip hides its @ icon from assistive tech, and in the editor the text's @ is hidden until the caret reaches the link.
  • A page block is one unit for the caret, which selects it whole, and your card's links and buttons keep their own clicks. A LinkChip is a real link.
  • A box from taskBox replaces the checkbox, so its name, role, and keyboard path are yours.
  • Known gap: a dead wikilink is marked by color, a dashed border, and an icon, with its state in title rather than in accessible text.

When to use something else

  • Block editor when the document is a structured JSON tree, like an email built from sections, images, and buttons. Its output is compiled HTML, where this editor's output is the markdown file.
  • Textarea for plain text that needs no formatting, links, or tasks. It has no parser, so there is nothing to round trip, and it is the lighter choice for a comment box.
  • A rich-text editor such as Tiptap on its own, or Milkdown, when the saved format is HTML or ProseMirror JSON and byte-exact markdown does not matter. This editor is Tiptap with a fidelity layer, and the layer is only worth its weight when the file has to come back as it went in.

Install

shadcn add @gesso/markdown-editor

It brings the Tiptap packages and the kit items its chrome is built from, among them Checkbox, Alert, ChipTrigger, and Calendar.

On this page