Toolbar
In edit-rich mode the toolbar has three groups: format (bold, italic,
strikethrough, inline code, link), block (heading 1 to 3, bullet,
numbered and check list, quote; pressing the active one returns to a
paragraph) and insert (table, drawing). Hide it with toolbar={false}.
Below a 40rem pane it scrolls sideways, order preserved, and on a coarse
pointer its buttons have 44px targets.
The link button needs a non-empty selection (it is disabled for a bare
caret) and opens a bubble under it with a URL input, apply and remove;
nothing is written to the document until a URL is applied, so Escape,
moving the caret or leaving the bubble abandons the link. The bubble also
opens when the caret enters an existing link. javascript:, data: and
other script-capable schemes are refused, for pasted links too (an HTML
paste keeps only the text of an unsafe href).
Extending the toolbar
Pass a function as toolbar. It receives the default button groups
(format, block, insert, history) and returns the toolbar to render. Use
MarkdownEditor.ToolbarButton for your own buttons so they match the
built-ins and keep the editor selection when clicked.
<MarkdownEditor
value={value}
onValueChange={setValue}
toolbar={(items) => (
<MarkdownEditor.Toolbar>
{items.format}
<MarkdownEditor.ToolbarDivider />
{items.block}
<MarkdownEditor.ToolbarDivider />
{items.insert}
<MarkdownEditor.ToolbarDivider />
{items.history}
<MarkdownEditor.ToolbarButton label="Save" onClick={save}>
💾
</MarkdownEditor.ToolbarButton>
</MarkdownEditor.Toolbar>
)}
/>
MarkdownEditor.Toolbar hides itself in view / edit-raw / readOnly.
The undo / redo buttons (items.history) are only in the default layout
through this function form; the plain toolbar={true} layout is format,
block and insert. Buttons made with ToolbarButton take part in the
toolbar's arrow-key navigation (they carry data-toolbar-item); anything
else you place in the toolbar keeps its own focus handling.
This example reorders the groups (history first) and adds a copy button:
Placing your own toolbar (compound components)
When the toolbar must live somewhere else in your layout (an app bar, a
panel header), compose the editor from its parts. Everything under
MarkdownEditor.Root shares one editor instance, so the toolbar can sit
anywhere in that subtree.
Below a 40rem pane the toolbar is a horizontal scroll container and clips
overflow, so anything that pops out of an item you add (a menu, a select)
should render in a portal.
<MarkdownEditor.Root value={value} onValueChange={setValue}>
<header className="app-bar">
<MarkdownEditor.Toolbar>
<MarkdownEditor.FormatButtons />
<MarkdownEditor.ToolbarDivider />
<MarkdownEditor.InsertButtons />
</MarkdownEditor.Toolbar>
<MyAppButtons />
</header>
<MarkdownEditor.Content placeholder="Write…">
<MarkdownEditor.Outline />
</MarkdownEditor.Content>
</MarkdownEditor.Root>
| Part | Role |
|---|---|
Root | Lexical composer + plugins. Takes every MarkdownEditor prop except placeholder, toolbar, outline, collapsible, showCharacterCount |
Content | The editable surface. Takes placeholder, collapsible, showCharacterCount; children are docked sidebars |
Toolbar | Container; renders the default groups when empty |
FormatButtons, BlockButtons, InsertButtons, HistoryButtons | Built-in groups (<InsertButtons drawing={false} /> hides the drawing button) |
ToolbarButton, ToolbarDivider | Primitives for your own items |
Outline | Table-of-contents sidebar |
CharacterCount | Character counter, for placing it yourself |
The same parts are exported at the top level (Toolbar, ToolbarButton,
ToolbarDivider, FormatButtons, BlockButtons, InsertButtons,
HistoryButtons, CharacterCount) for code that prefers named imports.
Here the toolbar sits in an app bar next to a Save button, and the outline
is docked inside Content:
Headless: useMarkdownEditor()
For fully custom UI (buttons in your own design system, keyboard
shortcuts, status bars), call the hook from any component rendered under
MarkdownEditor.Root:
import { useMarkdownEditor } from '@zuilib/text-editor'
function BoldButton() {
const { activeFormats, toggleFormat } = useMarkdownEditor()
return (
<MyButton pressed={activeFormats.has('bold')} onClick={() => toggleFormat('bold')}>
B
</MyButton>
)
}
It returns a MarkdownEditorApi:
| Member | Type | Notes |
|---|---|---|
editor | LexicalEditor | The underlying instance, for dispatching your own commands |
activeFormats | ReadonlySet<TextFormatType> | Formats active at the selection (bold, italic, underline, strikethrough, code) |
toggleFormat(format) | ||
blockType / setBlockType(type) | BlockType | null | Kind of the selection's block: paragraph, h1..h6, quote, bullet, number, check. Setting the current list kind again lifts the list |
link / setLink(url) | { key, url } | null | The link the caret is in; setLink(null) removes it. With a URL it is a no-op for a collapsed caret outside a link |
hasSelection | boolean | Whether the selection is a non-empty range (what the link button checks) |
insertTable(options?) | { rows?, columns?, width? } | width defaults to newBlockWidth.table; when given it is written explicitly, even full |
insertDrawing(options?) | { width? } | Same rule with newBlockWidth.drawing |
blockWidth / setBlockWidth(width) | BlockWidth | null | Width of the block containing the selection: the focused drawing, or else the table the caret is in; null outside both |
tableDensity / setTableDensity(density) | TableDensity | null | null outside tables |
tableCell | TableCellPosition | null | { row, column, rows, columns, hasHeader } for the selection's cell |
insertTableRow(position?) | 'above' | 'below' (default below) | Never inserts above the header row: above there inserts right below it |
deleteTableRow() | No-op on the last row; deleting the header promotes the next row | |
insertTableColumn(position?) | 'before' | 'after' (default after) | |
deleteTableColumn() | No-op on the last column | |
canUndo, canRedo, undo(), redo() | ||
tableWidth / setTableWidth | Deprecated table-only aliases of blockWidth / setBlockWidth |
Insertions and deletions move the caret into whatever was created. The rules behind the table members are in Tables.
Every button below is a plain <button> driven by the hook; the status on
the right reads activeFormats and tableCell: