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
---
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:
- Parse stamps a source. Each top-level block and each list item keeps the exact markdown it came from.
- 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.
- 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:
| Engine | Untouched notes saved byte for byte |
|---|---|
| Milkdown, with its markdown plugin | 194 of 361 (54%) |
@tiptap/markdown, stock | 132 of 361 (37%) |
| This editor | 558 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
| Markdown | In the editor | Saves 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 UUID | A page block, drawn by pageBlock | Its own source |
[@Ana Ruiz](mention:u_42) | A mention chip with an @ glyph | Unchanged 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 end | Nothing. The caret steps over it and Backspace does not delete it. | Its own source |
#tag, #area/tag | A tag chip | Unchanged text |
> [!tip] Title | An Alert with a title; its icon opens a menu of types | A callout |
==text== | A highlight | ==text== |
--- with cards on | A break between floating cards | --- |
(ai:: model) through (ai-end) | A tinted run with a byline: model, tokens, cache, effort | Unchanged |
(gallery:: bento) then images | A grid: bento, masonry, or tide | Unchanged |
(area:: slug) | Tints its card in the area's color | Unchanged |
YAML frontmatter that opens on a key: line | Hidden, 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.
| Key | Chip | Editor |
|---|---|---|
due, start | Date, filled amber once a task is past its due | Calendar with Today, Tomorrow, Next week |
deadline | Flag and date, filled orange, and red once a task is past it | Calendar |
repeat | The rule | Menu of daily, weekdays, weekends, weekly, every 2 weeks, monthly |
waiting | "waiting since" and the date, filled amber | Calendar |
decide | "decide", or "proposal" for ?, filled blue | Menu of the options split on |. Picking one adds (decided:: choice · date) |
decided | The choice, filled green | Text |
dropped | "didn't do", filled gray, and the line strikes through | Menu with Reopen task |
ai-done | "review", filled purple | Menu with Mark reviewed |
area | The label, with the area's color dot | Text |
habit | The label, with a green target | Text |
ai, ai-end, tokens, cache, effort, gallery | Hidden, read by the byline and gallery | None |
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.
| Where | Color |
|---|---|
| An open task's box | Blue while it has a decide, purple while it waits on review, amber while it is waiting |
==highlight== | The amber CategoryTag fill |
An (ai:: model) run | A 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 |
#tag | Neutral, 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.
Links
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:
| Kind | Claims | Chip | A click |
|---|---|---|---|
internal | [[wikilinks]] | bg-selected in text-tone-ink with a file icon, or amber and dashed with a broken-link icon when nothing resolves | Calls onOpenLink |
mention | mention: links | bg-selected in text-tone-ink with an @ glyph, and no hover | Nothing |
| Yours | Whatever your linkKinds test for | Your icon, or a plain link | Your open, or a new tab |
code | cursor://, vscode://, windsurf://, zed:// | Muted, with a code icon and ↗ | Hands off to the editor app |
notion | notion.so and notion.com pages | Muted, with the Notion mark and ↗ | A new tab |
web | Any other http(s) URL | Muted, 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
| Keys | Does |
|---|---|
/ | 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 start | A 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 end | Pulls the next block's text up, text only, so containers never change type |
| Enter in a task | A new unchecked task |
| Tab, ⇧Tab | Indent 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 chip | Open its editor |
| ⌘B, ⌘I, ⌘E, ⌘⇧S, ⌘⇧H | Bold, italic, code, strikethrough, highlight |
Ref
type MarkdownEditorHandle = {
getMarkdown: () => string;
setMarkdown: (markdown: string) => void;
focus: () => void;
flashCard: (index: number) => void;
editor: Editor | null;
};Props
| Prop | Type | Default | What it does |
|---|---|---|---|
defaultValue | string | "" | Markdown to load, frontmatter included |
documentId | string | A change reloads defaultValue. Wikilinks resolve from its folder. | |
onChange | (markdown) => void | The document after a real edit | |
onBaseline | (markdown) => void | What loaded, as the editor reads it. Never a write. | |
links | MarkdownLinkEntry[] | [] | Notes for wikilinks and the [[ picker |
onOpenLink | (entry, target) => void | A wikilink was clicked; entry is null when it resolves to nothing | |
mentions | MarkdownMentionEntry[] | [] | People @ can mention, as { id, label, detail? }. No @ picker without them. |
linkKinds | MarkdownLinkKind[] | Extra link kinds, tried after wikilinks and mentions and before the built-in hosts | |
slashCommands | commands or (defaults) => commands | the defaults | Replace or extend the slash menu |
fields | Record<string, MarkdownFieldSpec> | Field chips, merged over the defaults | |
onFieldClick | (field) => boolean | Return true to handle a chip click yourself | |
areas | Record<string, { label, color }> | Labels and categorical colors for (area:: slug) | |
tagColor | (tag) => CategoryColor | null | A color per #tag. hashTagColor hashes the name to a hue. | |
cards | boolean | false | Draw --- as card breaks, with the card rail |
cardActions | MarkdownCardAction[] | Extra actions on the card rail | |
sinkCompletedTasks | boolean | true | A ticked task sinks below its siblings |
clearFieldsOnDone | string[] | ["ai-done", "waiting"] | Fields a tick removes from the task's line |
taskBox | (fields) => ReactNode | null | Draws the box for a task line your app owns, from the line's fields. Null keeps the checkbox. | |
pageBlock | (page: { id, title }) => ReactNode | null | Draws a page block as your card. Null keeps the quiet row. | |
onUploadImage | (file) => Promise<string> | Turns on image paste, drop, and galleries | |
placeholder | string | "Write something, or press / for commands" | Shown in the empty block at the caret |
readOnly | boolean | false | Links still open; nothing edits |
autoFocus | boolean | false | Focus the end of the document on load |
aria-label | string | "Document" | The accessible name of the textbox |
className | string | On the editor's root, which has no horizontal padding | |
ref | Ref<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 byteparseTaskLine, 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
textboxwitharia-multiline. While the slash menu, the note picker, or the mention picker is open it carriesaria-expanded,aria-controls, andaria-activedescendantpointing at the active option, so focus never leaves the text. The mention picker is alistboxnamed "Mention". - A task's checkbox is
role="checkbox"witharia-checkedand 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-hiddenand 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.
MentionChiphides 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
LinkChipis a real link. - A box from
taskBoxreplaces 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
titlerather 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-editorIt brings the Tiptap packages and the kit items its chrome is built from, among them Checkbox, Alert, ChipTrigger, and Calendar.