Headless editing
'use client'
import {
useMarkdownEditor,
MarkdownEditorProvider,
MarkdownEditorContent,
} from '@react-markdown-kit/editor'
export function Composer({ value, onChange }) {
const editor = useMarkdownEditor({ value, onChange })
return (
<MarkdownEditorProvider editor={editor}>
<MyToolbar />
<MarkdownEditorContent aria-label="Message" />
<MyStatusBar />
</MarkdownEditorProvider>
)
}<MarkdownEditor> is a thin component built from these same pieces. The default chrome doesn't use anything private, so anything it does you can do too.
Use this when the editor has to sit inside your own layout, for example in a CMS, a report builder, a chat composer or a panel in a bigger admin page.
The four exports#
useMarkdownEditor(options) owns the value, the mode, the document identity and the parsing. It renders nothing and returns an editor instance.
It takes the same options as <MarkdownEditor> minus the chrome: value, defaultValue, onChange, mode, defaultMode, onModeChange, preset, extensions, readOnly, documentKey, onUploadImage and onDiagnostics.
MarkdownEditorProvider puts the instance in context.
MarkdownEditorContent renders the surface for the active mode. It accepts aria-label, aria-labelledby and aria-describedby, plus an editor prop if you'd rather skip the provider.
useMarkdownEditorContext() reads the instance from context. Outside a provider it throws a named error instead of returning null. useOptionalMarkdownEditorContext() returns null there instead, for chrome that takes an editor prop as well, the way <MarkdownEditorContent> does.
The editor instance#
interface MarkdownEditorInstance {
getMarkdown(): string
getDocument(): MarkdownDocument
focus(): void
blur(): void
undo(): void
redo(): void
setMode(mode: MarkdownEditorMode): void
readonly mode: MarkdownEditorMode
readonly commands: MarkdownEditorCommands
readonly diagnostics: readonly MarkdownDiagnostic[]
readonly readOnly: boolean
getNativeEditor(): unknown
}getMarkdown() returns the current source. getDocument() returns the compiled MarkdownDocument, which you can pass straight to the renderer.
Commands#
Every button in the default toolbar calls one of these.
interface MarkdownEditorCommands {
toggleMark(mark: 'strong' | 'emphasis' | 'delete' | 'code'): void
setBlockType(type: 'paragraph' | 'heading1' | 'blockquote' | 'codeBlock' | ...): void
toggleBulletList(): void
toggleOrderedList(): void
toggleTaskList(): void
insertLink(url: string, options?: { title?: string; text?: string }): void
removeLink(): void
insertImage(image: { src: string; alt?: string; title?: string }): void
insertThematicBreak(): void
insertMarkdown(source: string): void
setMarkdown(source: string): void
uploadImage(file: File): Promise<void>
}insertMarkdown parses with the active preset and inserts at the selection. That's what you'd use for a slash command or a snippet menu that inserts a table.
setMarkdown replaces the whole document.
A custom toolbar#
'use client'
import { useMarkdownEditorContext } from '@react-markdown-kit/editor'
import { Button, ButtonGroup } from './my-design-system'
function MyToolbar() {
const editor = useMarkdownEditorContext()
const { commands } = editor
return (
<ButtonGroup aria-label="Formatting">
<Button onClick={() => commands.toggleMark('strong')}>Bold</Button>
<Button onClick={() => commands.toggleMark('emphasis')}>Italic</Button>
<Button onClick={() => commands.setBlockType('heading2')}>Heading</Button>
<Button onClick={() => commands.toggleBulletList()}>List</Button>
<Button onClick={() => commands.insertMarkdown('\n| a | b |\n| - | - |\n| 1 | 2 |\n')}>
Table
</Button>
<Button onClick={() => editor.undo()}>Undo</Button>
<Button onClick={() => editor.setMode('source')}>Markdown</Button>
</ButtonGroup>
)
}None of these components come from this package, so use whatever component library you already have.
Active state#
A custom toolbar built on commands can run every command, but you don't get active-button highlighting with it.
If you want highlighting without writing your own selection listener, use the toolbar render prop on <MarkdownEditor>. Each item comes with active, disabled, label, icon, group and run(), and you supply the markup.
<MarkdownEditor
value={value}
onChange={setValue}
toolbar={(items) =>
items.map((item) => (
<Button key={item.id} onClick={item.run} pressed={item.active} disabled={item.disabled}>
{item.icon}
{item.label}
</Button>
))
}
/>You keep your own markup and only lose the default container.
A status bar#
function MyStatusBar() {
const editor = useMarkdownEditorContext()
return (
<footer>
<span>{editor.getMarkdown().length} characters</span>
{editor.diagnostics.length > 0 ? <span>{editor.diagnostics.length} notes</span> : null}
</footer>
)
}getDocument() gives you the tree, so building a table of contents is mostly a walk over document.tree.children looking for heading nodes.
No Lexical types required#
Lexical is what the rich surface uses internally, but it isn't part of the public API.
Nothing this package exports needs a Lexical type. You can type every prop, handler and the instance itself without lexical in your type path.
A test asserts that src/index.ts and src/types.ts import nothing from lexical or @lexical/* (packages/editor/tests/styling.test.tsx). The opt-in @react-markdown-kit/editor/lexical entry is the exception, for extension authors.
There's one escape hatch, on purpose:
const native = editor.getNativeEditor()It returns unknown so Lexical doesn't leak into your types by accident. You cast it yourself.
getNativeEditor() has weaker stability guarantees than the rest of the instance. We might replace the editing engine in a future major version, and that wouldn't count as a breaking change to the rest of the API.
Only use it for things the public commands can't do, and expect to revisit that code on a major upgrade. If you find you need it a lot, please report it as a gap in commands.
Headless without React#
There's also a lower level with no React and no DOM.
import { createMarkdownBridge } from '@react-markdown-kit/editor'
const bridge = createMarkdownBridge({ preset: appMarkdown, headless: true })
bridge.load(source)
const out = bridge.getMarkdown()This is the same pipeline the React editor runs, which makes it handy on a server, in a worker or in tests. A round trip that passes here also holds in the browser, and that's how the preservation gate is tested. See Round-trip preservation.
Related#
The ready-made component and its props are covered in Editor basics.
Styling the surface and your own chrome is covered in Styling.
Read next#
The three modes and a round-trip check you can run on your own Markdown: Editor demo.
Last updated on