Skip to content

Editor design lessons ​

A collaboration binding connects two document models, two position systems and an input lifecycle. Matching the visible text is only one part of that work. This guide records the differences we encountered in ProseMirror, Slate, Lexical and the custom schema demo, and the fixes that other editor implementations can reuse.

The examples describe this repository's bindings and installed dependencies: Slate 0.146.0; Lexical, rich-text and table packages 0.52.0; ProseMirror model 1.25.12, state 1.4.4, transform 1.12.2, view 1.42.6 and tables 1.8.5. They are a tested integration snapshot. They do not rank the editors or establish behavior for every version and plugin combination.

Compare the integration models ​

ConcernProseMirrorSlateLexicalCustom schema demo
Native documentSchema-constrained node tree in editor stateJSON elements and text leavesRegistered node classes in editor stateShared document projected into application DOM
Local change entry point used hereTransactions and StepsEditor operations and change callbackUpdate listener and commandsInput handlers and doc.edit()
Native positionDocument position and transaction mappingNode path plus text-leaf offsetNode key plus point offset/typeApplication path or sequence position
Persistent collaborative selectionToken anchors, converted to native positionsToken anchors, converted to current leavesToken anchors, converted to current node keysToken anchors resolved into visible DOM
Table representation used hereVisible cells; reference supplies covered cellsFull grid; renderer hides covered cellsVisible cells; NodeState retains covered cellsFull grid; renderer follows computed visibility
Remote display update used hereChanged-range transaction with mapped selectionNative tree reconstructionNative tree reconstructionApplication-controlled projection and DOM reuse
Shared history authorityCRDT historyCRDT historyCRDT historyCRDT history
Application responsibilitiesSchema rendering, plugins, toolbar and lifecycleRenderers, inline/void behavior, UI and lifecycleNode registration, plugins, codecs, UI and lifecycleAll native input, DOM, selection and lifecycle behavior

For setup code, use ProseMirror, Slate, Lexical or the shared editing API.

How collaborative editing feels in this integration ​

The following assessment comes from our binding design and finite regression cases. It is an engineering assessment of the current integration. We have not run a comparative user study or established an editor latency ranking. All three bindings use the same convergence and history rules.

EditorUseful while editing with othersWhere the current experience needs careAssessment
ProseMirrorChanged-range transactions can preserve native state outside the changed area. Step mapping provides an explicit way to keep a selection attached while a peer edits.Position conversion must include table spans and flattened groups. The comparison app's composition facade differs from default PM wiring.Favorable for continuous shared text editing when transaction mapping and composition integration are complete. This is a design assessment, not a measured smoothness result.
SlateA custom block/tree UI can closely follow the shared schema. Token anchors preserve forward/backward ranges even when formatting changes the text leaves.Remote tree reconstruction needs selection restoration. The composing pane defers remote display until composition ends. Renderers and native normalization need application-level work.Favorable for application-specific structured editing when that application owns the input and rendering details. The current binding requires more explicit care around display replacement.
LexicalRegistered commands and node classes let rich native interactions share one document. Token anchors and focus guards preserve editing context across remote root replacement.Runtime node keys change after replacement. Native table transforms must agree with the shared projection; the former extra-column bug shows the consequence. Remote display is deferred in the composing pane.Favorable for extensible rich editing with tested plugin and schema combinations. Native plugin behavior is an important part of collaboration acceptance.
Custom schema demoShared structure, token identities and concurrent intent are directly visible. Rendering can reuse unchanged DOM without a second editor model.The application owns keyboard behavior, composition, native selection, table UI and accessibility. The current demo does not establish a finished Word-like user experience.Useful for exploring document rules and building a tailored editor. It carries the largest application responsibility for a complete editing experience.

For users, the important outcomes are stable input, a selection that remains attached to the intended text, no focus theft from a peer, predictable undo, and preserved content after a table merge. Passing model equality alone does not establish those outcomes. Our browser cases therefore check native input, DOM ranges, active focus and table geometry in addition to native/client/server models.

