@llui/markdown-editor
A WYSIWYG Markdown editor you drop into an LLui app as a single component. The user edits rich text; the editor's state holds the Markdown. It is built on @llui/lexical and ships a transformer registry (GFM, callouts, …), a toolbar surface, and a set of opt-in plugins for the features beyond plain prose — links, images, tables, math, mentions, emoji, slash commands, and more.
pnpm add @llui/markdown-editor @llui/lexical @llui/dom lexical
@llui/lexical and lexical come along as the editing engine.
Quick start
markdownEditor() returns an ordinary LLui component — mount it with mountApp, or place it inside a larger view. Its state exposes the live Markdown (value), word/char counts, and dirty/read-only flags.
import { mountApp } from '@llui/dom'
import { markdownEditor, corePlugin, linkPlugin } from '@llui/markdown-editor'
const app = mountApp(
document.getElementById('editor')!,
markdownEditor({
toolbar: true,
plugins: [corePlugin(), linkPlugin()],
defaultValue: '# Hello\n\nStart typing…',
changeDebounceMs: 150,
}),
)
// The editor's state IS the source of truth for the Markdown:
app.subscribe((s) => console.log(s.value, `${s.wordCount} words`))
Plugins
Features are plugins you compose into the plugins list — only what you pass is wired in, so unused features tree-shake away. corePlugin() covers headings, lists, blockquotes, inline marks, and code. The rest are opt-in:
linkPlugin, imagePlugin, tablePlugin, hrPlugin, mathPlugin, mermaidPlugin, mentionPlugin, emojiPlugin, calloutPlugin, slashPlugin, contextMenuPlugin, floatingToolbarPlugin.
Author your own with definePluginUI and the MarkdownPlugin / CommandItem contract — the same shape the built-ins use.
Transformers
The Markdown ⇄ editor mapping is a transformer registry. GFM_TRANSFORMERS / GFM_NODES add GitHub-Flavored Markdown (tables, task lists, strikethrough); buildTransformers / orderTransformers let a plugin contribute its own (the callout plugin, for instance, registers a > [!NOTE] transformer).
Toolbar
markdownEditor({ toolbar: true }) renders the default toolbar. For a custom chrome, drive it yourself with connectToolbar (a Signal-handle part bag you spread onto your own elements) or the prebuilt toolbar() surface, plus computeFormatState to derive button active-states from the current selection.
API
Functions
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>
init()
function init(opts: InitOptions): [EditorState, EditorEffect[]]
update()
function update(state: EditorState, msg: EditorMsg): [EditorState, EditorEffect[]]
countWords()
Count whitespace-delimited words (shared by init and the format handler).
function countWords(text: string): number
computeFormatState()
Read the full format surface at the current selection (opens a read ctx).
function computeFormatState(editor: LexicalEditor, history: Pick<SelectionContext, 'canUndo' | 'canRedo'>): FormatState
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
corePlugin()
function corePlugin(_opts: CorePluginOptions = {}): MarkdownPlugin
linkPlugin()
function linkPlugin(opts: LinkPluginOptions = {}): MarkdownPlugin
$insertCallout()
Insert a fresh callout at the current selection; returns the created node.
function $insertCallout(kind: CalloutKind = 'note', textValue = 'New callout'): LLuiDecoratorNode
calloutPlugin()
function calloutPlugin(opts: CalloutPluginOptions = {}): MarkdownPlugin
$insertHorizontalRule()
Insert a horizontal rule at the current selection.
function $insertHorizontalRule(): void
hrPlugin()
function hrPlugin(): MarkdownPlugin
slashPlugin()
function slashPlugin(): MarkdownPlugin
contextMenuPlugin()
function contextMenuPlugin(): MarkdownPlugin
floatingToolbarPlugin()
function floatingToolbarPlugin(): MarkdownPlugin
mathPlugin()
function mathPlugin(opts: MathPluginOptions = {}): MarkdownPlugin
mermaidPlugin()
function mermaidPlugin(opts: MermaidPluginOptions = {}): MarkdownPlugin
mentionPlugin()
function mentionPlugin(opts: MentionPluginOptions = {}): MarkdownPlugin
emojiPlugin()
function emojiPlugin(opts: EmojiPluginOptions = {}): MarkdownPlugin
imagePlugin()
function imagePlugin(opts: ImagePluginOptions = {}): MarkdownPlugin
tablePlugin()
function tablePlugin(): MarkdownPlugin
orderTransformers()
Stable-sort transformers into the order Lexical expects.
function orderTransformers(transformers: readonly Transformer[]): Transformer[]
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[]
connectToolbar()
Build reactive toolbar parts from the format signal. Spread item(id) onto a
<button>; aria-pressed / data-active / disabled track the format.
function connectToolbar(format: Signal<FormatState>, send: Send<EditorMsg>, items: readonly CommandItem[]): ToolbarParts
toolbar()
A ready-made grouped toolbar. Items not surfaced to 'toolbar' are dropped.
function toolbar(opts: ToolbarOptions): Mountable
linkDialog()
Render the link dialog. Hidden (portal, nothing inline) until dialog.open.
function linkDialog(opts: LinkDialogOptions): Mountable
Types
CollabFactory
Builds the collab binding from the editor-supplied hooks.
export type CollabFactory = (hooks: CollabHooks) => CollabBinding
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'
OverlayKind
Which floating surface is currently open.
export type OverlayKind = 'none' | 'floating' | 'slash' | 'context' | 'link'
EditorMsg
export type EditorMsg =
| { type: 'markdownChanged'; value: string }
| { type: 'formatChanged'; format: FormatState; wordCount: number; charCount: number }
| { type: 'runCommand'; id: string }
| { type: 'setValue'; value: string }
| { type: 'openOverlay'; overlay: OverlayKind; x?: number; y?: number }
| { type: 'closeOverlay' }
| { type: 'slashQuery'; query: string }
| { type: 'setReadOnly'; readonly: boolean }
| { type: 'collabStatus'; connected: boolean }
| { type: 'collabSync'; synced: boolean }
| { type: 'collabPeers'; peers: number }
/** Route a message to a plugin's UI reducer (see {@link PluginUI}). */
| { type: 'plugin'; name: string; msg: unknown }
EditorOutMsg
The subset of messages a plugin may emit through its PluginContext (e.g. a
register listener routing an editor event into its own plugin UI).
export type EditorOutMsg = Extract<
EditorMsg,
{ type: 'openOverlay' | 'closeOverlay' | 'slashQuery' | 'plugin' }
>
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 }
ItemSurface
Which surfaces a command item appears in (default: all).
export type ItemSurface = 'toolbar' | 'floating' | 'slash' | 'context'
HostEmit
The host message type a plugin effect may emit (the editor's full Msg).
export type HostEmit = (msg: unknown) => void
CalloutKind
export type CalloutKind = 'note' | 'tip' | 'warning' | 'danger'
Interfaces
EditorConfig
export interface EditorConfig {
/** Plugins composing the feature set; order defines transformer precedence.
* Defaults to `[corePlugin(), linkPlugin()]` so the minimal editor has GFM + links. */
plugins?: readonly MarkdownPlugin[]
/** Initial markdown (uncontrolled seed). */
defaultValue?: string
/** Controlled: the consumer owns this signal; the editor follows it. */
value?: Signal<string>
/** Debounced markdown-emission window (ms). Default 300. */
changeDebounceMs?: number
placeholder?: string
readonly?: boolean
/** Lexical theme class map. */
theme?: EditorThemeClasses
/** Editor namespace (instance isolation). */
namespace?: string
/** Outbound markdown (after debounce). */
onChange?: (markdown: string) => void
/** Outbound format surface (for chrome built outside this package). */
onFormatChange?: (format: FormatState) => void
/** Receives the live Lexical editor at mount (imperative access, collab hooks). */
onReady?: (editor: LexicalEditor) => void
/** Render the built-in toolbar above the editor. Default false (minimal). */
toolbar?: boolean
/** Convert plain-text Markdown to rich content on paste. Default true.
* Pastes that carry `text/html` are always left to Lexical's HTML import,
* regardless of this flag. Set false to paste Markdown as literal text. */
pasteMarkdown?: boolean
/** Enable collaborative editing. The editor hands you a markdown `seed` and
* status sinks; return a binding (build it with `yjsCollab` from
* `@llui/lexical-collab`, wiring your own provider). Mutually exclusive with
* `value` — the shared CRDT document, not a markdown signal, owns the content.
* `defaultValue` becomes the seed the bootstrapping peer writes. */
collab?: CollabFactory
}
CollabBinding
Disposer-returning binding the collab layer installs on the live editor.
@llui/lexical-collab's YjsCollab satisfies this structurally, so
@llui/markdown-editor needs no Yjs dependency of its own.
export interface CollabBinding {
register: (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
}
EditorParts
Hooks the chrome layer (toolbar/menus) uses to compose around the editor.
export interface EditorParts {
/** The merged, surface-filtered command items. */
items: readonly CommandItem[]
/** Reactive format signal for `connect`-style toolbars. */
format: Signal<FormatState>
}
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
}
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
}
EditorState
export interface EditorState {
/** Last serialized markdown (mirror of the live document). */
value: string
format: FormatState
wordCount: number
charCount: number
ui: {
activeOverlay: OverlayKind
slashQuery: string
menu: { x: number; y: number }
}
/** Per-plugin UI state slices, keyed by plugin name (see {@link PluginUI}). */
plugins: Record<string, unknown>
dirty: boolean
readonly: boolean
/** Collaborative-session status (always present; inert unless `collab` set). */
collab: CollabStatus
}
InitOptions
export interface InitOptions {
value: string
readonly: boolean
/** Whether a collaborative session is wired (drives `collab.enabled`). */
collab?: boolean
}
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[]
}
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
}
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
}
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
}
PluginUISpec
A typed plugin UI module (authored via {@link definePluginUI}).
export interface PluginUISpec<S, M, E = never> {
/** Initial slice state (JSON-serializable). */
init: () => S
/** Pure reducer over the slice; may return effects. */
update?: (state: S, msg: M) => S | [S, E[]]
/** View contribution (overlays/panels), rendered by the host. */
view?: (args: PluginViewArgs<S, M>) => Renderable
/** Effect handler with live-editor access + host dispatch. */
onEffect?: (effect: E, ctx: PluginEffectContext<M>) => void
}
PluginUI
The type-erased form stored on a plugin and consumed by the host.
export interface PluginUI {
init: () => unknown
update?: (state: unknown, msg: unknown) => unknown | [unknown, unknown[]]
view?: (args: PluginViewArgs<unknown, unknown>) => Renderable
onEffect?: (effect: unknown, ctx: PluginEffectContext<unknown>) => void
}
CorePluginOptions
export interface CorePluginOptions {
/** Reserved for future core options. */
readonly _?: never
}
LinkPluginOptions
export interface LinkPluginOptions {
/** Default URL pre-filled when there's no existing link (default ''). */
defaultUrl?: string
}
CalloutData
export interface CalloutData {
kind: CalloutKind
text: string
}
CalloutPluginOptions
export interface CalloutPluginOptions {
/** Default kind for the toolbar/slash insert action. */
defaultKind?: CalloutKind
}
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
}
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
}
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[]
}
EmojiPluginOptions
export interface EmojiPluginOptions {
/** Extra/override shortcode → emoji entries (merged over the defaults). */
emoji?: Readonly<Record<string, 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>
}
ToolbarItemParts
export interface ToolbarItemParts {
type: 'button'
'data-scope': 'md-toolbar'
'data-part': 'item'
'data-id': string
'aria-label': string
title: string
'aria-pressed': Signal<'true' | 'false'>
'aria-disabled': Signal<'true' | undefined>
disabled: Signal<boolean>
'data-active': Signal<'' | undefined>
onClick: (e: MouseEvent) => void
}
ToolbarParts
export interface ToolbarParts {
root: {
role: 'toolbar'
'aria-label': string
'data-scope': 'md-toolbar'
'data-part': 'root'
}
item: (id: string) => ToolbarItemParts
}
ToolbarOptions
export interface ToolbarOptions {
format: Signal<FormatState>
send: Send<EditorMsg>
items: readonly CommandItem[]
/** Explicit grouped layout of ids; defaults to grouping by `item.group`. */
groups?: readonly (readonly string[])[]
/** Glyph overrides (id → text/emoji). Merged over {@link DEFAULT_GLYPHS}. */
glyphs?: Readonly<Record<string, string>>
/** Render the `block` group as a `<select>` dropdown instead of buttons
* (default true). */
blockSelect?: boolean
/** Collaborative-session status. When supplied AND `enabled`, the toolbar
* appends a presence indicator (connection dot + live peer count). */
collab?: Signal<CollabStatus>
'aria-label'?: string
}
LinkDialogOptions
export interface LinkDialogOptions {
/** The `{ open }` slice driving the modal. */
dialog: Signal<DialogState>
/** The URL input value. */
url: Signal<string>
/** Called as the user edits the URL. */
onInput: (url: string) => void
/** Called on Apply / Enter. */
onSubmit: () => void
/** Called when the dialog requests open/close (dismiss, close button). */
onDialog: (msg: DialogMsg) => void
/** Dialog instance id for ARIA wiring (default 'md-link-dialog'). */
id?: string
}
Constants
GFM_NODES
Node classes required to render the GFM superset.
const GFM_NODES: ReadonlyArray<Klass<LexicalNode>>
INLINE_TEXT_TRANSFORMERS
Inline text-format transformers (no block nodes, no node registration). These
are the only transformers a single-block / inline-only editor needs; LINK is
kept separate since it requires LinkNode to be registered.
const INLINE_TEXT_TRANSFORMERS: readonly Transformer[]
GFM_TRANSFORMERS
Markdown ↔ node transformers for the GFM superset.
const GFM_TRANSFORMERS: readonly Transformer[]
Related
@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.