@llui/lexical-collab
Current package version: 0.4.2
Adds real-time collaborative editing to the @llui/lexical binding. yjsCollab(...) produces a foreign fragment you spread into lexicalForeign({ …, ...collab.foreign }) — or you pass the handle through the markdown editor's collab option, which does this for you. Sync runs over a Yjs provider you inject, so you keep control of the transport (WebSocket, WebRTC, …).
There is nothing else to remember: foreign carries the binding registration in the seam's externalUndo slot — whose presence forces the built-in @lexical/history stack off — together with seedMode: 'deferred'. The handle deliberately exposes no register member, so the combination that would run a local undo stack beside the CRDT one (and double-apply every undo) cannot be expressed.
The registration is also branded: collab.externalUndo has type ExternalUndoOwner, which lexicalForeign's register slot rejects at compile time. So re-routing it under another key does not compile either. Plain registration functions are unaffected — a binding that splits register and externalUndo (see @llui/lexical-loro) type-checks unchanged.
pnpm add @llui/lexical-collab @llui/lexical @lexical/yjs yjs
@lexical/yjs and yjs are peer dependencies.
What it gives you
- CRDT sync — document state converges across clients through the injected
CollabProvider. - Scoped undo — undo/redo is bound to each client's own edits rather than the shared global stream.
- Presence cursors — remote users (
CollabUser) surface as live selection/cursor decorations.
API
Functions
yjsCollab()
Build (but do not yet bind) a collaborative editing handle.
function yjsCollab(config: YjsCollabConfig): YjsCollab
Types
CollabProvider
A Yjs network/transport provider. Structurally identical to @lexical/yjs's
Provider; re-exported so consumers type their factory without reaching into
@lexical/yjs directly. y-websocket / y-webrtc / @hocuspocus/provider
all satisfy it.
export type CollabProvider = Provider
Interfaces
CollabForeignOptions
The complete set of lexicalForeign options a Yjs session requires, as ONE
object to spread — lexicalForeign({ …, ...collab.foreign }).
Both members are preconditions the host used to have to remember, and both
fail SILENTLY when forgotten: a surviving @lexical/history stack
double-applies undo across peers, and a boot-time seed duplicates content on
every peer that mounts. Bundling them with the registration itself is what
makes the unsafe wiring unrepresentable rather than merely documented.
export interface CollabForeignOptions {
/** The binding's registration, in the seam slot whose presence forces the
* built-in `@lexical/history` stack off. Branded as an `ExternalUndoOwner`, so
* the type system also refuses it in the seam's `register` slot. */
readonly externalUndo: ExternalUndoOwner
/** The sync-gated bootstrap replaces the seam's boot-time seed.
*
* `'auto'` is unconditionally wrong for a CRDT session — it seeds the local
* document at boot on EVERY peer — so it is folded in here rather than left as
* a second thing to remember. A host that genuinely needs to override it can
* still spread this fragment first and set `seedMode` after. */
readonly seedMode: 'deferred'
}
CollabUser
Local presence identity broadcast to peers (name + caret colour).
export interface CollabUser {
/** Display name shown on the remote caret. */
name: string
/** Caret / selection colour (any CSS colour). */
color: string
/** Arbitrary extra data merged into this client's awareness state. */
awarenessData?: Record<string, unknown>
}
YjsCollab
Live handle returned by {@link yjsCollab}.
export interface YjsCollab {
/** The seam options this session requires, ready to spread into
* `lexicalForeign`. The preferred wiring — see {@link CollabForeignOptions}. */
readonly foreign: CollabForeignOptions
/** Wire the binding onto an editor. Returns a disposer that tears down every
* listener, the provider connection, and the cursors overlay.
*
* Named for the `lexicalForeign` slot it must occupy: the seam forces the
* built-in `@lexical/history` stack off whenever `externalUndo` is set, and
* this binding owns undo (a Yjs `UndoManager` scoped to the local origin).
* There is deliberately no `register` member — that slot leaves local history
* registered, and a CRDT session cannot survive it. This value is BRANDED as
* an `ExternalUndoOwner`, so re-routing it into `register` under another name
* is a compile error too. Prefer spreading
* {@link YjsCollab.foreign}, which also carries `seedMode`; reach for this
* directly only when handing the binding to a host that assembles the seam
* options itself (e.g. `@llui/markdown-editor`'s `collab` option). */
readonly externalUndo: ExternalUndoOwner
/** The shared Yjs document. */
readonly doc: YDoc
/** The network provider. */
readonly provider: CollabProvider
/** Connect the provider (no-op if `autoConnect` already connected). */
connect: () => void
/** Disconnect the provider. */
disconnect: () => void
/** Release resources this handle OWNS. Call AFTER the disposer returned by
* {@link YjsCollab.externalUndo} has run (the seam runs it at unmount).
* If `yjsCollab` created the `YDoc` itself (no `doc` was supplied and the
* provider factory didn't substitute one), the document is `destroy()`d and its
* `docMap` entry removed. A caller-supplied `doc` is caller-owned and left
* untouched — you destroy it yourself once every binding over it is gone. */
destroy: () => void
}
YjsCollabConfig
export interface YjsCollabConfig {
/** Shared document id (room name). Must match across peers. */
id: string
/** The shared Yjs document. Created if omitted (and registered in `docMap`). */
doc?: YDoc
/** Doc registry shared with the provider factory. Created if omitted. */
docMap?: Map<string, YDoc>
/** A ready provider. Mutually exclusive with `providerFactory`. */
provider?: CollabProvider
/** Factory building the provider from the (id, docMap). Preferred — it lets
* this module own doc creation/registration before the provider binds. */
providerFactory?: (id: string, docMap: Map<string, YDoc>) => CollabProvider
/** Local presence identity. Presence is disabled when omitted. */
user?: CollabUser
/** Whether THIS peer may seed an empty shared document. Default `true`.
* In a multi-peer app exactly one peer should bootstrap (e.g. the creator);
* the seed only runs if the shared doc is still empty after first sync. */
shouldBootstrap?: boolean
/** Seed an empty shared document (runs once, inside an editor update, only on
* the bootstrapping peer). Without it an empty paragraph is inserted. */
seed?: (editor: LexicalEditor) => void
/** Overlay element that hosts remote carets. Created over the editor when
* omitted; its offsetParent is made `position: relative` if it is static. */
cursorsContainer?: HTMLElement
/** Per-node properties excluded from CRDT sync (advanced). */
excludedProperties?: ExcludedProperties
/** Connect the provider at mount. Default `true`. */
autoConnect?: boolean
/** Connection status changed (`'connected'` ⇄ disconnected). */
onStatus?: (connected: boolean) => void
/** Provider sync state changed (initial document handshake complete). */
onSync?: (synced: boolean) => void
/** Remote peer count changed (distinct awareness states, excluding self). */
onPeers?: (count: number) => void
}