The remaining experience limits are explicit: real OS IME acceptance, larger-document input-to-screen targets and the full set of native commands still need their own evidence. A composing pane intentionally delays peer display until its draft completes; the live replica continues receiving updates. That tradeoff should be visible in application UX decisions.

ProseMirror: preserve transaction meaning ​

ProseMirror uses immutable documents and transactions made of Steps. Step maps translate positions across document changes. Child indices, document positions and node-local offsets have different meanings. These are part of the ProseMirror document and transform model.

Our binding translates common text insertion/deletion, paragraph split/join and formatting directly into shared model commands. Other native changes go through model comparison and verification. This gives us an explicit point to preserve editing intent. For example, an Enter must remain a split at its observed boundary when another participant inserts trailing text.

A transaction can contain several Steps. Each Step must use its own before/after document and the matching shared model. Using the transaction's initial document for every Step produces stale positions. Remote application is tagged and excluded from native history so it does not return as another local change.

Problem we solved: covered table text disappeared from comparison readback. A native merged table contains no node for a covered cell. The converter needs the matching full model to reconstruct that cell. The comparison app omitted the reference. Supplying it restored equality; copying the complete covered cell also preserved its attributes. A nested attribute regression checks that modifying the returned model cannot modify the reference.

Problem we solved: unchanged content still incurred conversion work. The binding now reuses its model and native document for the same Doc object and version. Object identity matters: reset or a composition facade can replace a document without changing its version number.

The public PM binding and the composition-aware comparison app have distinct input wiring. The app uses an input-session facade. Its simulated composition results do not certify the default /pm input implementation or an OS keyboard.

Source and tests: PM binding, conversion, cache regressions and native command traces.

Slate: JSON trees still need precise coordinates ​

Slate represents content as JSON objects with element and text interfaces. Applications can add properties for their own document types. See Slate interfaces.

That structure lets our codec retain schema type names, attributes, nested flows and the full table grid. It also means that the binding and renderer must agree on inline elements, void elements and the empty text boundaries required by the native editor. The binding wraps isInline, isVoid and the change callback while preserving the application's previous hooks.

Problem we solved: the caret moved after a remote edit. A saved numeric offset became stale after a remote insertion. A saved path became stale after a split. Formatting split one text leaf into several leaves, so clamping the old leaf offset still selected the wrong position. The fix stores both selection ends as document token anchors, then reconstructs the current path and leaf offset. Tests include backward ranges and UTF-16 emoji offsets.

Problem we solved: native normalization obscured move intent. The binding recognizes native move_node updates as moves and verifies the resulting shared model. Mixed movement and other mutations in one update are rejected and restored. Separate native updates keep the intended move identifiable. This is a binding constraint; it does not add arbitrary text-range or table-axis moves to the shared API.

Remote display currently reconstructs the Slate tree. During composition, the binding keeps that display stable while the live client receives changes. The draft operations are committed on completion. The application should use one composition wiring method and one shared history authority.

Source and tests: Slate binding, codec and coordinates and binding regressions.

Lexical: native node identity and plugins matter ​

Lexical editor state contains the node tree and selection. Updates occur in a controlled read/write context; committed state is immutable. JSON serialization does not preserve runtime node keys or selection. See Lexical editor state.

Our schema builder registers native classes and schema nodes. NodeState retains schema identity, marks, attributes and cell-body information. The binding tags remote updates, maps selection through document tokens and avoids taking DOM focus from an inactive editor.

Problem we solved: selection referred to detached keys. Replacing a native root created new node keys. Restoring the old keys could not restore the selection. Token anchors now resolve into the current native tree. For a cross-container native move, retained native keys identify one reparented block. The destination container must already exist; several reparented blocks in one update are rejected.

Problem we solved: merged tables gained extra columns. The first representation kept every full-grid cell as a native cell and hid covered DOM cells. The native table plugin still counted those hidden nodes. Together with the owner's column span, they occupied duplicate coordinates, and the plugin padded the grid.

