@llui/markdown-editor

Current package version: 0.8.8

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/components @llui/interactions @llui/markdown @llui/dom lexical

The editor declares these as peers. Resolve one @llui/dom and @llui/interactions instance across the host and editor so render context, focus, dismissal, and nested-layer ownership stay shared. @llui/lexical and lexical provide 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, 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.

Tables: a plugin with its own entry point

tablePlugin() is the one plugin the barrel does not re-export:

import { tablePlugin } from '@llui/markdown-editor/plugins/table'

markdownEditor({ plugins: [corePlugin(), tablePlugin()] })
function tablePlugin(): MarkdownPlugin

It is the only module in the package that imports @lexical/table, so a barrel re-export made that package's peer mandatory for every consumer — a bundler cannot drop a dependency the entry module it resolves still references. Behind its own entry point, @lexical/table is an optional peer: an editor with no tables neither installs nor bundles it.

Nothing else in the package needs the same treatment. Every other plugin's imports (lexical, @lexical/utils, @lexical/link, @llui/dom, @llui/components/*, …) are peers the editor core itself already pulls in, so moving them out of the barrel would remove no dependency.

Images: displaying a src the document doesn't store

A document whose images are files next to it stores relative paths — ![](attachments/a.png) — because that is what keeps the file portable. A webview resolves a relative src against its own origin, not the document's folder, so the image is broken until something maps it. resolveSrc is that seam:

imagePlugin({
  resolveSrc: (src) => (isRelative(src) ? convertFileSrc(join(vaultRoot, src)) : src),
})

It applies only when building the DOM. The node's data, the markdown the editor emits, and the URL the insert dialog shows all keep the stored value, so the file of record stays portable and the same document opens correctly from another machine.

Two properties worth stating, because both are easy to assume backwards:

  • The argument is the stored src after the image-src allowlist has run — an unsafe URL never reaches your resolver (the image renders blocked instead, <img data-blocked="true"> with no src, so nothing is requested).
  • The result is not re-checked against the allowlist. That is deliberate: returning an app-private scheme (asset:, tauri:) is the whole point, and re-running the allowlist would drop exactly the URLs the seam exists to produce. Treat it as trusted code, and return src unchanged for anything you do not own.

The read-only renderer's counterpart is @llui/markdown's transformLink, which sees links and images with their mdast node. One host function can back both, so a preview and the editor resolve identically.

When the rendering itself has to change — not just the URL — a plugin listed after imagePlugin() that contributes a decorator bridge of type IMAGE_BRIDGE_TYPE replaces the image sub-view wholesale (bridges are registered per editor; the last registration wins). That is the deeper escape hatch: it also means re-declaring the element's markup, so prefer resolveSrc when a URL is all that differs.

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

The core list importer follows the parent item's Markdown content column rather than assuming a fixed four-space indent. Nested bullet, ordered, and task lists therefore accept CommonMark's marker-dependent indentation, including tabs and multi-digit ordered markers, while indented code remains literal item content.

Image lines are parsed with the CommonMark parser from @llui/markdown rather than a regex, so titles (![a](x.png "Title")), angle-bracket destinations (![a](<my file.png>)), and filenames carrying parentheses all mean what CommonMark says they mean. A line that is not exactly one image — an inline image, two on a line, a linked image — is declined and left as text rather than mangled into a block image. parseImageLine / formatImageLine are exported for hosts that need the editor's exact spelling (a paste handler writing an attachment path, say); formatImageLine is the true inverse of parseImageLine.

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

$isMarkdownListNode()

function $isMarkdownListNode(node: LexicalNode | null | undefined): node is MarkdownListNode

$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: ReadSignal<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

formatImageLine()

Render {@link ImageData} back to a markdown line — the exact inverse of {@link parseImageLine} for every value the parser can produce (and for hand-built node data too: control characters are percent-encoded rather than emitted into a line they would break).

function formatImageLine(data: ImageData): string

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[]]

isImageData()

function isImageData(value: unknown): value is ImageData

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[]

parseImageLine()

Parse a single line as CommonMark and return its image, or null when the line is not EXACTLY one image (inline image, two images, a linked image, an image in a list item or blockquote, plain text).

src keeps whatever the document spelled — percent-encoding included; only CommonMark's own escapes and character references are resolved, because those are markdown syntax rather than part of the URL.

function parseImageLine(line: string): ImageData | null

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

publishSurfaceOpen()

Publish the open-state readers for editor's plugin UIs, and return the detach. For the HOST (markdownEditor, or any host built on the same plugin contract); a plugin never calls this.

