Skip to main content

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.

EntryNode / transformerPluginMarkdown
@zuilib/text-editor/mentionsMentionNode, MENTIONMentionsPlugin[@name](mention:id)
@zuilib/text-editor/commentsMarkNode (@lexical/mark), COMMENTCommentsPlugin<!-- zui:comment id -->text<!-- /zui:comment -->
@zuilib/text-editor/imagesImageNode, IMAGEImagesPlugin, ImageButton![alt](src), ![alt](src "title")
@zuilib/text-editor/pastenonePastePluginunchanged

Mentions

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

PropDescription
triggerOne 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
maxItemsOptions listed at most (default 8)
labelssuggestions(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

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

PropDescription
comments{ id, quote, range?, resolved? }[]; a marker whose id is missing here is listed with labels.unknown(id) and only offers delete
onAdd, onResolve, onDeleteSee above
createIdId generator (default crypto.randomUUID)
labelsadd, 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

Loading editor
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 ![alt](src) or ![alt](src "title"). Commands: INSERT_IMAGE_COMMAND ({ src, alt?, title? }) and UPLOAD_IMAGES_COMMAND (File[]). Labels: altText, altPlaceholder, uploading, insertImage. nodeClassNames key image (zui-image).

Paste normalisation

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

PropDescription
when'rich-sources' (default) acts only on HTML with an Office / Docs / Confluence fingerprint (isRichSourceHtml); 'always' cleans every HTML paste
tables, links, imagesfalse 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.