The corrected native table contains only visible cells. Table NodeState retains complete covered cells for serialization and readback. Logical column mapping skips positions covered by spans from earlier rows. Origin metadata remaps retained content when a native row or column is inserted before a merge. Native unmerge restores retained content into split cells and refreshes their metadata and presentation.

A codec-only roundtrip did not expose the extra-column bug. Tests with registerTablePlugin did. Native plugin transforms belong in integration tests whenever they can normalize the tree.

Source and tests: Lexical binding, conversion, logical grid columns, binding regressions and merged-grid regressions.

One table, several valid native representations ​

A merge changes visibility without discarding the covered cell's content:

text
Shared full grid                  Visible Lexical / PM grid

A spans two columns | B covered   A spans two columns
C                   | D           C                   | D

Retained B: text + marks + nodes + attributes
Unmerge:   B becomes visible with its retained content

Slate keeps B in its native tree and hides it in the renderer. Lexical keeps B in serialized NodeState. PM reconstructs B from the matching full-model reference. These storage choices belong to the bindings. The shared merge and history rules are the same.

Our browser cases fill nine distinct cells, merge horizontally, vertically and across rectangles, then edit covered text from another actor. They check visible-cell counts, row/column spans, five native models, five client models and the server model. Undo/redo must preserve the foreign text, bold mark and background attribute. A second script restores forward/backward ranges in a visible cell after the covered columns and replaces the selected text with real native input.

Browser cases: merged grids and merged selections.

Equal text can hide different authors ​

A model comparison can produce the correct text and still lose input ownership. Suppose Alice inserts actorA and Bob then inserts actorB before it. A common-prefix diff can reuse Alice's existing actor tokens and insert only B actor. The display reads correctly, but Alice's later undo removes tokens that Bob's displayed prefix now depends on.

Preserve native text operation coordinates or a verified before/after selection splice where available. Validate the resulting model and keep a guarded fallback for other native structures. Check author undo and redo after each edit, not only replica convergence or final JSON equality. The Client facade does not change these ownership rules. Its local views share one participant's History; separate synchronized clients have separate participant histories.

Shared lessons for any editor ​

FailureWhy it happened hereRule we now apply
A remote update returned as another local editNative change listeners observed binding applicationTag or guard remote application; commit each local edit once
Composition removed a foreign insertionA stale display was compared with the newer live modelRetain original draft operations and their token identities
Draft IDs collided after undo or reconnectLive and draft clocks advanced independentlyReserve operation numbers in both documents, including update-only snapshots
Reset revived an old draftEmpty reset could produce no model-difference eventCancel the input session from reset lifecycle events
Undo removed another actor's workA whole native snapshot was treated as historyUse conditional CRDT history; preserve foreign writes
An inactive editor stole focusRemote selection application updated DOM focusRestore selections according to editor focus and native APIs
Error reporting prevented recoveryA throwing callback ran before the replacement update completedRestore shared content before reporting the conversion error
Unmount left listeners behindThe binding removed too little or replaced another owner's hooksTrack owned registrations and restore hooks only while still owned

The shared input session and model translation implement reusable parts of these rules. The comparison input tests exercise draft cancellation, clock reservation and coordinate conversion.

The custom demo: input and rendering are application code ​

The schema demo exposes the shared document directly. This makes token identities, sequence boundaries and merge rules visible. It also leaves browser input, DOM selection, composition, rendering and cleanup to the application.

We reduced repeated paragraph reconstruction and reused unchanged paragraph DOM. Paint-related work remained separate from CRDT receipt. An application that owns its renderer must measure input, transport, peer receipt, DOM updates and rendering opportunity independently. A lower core receive time does not establish a lower input-to-screen time.

The same rule applies to every binding. PM can map a changed-range transaction; Slate and Lexical currently rebuild native trees for remote changes. Full model reads, native conversion and rendering can dominate even after a safe comparison window reduces event verification. The window limit counts top-level nodes, so it does not bound the size of a nested table or subtree.

