@llui/markdown-editor

A WYSIWYG Markdown editor you drop into an LLui app as a single component. The user edits rich text; the editor's state holds the Markdown. It is built on @llui/lexical and ships a transformer registry (GFM, callouts, …), a toolbar surface, and a set of opt-in plugins for the features beyond plain prose — links, images, tables, math, mentions, emoji, slash commands, and more.

pnpm add @llui/markdown-editor @llui/lexical @llui/dom lexical

@llui/lexical and lexical come along as the editing engine.

Quick start

markdownEditor() returns an ordinary LLui component — mount it with mountApp, or place it inside a larger view. Its state exposes the live Markdown (value), word/char counts, and dirty/read-only flags.

import { mountApp } from '@llui/dom'
import { markdownEditor, corePlugin, linkPlugin } from '@llui/markdown-editor'

const app = mountApp(
  document.getElementById('editor')!,
  markdownEditor({
    toolbar: true,
    plugins: [corePlugin(), linkPlugin()],
    defaultValue: '# Hello\n\nStart typing…',
    changeDebounceMs: 150,
  }),
)

// The editor's state IS the source of truth for the Markdown:
app.subscribe((s) => console.log(s.value, `${s.wordCount} words`))

Plugins

Features are plugins you compose into the plugins list — only what you pass is wired in, so unused features tree-shake away. corePlugin() covers headings, lists, blockquotes, inline marks, and code. The rest are opt-in:

linkPlugin, imagePlugin, tablePlugin, hrPlugin, mathPlugin, mermaidPlugin, mentionPlugin, emojiPlugin, calloutPlugin, slashPlugin, contextMenuPlugin, floatingToolbarPlugin.

Author your own with definePluginUI and the MarkdownPlugin / CommandItem contract — the same shape the built-ins use.

Transformers

The Markdown ⇄ editor mapping is a transformer registry. GFM_TRANSFORMERS / GFM_NODES add GitHub-Flavored Markdown (tables, task lists, strikethrough); buildTransformers / orderTransformers let a plugin contribute its own (the callout plugin, for instance, registers a > [!NOTE] transformer).

Toolbar

markdownEditor({ toolbar: true }) renders the default toolbar. For a custom chrome, drive it yourself with connectToolbar (a Signal-handle part bag you spread onto your own elements) or the prebuilt toolbar() surface, plus computeFormatState to derive button active-states from the current selection.

API

Functions

$createWikiLinkNode()

Build a wikilink node. target/alias are sanitized to values the [[…]] syntax can express (see {@link sanitizeWikiLinkTarget}); a target with nothing usable left falls back to the literal text Page rather than yielding an invisible token.

function $createWikiLinkNode(target: string, alias: string | null = null): WikiLinkNode

$getFrontmatter()

The document's frontmatter body, or null when it has none.

function $getFrontmatter(): string | null

$insertCallout()

Insert a fresh callout at the current selection; returns the created node.

function $insertCallout(kind: CalloutKind = 'note', textValue = 'New callout'): LLuiDecoratorNode

$insertHorizontalRule()

Insert a horizontal rule at the current selection.

function $insertHorizontalRule(): void

$insertMarkdownAtSelection()

Parse markdown with transformers and insert the produced nodes at the current range selection. The document is parsed into a detached scratch container (so the live root is never cleared) and the selection captured before the import — which moves the caret into the scratch node — is restored before the nodes are spliced in. Returns true only when nodes were actually inserted; false (a no-op) when there is no range selection to insert into or the markdown parsed to nothing — letting the caller fall back to the default paste behaviour instead of silently swallowing the event.

function $insertMarkdownAtSelection(markdown: string, transformers: Array<Transformer>): boolean

$isWikiLinkNode()

function $isWikiLinkNode(node: LexicalNode | null | undefined): node is WikiLinkNode

$setFrontmatter()

Set (or, with null, remove) the document's frontmatter. The block is always kept as the FIRST child of the root — it is only frontmatter there, and the exporter relies on that position (see the transformer's export).

function $setFrontmatter(source: string | null): void

blockAtPoint()

The block whose vertical band contains clientY, or null when the pointer is in no block's band. TWO passes, and the order matters. A block's OWN rect always wins outright; only a point in no block at all falls through to the widened search, where the NEAREST band within tolerance wins (ties biased upward, matching how a reader attributes a gap to the block above it). A single widened pass with first-match-wins — which this was — is wrong wherever two rects touch or nearly touch, and touching rects are the common case, not the exotic one: list items, table rows, consecutive lines, and any margin-collapsed heading. With tolerance = 6 and adjacent rects [0,20] and [20,40], every y in [20,26] resolved to the FIRST block, so the block below lost the top 6px of its own body — the grip targeted, grabbed and dragged the wrong block. Generally, for an inter-block gap g < tolerance, block N stole the first tolerance - g px of block N+1.