Call it from the host's lexicalForeign register hook, whose return value IS the disposer — attach and detach are then the same closure, in the same disposer chain that releases the mount's other editor references. NOT from onReady: that hook has no symmetric teardown, so the detach would have to be written somewhere else and could drift out of step with the attach.

register runs while the editor is being built, before any plugin's own register can matter and long before a key can be pressed — the readers only have to exist by the first keystroke, not by any earlier moment.

function publishSurfaceOpen(editor: LexicalEditor, readers: SurfaceReaders): () => void

registerListNodeUpgrade()

Replace a stock ListNode with a MarkdownListNode the first time the document touches it, so a list that never went through $createListNode cannot keep the merging transform.

The registration IS the back-compat half's other end. MARKDOWN_LIST_NODES keeps stock ListNode registered so JSON written before this node existed still loads — but $parseSerializedNodeImpl (LexicalUpdates.ts:433) calls nodeClass.importJSON with NO replacement resolution, so that JSON, and any CRDT document created by an older build, yields a GENUINE stock ListNode carrying ListNode.$config().$transform. Without this the #129 guarantee held only for documents created after the fix — the opposite of the ones the issue was reported from.

The upgrade is on TOUCH, and the window that leaves open is ONE MICROTASK — not "until the user edits". registerNodeTransform itself calls markNodesWithTypesAsDirty (LexicalEditor.ts:1545), which dirties every already-loaded node of the registered types in an editor.update of its own, so both registration orders converge: setEditorState THEN register gives list synchronously and md-list after one microtask; register THEN setEditorState gives md-list immediately. That holds for a non-editable editor and for a list nobody ever edits, too.

What it CANNOT do is pre-empt a stock node's OWN merging transform inside the update that first dirties it. A stock ListNode arriving BETWEEN two settled md-lists in a live update — exactly what @lexical/yjs produces, since Utils.ts:409 builds from registeredNodes.get(type) with the same absent replacement resolution, so an older build feeds stock nodes into LIVE updates and not only at load — runs mergeNextSiblingListIfSameType first: md-list/mk=-(a) + stock b + md-list/mk=*(c) settles as one md-list/mk=-(a|b|c) and the -/* boundary is gone. That is identical to pre-#129 behaviour (stock merges all three as well), so it is not a regression — but the #129 guarantee does NOT extend to a live collab document mixing old and new builds. It is not closable from userland; #154 tracks the two upstream levers that would close it.

Register it wherever MARKDOWN_LIST_NODES is registered; corePlugin does.

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

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

surfaceGate()

The gate a plugin's register half reads: "is MY surface up?". Resolved at call time, so it is correct however late the host publishes and however the surface was closed (Escape, a chosen row, the caret leaving the trigger).

function surfaceGate(editor: LexicalEditor, plugin: string): () => boolean

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' }

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.

At least one slot must be filled — a binding that fills neither registers nothing at all, which the union below rejects at compile time. Either way the built-in history stack is off in collab mode (the editor hard-codes it), so a binding that owns no undo shows as "no undo" rather than fighting a local stack.