See performance evidence for measured intervals, controls and remaining costs. Source: custom editor.

Reuse the tests, with their evidence limits ​

Start a new editor integration with distinct text in neighboring paragraphs and cells. Then repeat with identical paragraphs, where an accidental match can hide a path error. Test forward/backward selections, emoji, formatting leaf splits, remote input at the same boundary, native Enter/Backspace, movement, hidden cells, reset, reconnect and disposal. For history, interleave a foreign edit between the local action and its undo/redo.

Keep three checks separate: native state, the client document and the server document. Add DOM geometry and focus checks when native state can still render incorrectly. Run conversion tests with the same native plugins used by the application. Inspect returned assertions and browser errors as well as process exit codes.

The recorded integration includes 567 library tests, 31 compatibility unit tests and 340 Chrome checks. These are finite cases at their recorded source. Thirty browser operation traces within that count record operations and convergence; they are not OS IME acceptance. Simulated composition does not prove a real Korean keyboard session. Codec coverage also does not provide every keyboard gesture, native table command or custom plugin combination.

The merged-grid report, collaboration rules and status page describe the current proof boundaries. Use the framework guides for API setup and this guide when designing the binding's failure and regression cases.

Rust experiment ​

Use UTF-16 offsets at the model boundary. A backward selection keeps its direction. This example selects across an emoji, which occupies two UTF-16 units.

Use the native SDK crate setup to run this complete Rust program.

rust
use doc_sync_native_sdk::{
    Attributes, Document, EditOptions, Key, Position, Result, SchemaPreset, Selection,
};

fn main() -> Result<()> {
    let mut doc = Document::new("selection-host", SchemaPreset::Text)?;
    doc.insert_text(
        &Key::root(),
        "A😀B",
        Attributes::new(),
        EditOptions::default(),
        0.0,
    )?;
    let range = Selection {
        anchor: Position(vec![0, 3]),
        focus: Position(vec![0, 1]),
    };
    let stable = doc
        .capture_selection(Some(range.clone()))?
        .expect("visible range");
    assert_ne!(stable.anchor, stable.focus);
    assert_eq!(doc.resolve_selection(Some(stable))?, Some(range));
    Ok(())
}

This example uses one of six fixed SchemaPreset values. A separate runtime schema API accepts limited Document/Graphic descriptors and custom sparse-sheet fields. Live schema changes, the full doc.edit() compiler, WASM, Node, HTTP, MCP and editor bindings are not included.

Raw protocol probe and reference checks

Position units must stay consistent across an engine adapter. The native fixture below stores A😀B as four UTF-16 units and resolves a backward range. An emoji occupies two units.

Build the private executable with the commands in the Rust guide. Run the following command from the repository root. Each input line produces one JSON response.

sh
experiments/rust-shadow/target/debug/doc-sync-rust-shadow <<'JSONL'
{"cmd":"new","replica":"A","actor":"A","schema":"flow-root-paragraph:boolean-marks:bold,italic:v1"}
{"cmd":"apply","replica":"A","schema":"flow-root-paragraph:boolean-marks:bold,italic:v1","ops":[{"t":"text","id":{"lamport":1,"actor":"A"},"origin":{"lamport":0,"actor":"~root"},"units":[65,55357,56832,66]}]}
{"cmd":"selection_capture","replica":"A","range":{"anchor":[0,3],"focus":[0,1]}}
{"cmd":"selection_resolve","replica":"A","range":{"anchor":"3@A","focus":"1@A"}}
JSONL

Capture returns anchor: "3@A" and focus: "1@A". Resolve returns {anchor: [0,3], focus: [0,1]}. The direction and UTF-16 offsets remain intact. This checks the private engine contract, not DOM ranges, native editor keys, composition or focus. The production bindings remain TypeScript; see cursor limits.

Independent packages. Schema-defined document behavior.