Skip to main content

Editor

import { MarkdownEditor } from '@zuilib/text-editor'

<MarkdownEditor value={value} onValueChange={setValue} mode="edit-rich" outline />

Props​

MarkdownEditorProps:

PropTypeDefaultDescription
valuestringMarkdown source (controlled). Omit for an uncontrolled editor
defaultValuestring''Initial markdown of an uncontrolled editor
onValueChange(value: string) => voidCalled on every edit with the new markdown
debounceMsnumber0Milliseconds 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
placeholderstringlabels.placeholderEmpty-state hint
readOnlybooleanfalseDisables editing in the Lexical modes
autoFocusbooleanfalseFocus on mount
classNamestringRoot wrapper class
toolbarboolean | (items) => ReactNodetrueFormatting / insert toolbar in edit-rich; the function form customises it (Toolbar)
outlinebooleanfalseTable-of-contents sidebar
collapsiblebooleantrueCollapse sections under headings
maxTextWidthstringMax 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) => voidconsole.errorCalled when Lexical throws inside an update
nodesKlass<LexicalNode>[]Extra node classes (Extending); mount-time only
transformersTransformer[] | (defaults) => Transformer[]Extra markdown transformers; mount-time only
nodeClassNamesEditorThemeClassesClass names for the rendered nodes, merged over the built-ins; mount-time only
onEditorReady(editor: LexicalEditor) => voidThe Lexical instance, once it exists
labelsDeepPartial<EditorLabels>EnglishEvery chrome string (Localisation)
maxLengthnumberPlain-text limit; input past it is rejected (Form integration)
onCharacterCountChange(length: number) => voidPlain-text length after every change and once on mount
showCharacterCountbooleanfalseCharacter counter under the document
id, name, aria-label, aria-labelledby, aria-describedby, aria-invalid, aria-requiredForwarded to the editable surface
childrenReactNodeExtra plugins or UI under the root

Modes​

modeUINotes
'edit-rich'Lexical rich editorShortcuts, toolbar, tables, canvas
'edit-raw'Plain <textarea>Direct markdown source editing
'view'Read-only renderOutline 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.

Loading editor

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:

ContentMarkdown
Headings, lists, quotes, links, emphasisCommonMark
Checklists- [ ] / - [x]
TablesGFM 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)
FrontmatterA leading --- block, kept verbatim
Horizontal rule--- (also *** / ___ on input); a rule that opens the document is written as ***
FootnotesGFM [^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:

TypeResult
# … ###### Heading 1–6
- or * Bullet list
1. Numbered list
- [ ] Checklist item
> Blockquote
``` (optionally with a language) then EnterFenced code block
| a | b |Table
**bold**, *italic*, ~~strike~~, `code`Inline formatting
[text](url)Link
--- then space or EnterHorizontal rule
[^id] after some textFootnote cue (the note is created when you click the cue)
[^id]: at the start of a paragraphFootnote 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.

Loading editor

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
Loading editor

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.

Loading editor

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>
PropBehaviour
nodesAppended 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
transformersAn 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
childrenAny component under the root that calls useMarkdownEditor() or useLexicalComposerContext() is a plugin
nodeClassNamesMerged over the built-in class names one level deep: { heading: { h1: 'x' } } keeps h2. Mount-time only
onEditorReadyCalled 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.