export type CollabBinding =
  | (CollabBindingSlots & { externalUndo: (editor: LexicalEditor) => () => void })
  | (CollabBindingSlots & { register: ForeignRegister })

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 }
  /** The seam's verdict on the preceding `applyValue` effect: `applied` is true
   * only when the document was actually written. Reported by the seam because it
   * is the sole authority on that question — nothing here may re-derive it by
   * comparing values (issue #70). */
  | { type: 'valueApplied'; applied: boolean }
  | { 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'

ListMarker

The character a list was authored with: a bullet (-/*/+) or an ordered delimiter (./)). Both are "the marker" for CommonMark's purposes — §5.3 gives them the same rule.

export type ListMarker = '-' | '*' | '+' | '.' | ')'

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
>

SurfaceReaders

plugin name → "is that plugin's surface up right now?".

export type SurfaceReaders = ReadonlyMap<string, () => boolean>

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
}

CollabBindingSlots

The two disposer-returning registration slots a collab binding may fill. Both are wired at mount, in this order, after the editor's own registrations. See {@link CollabBinding}, which requires at least one of them.

export interface CollabBindingSlots {
  /**
   * Document sync / presence / anything that is NOT the undo stack.
   * `@llui/lexical-loro` splits its binding this way.
   *
   * Typed as `ForeignRegister`, so it rejects a binding branded as an undo owner
   * (`@llui/lexical-collab`'s) — that belongs in `externalUndo` below.
   */
  register?: ForeignRegister
  /**
   * A CRDT-aware undo owner. 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.
   *
   * `@llui/lexical-collab` exposes its WHOLE binding here (its undo commands live
   * inside it) precisely so no caller can route it through `register` and keep
   * the local stack alive; `@llui/lexical-loro` sets it alongside a separate
   * `register`.
   */
  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?: ReadSignal<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: ReadSignal<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>
  /** Whether the DOCUMENT has moved since the seed. Set only when the seam
   * reports a real write — an edit (`markdownChanged`) or a push it accepted
   * (`valueApplied`). A push the seam declines leaves it alone, so this stays
   * "the document changed" and never degrades into "a push was made". */
  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
}

ImageData

An image node's serialized data — exactly the three CommonMark fields, so the markdown is the source of truth and the node holds nothing derived. src is stored VERBATIM (a document-relative path stays document-relative); mapping it to a loadable URL is a render-time concern.

export interface ImageData {
  src: string
  alt: string
  title?: string
}

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>
  /**
   * Map the stored `src` to the URL the `<img>` should load. RENDER-TIME ONLY:
   * the node's data, the serialized markdown, and the URL the insert dialog shows
   * are all unchanged, so a document that stores a portable relative path
   * (`attachments/a.png`) keeps storing it while the editor displays whatever the
   * host can actually load (`asset://localhost/…/attachments/a.png`).
   *
   * The argument is the stored value after the image-src allowlist has run, so it
   * is never a `javascript:` URL; an unsafe src never reaches the resolver at all
   * (the image renders blocked instead). The RESULT is not re-checked — returning
   * an app-private scheme is the point — so treat it as the trusted boundary it
   * is, and return `src` unchanged for anything the host does not own.
   *
   * Called during rendering, so it must be a pure function of `src` for the
   * lifetime of the mount: it re-runs when the node's `src` changes, not when
   * something the closure captured does. If the mapping itself changes (the host
   * opens a different vault), remount the editor.
   *
   * The read-only renderer's counterpart is `@llui/markdown`'s `transformLink`,
   * which sees links and images with their mdast node; one host function can back
   * both. Defaults to identity.
   */
  resolveSrc?: (src: string) => 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: ReadSignal<DialogState>
  /** The URL input value. */
  url: ReadSignal<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[]]
  /** See {@link PluginUISpec.isOpen}. */
  isOpen?: (state: unknown) => boolean
  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[]]
  /**
   * Is this plugin's floating surface UP right now? Declare it whenever the
   * plugin's `register` half claims keys for that surface (a typeahead answering
   * `true` to KEY_ESCAPE / KEY_ENTER / the arrows at COMMAND_PRIORITY_HIGH).
   *
   * The host publishes it per editor so `register` can gate on it — see
   * `surfaceGate` in `./surface-open.js`, and issue #130 for what claiming a key
   * on any other basis costs the host handler sitting below.
   *
   * Pure and cheap: it is called on the keystroke path, from live state.
   */
  isOpen?: (state: S) => boolean
  /** 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': ReadSignal<'true' | 'false'>
  'aria-disabled': ReadSignal<'true' | undefined>
  disabled: ReadSignal<boolean>
  'data-active': ReadSignal<'' | undefined>
  onClick: (e: MouseEvent) => void
}

ToolbarOptions

export interface ToolbarOptions {
  format: ReadSignal<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?: ReadSignal<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

MarkdownListNode

A list that remembers the character it was authored with.

class MarkdownListNode extends ListNode {
  $config()
  getMarker(): ListMarker | null
  setMarker(marker: ListMarker | null): this
}

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

CHECK_LIST_TRANSFORMER

- [ ] / - [x] / - [X] (and a bare [ ]) ⇄ a check list.

const CHECK_LIST_TRANSFORMER: ElementTransformer

CODE_INFO_TRANSFORMER

A drop-in replacement for @lexical/markdown's CODE that treats the whole remainder of the opening-fence line as the info string (CommonMark's rule) instead of a single [\w-]+ token.

const CODE_INFO_TRANSFORMER: MultilineElementTransformer

CODE_LANGUAGE_PLUGIN

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

const CODE_LANGUAGE_PLUGIN

COLLAB_OFF

const COLLAB_OFF: CollabStatus

DEFAULT_EMOJI

A small default shortcode → emoji map. Extend via emojiPlugin({ emoji }).

const DEFAULT_EMOJI: Readonly<Record<string, string>>

DEFAULT_GLYPHS

Compact glyphs so the default toolbar reads as a real toolbar without icon assets. SVG strings render as icons; everything else as text. Override via ToolbarOptions.glyphs.

const DEFAULT_GLYPHS: Readonly<Record<string, string>>

defaultTheme

The class hooks Lexical needs for text-decoration formats it renders as a plain .

const defaultTheme: EditorThemeClasses

EMPTY_FORMAT

const EMPTY_FORMAT: FormatState

FRONTMATTER_BRIDGE_TYPE

The decorator bridge id for the frontmatter block.

const FRONTMATTER_BRIDGE_TYPE

FRONTMATTER_TRANSFORMER

const FRONTMATTER_TRANSFORMER: MultilineElementTransformer

GFM_NODES

Node classes required to render the GFM superset.

LexicalNodeConfig, not Klass<LexicalNode>: lists are registered as a { replace, with, withKlass } redirect onto MarkdownListNode, which is the only way to take a node's own $config transform out of play. See nodes/list.ts — without it two adjacent lists with different markers cannot exist in the tree at all, whoever built them.

const GFM_NODES: ReadonlyArray<LexicalNodeConfig>

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

IMAGE_BRIDGE_TYPE

The decorator bridge id an image node renders through. Exported because it is the address a consumer needs to REPLACE the image rendering wholesale: a plugin listed after imagePlugin() contributing a bridge of this type wins (bridges are registered per editor, last registration first). Reach for that only when the rendering itself must change — mapping the URL is what ImagePluginOptions.resolveSrc is for.

const IMAGE_BRIDGE_TYPE

IMAGE_TRANSFORMER

![alt](src "title") ⇄ an image decorator node. Contributed by imagePlugin(); exported for consumers assembling a transformer set by hand.

const IMAGE_TRANSFORMER: ElementTransformer

MARKDOWN_LIST_NODES

The node registrations a marker-aware editor needs: the stock ListNode (which the replacement is keyed on and which still deserializes any document saved before this existed — see registerListNodeUpgrade above for what has to happen to such a node next), this subclass, and the redirect that makes $createListNode — and therefore every list command, transformer and DOM conversion in @lexical/list — produce the subclass.

const MARKDOWN_LIST_NODES: readonly LexicalNodeConfig[]

ORDERED_LIST_TRANSFORMER

1. a / 1) a ⇄ an ordered list.

Upstream only reads .; CommonMark gives ) equal standing and §5.3 treats a delimiter change exactly like a bullet change, which is the third case in #129's acceptance criteria. No blank line is needed between them: $convertFromMarkdownString's shouldMergeAdjacentLines defaults to false and no call site in this package overrides it, so 1. a\n1) b imports as two ordered lists. (Stock swallows 1) b as text, so this is strictly better.)

const ORDERED_LIST_TRANSFORMER: ElementTransformer

STRIKETHROUGH_CLASS

const STRIKETHROUGH_CLASS

UNORDERED_LIST_TRANSFORMER

- a / * a / + a ⇄ a bullet list.

const UNORDERED_LIST_TRANSFORMER: ElementTransformer

Public Entry Points

@llui/markdown-editor/plugins/core

Functions

corePlugin() from @llui/markdown-editor/plugins/core
function corePlugin(_opts: CorePluginOptions = {}): MarkdownPlugin

Interfaces

CorePluginOptions from @llui/markdown-editor/plugins/core
export interface CorePluginOptions {
  /** Reserved for future core options. */
  readonly _?: never
}