function blockAtPoint(blocks: readonly BlockRect[], clientY: number, tolerance: number = HOVER_TOLERANCE): BlockRect | null

blockDragPlugin()

Reorder top-level blocks by dragging a hover gutter grip, or from the keyboard (focus the grip, Enter/Space to grab, ↑/↓ to move, Enter/Space to drop, Escape to cancel). Every reorder is one Lexical node move, hence one undo step.

function blockDragPlugin(options: BlockDragOptions = {}): MarkdownPlugin

blockUnderlineFormat()

Swallow the underline text-format command. registerRichText wires Cmd+U to FORMAT_TEXT 'underline', but the GFM markdown dialect this editor serializes has no underline representation, so an applied underline would be silently stripped on save. Intercepting at CRITICAL priority (ahead of rich-text) keeps the WYSIWYG surface and the serialized dialect in lock-step: underline can be neither applied nor lost. Returns a disposer.

function blockUnderlineFormat(editor: LexicalEditor): () => void

buildTransformers()

Collect every plugin's transformers (de-duplicated by reference) and order them. The result is passed to $convertTo/FromMarkdownString and registerMarkdownShortcuts.

function buildTransformers(plugins: readonly MarkdownPlugin[]): Transformer[]

calloutPlugin()

function calloutPlugin(opts: CalloutPluginOptions = {}): MarkdownPlugin

codeLanguagePlugin()

function codeLanguagePlugin(opts: CodeLanguagePluginOptions = {}): MarkdownPlugin

computeFormatState()

Read the full format surface at the current selection (opens a read ctx).

function computeFormatState(editor: LexicalEditor, history: Pick<SelectionContext, 'canUndo' | 'canRedo'>): FormatState

connectToolbar()

Build reactive toolbar parts from the format signal. Spread item(id) onto a <button>; aria-pressed / data-active / disabled track the format.

function connectToolbar(format: Signal<FormatState>, send: Send<EditorMsg>, items: readonly CommandItem[]): ToolbarParts

contextMenuPlugin()

function contextMenuPlugin(): MarkdownPlugin

corePlugin()

function corePlugin(_opts: CorePluginOptions = {}): MarkdownPlugin

countWords()

Count whitespace-delimited words (shared by init and the format handler).

function countWords(text: string): number

definePluginUI()

Author a plugin UI module with full State/Msg/Effect types, erased for storage. The casts are confined to this boundary (the host only knows unknown), exactly like the decorator bridge.

function definePluginUI<S, M, E = never>(spec: PluginUISpec<S, M, E>): PluginUI

emojiPlugin()

function emojiPlugin(opts: EmojiPluginOptions = {}): MarkdownPlugin

findDropTarget()

The slot clientY points at, expressed relative to a neighbouring block. The document has n + 1 slots for n blocks; the slot index is the count of blocks whose vertical midpoint is above the pointer. Two of those slots are where sourceKey already sits — dropping there is a no-op, so both return null and the caller shows no indicator and commits nothing. That check is what stops a 1px twitch from producing a spurious undo entry.

function findDropTarget(blocks: readonly BlockRect[], clientY: number, sourceKey: NodeKey): DropTarget | null

floatingToolbarPlugin()

function floatingToolbarPlugin(): MarkdownPlugin

Serialize a wikilink back to markdown. Inverse of {@link parseWikiLinkInner} for every link built through this module's constructors — see {@link sanitizeWikiLinkTarget} for why that qualifier is load-bearing.

function formatWikiLink(link: WikiLink): string

frontmatterPlugin()

function frontmatterPlugin(opts: FrontmatterPluginOptions = {}): MarkdownPlugin

hrPlugin()

function hrPlugin(): MarkdownPlugin

imagePlugin()

function imagePlugin(opts: ImagePluginOptions = {}): MarkdownPlugin

indicatorRect()

Where to draw the indicator line for a resolved {@link DropTarget}: on the target's top edge for before, its bottom edge for after.

function indicatorRect(blocks: readonly BlockRect[], target: DropTarget): IndicatorRect | null

init()

function init(opts: InitOptions): [EditorState, EditorEffect[]]

linkDialog()

Render the link dialog. Hidden (portal, nothing inline) until dialog.open.

function linkDialog(opts: LinkDialogOptions): Mountable

linkPlugin()

function linkPlugin(opts: LinkPluginOptions = {}): MarkdownPlugin

markdownEditor()

Build the markdown editor component. Embed it with mountApp(el, markdownEditor(...)) or compose it inside a larger component.

