@react-markdown-kit/editor
v0.2.0
Published
Rich, source and preview Markdown editing for React. Markdown in, Markdown out.
Maintainers
Readme
@react-markdown-kit/editor
A React Markdown editor with rich, source and preview modes. Markdown in, Markdown out. Built on Lexical, but Lexical never appears in the public types. Open a document and close it without typing, and the file is unchanged.
npm install @react-markdown-kit/editor @react-markdown-kit/renderer'use client'
import { useState } from 'react'
import { MarkdownEditor } from '@react-markdown-kit/editor'
export function Notes() {
const [value, setValue] = useState('# Notes\n\nStart writing.')
return <MarkdownEditor value={value} onChange={setValue} />
}Your application keeps storing Markdown strings. Nothing asks you to persist editor JSON or a proprietary format.
Links
- Docs: Editor basics
- Demo: reactmarkdownkit.com/markdown-editor
- Round-trip guarantee: the docs page,
backed by
packages/editor/tests/roundtrip.test.tsandtests/roundtrip.test.ts - Source: github.com/mahuzedada/react-markdown-kit
The round trip is the point
A Markdown editor that parses into an editing model and serializes back out can damage the document on every save, and the damage compounds.
This editor converts between a real CommonMark syntax tree and the editing model, then writes unchanged blocks back from their original source bytes. Opening a document and closing it changes nothing.
The test corpus comes from
docs/AUDIT.md,
an audit of a prior editor with seven reproducible corruption bugs, expanded to
22 cases. All 22 round-trip byte for byte through the editor bridge, each
asserted twice: once with GitHub Flavored Markdown and once with plain
CommonMark
(packages/editor/tests/roundtrip.test.ts).
A second suite,
tests/roundtrip.test.ts,
checks the serializer path with no source bytes to fall back on: 22 of 22 keep
their meaning, 22 of 22 are idempotent, 14 of 22 are byte-identical, and the
14 may not shrink.
> a blockquote with a blank line unchanged
>
> b
C:\path\to Windows paths unchanged
~~~js tilde fences unchanged
[t][ref] reference links unchanged
<div>…</div> HTML blocks unchanged
3. three explicit list numbering unchangedUnsupported syntax is preserved, not flattened. Anything the editor has no rich representation for becomes an opaque block that keeps its exact source slice. Editing the paragraph next to it cannot touch it, and moving it around keeps it intact.
Three modes
<MarkdownEditor mode={mode} onModeChange={setMode} />rich for WYSIWYG, source for raw Markdown, preview for read-only output.
Preview delegates to @react-markdown-kit/renderer rather than shipping a
second renderer, so what you preview is what your application renders.
Typing Markdown in rich mode
Markdown typed on the rich surface turns into the block or format it names.
| Type | Result |
|---|---|
| # to ###### | Heading |
| - , * , + | Bullet list |
| 1. | Ordered list, starting at the typed number |
| [ ] , [x] | Task item, also at the start of a bullet item |
| > | Blockquote |
| ```ts then space or Enter | Code block with that language |
| ---, ***, ___ then Enter | Thematic break |
| \| a \| b \| then Enter | Table with that header; later row lines join it, and \| :-- \| --: \| sets alignment |
| **bold**, *em*, `code`, ~~strike~~ | Inline formats, on the closing character |
| [text](url),  | Link or image, on the closing ) |
Undo after a shortcut gives back the typed text. Only a typed character
triggers one: loading, pasting, deleting or undoing never does. Nothing
converts inside a code block, and tables, task items and ~~ stay as typed in a CommonMark
document. In a list, Enter starts the next item, Enter on an empty item leaves
the list, and Backspace at the start of an item removes its checkbox and then
the bullet. In a blockquote, Enter on an empty last line leaves the quote.
Controlled or uncontrolled
<MarkdownEditor value={value} onChange={setValue} />
<MarkdownEditor defaultValue="# Hello" onChange={handleChange} />
<MarkdownEditor />An echoed value does not reset selection or undo history. To replace the
document deliberately, change documentKey:
<MarkdownEditor documentKey={note.id} value={note.markdown} onChange={setMarkdown} />Build your own interface
The default editor ships a toolbar so it is useful immediately. When you need your own, keep the engine and replace the chrome:
const editor = useMarkdownEditor({ value, onChange, preset })
<MarkdownEditorProvider editor={editor}>
<MyToolbar />
<MarkdownEditorContent />
<MyStatusBar />
</MarkdownEditorProvider>The instance exposes getMarkdown(), getDocument(), focus(), undo(),
redo(), setMode() and commands. No Lexical type appears in the public
types, so your components never import the engine. getNativeEditor() is
available as an escape hatch and is explicitly implementation-coupled with
weaker stability guarantees.
Images
The application owns storage. The editor owns the interaction.
<MarkdownEditor
onUploadImage={async (file, context) => {
const asset = await upload(file, context.signal)
return { src: asset.url, alt: asset.alt ?? '' }
}}
/>A blob: URL is never written into the document.
Shared dialect
Define what Markdown means once, and use it in the renderer, the editor and the variables plugin:
import { defineMarkdownPreset, gfm } from '@react-markdown-kit/renderer'
export const appMarkdown = defineMarkdownPreset({ extensions: [gfm()] })<MarkdownEditor preset={appMarkdown} value={value} onChange={setValue} />Styling
No styling dependency: no Tailwind, no design system, no icon package. Icons are inline SVG.
The editor is fully functional with no stylesheet loaded.
packages/editor/tests/styling.test.tsx
edits, switches mode and serializes with no CSS in the document. The optional
theme is opt-in and scoped:
import '@react-markdown-kit/editor/styles.css'.rmk-editor { --rmk-border: #cfe0dd; --rmk-radius: 8px; }Or pass your own classes per part, which replace rather than merge:
<MarkdownEditor
classNames={{
root: 'rounded-lg border border-slate-200',
toolbar: 'flex gap-1 border-b p-2',
content: 'min-h-64 p-4',
}}
/>Full contract in
docs/STYLING.md.
Headless, without React
The same bridge the React editor uses, for a server or a script:
import { createMarkdownBridge, serializeDocument } from '@react-markdown-kit/editor'Exports
| Export | Purpose |
| --- | --- |
| MarkdownEditor (also default) | The batteries-included editor |
| useMarkdownEditor | Headless instance |
| MarkdownEditorProvider | Context for composable layouts |
| MarkdownEditorContent | The editable surface |
| useMarkdownEditorContext | Read the instance from context |
| useOptionalMarkdownEditorContext | The same, null outside a provider, for chrome that also takes an editor prop |
| createMarkdownBridge, serializeDocument | Markdown in and out, no React |
Extensions that bring their own block
The root entry never names Lexical. An extension that needs its own editor node imports the one entry that does:
import { lexicalAdapter, type EditorBlockAdapter } from '@react-markdown-kit/editor/lexical'
export function myBlocks(): MarkdownExtension {
return {
name: 'my-blocks',
capabilities: {
syntax: { nodeTypes: ['myBlock'], transform, toMarkdownExtensions },
editor: lexicalAdapter({
nodes: [MyBlockNode],
blocks: [adapter], // mdast `myBlock` <-> MyBlockNode, and back
plugins: [registerInsert], // (editor) => unregister
commands: [{ id: 'myBlock', label: 'Insert block', icon, run }],
}),
},
}
}A block adapter's $export returns the original source while the block is
untouched, so the writer hands back those exact bytes; once edited it returns
null and the block serializes through the extension's toMarkdownExtensions.
An inline adapter (inlines) does the same for an inline node such as a
chip; its $export returns the mdast plus its Markdown spelling, which is the
node's identity for the writer. A decorator component reaches the engine with
useLexicalEditor(). @react-markdown-kit/mermaid/editor (a block),
@react-markdown-kit/slides/editor (three blocks and an Enter shortcut) and
@react-markdown-kit/variables/editor (an inline) are the shipped examples.
Plugins
Variables, Mermaid flowcharts and slides are separate packages, used only through extensions:
import { variableChips } from '@react-markdown-kit/variables/editor'
import { mermaid } from '@react-markdown-kit/mermaid/editor'
import { slides } from '@react-markdown-kit/slides/editor'
<MarkdownEditor
value={source}
onChange={setSource}
extensions={[variableChips({ previewData: sample }), mermaid(), slides()]}
/>Every {{placeholder}} becomes a chip with a sample-data preview (never
written back to the source), a ```mermaid flowchart opens on a
drawing canvas, and a deck gets numbered slide breaks, ??? / -- dividers,
directive chips, four toolbar buttons and a Preview that is the interactive
deck. See each package's README:
variables,
mermaid,
slides.
License
MIT