@llui/markdown-editor/plugins/callout

Functions

$insertCallout() from @llui/markdown-editor/plugins/callout

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

function $insertCallout(kind: CalloutKind = 'note', textValue = 'New callout'): LLuiDecoratorNode
calloutPlugin() from @llui/markdown-editor/plugins/callout
function calloutPlugin(opts: CalloutPluginOptions = {}): MarkdownPlugin

Types

CalloutKind from @llui/markdown-editor/plugins/callout
export type CalloutKind = 'note' | 'tip' | 'warning' | 'danger'

Interfaces

CalloutData from @llui/markdown-editor/plugins/callout
export interface CalloutData {
  kind: CalloutKind
  text: string
}
CalloutPluginOptions from @llui/markdown-editor/plugins/callout
export interface CalloutPluginOptions {
  /** Default kind for the toolbar/slash insert action. */
  defaultKind?: CalloutKind
}

@llui/markdown-editor/surfaces/toolbar

Functions

connectToolbar() from @llui/markdown-editor/surfaces/toolbar

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: ReadSignal<FormatState>, send: Send<EditorMsg>, items: readonly CommandItem[]): ToolbarParts
toolbar() from @llui/markdown-editor/surfaces/toolbar

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

function toolbar(opts: ToolbarOptions): Mountable