function markdownEditor(config: EditorConfig = {}): SignalComponentDef<EditorState, EditorMsg, EditorEffect>

mathPlugin()

function mathPlugin(opts: MathPluginOptions = {}): MarkdownPlugin

mentionPlugin()

function mentionPlugin(opts: MentionPluginOptions = {}): MarkdownPlugin

mergeTheme()

Merge a consumer theme over the default. text is merged per-key so a consumer overriding (say) strikethrough keeps the other default entries. Always returns a FRESH theme (never the shared defaultTheme singleton): Lexical caches resolved class arrays by MUTATING the text object it is handed (text.__lexicalClassNameCache). Handing it a fresh copy keeps the exported singleton clean, and stripping any inherited cache prevents a stale entry from a previously-used theme object shadowing an overridden class.

function mergeTheme(theme?: EditorThemeClasses): EditorThemeClasses

mermaidPlugin()

function mermaidPlugin(opts: MermaidPluginOptions = {}): MarkdownPlugin

normalizeCodeInfo()

Canonicalize a fence info string. CommonMark's info string is the remainder of the opening-fence line with the surrounding whitespace stripped; a blank one means "no language". Two characters are removed rather than preserved, because keeping them would emit markdown that no longer re-imports to the same block:

  • a backtick — illegal in a backtick-fenced info string (it would terminate or corrupt the fence);
  • a newline — it would end the fence line entirely. Everything else survives verbatim, including spaces ('lance table') and punctuation ('c++', 'objective-c').
function normalizeCodeInfo(raw: string | null | undefined): string | null

orderTransformers()

Stable-sort transformers into the order Lexical expects.

function orderTransformers(transformers: readonly Transformer[]): Transformer[]

parseWikiLinkInner()

Parse the content BETWEEN the brackets. Returns null when the content is not a valid wikilink body. Deliberate choices, each load-bearing for exact round-tripping:

  • split on the FIRST | only, so [[a|b|c]] has alias b|c and re-exports byte-identically;
  • an EMPTY alias ([[a|]]) normalizes to no alias — the alternative (keeping alias: '') would render a zero-width, unclickable node;
  • NO trimming. [[ a ]] keeps its spaces, because trimming would make import→export lossy. Presentation trimming is the host's call in onNavigate/resolve, not the document's.
function parseWikiLinkInner(inner: string): WikiLink | null

registerMarkdownPaste()

Register the markdown-on-paste handler on editor. Returns a disposer. Plain-text pastes are converted as Markdown. Pastes that also carry text/html are ignored so Lexical's richer HTML import handles them.

function registerMarkdownPaste(editor: LexicalEditor, transformers: Array<Transformer>): () => void

sanitizeWikiLinkAlias()

Sanitize an alias. Returns null when nothing usable survives.

function sanitizeWikiLinkAlias(raw: string | null): string | null

sanitizeWikiLinkTarget()

Sanitize a target. Returns null when nothing usable survives.

function sanitizeWikiLinkTarget(raw: string): string | null

serializeFrontmatter()

Render the fences back around an opaque body. An empty (or blank-only) body collapses to the canonical two-line form so the result stays idempotent.

function serializeFrontmatter(source: string): string

setTransformerPrecedence()

Declare that transformer must be consulted before same-rank peers with a higher value. Call at module scope, beside the transformer's definition.

function setTransformerPrecedence(transformer: Transformer, value: number): void

singleBlockPlugin()

function singleBlockPlugin(opts: SingleBlockPluginOptions = {}): MarkdownPlugin

slashPlugin()

function slashPlugin(): MarkdownPlugin

splitFrontmatter()

Split a leading frontmatter block off a markdown string: [body, rest], or null when the document has none (no line-0 fence, or no closing fence). Exported because a consumer often needs the metadata BEFORE building an editor — the same predicate the importer uses, so the two never disagree.

function splitFrontmatter(markdown: string): [source: string, rest: string] | null

tablePlugin()

function tablePlugin(): MarkdownPlugin

toolbar()

A ready-made grouped toolbar. Items not surfaced to 'toolbar' are dropped.

function toolbar(opts: ToolbarOptions): Mountable

update()

function update(state: EditorState, msg: EditorMsg): [EditorState, EditorEffect[]]

wikilinkPlugin()

function wikilinkPlugin(opts: WikiLinkPluginOptions = {}): MarkdownPlugin

Types

BlockType

The block kind at the selection — base rich-text kinds plus list/code, resolved by the markdown layer.

export type BlockType =
  | 'paragraph'
  | 'h1'
  | 'h2'
  | 'h3'
  | 'h4'
  | 'h5'
  | 'h6'
  | 'quote'
  | 'code'
  | 'bullet'
  | 'number'
  | 'check'
  | 'other'

