If you want to update this page or add new content, please submit a pull request to the Homepage.

ProseMirror

@yorkie-js/prosemirror is a ProseMirror binding for the Yorkie JS SDK that provides two-way synchronization between a ProseMirror editor and Yorkie's Tree CRDT. It handles content syncing, mark-to-element conversion, remote cursor display, and IME composition safely — so you can focus on building your editor.

Installation

npm install @yorkie-js/prosemirror

Peer Dependencies

The following packages must be installed alongside:

npm install @yorkie-js/sdk prosemirror-model prosemirror-state prosemirror-view

Quick Start

Below is a minimal example that connects a ProseMirror editor to Yorkie for real-time collaboration.

1import yorkie from '@yorkie-js/sdk';
2import { EditorState } from 'prosemirror-state';
3import { EditorView } from 'prosemirror-view';
4import { schema } from 'prosemirror-schema-basic';
5import { exampleSetup } from 'prosemirror-example-setup';
6import {
7 YorkieProseMirrorBinding,
8 remoteSelectionPlugin,
9} from '@yorkie-js/prosemirror';
10
11async function main() {
12 // 1. Connect to Yorkie
13 const client = new yorkie.Client({
14 rpcAddr: 'https://api.yorkie.dev',
15 apiKey: 'xxxxxxxxxxxxxxxxxxxx',
16 });
17 await client.activate();
18
19 const doc = new yorkie.Document('my-prosemirror-doc');
20 await client.attach(doc, { initialPresence: {} });
21
22 // 2. Create the ProseMirror editor
23 const view = new EditorView(document.getElementById('editor')!, {
24 state: EditorState.create({
25 doc: schema.node('doc', null, [
26 schema.node('paragraph', null, [schema.text('Hello, world!')]),
27 ]),
28 plugins: [
29 ...exampleSetup({ schema }),
30 remoteSelectionPlugin(), // Renders remote selection highlights
31 ],
32 }),
33 });
34
35 // 3. Bind ProseMirror to Yorkie
36 const binding = new YorkieProseMirrorBinding(view, doc, 'tree', {
37 cursors: {
38 enabled: true,
39 overlayElement: document.getElementById('cursor-overlay')!,
40 wrapperElement: document.getElementById('editor-wrapper')!,
41 },
42 });
43 binding.initialize();
44}
45
46main();

The HTML structure should include an overlay container for remote cursors:

<div id="editor-wrapper" style="position: relative;">
<div id="editor"></div>
<div id="cursor-overlay"></div>
</div>

The cursor-overlay element must be positioned absolutely within the wrapper:

#cursor-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 10;
}

When initialize() is called:

  • If the Yorkie document has no tree yet, the binding creates one from the current ProseMirror document.
  • If a tree already exists (another client created it), the binding loads it into the editor.

When you're done, call binding.destroy() to clean up subscriptions and event listeners.

Configuration

YorkieProseMirrorBinding accepts an options object as its fourth argument:

1const binding = new YorkieProseMirrorBinding(view, doc, 'tree', {
2 markMapping: { strong: 'strong', em: 'em', code: 'code', link: 'link' },
3 wrapperElementName: 'span',
4 cursors: { enabled: true, overlayElement: cursorOverlayEl },
5 onLog: (type, message) => console.log(`[${type}] ${message}`),
6});
OptionTypeDefaultDescription
markMappingMarkMappingAuto-generated from schemaMaps ProseMirror mark names to Yorkie element type names
wrapperElementNamestring'span'Element name used to wrap bare text alongside mark elements
cursorsCursorOptionsundefinedRemote cursor display configuration
onLog(type, message) => voidundefinedCallback for sync event logging

Mark Mapping

ProseMirror represents formatting as marks on text nodes (e.g., bold text has a strong mark). Yorkie's Tree CRDT has no mark concept — formatting is represented as wrapper elements instead.

The binding bridges this gap through mark mapping:

ProseMirror: paragraph > text("hello", marks=[strong])
Yorkie Tree: <paragraph><strong><text>hello</text></strong></paragraph>

Default Mapping

If you don't provide a markMapping, the binding auto-generates one from your ProseMirror schema using identity mapping (mark name = element name). The built-in defaultMarkMapping covers the common cases:

1import { defaultMarkMapping } from '@yorkie-js/prosemirror';
2// { strong: 'strong', em: 'em', code: 'code', link: 'link' }

Custom Mapping

If your schema uses different mark names, provide a custom mapping:

1const binding = new YorkieProseMirrorBinding(view, doc, 'tree', {
2 markMapping: {
3 bold: 'strong', // PM's 'bold' mark → Yorkie's 'strong' element
4 italic: 'em', // PM's 'italic' mark → Yorkie's 'em' element
5 },
6});

Building from Schema

Use buildMarkMapping() to auto-generate a mapping from your schema with optional overrides:

1import { buildMarkMapping } from '@yorkie-js/prosemirror';
2
3const mapping = buildMarkMapping(mySchema, {
4 bold: 'strong', // Override specific marks
5});

Remote Cursors

The binding provides two complementary systems for showing other users' cursors and selections.

Cursor Overlays

Cursor carets are rendered as DOM overlays positioned above the editor. Enable them via the cursors option:

1const binding = new YorkieProseMirrorBinding(view, doc, 'tree', {
2 cursors: {
3 enabled: true,
4 overlayElement: document.getElementById('cursor-overlay')!,
5 wrapperElement: document.getElementById('editor-wrapper')!, // Optional, for positioning reference
6 colors: ['#FECEEA', '#FEF1D2', '#A9FDD8', '#D7F8FF', '#CEC5FA'], // Optional custom colors
7 },
8});

Each remote cursor displays as a colored vertical caret with a label showing the last 2 characters of the client ID.

Selection Highlights

remoteSelectionPlugin() is a ProseMirror plugin that renders inline decorations for remote users' text selections:

1import { remoteSelectionPlugin } from '@yorkie-js/prosemirror';
2
3const state = EditorState.create({
4 plugins: [
5 ...otherPlugins,
6 remoteSelectionPlugin(),
7 ],
8});

The binding automatically dispatches selection updates to this plugin when remote users change their selections.

How It Works

Two-Way Sync

Upstream (ProseMirror → Yorkie): When the local user edits, the binding compares the old and new ProseMirror documents using a two-level diffing strategy:

  1. Block-level diff — identifies which top-level blocks changed
  2. Character-level diff — if only one block changed with identical structure, produces minimal tree.edit() calls for efficient concurrent editing
  3. Full block replacement — falls back to replacing entire blocks for structural changes (e.g., toggling bold, splitting paragraphs)

Downstream (Yorkie → ProseMirror): When remote changes arrive, the binding builds a new ProseMirror document from the Yorkie tree and applies the minimal diff as a transaction, preserving local cursor position and undo history.

IME Composition Handling

During IME input (Chinese, Japanese, Korean), the binding defers remote changes that overlap the block being composed. Non-overlapping changes in other blocks are applied immediately. Deferred changes are flushed when composition ends, preventing the browser from breaking the active composition.

Lower-Level Utilities

For custom integrations, the package exports individual functions:

Format Conversion

1import { pmToYorkie, yorkieToJSON } from '@yorkie-js/prosemirror';
2
3// ProseMirror Node → Yorkie tree JSON
4const yorkieDoc = pmToYorkie(pmNode, markMapping, 'span');
5
6// Yorkie tree → ProseMirror-compatible JSON (for Node.fromJSON)
7const pmJSON = yorkieToJSON(tree, schema, elementToMarkMapping, 'span');

Manual Sync

1import {
2 syncToYorkie,
3 syncToPMIncremental,
4 syncToPM,
5 diffDocs,
6 buildDocFromYorkieTree,
7 applyDocDiff,
8} from '@yorkie-js/prosemirror';
9
10// Upstream: diff PM docs and apply to Yorkie tree
11syncToYorkie(tree, oldDoc, newDoc, markMapping, onLog, 'span');
12
13// Downstream: incremental sync (preferred, preserves undo history)
14syncToPMIncremental(view, tree, schema, elementToMarkMapping, onLog, 'span');
15
16// Downstream: full rebuild (used on initialization and as error recovery)
17syncToPM(view, tree, schema, elementToMarkMapping, onLog, 'span');
18
19// Block-level diff between two PM docs
20const diff = diffDocs(currentDoc, newDoc); // Returns { fromPos, toPos, slice }

Position Mapping

Bidirectional mapping between ProseMirror positions and Yorkie flat indices, useful for cursor synchronization:

1import {
2 buildPositionMap,
3 pmPosToYorkieIdx,
4 yorkieIdxToPmPos,
5} from '@yorkie-js/prosemirror';
6
7const treeJSON = JSON.parse(tree.toJSON());
8const map = buildPositionMap(pmDoc, treeJSON);
9
10// Convert positions between coordinate systems
11const yorkieIdx = pmPosToYorkieIdx(map, pmPos);
12const pmPos = yorkieIdxToPmPos(map, yorkieIdx);

Examples

Reference