Interfaces

ToolbarItemParts from @llui/markdown-editor/surfaces/toolbar
export interface ToolbarItemParts {
  type: 'button'
  'data-scope': 'md-toolbar'
  'data-part': 'item'
  'data-id': string
  'aria-label': string
  title: string
  'aria-pressed': ReadSignal<'true' | 'false'>
  'aria-disabled': ReadSignal<'true' | undefined>
  disabled: ReadSignal<boolean>
  'data-active': ReadSignal<'' | undefined>
  onClick: (e: MouseEvent) => void
}
ToolbarOptions from @llui/markdown-editor/surfaces/toolbar
export interface ToolbarOptions {
  format: ReadSignal<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?: ReadSignal<CollabStatus>
  'aria-label'?: string
}
ToolbarParts from @llui/markdown-editor/surfaces/toolbar
export interface ToolbarParts {
  root: {
    role: 'toolbar'
    'aria-label': string
    'data-scope': 'md-toolbar'
    'data-part': 'root'
  }
  item: (id: string) => ToolbarItemParts
}

Constants

DEFAULT_GLYPHS from @llui/markdown-editor/surfaces/toolbar

Compact glyphs so the default toolbar reads as a real toolbar without icon assets. SVG strings render as icons; everything else as text. Override via ToolbarOptions.glyphs.

const DEFAULT_GLYPHS: Readonly<Record<string, string>>

@llui/markdown-editor/plugins/block-drag

Functions

blockAtPoint() from @llui/markdown-editor/plugins/block-drag

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() from @llui/markdown-editor/plugins/block-drag

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
findDropTarget() from @llui/markdown-editor/plugins/block-drag

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
indicatorRect() from @llui/markdown-editor/plugins/block-drag

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

Types

Place from @llui/markdown-editor/plugins/block-drag

Which side of the target block the source lands on.

export type Place = 'before' | 'after'

Interfaces

BlockDragOptions from @llui/markdown-editor/plugins/block-drag
export interface BlockDragOptions {
  /** Gutter grip inset, in px left of the block's left edge. Default 28. */
  gutterOffset?: number
}
BlockRect from @llui/markdown-editor/plugins/block-drag

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
}
DropTarget from @llui/markdown-editor/plugins/block-drag

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

export interface DropTarget {
  key: NodeKey
  place: Place
}
IndicatorRect from @llui/markdown-editor/plugins/block-drag

Viewport position of the drop-indicator line.

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

Constants

BLOCK_DRAG_Z from @llui/markdown-editor/plugins/block-drag

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

@llui/markdown-editor/plugins/code-language

Functions

codeLanguagePlugin() from @llui/markdown-editor/plugins/code-language
function codeLanguagePlugin(opts: CodeLanguagePluginOptions = {}): MarkdownPlugin
normalizeCodeInfo() from @llui/markdown-editor/plugins/code-language

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

Types

CodeLanguageEffect from @llui/markdown-editor/plugins/code-language

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

export type CodeLanguageEffect = { type: 'apply'; key: string; language: string | null }
CodeLanguageMsg from @llui/markdown-editor/plugins/code-language
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' }

Interfaces

CodeLanguagePluginOptions from @llui/markdown-editor/plugins/code-language
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 from @llui/markdown-editor/plugins/code-language

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
}

Constants

CODE_INFO_TRANSFORMER from @llui/markdown-editor/plugins/code-language

A drop-in replacement for @lexical/markdown's CODE that treats the whole remainder of the opening-fence line as the info string (CommonMark's rule) instead of a single [\w-]+ token.

const CODE_INFO_TRANSFORMER: MultilineElementTransformer
CODE_LANGUAGE_PLUGIN from @llui/markdown-editor/plugins/code-language

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

const CODE_LANGUAGE_PLUGIN

Functions

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
function $isWikiLinkNode(node: LexicalNode | null | undefined): node is WikiLinkNode

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

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

Sanitize an alias. Returns null when nothing usable survives.

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

Sanitize a target. Returns null when nothing usable survives.

function sanitizeWikiLinkTarget(raw: string): string | null
function wikilinkPlugin(opts: WikiLinkPluginOptions = {}): MarkdownPlugin

Types

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

Interfaces

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
}

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

export interface WikiLink {
  target: string
  alias: string | null
}
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

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
}

@llui/markdown-editor/plugins/table

Functions

tablePlugin() from @llui/markdown-editor/plugins/table
function tablePlugin(): MarkdownPlugin
  • @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.