@llui/lexical

Current package version: 0.5.2

The seam between Lexical (Meta's extensible text-editor framework) and the LLui signal runtime. It mounts a Lexical editor as a foreign() island inside an LLui view, defines a small plugin contract so editor behavior composes the same way LLui views do, and bridges Lexical DecoratorNodes to LLui sub-views so rich embeds (callouts, math, images, …) are authored as ordinary LLui components.

This is the low-level layer. If you want a ready-made WYSIWYG editor, reach for @llui/markdown-editor, which is built on top of this package. Use @llui/lexical directly when you are building your own editor surface or a custom node type.

pnpm add @llui/lexical @llui/dom lexical

lexical is a peer dependency — you bring the Lexical version you want.

The three seams

  • lexicalForeign(opts) — wraps a Lexical editor as an LLui foreign() mountable. LLui owns the host node; Lexical owns everything inside it. Editor output (selection/format changes, content edits) flows back out as messages, so the surrounding LLui component reacts with the normal update/view cycle.
  • Plugin contract (LexicalPlugin, PluginContext, decoratorBridge, registerShortcuts) — register(editor, ctx) receives the live editor plus a PluginContext: emit (send a message to the host's update loop) and the commit hub's onCommit / withFacts (see below). A plugin registers commands, nodes, and key bindings, and declares its own nodes/shortcuts/UI as plain values on the plugin object. Plugins are plain values you compose into a list, mirroring how LLui structural primitives compose.
  • Decorator bridge (LLuiDecoratorNode, registerDecoratorBridges, DecoratorBridge) — a Lexical DecoratorNode whose rendered body is an LLui sub-view. Author an embed once as an LLui component; the bridge mounts/unmounts it as Lexical inserts and removes the node.

Reading selection state

readBaseFormat / $readBaseFormat collapse the active Lexical selection into a plain, serializable BaseFormat (block type, alignment, bold/italic/…) — the shape a toolbar binds to. The $-prefixed variant runs inside a Lexical read transaction; the unprefixed one wraps that for you.

The commit hub

A plugin that reacts to the caret subscribes with ctx.onCommit(facts => …) rather than registering its own editor.registerUpdateListener. lexicalForeign builds one CommitHub per editor (createCommitHub) and hands it to every plugin as the PluginContext, so one update listener, one editorState.read(), one caret measurement and one shared ancestor walk serve every subscriber. The listener is registered on the FIRST subscription, so an editor whose plugins never subscribe pays nothing.

The callback runs inside the shared read: bare $ helpers are free, writes are forbidden, and ctx.emit is buffered until the read closes — which is what keeps the read-then-dispatch anti-pattern out of the plugin contract. ctx.withFacts(fn) derives the same CommitFacts on demand for the scroll/resize path, where geometry moved but the document did not. facts.selectionOnly marks a commit that dirtied no node, so an element-anchored overlay can skip its getBoundingClientRect outright.

A commit is batched: every subscriber refreshes first, then every buffered emission drains in subscription order. Do not write a plugin that depends on a sibling's message having been reduced by the time yours runs.

Breaking: PluginContext therefore carries onCommit and withFacts as well as emit — code that hand-rolled a { emit } context (tests, custom hosts) should call createCommitHub(editor, emit) instead.

API

Functions

$createLLuiDecoratorNode()

Create a decorator node for bridgeType carrying data.

function $createLLuiDecoratorNode(bridgeType: string, data: unknown): LLuiDecoratorNode

$isLLuiDecoratorNode()

function $isLLuiDecoratorNode(node: LexicalNode | null | undefined): node is LLuiDecoratorNode

$readBaseFormat()

Read the base format at the current selection. Must run inside a Lexical read/update context (it calls $-prefixed APIs).

function $readBaseFormat(): BaseFormat

createCommitHub()

Create the per-editor commit hub. emit is the host's raw emit; the returned emit is the buffered one every plugin should use.

function createCommitHub<Emit>(editor: LexicalEditor, emit: (msg: Emit) => void): CommitHub<Emit>

createWidgetRuntime()

Build the widget runtime for a set of registrations.

Called by lexicalForeign ONLY when at least one widget is registered — when none are, createEditor is invoked exactly as it was before this seam existed, so every existing consumer sees zero behaviour change and zero exposure to the experimental APIs above.

function createWidgetRuntime(widgets: readonly NodeWidget[]): WidgetRuntime

decoratorBridge()

Author-facing constructor for a {@link DecoratorBridge}. The view builder receives a REACTIVE Signal<Data> (not a snapshot) plus the node api, and returns the sub-view's DOM. The bridge wraps it in a tiny host component whose single state field IS the data, so a later mount.update(next) simply drives that signal — the sub-view re-renders in place, never remounting (the fix for focus/selection loss on every data commit). Data is narrowed from the node's serialized payload at mount (the single deserialization-boundary cast, exactly like JSON.parse returning a declared type).

function decoratorBridge<Data>(type: string, view: (data: Signal<Data>, api: DecoratorApi<Data>) => Renderable): DecoratorBridge

externalUndoOwner()

Mark register as the owner of the undo/redo stack, so it can only be wired into the seam slot that turns the built-in history stack off. The brand is inert at runtime — register is returned as-is, carrying one extra symbol property that nothing reads.

function externalUndoOwner(register: (editor: LexicalEditor) => () => void): ExternalUndoOwner

isMacPlatform()

Best-effort macOS detection (browser only; defaults to false off-DOM).

function isMacPlatform(): boolean

isNodeWidgetHost()

True when el is a widget host produced by this seam. Exported so a consumer (or a test) can assert overlay-vs-document without reaching for the experimental isDOMUnmanaged itself.

function isNodeWidgetHost(el: Node): boolean

lexicalForeign()

Mount Lexical into an LLui view. Returns a Mountable placed in the view array; Lexical is created on mount and destroyed on the component's dispose.

function lexicalForeign<Emit = unknown>(opts: LexicalForeignOptions<Emit>): Mountable

matchesCombo()

Does a keyboard event satisfy a parsed chord? mod maps to ⌘ on macOS and Ctrl elsewhere; all four modifier keys must match the resolved requirement exactly (no extras held).

function matchesCombo(event: KeyboardEvent, combo: ParsedCombo, isMac: boolean): boolean

nodeWidget()

Author-facing constructor for a {@link NodeWidget}.

It exists for inference: Source is inferred from source and flows into render, equals, and decorateHost without the caller writing any type parameters. The returned descriptor is type-erased (the registry is monomorphic); the casts below are the single erasure boundary, and they are sound because WidgetContext is covariant in N and each callback only ever receives back the values this same spec produced.

function nodeWidget<N extends LexicalNode, Source>(spec: WidgetSpec<N, Source>): NodeWidget

parseCombo()

Parse a chord like Mod-Shift-7 into its parts. Case-insensitive on modifiers; the final segment is the key (lower-cased for letters).

function parseCombo(combo: string): ParsedCombo

readBaseFormat()

Convenience wrapper that opens a read context on editor.

function readBaseFormat(editor: LexicalEditor): BaseFormat

registerDecoratorBridges()

Wire decorator bridges onto an editor: register the bridge registry, place each decoration element into its node's DOM, and dispose sub-apps when their nodes are destroyed. Returns a disposer that tears down all live sub-apps. Typically called from a plugin's register.

function registerDecoratorBridges(editor: LexicalEditor, bridges: readonly DecoratorBridge[]): () => void

registerShortcuts()

Register a set of shortcuts on the editor through one KEY_DOWN handler. Returns a disposer. The first matching shortcut whose run returns true wins and the event is consumed.

function registerShortcuts(editor: LexicalEditor, shortcuts: readonly ShortcutSpec[]): () => void

Types

Alignment

export type Alignment = 'left' | 'center' | 'right' | 'justify' | 'start' | 'end' | null

BaseBlockType

Block kinds resolvable without list/code packages. Anything else → 'other', which the markdown layer refines (list, code, …).

export type BaseBlockType =
  | 'paragraph'
  | 'h1'
  | 'h2'
  | 'h3'
  | 'h4'
  | 'h5'
  | 'h6'
  | 'quote'
  | 'other'

CommitListener

A per-commit subscriber. Runs inside the shared read context.

export type CommitListener = (facts: CommitFacts) => void

ExternalUndoOwner

A registration function that OWNS the editor's undo/redo stack. Built with {@link externalUndoOwner}; accepted by {@link LexicalForeignOptions.externalUndo} and REJECTED by {@link LexicalForeignOptions.register}.

export type ExternalUndoOwner = ((editor: LexicalEditor) => () => void) & {
  readonly [EXTERNAL_UNDO_BRAND]: true
}

ForeignRegister

A registration function that does NOT own undo — the shape of the seam's register slot. Any plain (editor) => () => void satisfies it; only an {@link ExternalUndoOwner} does not.

export type ForeignRegister = ((editor: LexicalEditor) => () => void) & {
  readonly [EXTERNAL_UNDO_BRAND]?: never
}

SerializedLLuiDecoratorNode

export type SerializedLLuiDecoratorNode = Spread<
  { bridgeType: string; data: unknown },
  SerializedLexicalNode
>

WidgetPlacement

Where a widget's DOM sits relative to the host node's lexical-managed children.

'tail' — after every managed child (the default, and the safe choice). 'head' — before every managed child.

There is deliberately no third value: an interleaved widget would skew ElementDOMSlot.resolveChildIndex, which counts raw childNodes, and mis-place the caret on click. See the header, clobbering path 2.

export type WidgetPlacement = 'head' | 'tail'

Interfaces

BaseFormat

The generic format surface at the current selection.

export interface BaseFormat {
  bold: boolean
  italic: boolean
  strikethrough: boolean
  underline: boolean
  code: boolean
  blockType: BaseBlockType
  alignment: Alignment
  /** The resolved top-level block element key (lets the markdown layer refine). */
  blockKey: string | null
  hasSelection: boolean
  isCollapsed: boolean
}

CommitFacts

The selection facts shared by every per-commit plugin, derived once.

A facts object is valid ONLY for the duration of the callback it is handed to: it is scoped to one read context, and its geometry memo is scoped to one commit. Do not retain it — copy out the plain values instead.

export interface CommitFacts {
  /** The editor this commit belongs to. */
  editor: LexicalEditor
  /**
   * The commit dirtied no node — a pure selection move.
   *
   * The reconciler wrote nothing, so no element's box can have shifted: an
   * element-anchored overlay whose anchor node is unchanged may skip its
   * `getBoundingClientRect` outright. (Scroll and resize move boxes without any
   * commit at all; those arrive through the host's viewport listener, not here.)
   */
  selectionOnly: boolean
  /** The live selection, or null. Already resolved — no `$getSelection()` needed. */
  selection: BaseSelection | null
  /** The selection is a `RangeSelection` (caret or text range). */
  isRange: boolean
  /** The selection is a collapsed `RangeSelection` — i.e. a bare caret. */
  isCollapsed: boolean
  /** The range anchor's node, or null when the selection is not a range. */
  anchorNode: LexicalNode | null
  /**
   * The anchor text node's content up to the caret — the substring every
   * typeahead trigger (`/`, `@`, `[[`) scans. Null unless the selection is a
   * collapsed range whose anchor is a `TextNode`, which is exactly the condition
   * all three typeaheads already guard on.
   *
   * Derived on first read and memoized for the commit (it is an O(text-node)
   * string copy), so an editor with no typeahead plugin never pays for it.
   */
  readonly textBeforeCaret: string | null
  /**
   * `node` and its ancestors up to (excluding) the root — i.e. exactly the chain
   * `$findMatchingParent` walks, memoized per commit per node key.
   *
   * This is the one shared fact that is a WALK rather than a field, and it is
   * where the real per-commit work was: three plugins each climbed the tree from
   * the same anchor on every keystroke (code-language looking for a `CodeNode`,
   * the floating toolbar for a `LinkNode`, table for a `TableCellNode` — and
   * `$getTableCellNodeFromLexicalNode` is literally `$findMatchingParent`). They
   * now scan one shared array instead. Pass `facts.anchorNode` for the usual
   * case; a plugin working from some other node (a table selection's anchor,
   * which the hub cannot resolve without depending on `@lexical/table`) passes
   * that instead and simply misses the memo.
   */
  ancestorsOf: (node: LexicalNode | null) => readonly LexicalNode[]
  /**
   * Viewport rect of the DOM selection range, measured at most once per commit
   * however many subscribers ask. Null off-DOM or with no live range.
   */
  caretRect: () => DOMRect | null
  /**
   * Viewport rect of a node's element, measured at most once per commit per key.
   * Null when the key has no element (not yet reconciled, or detached).
   */
  elementRect: (key: string) => DOMRect | null
}

CommitHub

The host-owned hub. It IS the {@link PluginContext} a plugin's register receives — lexicalForeign builds one per editor and hands it straight over, and a test driving a plugin standalone can do the same rather than hand-rolling a context object.

export interface CommitHub<Emit> extends PluginContext<Emit> {
  /** Tear down the update listener (if one was ever registered). */
  dispose: () => void
}

DecoratorApi

Imperative handle a decorator sub-view uses to talk to its Lexical node.

export interface DecoratorApi<Data> {
  /** Persist new node data (writes into the Lexical node → markdown-serializable). */
  update: (next: Data) => void
  /** The owning Lexical editor (for dispatching commands, reading state). */
  editor: LexicalEditor
}

DecoratorBridge

Bridges a custom node type to an LLui sub-view. The sub-view's Data type is erased here: a bridge is stored monomorphically in the registry, and mount builds + mounts the sub-app ONCE, returning a {@link DecoratorMount} whose update channel reactively pushes later data changes in place. Authors construct bridges with the typed {@link decoratorBridge} helper.

export interface DecoratorBridge {
  /** The id used by the contributing markdown transformer. */
  type: string
  /** Mount the sub-view for a node's (deserialized) data ONCE; returns a live
   * {@link DecoratorMount} (dispose + reactive data-push). */
  mount: (container: Element, data: unknown, api: DecoratorApi<unknown>) => DecoratorMount
}

ForeignController

The seam's inbound write path — the ONE place that decides whether a value coming from the host is an echo of the live document.

Handed to the host at {@link LexicalForeignOptions.onReady} so an imperative push (a setValue-style message) goes through the same authority as the controlled value signal. A host that writes markdown into the editor any other way has re-opened the seam and owns the consequences.

export interface ForeignController {
  /** Apply `value` to the document unless the document already holds it —
   * "already holds it" meaning `value` deserializes to the same document, not
   * that it is the same string. Returns whether the document was written. */
  applyValue: (value: string) => boolean
}

LexicalForeignOptions

export interface LexicalForeignOptions<Emit = unknown> {
  /** Editor namespace (instance isolation; required for distinct editors). */
  namespace: string
  theme?: EditorThemeClasses
  /** Node classes registered in addition to the plugins' own nodes. */
  nodes?: ReadonlyArray<LexicalNodeConfig>
  /** Plugins: their `nodes` are merged, `register`/`shortcuts` wired at mount. */
  plugins?: ReadonlyArray<LexicalPlugin<Emit>>
  /** Non-document overlay DOM registrations, composed with the plugins' own
   * `widgets`. See {@link nodeWidget}. When the composed list is EMPTY the
   * editor is created exactly as it was before this option existed — no
   * render-config override, no experimental API in play. */
  widgets?: ReadonlyArray<NodeWidget>
  /** Serialize the live document → string (runs in a read context the seam
   * opens, so bare `$` helpers are fine and no read of its own is needed).
   * Called with the editor whose document the seam is asking about — usually the
   * live one, but on the inbound path also a scratch editor holding a candidate
   * document, so read through the argument rather than a captured editor. */
  serialize: (editor: LexicalEditor) => string
  /** Deserialize a string into the document (runs in an update context). */
  deserialize: (editor: LexicalEditor, value: string) => void
  /** Initial document (uncontrolled) — ignored when `value` is provided. */
  defaultValue?: string
  /** Controlled document signal; the editor follows it (echo-guarded). */
  value?: ReadSignal<string>
  /** Reactive read-only flag (always supplied by the host's state). */
  readonly: ReadSignal<boolean>
  /** Debounce window (ms) for outbound serialization. Default 300. */
  changeDebounceMs?: number
  /** Register the built-in `@lexical/history` undo stack. Default `true`.
   * Set `false` when an external owner provides history (e.g. a CRDT undo
   * manager in collab mode) — a local stack would shadow it and cross peers.
   * Prefer {@link ForeignOptions.externalUndo} over setting this manually:
   * it owns undo AND disables the built-in stack in one place, so the two
   * can't both be live. */
  history?: boolean
  /** An external owner of the undo/redo stack (e.g. `@llui/lexical-collab`'s
   * CRDT undo manager). When set, the built-in `@lexical/history` stack is
   * **forced off** — so a collab consumer cannot accidentally run both and
   * double-apply undo (the conflict is unrepresentable, not a doc footnote).
   * Registered after rich-text like {@link LexicalForeignOptions.register};
   * return a disposer. Setting `externalUndo` together with `history: true` is a
   * configuration error and is reported.
   *
   * Accepts a plain function (`@llui/lexical-loro` passes one) as well as an
   * {@link ExternalUndoOwner} — the branded form, which the `register` slot
   * refuses so an undo owner can never be wired into it by mistake. */
  externalUndo?: (editor: LexicalEditor) => () => void
  /** When the document is seeded. `'auto'` (default) seeds from
   * `value`/`defaultValue` at mount. `'deferred'` skips the boot-time seed so an
   * external owner controls it (e.g. collab seeds once, gated on provider sync,
   * only if the shared doc is still empty). */
  seedMode?: 'auto' | 'deferred'
  /** Outbound: serialized document changed (debounced, real edits only). */
  onChange?: (value: string) => void
  /** Outbound: selection / format / structure changed (every commit). */
  onSelectionChange?: (ctx: SelectionContext) => void
  /** Host emit, handed to each plugin's `register` context. */
  emit?: (msg: Emit) => void
  /** Receives the live editor at mount (host dispatches commands through it),
   * plus the seam's {@link ForeignController} — the only sanctioned way for the
   * host to push a value into the document. */
  onReady?: (editor: LexicalEditor, controller: ForeignController) => void
  /** Extra registration after rich-text (e.g. markdown shortcuts). Disposer.
   *
   * This slot leaves the built-in `@lexical/history` stack registered, so it
   * REJECTS an {@link ExternalUndoOwner} — a binding that owns undo belongs in
   * {@link LexicalForeignOptions.externalUndo}, which turns that stack off. Any
   * plain `(editor) => () => void` is accepted unchanged. */
  register?: ForeignRegister
  onError?: (error: Error) => void
}

LexicalPlugin

A composable unit of editor behaviour.

export interface LexicalPlugin<Emit = unknown> {
  /** Stable identifier (also used for de-duplication and overrides). */
  name: string
  /**
   * Lexical node classes registered on the editor config.
   *
   * `LexicalNodeConfig` — not `Klass<LexicalNode>` — so a plugin can register
   * the `{ replace, with, withKlass }` replacement form. Subclassing a built-in
   * node (e.g. to reserve a DOM slot boundary via `getDOMSlot`) is only
   * expressible that way, and the runtime already passes these straight to
   * `createEditor`.
   */
  nodes?: ReadonlyArray<LexicalNodeConfig>
  /** Imperative registration (commands, listeners). Returns a disposer. */
  register?: (editor: LexicalEditor, ctx: PluginContext<Emit>) => () => void
  /** Keyboard shortcuts wired through a single KEY_DOWN command. */
  shortcuts?: readonly ShortcutSpec[]
  /** Decorator bridges this plugin owns. */
  decorators?: readonly DecoratorBridge[]
  /**
   * Non-document overlay DOM this plugin attaches to node types — computed
   * results, badges, ghosts. Unlike `decorators`, a widget is NOT a node: it is
   * never serialized, never in the undo stack, never in the clipboard. See
   * {@link nodeWidget}.
   */
  widgets?: readonly NodeWidget[]
}

NodeWidget

An opaque widget registration, contributed via lexicalForeign({ widgets }) or a plugin's widgets field.

export interface NodeWidget {
  readonly id: string
  /** @internal */ readonly __spec: ErasedWidgetSpec
}

ParsedCombo

A parsed chord. mod means ⌘ on macOS / Ctrl elsewhere.

export interface ParsedCombo {
  key: string
  mod: boolean
  shift: boolean
  alt: boolean
  ctrl: boolean
}

PluginContext

Context handed to plugin.register so a plugin can talk back to the host (e.g. open a slash menu) without owning the host's send. Emit is the host message type; @llui/lexical leaves it unknown, hosts narrow it.

export interface PluginContext<Emit = unknown> {
  /** Emit a host message into the embedding component's update loop. */
  emit: (msg: Emit) => void
  /**
   * Subscribe to the host's SHARED per-commit selection facts instead of
   * registering a private `editor.registerUpdateListener`. One listener, one
   * editor-state read and one caret measurement serve every subscriber; see
   * {@link CommitFacts} for what the callback may assume (notably: it runs
   * inside a read context, so `$` walks are free and writes are forbidden —
   * `emit` from here is buffered until the read closes). Returns a disposer.
   *
   * A commit is BATCHED: every subscriber refreshes first, then every buffered
   * emission drains in subscription order. A subscriber therefore measures
   * against the DOM as the commit left it, never as another plugin's overlay
   * reconcile has since changed it. Do not rely on a sibling plugin's message
   * having been reduced by the time yours runs.
   */
  onCommit: (listener: CommitListener) => () => void
  /**
   * Derive the same facts on demand, outside a commit — the scroll / resize
   * path, where geometry moved but the document did not.
   */
  withFacts: (fn: CommitListener) => void
}

SelectionContext

Context handed to the selection callback on every commit.

export interface SelectionContext {
  editor: LexicalEditor
  canUndo: boolean
  canRedo: boolean
}

ShortcutSpec

A keyboard shortcut bound to an editor action.

combo is a normalized chord: Mod resolves to ⌘ on macOS and Ctrl elsewhere, e.g. Mod-b, Mod-Shift-7, Mod-Alt-1. run returns true when it handled the event (which stops propagation / prevents default).

export interface ShortcutSpec {
  combo: string
  run: (editor: LexicalEditor) => boolean
}

WidgetContext

Everything a widget renderer is told about its host.

export interface WidgetContext<N extends LexicalNode> {
  /** The host node. Read inside the active editor state (the runtime calls
   * every hook from inside the reconciler, so `$`-prefixed reads are legal). */
  readonly node: N
  /** The host node's key. Stable for the node's lifetime — the identity a
   * consumer should key a memo cache by. */
  readonly key: NodeKey
  readonly editor: LexicalEditor
}

WidgetDisposeContext

What a widget's dispose is told. No node: teardown most often fires because the node was destroyed.

export interface WidgetDisposeContext {
  readonly key: NodeKey
  readonly editor: LexicalEditor
}

WidgetRuntime

What {@link createWidgetRuntime} hands back to lexicalForeign.

export interface WidgetRuntime {
  /** Passed straight to `createEditor({ dom })`. `CreateEditorArgs.dom` is a
   * `Partial<EditorDOMRenderConfig>` spread over `DEFAULT_EDITOR_DOM_CONFIG`
   * (LexicalEditor.ts:1037-1041), so supplying only these two members leaves
   * the other seven at their defaults. */
  readonly domConfig: Partial<EditorDOMRenderConfig>
  /** Wire teardown for the given editor. Call BEFORE `setRootElement` (the
   * first reconcile). Returns a disposer. */
  attach: (editor: LexicalEditor) => () => void
}

WidgetSpec

A widget's rendering contract.

The source / equals / render split is load-bearing, not ceremony. The tempting thinner API — (node) => HTMLElement | null — gives the runtime no way to know whether a rebuild is NEEDED, so every reconcile of the host would allocate a subtree and run the consumer's (possibly expensive) computation; and a fresh element every commit destroys the widget's own DOM state (scroll position in a wide result table, a focused cell). Here source is the cheap pure projection, equals is the gate, and render mutates a STABLE host. Same shape, and same reason, as DecoratorMount.update.

Neither source nor render may throw: they run inside Lexical's reconciler, where an exception aborts the commit mid-flight.

export interface WidgetSpec<N extends LexicalNode, Source> {
  /** Debug/dedup id; also the value of the host element's `data-llui-widget`.
   * Records are keyed by `${nodeKey}:${id}`, so several widgets may attach to
   * one node as long as their ids differ. */
  readonly id: string

  /** The Lexical node class this widget attaches to. Matched with `instanceof`,
   * so a `{ replace, with, withKlass }` replacement subclass still matches its
   * base klass without any extra resolution. */
  readonly klass: Klass<N>

  /**
   * Derive the widget's INPUT from the node. Runs inside the active editor
   * state on every reconcile of the host. MUST be pure and cheap — it is the
   * gate that makes unrelated edits free.
   *
   * Return `null` for "this node has no widget right now": any existing host is
   * removed and `dispose` runs.
   */
  readonly source: (ctx: WidgetContext<N>) => Source | null

  /** Equality on `Source`. Default `Object.is`. When it holds against the last
   * render's source, `render` is SKIPPED entirely. */
  readonly equals?: (a: Source, b: Source) => boolean

  /**
   * Build/refresh the widget DOM. Called only when the source changed.
   *
   * `host` is a stable, runtime-owned element that is already marked unmanaged,
   * already `contenteditable=false`, and already positioned at the placement
   * boundary. The renderer owns only `host`'s CHILDREN and may
   * `replaceChildren(...)` freely; it must not move or unparent `host` itself.
   */
  readonly render: (host: HTMLElement, source: Source, ctx: WidgetContext<N>) => void

  /**
   * OPTIONAL: style the host node's own DOM in the same pass.
   *
   * Overlay DOM covers "render a computed result"; it does not cover "highlight
   * the source span that produced it" (ProseMirror's `Decoration.inline`). The
   * alternative — a node transform writing `style`/format onto the node — is a
   * DOCUMENT MUTATION and would round-trip into the serialized output, which is
   * exactly what this seam exists to avoid. So the escape hatch lives here.
   *
   * Unlike `render` this runs on EVERY decorate pass, including when `source` is
   * `null` and including when the source is unchanged — because `dom` may be a
   * brand-new element (the `$updateDOM` replacement path) that has none of the
   * previous element's classes. Keep it idempotent, e.g. `classList.toggle`.
   */
  readonly decorateHost?: (dom: HTMLElement, source: Source | null, ctx: WidgetContext<N>) => void

  /** Placement. Default `'tail'`. */
  readonly placement?: WidgetPlacement

  /** Extra classes on the host element (the runtime always adds
   * `llui-node-widget`). */
  readonly className?: string

  /** Tag for the widget host element. Defaults to `'span'` when the node
   * reports `isInline()`, `'div'` otherwise — so an inline widget cannot
   * illegally nest a block element inside a `<span>`. */
  readonly tag?: keyof HTMLElementTagNameMap

  /** Torn down when the host node is destroyed, when `source` goes `null`, or
   * when the editor disposes. Use it to release listeners the renderer attached
   * inside `host`.
   *
   * Its context deliberately omits `node`: the commonest teardown trigger is the
   * node's DESTRUCTION, at which point no node instance exists to hand back.
   * Making that unrepresentable beats handing over a stale or fabricated one. */
  readonly dispose?: (host: HTMLElement, ctx: WidgetDisposeContext) => void
}

Classes

LLuiDecoratorNode

A generic decorator node that mounts an LLui sub-view via a registered {@link DecoratorBridge}.

class LLuiDecoratorNode extends DecoratorNode<HTMLElement> {
  __bridgeType: string
  __data: unknown
  getType(): string
  clone(node: LLuiDecoratorNode): LLuiDecoratorNode
  constructor(bridgeType: string, data: unknown, key?: NodeKey)
  createDOM(_config: EditorConfig): HTMLElement
  updateDOM(): false
  isInline(): false
  importDOM(): DOMConversionMap | null
  getBridgeType(): string
  getData(): unknown
  setData(data: unknown): void
  decorate(editor: LexicalEditor): HTMLElement
  exportJSON(): SerializedLLuiDecoratorNode
  importJSON(json: SerializedLLuiDecoratorNode): LLuiDecoratorNode
  updateFromJSON(json: LexicalUpdateJSON<SerializedLLuiDecoratorNode>): this
}

Constants

EXTERNAL_UNDO_BRAND

Marker key of an {@link ExternalUndoOwner}. Exported only because the phantom property below names it — nothing reads it at runtime.

const EXTERNAL_UNDO_BRAND: unique symbol

PROGRAMMATIC_TAG

Lexical update tag marking a programmatic write (seed / controlled setValue), so the outbound change listener doesn't echo it back to the host.

const PROGRAMMATIC_TAG

WIDGET_ATTR

The attribute carrying the widget's id on its host element.

const WIDGET_ATTR

WIDGET_CLASS

The class the runtime always stamps on a widget host, so an app can style every widget (and a test can find them) without knowing each id.

const WIDGET_CLASS