Editor
import { MarkdownEditor } from '@zuilib/text-editor'
<MarkdownEditor value={value} onValueChange={setValue} mode="edit-rich" outline />
Props
MarkdownEditorProps:
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | Markdown source (controlled). Omit for an uncontrolled editor | |
defaultValue | string | '' | Initial markdown of an uncontrolled editor |
onValueChange | (value: string) => void | Called on every edit with the new markdown | |
debounceMs | number | 0 | Milliseconds after the last edit before onValueChange fires; the document is never delayed, and a pending call is flushed when the editor unmounts |
mode | 'edit-rich' | 'edit-raw' | 'view' | 'edit-rich' | Editing surface, see below |
placeholder | string | labels.placeholder | Empty-state hint |
readOnly | boolean | false | Disables editing in the Lexical modes |
autoFocus | boolean | false | Focus on mount |
className | string | Root wrapper class | |
toolbar | boolean | (items) => ReactNode | true | Formatting / insert toolbar in edit-rich; the function form customises it (Toolbar) |
outline | boolean | false | Table-of-contents sidebar |
collapsible | boolean | true | Collapse sections under headings |
maxTextWidth | string | Max width of the text column as a CSS length, '48rem' recommended (Layout) | |
newBlockWidth | { table?, drawing? } | BlockWidth written into tables / drawings the toolbar inserts (Layout) | |
drawingStyle | 'clean' | 'ink' | 'clean' | Diagram rendering style (Diagrams) |
onError | (error: Error) => void | console.error | Called when Lexical throws inside an update |
nodes | Klass<LexicalNode>[] | Extra node classes (Extending); mount-time only | |
transformers | Transformer[] | (defaults) => Transformer[] | Extra markdown transformers; mount-time only | |
nodeClassNames | EditorThemeClasses | Class names for the rendered nodes, merged over the built-ins; mount-time only | |
onEditorReady | (editor: LexicalEditor) => void | The Lexical instance, once it exists | |
labels | DeepPartial<EditorLabels> | English | Every chrome string (Localisation) |
maxLength | number | Plain-text limit; input past it is rejected (Form integration) | |
onCharacterCountChange | (length: number) => void | Plain-text length after every change and once on mount | |
showCharacterCount | boolean | false | Character counter under the document |
id, name, aria-label, aria-labelledby, aria-describedby, aria-invalid, aria-required | Forwarded to the editable surface | ||
children | ReactNode | Extra plugins or UI under the root |
Modes
mode | UI | Notes |
|---|---|---|
'edit-rich' | Lexical rich editor | Shortcuts, toolbar, tables, canvas |
'edit-raw' | Plain <textarea> | Direct markdown source editing |
'view' | Read-only render | Outline and folding still work; the canvas renders read-only |
All three are driven by the same value string, so switching modes is a
prop change. Switching from edit-raw to edit-rich re-imports the latest
text. Without a value the editor is uncontrolled (defaultValue seeds
it) and both surfaces keep what was typed.
The controlled value
value is the source of truth. On every keystroke the editor serialises
the document back to markdown and calls onValueChange with the full string;
store it and pass it back in. A value that differs from what the editor
last emitted replaces the document, so loading a file or resetting to ''
is a prop change; passing back the string just emitted is a no-op and
keeps the caret. A value equal to one of the last eight values the
editor emitted within the last second is treated as a stale echo of a slow
state round trip and ignored, so it cannot stomp what has been typed
since; an older value (a "discard changes" button) is applied. Because
onValueChange runs per edit, keep the React state update synchronous and
either debounce persistence behind it or set debounceMs (the
document itself is never delayed). Nothing is kept in the editor that the
markdown cannot express:
| Content | Markdown |
|---|---|
| Headings, lists, quotes, links, emphasis | CommonMark |
| Checklists | - [ ] / - [x] |
| Tables | GFM pipe tables; non-default settings as <!-- width: …; density: … --> on the line above (Tables) |
| Fenced code | ```lang fences; untagged blocks export as bare fences (Code blocks) |
| Diagrams | ```drawing fenced JSON, or a ```diagram skeleton on input (Diagrams) |
| Frontmatter | A leading --- block, kept verbatim |
| Horizontal rule | --- (also *** / ___ on input); a rule that opens the document is written as *** |
| Footnotes | GFM [^id] cues and [^id]: text notes, notes gathered at the end in numbering order (Footnotes) |
Markdown shortcuts
In edit-rich mode the usual markdown shortcuts apply while typing:
| Type | Result |
|---|---|
# … ###### | Heading 1–6 |
- or * | Bullet list |
1. | Numbered list |
- [ ] | Checklist item |
> | Blockquote |
``` (optionally with a language) then Enter | Fenced code block |
| a | b | | Table |
**bold**, *italic*, ~~strike~~, `code` | Inline formatting |
[text](url) | Link |
--- then space or Enter | Horizontal rule |
[^id] after some text | Footnote cue (the note is created when you click the cue) |
[^id]: at the start of a paragraph | Footnote note, moved to the notes section |
Outline and folding
outline docks a collapsible table-of-contents sidebar: a live heading
list indented by level, click to scroll, current section highlighted. It is
pure UI, leaves the document untouched, and is available in edit-rich and view.
<MarkdownEditor value={value} onValueChange={setValue} outline />
collapsible (default on) shows a chevron in the gutter of each heading on
hover; clicking it collapses the section until the next heading of the same
or higher level. Folding never changes the markdown, fold state resets on
remount, and a folded section expands again if the caret enters it.
Below a 40rem pane the outline becomes a collapsible strip above the
document; on a touch screen the fold chevrons are always visible.
Frontmatter
A document that starts with a --- block keeps it as a frontmatter block:
rendered as a distinct block at the top, editable, and emitted verbatim.
---
title: Quarterly review
status: draft
---
# Quarterly review
Horizontal rules and footnotes
--- on a line of its own is a horizontal rule: click it to select,
Backspace to remove. The toolbar button and insertHorizontalRule() add
one after the current block.
Footnotes follow GFM. [^id] in the text is a cue, rendered as a
superscript number in first-reference order (the id is never shown);
[^id]: text is a note. Notes live in one section that is always the last
block of the document, sorted into numbering order, with further lines of a
note as four-space continuation lines; a note nothing cites stays, shown
without a number. The toolbar button, insertFootnote() or
INSERT_FOOTNOTE_COMMAND insert a cue and an empty note and put the caret
in the note. Click a cue (or press Enter on a selected cue) to go to its
note; Escape, or the note's ↩, brings the caret back after the cue. Enter
inside a note adds a line; Backspace on an empty note removes it.
Form integration
The editor is a controlled input with the attributes a form field needs.
id and the aria-* props land on the editable surface (the
contenteditable, or the textarea in edit-raw); name renders a hidden
input carrying the markdown so a native form post includes it. Keyboard
focus shows a ring (--ring, then --primary); aria-invalid adds an
inset outline in --danger.
import FormField from '@zuilib/primitives/form-field'
import Label from '@zuilib/primitives/label'
import FieldDescription from '@zuilib/primitives/field-description'
import FieldError from '@zuilib/primitives/field-error'
<FormField control={form.control} name="body">
{({ field, error, ids }) => (
<>
<Label htmlFor={ids.control}>Body</Label>
<MarkdownEditor
id={ids.control}
name={field.name}
value={field.value}
onValueChange={field.onChange}
aria-describedby={ids.description}
aria-invalid={Boolean(error)}
aria-required
maxLength={2000}
showCharacterCount
/>
<FieldDescription id={ids.description}>Markdown, 2000 characters max.</FieldDescription>
<FieldError>{error?.message}</FieldError>
</>
)}
</FormField>
FormField wraps its children in a Field, so
the label, description and error are associated with the field
automatically; the explicit ids wire the editor's own surface in.
maxLength counts the document's plain text (in edit-raw, the
textarea's own maxLength counts the markdown). An edit that would pass
the limit is rejected at the caret; a controlled value is never
trimmed. onCharacterCountChange reports the length, showCharacterCount renders
length / max (or MarkdownEditor.CharacterCount where you want it), and
labels.count formats it. The visible count is not a live region; a
hidden status announces labels.limitExceeded(max) /
labels.limitRestored(max) only when the document crosses the limit.
Extending
Three props open the composer up; children of the root are plugins and share the editor instance.
<MarkdownEditor
value={value}
onValueChange={setValue}
nodes={[MentionNode]}
transformers={[MENTION]}
nodeClassNames={{ mention: 'zui-mention' }}
onEditorReady={(editor) => (editorRef.current = editor)}
>
<MentionPlugin />
</MarkdownEditor>
| Prop | Behaviour |
|---|---|
nodes | Appended to the built-in node list. Lexical registers node classes when the composer is created, so the prop is read on mount only; change the element's key to remount with another set |
transformers | An array goes before the built-ins, so it claims its syntax first on import and export. A function receives the built-in list (DRAWING_PRESET_TRANSFORMERS, or MARKDOWN_TRANSFORMERS on ./markdown, both on @zuilib/text-editor/lexical) and returns the whole list. Mount-time only. Transformers that are not multiline-element (and not CHECK_LIST) also act as typing shortcuts |
children | Any component under the root that calls useMarkdownEditor() or useLexicalComposerContext() is a plugin |
nodeClassNames | Merged over the built-in class names one level deep: { heading: { h1: 'x' } } keeps h2. Mount-time only |
onEditorReady | Called with the LexicalEditor once it exists |
@zuilib/text-editor/markdown is the same editor without the drawing canvas
(no node, transformers, button or canvas code in the bundle);
@zuilib/text-editor/drawing is the canvas on its own, and the two
together reassemble the default entry:
import { MarkdownEditor } from '@zuilib/text-editor/markdown'
import { DrawingNode, DRAWING, DIAGRAM, DrawingPlugin } from '@zuilib/text-editor/drawing'
<MarkdownEditor.Root value={value} onValueChange={setValue} nodes={[DrawingNode]} transformers={[DRAWING, DIAGRAM]}>
<MarkdownEditor.Content />
<DrawingPlugin />
</MarkdownEditor.Root>
Localisation
labels is a deep partial of EditorLabels (English defaults in
DEFAULT_LABELS): placeholder, toolbar.*, outline.*, folding.*,
table.*, layout.*, drawing.*, count and limitExceeded /
limitRestored (the counter's limit announcements). Strings with a
number are functions (table.deleteRow(3), drawing.deleteShapes(2),
count(length, max)). Document content is never translated.
<MarkdownEditor
labels={{
placeholder: 'Commencez à écrire…',
toolbar: { bold: 'Gras', insertTable: 'Insérer un tableau' },
drawing: { deleteShapes: (n) => `Supprimer ${n} formes` },
}}
/>
useLabels() returns the resolved labels inside the editor, for your own
plugins.
Sizing
The root (.zui-text-editor) is width: 100%; height: 100% and scrolls its
own content, so give its container a height (a flex child with
min-height: 0, or a fixed height) to get a scrolling editor; leave the
container unconstrained to let the editor grow with the document.