Plugins
Four plugins ship with the package. Each is opt-in: import it from its
entry point (or from the default entry), pass its node and transformer to
the editor and mount the plugin as a child. @zuilib/text-editor/markdown
does not include them; the one stylesheet covers all four. They are built
on the same extension API a host would use.
| Entry | Node / transformer | Plugin | Markdown |
|---|---|---|---|
@zuilib/text-editor/mentions | MentionNode, MENTION | MentionsPlugin | [@name](mention:id) |
@zuilib/text-editor/comments | MarkNode (@lexical/mark), COMMENT | CommentsPlugin | <!-- zui:comment id -->text<!-- /zui:comment --> |
@zuilib/text-editor/images | ImageNode, IMAGE | ImagesPlugin, ImageButton | ,  |
@zuilib/text-editor/paste | none | PastePlugin | unchanged |
Mentions
import { MentionNode, MENTION, MentionsPlugin } from '@zuilib/text-editor/mentions'
<MarkdownEditor value={value} onValueChange={setValue} nodes={[MentionNode]} transformers={[MENTION]}>
<MentionsPlugin search={(query) => api.people(query)} />
</MarkdownEditor>
Typing the trigger at the start of a word opens a role="listbox" under
the caret with the results of search(query), called on every keystroke
(stale results are dropped). Arrow keys move, Enter or Tab inserts the
highlighted item as a MentionNode followed by a space, Escape closes the
list until the query changes. While it is open the editable surface
carries aria-controls and aria-activedescendant. Enter with no results
is a normal Enter. The list does not open in readOnly mode, in inline
code or inside another mention; the query stops at whitespace and 40
characters.
| Prop | Description |
|---|---|
trigger | One non-word character (default '@') |
search | (query) => Promise<MentionItem[]>; an item is { id, name, hint? } |
render | (item, { active, query, trigger }) => ReactNode for the option body; the option element, its role and selection state stay the plugin's |
triggers | [{ trigger, search, render? }] for several triggers, as in the example (@ people, # tags); replaces the three props above |
maxItems | Options listed at most (default 8) |
labels | suggestions(trigger) (listbox aria-label), noResults, loading |
onSelect | (item, trigger) after an insertion |
The node is a token text node: the caret cannot enter it, Backspace
removes it whole and its text is the trigger plus the name. Markdown
writes [@name](mention:id), a link other renderers show as text with a
mention: href; the trigger must be a single non-word character for the
transformer to claim it. ] and \ in the name are backslash-escaped
and %, ) and whitespace in the id percent-encoded, so any host values
round-trip. INSERT_MENTION_COMMAND ({ id, name, trigger? })
inserts a mention at the selection without the menu. The nodeClassNames
key is mention (zui-mention by default); the element carries
data-mention="<id>" and data-slot="mention".
Comments
import { MarkNode, COMMENT, CommentsPlugin } from '@zuilib/text-editor/comments'
<MarkdownEditor value={value} onValueChange={setValue} nodes={[MarkNode]} transformers={[COMMENT]}>
<CommentsPlugin
comments={comments}
onAdd={(c) => setComments([...comments, c])}
onResolve={(id, resolved) => update(id, { resolved })}
onDelete={(id) => remove(id)}
/>
</MarkdownEditor>
The document owns the ranges; the host owns everything else (author,
body, thread). Selecting text shows a floating "Add comment" button above
it. The button or Mod+Shift+M wraps the selection in a MarkNode with a
fresh id, calls onAdd({ id, quote, range, resolved: false }) and puts
the caret at the end of the range. With the caret inside a commented
range (or right after it) a role="group" bubble under it lists the
comments there with resolve / reopen and delete; Mod+Shift+M there moves
focus into the bubble and Escape returns it to the surface. Delete removes
the id from the document before onDelete(id); resolve only calls
onResolve(id, resolved) and the host decides.
| Prop | Description |
|---|---|
comments | { id, quote, range?, resolved? }[]; a marker whose id is missing here is listed with labels.unknown(id) and only offers delete |
onAdd, onResolve, onDelete | See above |
createId | Id generator (default crypto.randomUUID) |
labels | add, comments (bubble aria-label), resolve, reopen, remove, resolved, unknown(id) |
renderComment | (comment, { resolve, remove }) => ReactNode replaces an entry's body |
range is { start, end } in $getRoot().getTextContent() when the
comment was added; it is informational and not updated by later edits,
the mark is the source of truth. Markdown writes
<!-- zui:comment id -->text<!-- /zui:comment -->, which every other
renderer hides, so the plain document stays readable. A range spanning
several blocks is one marker pair per block with the same id; overlapping
comments share a pair with the ids comma-separated (zui:comment a,b).
Mark elements get zui-comment, data-comment-ids and is-resolved when
every id on them is resolved; the colour reads --zui-comment-color and
falls back to --warning. Helpers for editor.update / read:
$getMarkNodes(), $getCommentIds(), $removeComment(id),
$plainTextOffset(node), plus formatCommentMarker, parseCommentIds,
ADD_COMMENT_COMMAND and COMMENTS_THEME. @lexical/mark installs with the
editor.
Images
import { ImageNode, IMAGE, ImagesPlugin, ImageButton } from '@zuilib/text-editor/images'
<MarkdownEditor
value={value}
onValueChange={setValue}
nodes={[ImageNode]}
transformers={[IMAGE]}
toolbar={(items) => (
<MarkdownEditor.Toolbar>
{items.format}
{items.insert}
<ImageButton />
</MarkdownEditor.Toolbar>
)}
>
<ImagesPlugin upload={(file) => api.upload(file)} maxSize={5 * 1024 * 1024} onError={toast} />
</MarkdownEditor>
Files pasted or dropped on the surface (Lexical's DRAG_DROP_PASTE) or
chosen through ImageButton are checked against accept (default any
image/*) and maxSize (bytes). A progress placeholder
(role="progressbar") then stands at the caret until upload(file)
resolves with { src, alt? }. The placeholder exports as nothing, so a
value read mid-upload has no half image; a rejected promise removes it.
onError receives { type: 'unsupported' | 'too-large' | 'upload' | 'unsafe-src', ... }.
Sources must pass isSafeUrl (http:, https:, relative paths; no
data: or blob:) on import, on insertion and on upload results, which
is why the example above uploads to a placeholder service instead of
showing the local file. Whitespace and parentheses in a source are
percent-encoded on the node (markdown destinations end at them), and ]
in alt text and " in titles are backslash-escaped, so any uploaded file
name round-trips.
Clicking an image selects it (Backspace removes it) and shows an alt-text
input under it; Enter or Escape commits and returns to the surface. The
image is inline; a paragraph holding only an image renders it as a block.
Markdown is  or . Commands:
INSERT_IMAGE_COMMAND ({ src, alt?, title? }) and
UPLOAD_IMAGES_COMMAND (File[]). Labels: altText, altPlaceholder,
uploading, insertImage. nodeClassNames key image (zui-image).
Paste normalisation
import { PastePlugin } from '@zuilib/text-editor/paste'
<MarkdownEditor value={value} onValueChange={setValue}>
<PastePlugin />
</MarkdownEditor>
HTML from Word, Google Docs, Confluence and Outlook is cleaned before the
built-in paste converts it. Kept: headings, paragraphs, lists (Word's
MsoListParagraph runs become real ul / ol), tables, links, code,
quotes, emphasis (b, i and styled spans become strong, em, s),
images, br and hr. Dropped: styles, classes, ids, spans, fonts, Office
namespaces, conditional comments, scripts, style sheets, forms, iframes
and SVG. Plain-text pastes and pastes into an input are untouched.
| Prop | Description |
|---|---|
when | 'rich-sources' (default) acts only on HTML with an Office / Docs / Confluence fingerprint (isRichSourceHtml); 'always' cleans every HTML paste |
tables, links, images | false flattens cells to paragraphs, keeps link text only, drops images |
transform | (html) => html runs on the cleaned HTML before conversion |
normalizePastedHtml(html, options) is the pure function behind the
plugin. @lexical/html and @lexical/clipboard install with the editor.