CalloutKind

export type CalloutKind = 'note' | 'tip' | 'warning' | 'danger'

CodeLanguageEffect

Write language (null clears it) onto the code block with node key key.

export type CodeLanguageEffect = { type: 'apply'; key: string; language: string | null }

CodeLanguageMsg

export type CodeLanguageMsg =
  | { type: 'show'; key: string; x: number; y: number; language: string | null }
  | { type: 'hide' }
  | { type: 'edit' }
  | { type: 'input'; language: string }
  | { type: 'commit' }
  | { type: 'cancel' }

CollabFactory

Builds the collab binding from the editor-supplied hooks.

export type CollabFactory = (hooks: CollabHooks) => CollabBinding

EditorEffect

export type EditorEffect =
  | { type: 'execCommand'; id: string }
  | { type: 'applyValue'; value: string }
  | { type: 'emitChange'; value: string }
  | { type: 'emitFormat'; format: FormatState }
  /** An effect produced by a plugin's UI reducer (see {@link PluginUI}). */
  | { type: 'pluginEffect'; name: string; effect: unknown }

EditorMsg

export type EditorMsg =
  | { type: 'markdownChanged'; value: string }
  | { type: 'formatChanged'; format: FormatState; wordCount: number; charCount: number }
  | { type: 'runCommand'; id: string }
  | { type: 'setValue'; value: string }
  | { type: 'openOverlay'; overlay: OverlayKind; x?: number; y?: number }
  | { type: 'closeOverlay' }
  | { type: 'slashQuery'; query: string }
  | { type: 'setReadOnly'; readonly: boolean }
  | { type: 'collabStatus'; connected: boolean }
  | { type: 'collabSync'; synced: boolean }
  | { type: 'collabPeers'; peers: number }
  /** Route a message to a plugin's UI reducer (see {@link PluginUI}). */
  | { type: 'plugin'; name: string; msg: unknown }

EditorOutMsg

The subset of messages a plugin may emit through its PluginContext (e.g. a register listener routing an editor event into its own plugin UI).

export type EditorOutMsg = Extract<
  EditorMsg,
  { type: 'openOverlay' | 'closeOverlay' | 'slashQuery' | 'plugin' }
>

InlineFormat

An inline text-format surfaced as a toolbar command item.

export type InlineFormat = 'bold' | 'italic' | 'strikethrough' | 'code'

ItemSurface

Which surfaces a command item appears in (default: all).

export type ItemSurface = 'toolbar' | 'floating' | 'slash' | 'context'

OverlayKind

Which floating surface is currently open.

export type OverlayKind = 'none' | 'floating' | 'slash' | 'context' | 'link'

Place

Which side of the target block the source lands on.

export type Place = 'before' | 'after'

SerializedWikiLinkNode

export type SerializedWikiLinkNode = Spread<
  { target: string; alias: string | null },
  SerializedTextNode
>

Interfaces

BlockDragOptions

export interface BlockDragOptions {
  /** Gutter grip inset, in px left of the block's left edge. Default 28. */
  gutterOffset?: number
}

BlockRect

The measured viewport geometry of one top-level block. Pure data — the unit of everything below, so all placement logic is testable without a DOM.

export interface BlockRect {
  key: NodeKey
  top: number
  bottom: number
  left: number
  width: number
}

CalloutData

export interface CalloutData {
  kind: CalloutKind
  text: string
}

CalloutPluginOptions

export interface CalloutPluginOptions {
  /** Default kind for the toolbar/slash insert action. */
  defaultKind?: CalloutKind
}

CodeLanguagePluginOptions

export interface CodeLanguagePluginOptions {
  /** Suggestions offered in the language input's `<datalist>`. Purely advisory —
   * ANY info string may be typed, including multi-token ones. */
  languages?: readonly string[]
  /** Placeholder shown when a block has no language (default `'plain text'`). */
  placeholder?: string
  /** Accessible label for the language input (default `'Code block language'`). */
  label?: string
}

CodeLanguageState

The language badge's state. JSON-serializable, like every LLui state slice.

export interface CodeLanguageState {
  /** Whether the badge is shown. */
  open: boolean
  /** Viewport x of the anchor (the code block's right edge). */
  x: number
  /** Viewport y of the anchor (the code block's top edge). */
  y: number
  /** Node key of the anchored code block (`''` when none). */
  key: string
  /** The input's current value (the block's info string, or the in-flight edit). */
  language: string
  /** The info string as last read from the node — the baseline `cancel` restores
   * and `commit` diffs against, so a no-op commit never touches the document. */
  committed: string
  /** Whether the input has focus; a refresh must not overwrite what's being typed. */
  editing: boolean
  /** A `hide` that arrived mid-edit, applied when the edit ends. */
  pendingHide: boolean
}

