Skip to main content

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).

Loading editor

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:

Loading editor

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>
PartRole
RootLexical composer + plugins. Takes every MarkdownEditor prop except placeholder, toolbar, outline, collapsible, showCharacterCount
ContentThe editable surface. Takes placeholder, collapsible, showCharacterCount; children are docked sidebars
ToolbarContainer; renders the default groups when empty
FormatButtons, BlockButtons, InsertButtons, HistoryButtonsBuilt-in groups (<InsertButtons drawing={false} /> hides the drawing button)
ToolbarButton, ToolbarDividerPrimitives for your own items
OutlineTable-of-contents sidebar
CharacterCountCharacter 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:

Loading editor

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:

MemberTypeNotes
editorLexicalEditorThe underlying instance, for dispatching your own commands
activeFormatsReadonlySet<TextFormatType>Formats active at the selection (bold, italic, underline, strikethrough, code)
toggleFormat(format)
blockType / setBlockType(type)BlockType | nullKind 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 } | nullThe link the caret is in; setLink(null) removes it. With a URL it is a no-op for a collapsed caret outside a link
hasSelectionbooleanWhether 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 | nullWidth of the block containing the selection: the focused drawing, or else the table the caret is in; null outside both
tableDensity / setTableDensity(density)TableDensity | nullnull outside tables
tableCellTableCellPosition | 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 / setTableWidthDeprecated 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:

Loading editor