CollabBinding

Disposer-returning binding the collab layer installs on the live editor. @llui/lexical-collab's YjsCollab and @llui/lexical-loro's LoroCollab both satisfy this structurally, so @llui/markdown-editor needs neither a Yjs nor a Loro dependency of its own.

export interface CollabBinding {
  register: (editor: LexicalEditor) => () => void
  /**
   * A CRDT-aware undo owner, if the binding provides one SEPARATELY from
   * `register`. When present it is handed to `lexicalForeign({ externalUndo })`,
   * which forces the built-in `@lexical/history` stack off so the two can never
   * both be live — this is what gives collab mode real, peer-scoped undo.
   *
   * Optional because not every binding splits undo out this way: `yjsCollab`
   * installs its own undo commands INSIDE `register`, so it leaves this unset and
   * still owns undo. A binding that sets neither would leave the editor with no
   * undo at all — see `@llui/lexical-loro`, which sets this.
   */
  externalUndo?: (editor: LexicalEditor) => () => void
}

CollabHooks

Hooks the editor injects into the {@link CollabFactory}: a markdown seed (run once by the bootstrapping peer to fill an empty shared doc from defaultValue) plus status sinks the editor mirrors into state.collab. Spread straight into yjsCollab({ id, provider, user, ...hooks }).

export interface CollabHooks {
  seed: (editor: LexicalEditor) => void
  onStatus: (connected: boolean) => void
  onSync: (synced: boolean) => void
  onPeers: (count: number) => void
}

CollabStatus

Live collaborative-session status (mirror of the CRDT provider state). enabled is false unless the editor was created with a collab factory.

export interface CollabStatus {
  enabled: boolean
  connected: boolean
  synced: boolean
  /** Remote peers currently present (excludes this client). */
  peers: number
}

CommandContext

Handed to a command item's run so it can talk back to the host (e.g. open the link dialog) instead of only mutating the editor.

export interface CommandContext {
  send: (msg: EditorMsg) => void
}

CommandItem

A user-invokable editor command surfaced to the chrome. Its reactive active/disabled state is read from {@link FormatState}; run mutates the live editor.

export interface CommandItem {
  /** Stable id (also the `runCommand` payload). */
  id: string
  label: string
  /** Optional icon hint (class / svg id); rendering is the consumer's CSS. */
  icon?: string
  /** Grouping key for menu sectioning. */
  group?: string
  /** Keyword aliases for slash/command-palette filtering. */
  keywords?: readonly string[]
  isActive?: (format: FormatState) => boolean
  isDisabled?: (format: FormatState) => boolean
  run: (editor: LexicalEditor, ctx: CommandContext) => void
  surfaces?: readonly ItemSurface[]
}

CorePluginOptions

export interface CorePluginOptions {
  /** Reserved for future core options. */
  readonly _?: never
}

DocCandidate

A document the host offers as a link target while the user types [[….

export interface DocCandidate {
  /** The link target written into the document (`[[target]]`). */
  readonly target: string
  /** Human-facing title shown in the results list (defaults to `target`). */
  readonly title?: string
  /** A short one-line snippet shown under the title. */
  readonly snippet?: string
  /** A longer content excerpt shown in the reference/preview pane. */
  readonly preview?: string
}

DropTarget

A resolved drop slot: "put the dragged block place this key".

export interface DropTarget {
  key: NodeKey
  place: Place
}

EditorConfig

export interface EditorConfig {
  /** Plugins composing the feature set; order defines transformer precedence.
   * Defaults to `[corePlugin(), linkPlugin()]` so the minimal editor has GFM + links. */
  plugins?: readonly MarkdownPlugin[]
  /** Initial markdown (uncontrolled seed). */
  defaultValue?: string
  /** Controlled: the consumer owns this signal; the editor follows it. */
  value?: Signal<string>
  /** Debounced markdown-emission window (ms). Default 300. */
  changeDebounceMs?: number
  placeholder?: string
  readonly?: boolean
  /** Lexical theme class map. */
  theme?: EditorThemeClasses
  /** Editor namespace (instance isolation). */
  namespace?: string
  /** Outbound markdown (after debounce). */
  onChange?: (markdown: string) => void
  /** Outbound format surface (for chrome built outside this package). */
  onFormatChange?: (format: FormatState) => void
  /** Receives the live Lexical editor at mount (imperative access, collab hooks). */
  onReady?: (editor: LexicalEditor) => void
  /** Render the built-in toolbar above the editor. Default false (minimal). */
  toolbar?: boolean
  /** Convert plain-text Markdown to rich content on paste. Default true.
   * Pastes that carry `text/html` are always left to Lexical's HTML import,
   * regardless of this flag. Set false to paste Markdown as literal text. */
  pasteMarkdown?: boolean
  /** Enable collaborative editing. The editor hands you a markdown `seed` and
   * status sinks; return a binding (build it with `yjsCollab` from
   * `@llui/lexical-collab`, wiring your own provider). Mutually exclusive with
   * `value` — the shared CRDT document, not a markdown signal, owns the content.
   * `defaultValue` becomes the seed the bootstrapping peer writes. */
  collab?: CollabFactory
}

EditorParts

Hooks the chrome layer (toolbar/menus) uses to compose around the editor.

export interface EditorParts {
  /** The merged, surface-filtered command items. */
  items: readonly CommandItem[]
  /** Reactive format signal for `connect`-style toolbars. */
  format: Signal<FormatState>
}

EditorState

export interface EditorState {
  /** Last serialized markdown (mirror of the live document). */
  value: string
  format: FormatState
  wordCount: number
  charCount: number
  ui: {
    activeOverlay: OverlayKind
    slashQuery: string
    menu: { x: number; y: number }
  }
  /** Per-plugin UI state slices, keyed by plugin name (see {@link PluginUI}). */
  plugins: Record<string, unknown>
  dirty: boolean
  readonly: boolean
  /** Collaborative-session status (always present; inert unless `collab` set). */
  collab: CollabStatus
}

EmojiPluginOptions

export interface EmojiPluginOptions {
  /** Extra/override shortcode → emoji entries (merged over the defaults). */
  emoji?: Readonly<Record<string, string>>
}

FormatState

The toolbar-facing format surface at the current selection (all primitives).

export interface FormatState {
  bold: boolean
  italic: boolean
  strikethrough: boolean
  code: boolean
  link: boolean
  blockType: BlockType
  alignment: Alignment
  canUndo: boolean
  canRedo: boolean
}

FrontmatterData

The frontmatter node's payload: the block body, verbatim, with no fences.

export interface FrontmatterData {
  /** The raw text between the opening and closing `---`. Never interpreted. */
  source: string
}

FrontmatterPluginOptions

export interface FrontmatterPluginOptions {
  /** Render the raw block to a preview (e.g. parse with your own YAML library
   * and draw a table). Return a DOM `Node` (mounted directly) or a **trusted**
   * HTML string — see `renderedPreview`'s security note. */
  render?: PreviewRender
  /** Accessible label for the raw-source editor (default `'Frontmatter'`). */
  label?: string
  /** Placeholder shown for an empty block (default `'key: value'`). */
  placeholder?: string
  /** Show the raw source editor. Set false for a `render`-only presentation
   * (the block still round-trips; it just isn't editable in place). Default true. */
  editable?: boolean
}

ImagePluginOptions

export interface ImagePluginOptions {
  /** Upload a chosen file and resolve to its URL. When omitted, the file picker
   * is hidden and only URL entry is offered. */
  upload?: (file: File) => Promise<string>
}

IndicatorRect

Viewport position of the drop-indicator line.

export interface IndicatorRect {
  x: number
  y: number
  width: number
}

InitOptions

export interface InitOptions {
  value: string
  readonly: boolean
  /** Whether a collaborative session is wired (drives `collab.enabled`). */
  collab?: boolean
}

LinkDialogOptions

export interface LinkDialogOptions {
  /** The `{ open }` slice driving the modal. */
  dialog: Signal<DialogState>
  /** The URL input value. */
  url: Signal<string>
  /** Called as the user edits the URL. */
  onInput: (url: string) => void
  /** Called on Apply / Enter. */
  onSubmit: () => void
  /** Called when the dialog requests open/close (dismiss, close button). */
  onDialog: (msg: DialogMsg) => void
  /** Dialog instance id for ARIA wiring (default 'md-link-dialog'). */
  id?: string
}

LinkPluginOptions

export interface LinkPluginOptions {
  /** Default URL pre-filled when there's no existing link (default ''). */
  defaultUrl?: string
  /**
   * Follow (open) a link's URL on ⌘/Ctrl-click. The host owns how a URL opens —
   * a plain browser wants a new tab, a Tauri/Electron shell wants the external
   * browser — so this is a seam. When omitted, a browser default opens the URL in
   * a new tab; off-browser (no `window`) it is a no-op.
   */
  onFollow?: (url: string) => void
}

MarkdownPlugin

A markdown editor plugin: engine wiring + transformers + UI items + an optional stateful UI extension (its own state slice, reducer, view, effects).

export interface MarkdownPlugin extends LexicalPlugin<EditorOutMsg> {
  /** Markdown ↔ node transformers contributed to the registry. */
  transformers?: readonly Transformer[]
  /** Command items surfaced to the toolbar / slash / context menus. */
  items?: readonly CommandItem[]
  /** A stateful UI extension keyed by this plugin's `name` (see {@link definePluginUI}). */
  ui?: PluginUI
  /** Receive the merged command items from all plugins (e.g. a slash menu lists
   * every plugin's items). Called once at editor construction. */
  onItems?: (items: readonly CommandItem[]) => void
}

MathPluginOptions

export interface MathPluginOptions {
  /** Typeset TeX to an HTML string (e.g. via KaTeX). When omitted, the raw TeX is
   * shown in a styled box. */
  /** Render the TeX source to a preview. Return a DOM `Node` (mounted
   * directly, no sanitization) or a **trusted HTML string** (injected as-is
   * — sanitize it yourself, e.g. via DOMPurify, since it carries document
   * content). See `renderedPreview`. */
  render?: PreviewRender
}

Mention

export interface Mention {
  id: string
  label: string
}

MentionPluginOptions

export interface MentionPluginOptions {
  /** Resolve candidates for a query (default: a small sample list). */
  source?: (query: string) => readonly Mention[]
}

MermaidPluginOptions

export interface MermaidPluginOptions {
  /** Render the diagram source to an HTML string (e.g. mermaid). When omitted,
   * the raw source is shown in a styled box. */
  /** Render the mermaid source to a preview. Return a DOM `Node` (mounted
   * directly, no sanitization) or a **trusted HTML string** (injected as-is
   * — sanitize it yourself, e.g. via DOMPurify, since it carries document
   * content). See `renderedPreview`. */
  render?: PreviewRender
}

PluginEffectContext

Context for a plugin's onEffect — reach the live editor and dispatch back.

export interface PluginEffectContext<M> {
  /** The live Lexical editor (null before mount). */
  editor: () => LexicalEditor | null
  /** Dispatch a message back into this plugin. */
  send: (msg: M) => void
  /** Dispatch a host editor message (e.g. `{type:'runCommand', id}`). */
  emit: (msg: unknown) => void
}

PluginUI

The type-erased form stored on a plugin and consumed by the host.

export interface PluginUI {
  init: () => unknown
  update?: (state: unknown, msg: unknown) => unknown | [unknown, unknown[]]
  view?: (args: PluginViewArgs<unknown, unknown>) => Renderable
  onEffect?: (effect: unknown, ctx: PluginEffectContext<unknown>) => void
}

PluginUISpec

A typed plugin UI module (authored via {@link definePluginUI}).

export interface PluginUISpec<S, M, E = never> {
  /** Initial slice state (JSON-serializable). */
  init: () => S
  /** Pure reducer over the slice; may return effects. */
  update?: (state: S, msg: M) => S | [S, E[]]
  /** View contribution (overlays/panels), rendered by the host. */
  view?: (args: PluginViewArgs<S, M>) => Renderable
  /** Effect handler with live-editor access + host dispatch. */
  onEffect?: (effect: E, ctx: PluginEffectContext<M>) => void
}

PluginViewArgs

Args for a plugin's view — its reactive state slice + a scoped dispatcher.

export interface PluginViewArgs<S, M> {
  state: Signal<S>
  send: (msg: M) => void
  editor: () => LexicalEditor | null
}

SingleBlockPluginOptions

export interface SingleBlockPluginOptions {
  /** Inline formats surfaced as toolbar items.
   * Default `['bold', 'italic', 'strikethrough', 'code']`. NOTE: this limits the
   * toolbar buttons only — markdown syntax (`*x*`) and Ctrl/⌘ shortcuts still
   * apply every inline format, and all inline markdown round-trips regardless. */
  formats?: readonly InlineFormat[]
  /** Allow soft line breaks within the single paragraph. When `false` (default)
   * Enter is inert and pasted/seeded line breaks collapse to spaces — a strict
   * single-line field. When `true`, Enter inserts a `\n` and merged lines are
   * joined with a line break instead of a space. A new paragraph is never made. */
  allowLineBreaks?: boolean
  /** Register `LinkNode` + the markdown link transformer so inline links
   * round-trip. Default `false`. Compose with `linkPlugin()` for the toolbar
   * button + insert dialog. */
  link?: boolean
}

ToolbarItemParts

export interface ToolbarItemParts {
  type: 'button'
  'data-scope': 'md-toolbar'
  'data-part': 'item'
  'data-id': string
  'aria-label': string
  title: string
  'aria-pressed': Signal<'true' | 'false'>
  'aria-disabled': Signal<'true' | undefined>
  disabled: Signal<boolean>
  'data-active': Signal<'' | undefined>
  onClick: (e: MouseEvent) => void
}

ToolbarOptions

export interface ToolbarOptions {
  format: Signal<FormatState>
  send: Send<EditorMsg>
  items: readonly CommandItem[]
  /** Explicit grouped layout of ids; defaults to grouping by `item.group`. */
  groups?: readonly (readonly string[])[]
  /** Glyph overrides (id → text/emoji). Merged over {@link DEFAULT_GLYPHS}. */
  glyphs?: Readonly<Record<string, string>>
  /** Render the `block` group as a `<select>` dropdown instead of buttons
   * (default true). */
  blockSelect?: boolean
  /** Collaborative-session status. When supplied AND `enabled`, the toolbar
   * appends a presence indicator (connection dot + live peer count). */
  collab?: Signal<CollabStatus>
  'aria-label'?: string
}

ToolbarParts

export interface ToolbarParts {
  root: {
    role: 'toolbar'
    'aria-label': string
    'data-scope': 'md-toolbar'
    'data-part': 'root'
  }
  item: (id: string) => ToolbarItemParts
}

A parsed wikilink. alias is null when the target is shown verbatim.

export interface WikiLink {
  target: string
  alias: string | null
}

WikiLinkPluginOptions

export interface WikiLinkPluginOptions {
  /**
   * Called when the user activates a wikilink. This is the host's resolution
   * seam: `@llui/markdown-editor` knows nothing about what a target names.
   *
   * The notification travels the same route as every other plugin event —
   * `ctx.emit` → the editor's update loop → this plugin's reducer → an effect —
   * rather than a raw DOM event, so an activation is an ordinary TEA message
   * that shows up in devtools, replay and agent traces.
   */
  onNavigate?: (link: WikiLink) => void
  /** Text used as the target when the insert command runs with no selection. */
  placeholderTarget?: string
  /**
   * Document-search seam: as the user types `[[query`, resolve matching
   * documents to offer as link targets, with an optional content preview shown in
   * the panel's reference pane. Sync or async (async is debounced; a stale
   * response for a superseded query is dropped). When omitted, the panel never
   * opens and `[[target]]` still works by typing the closing `]]`.
   */
  search?: (query: string) => readonly DocCandidate[] | Promise<readonly DocCandidate[]>
}

Classes

WikiLinkNode

An atomic inline wikilink. Extends TextNode so the caret, selection and text formats behave exactly as they do for prose, while token mode keeps it indivisible: the user can delete it or move past it, but never edit its interior into a state where the visible alias disagrees with __target.

class WikiLinkNode extends TextNode {
  __target: string
  __alias: string | null
  getType(): string
  clone(node: WikiLinkNode): WikiLinkNode
  constructor(target: string, alias: string | null, text?: string, key?: NodeKey)
  importJSON(serializedNode: SerializedWikiLinkNode): WikiLinkNode
  updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedWikiLinkNode>): this
  exportJSON(): SerializedWikiLinkNode
  createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement
  updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean
  getTarget(): string
  setTarget(target: string): this
  getAlias(): string | null
  setAlias(alias: string | null): this
  getLink(): WikiLink
  canInsertTextBefore(): boolean
  canInsertTextAfter(): boolean
}

Constants

BLOCK_DRAG_Z

Stacking levels for this plugin's two surfaces — deliberately below the shared OVERLAY_Z scale (60+) so document chrome never covers a menu.

const BLOCK_DRAG_Z

CODE_LANGUAGE_PLUGIN

This plugin's registry name (the plugin message envelope's name).

const CODE_LANGUAGE_PLUGIN

FRONTMATTER_BRIDGE_TYPE

The decorator bridge id for the frontmatter block.

const FRONTMATTER_BRIDGE_TYPE

GFM_NODES

Node classes required to render the GFM superset.

const GFM_NODES: ReadonlyArray<Klass<LexicalNode>>

GFM_TRANSFORMERS

Markdown ↔ node transformers for the GFM superset.

const GFM_TRANSFORMERS: readonly Transformer[]

HIGHLIGHT_TRANSFORMER

The ==highlight== transformer. NOT part of the default GFM set: ==..== is not GFM, so exporting it produces non-standard markdown other renderers won't understand. Offered as an opt-in a consumer can add to a plugin's transformers.

const HIGHLIGHT_TRANSFORMER: Transformer

STRIKETHROUGH_CLASS

const STRIKETHROUGH_CLASS
  • @llui/lexical — the low-level Lexical ↔ signal-runtime binding this editor is built on.
  • @llui/dom — the runtime; markdownEditor() is a standard LLui component.
  • Examples on GitHub — full editor wired with every plugin.