Wörter statt Zeichen: kollaborativer Rich Text ohne Yjs

Alice und Bob bearbeiten dieselbe Notiz. Beide sind offline, im Zug oder im Flugzeug. Alice macht ein Wort fett, Bob tippt drei Wörter weiter einen Tippfehler aus. Später synchronisieren die Geräte. Die Frage, an der sich Forschung und Frameworks seit fünfzig Jahren abarbeiten: Was jetzt?

Führt man die beiden HTML-Strings naiv zusammen, landet man bei zerrissenem Markup und verlorenem Text. Die interessante Frage ist nicht, ob man das lösen kann, sondern auf welcher Ebene man den Text zerlegt. Genau da unterscheiden sich die Ansätze.

Lieber gleich ausprobieren?

Hier läuft der Editor als Live-Demo.

Ein kurzer Rückblick

Vorweg, damit die Erwartung stimmt: Ich bin kein Experte für dieses Themengebiet. Was jetzt kommt, ist mein grober Überblick als Entwickler, der eine passende Lösung gesucht hat, keine Lehrbuchdarstellung. Wer es genau wissen will, findet in den verlinkten Quellen deutlich mehr Tiefe.

Operational Transformation (OT), die Technik hinter Google Docs, ist der Klassiker aus den späten 80ern. Die Idee: Jede Bearbeitung ist eine Operation (füge an Position 12 ein), und wenn zwei Operationen kollidieren, wird die eine gegen die andere transformiert. Das funktioniert gut, solange ein zentraler Server die Operationen in eine verbindliche Reihenfolge bringt. Ohne diesen Schiedsrichter wird OT schnell fragil, und über einen dummen Transport wie Dateisystem oder P2P bekommt man es kaum gebändigt.

CRDTs (Conflict-free Replicated Data Types) drehen den Spieß um: kein Server, keine Transformation. Bei den Text-CRDTs wie Yjs oder Automerge bekommt jedes einzelne Zeichen eine unveränderliche ID. Mergen heißt dann, die IDs deterministisch zusammenzusortieren. Nichts geht verloren, die Reihenfolge konvergiert immer. Der Preis dafür sind Metadaten pro Zeichen, Tombstones für gelöschte Zeichen (die will irgendwann die Garbage Collection loswerden) und ein binäres Wire-Format, das nicht mehr durch Menschen lesbar ist.

Peritext (Ink & Switch, 2022) und Fugue (2023) setzen den Feinschliff obendrauf. Peritext löst elegant, wie konkurrierende Formatierung mit den Textkanten mitwandert. Fugue beseitigt das letzte Interleaving-Problem, wenn zwei Leute an derselben Stelle einfügen. Beides ist der aktuelle Stand der Technik, und trotzdem bleibt alles in derselben Kostenfamilie: Zeichen-Metadaten, Runtime, Binärformat. Neuere Systeme wie Loro oder json-joy kombinieren Fugue und Peritext zu beeindruckend schnellen Bibliotheken. An dieser Rechnung ändern sie aber nichts.

Warum mir keiner davon passte

Meine Datenplattform hat ein paar harte Rahmenbedingungen. Der Transport soll dumm sein dürfen: eine Datei kopieren, ein P2P-Relay, notfalls Ende-zu-Ende verschlüsselt. Der Server sieht dann nur opake Bytes, alle Logik liegt auf den Clients. Und weil die Apps revisionssicher (GoBD-konform) laufen sollen, muss jede Transaktion inspizierbar sein: unkomprimiertes JSON, kein Binärblob, keine Runtime, die man mitschleppen muss.

Ein zeichenweiser CRDT verletzt genau diese Punkte. Yjs’ Binär-Updates sind das Gegenteil von auditierbar, und genau deshalb bin ich seinerzeit sogar wieder von Yjs weg. Aber was dann?

Mein Ansatz: eine Ebene tiefer

Die entscheidende Beobachtung war, dass ich die passende Zutat längst im Haus hatte. Ein Dokument ist bei mir eine geordnete Map von Blöcken, also Absätzen und Überschriften. Jeder Block trägt einen fractional key, einen Sortierschlüssel, der zwischen zwei beliebige Nachbarn passt. So können zwei Leute gleichzeitig an derselben Stelle einfügen, und beides überlebt. Blöcke mergen dadurch schon konfliktfrei.

Warum also einen fremden Algorithmus importieren? Ich wende einfach dasselbe Rezept eine Ebene tiefer an. Der Inhalt eines Blocks wird selbst zu einer geordneten Map, diesmal von Spans. Ein Span ist genau ein Token: ein Wort, ein Leerzeichen oder ein untrennbares Atom wie eine Mention.

"blockA": { "k": "a3", "t": "p",
  "s": {
    "w1": { "k": "a0", "x": "Hallo" },
    "w2": { "k": "a1", "x": " " },
    "w3": { "k": "a2", "x": "Welt" }
  },
  "m": { "mk1": { "f": "a0", "e": "a0", "y": "b" } }  // "Hallo" fett: von Schlüssel a0 bis a0
}

Jeder Span hat einen eigenen Sortierschlüssel k und einen Text x. Die Formatierung sitzt bewusst nicht am Span, sondern als eigener Eintrag m auf dem Block: eine Auszeichnung ist ein Bereich von Sortierschlüssel f bis e mit einem Typ y (b für fett). Das war es schon. Kein Merge-Handler, kein Sequence-CRDT, keine einzige neue Zeile Merge-Code. So eine Map ist ein ganz gewöhnlicher verschachtelter Record, und den mergt meine bestehende Last-Write-Wins-Logik feldweise von allein.

Warum das gut funktioniert

Drei Dinge bekomme ich damit praktisch geschenkt:

  • Verschiedene Wörter mergen. Alice ändert Wort 3, Bob Wort 7. Das sind verschiedene Felder, also überleben beide. Die kleinste Konflikteinheit ist von einem ganzen Block auf ein einzelnes Wort geschrumpft.
  • Formatierung kollidiert nicht mit Text. Weil Text (x am Span) und Auszeichnung (der Bereich m am Block) getrennte CRDT-Felder sind, überleben das Fettmachen und das Umschreiben desselben Wortes gleichzeitig.
  • Neu getippter Text erbt die Formatierung. Der Clou am Bereich statt am Feld: Wenn Alice einen Satz fett macht und Bob gleichzeitig mitten hinein ein Wort tippt, bekommt dieses Wort einen Sortierschlüssel zwischen f und e und ist damit automatisch fett. Genau das ist Peritexts Kernidee, nur mit Wort-Schlüsseln als Anker statt einer ID pro Zeichen. Ein Detail unterschlage ich ehrlich: Peritext lässt eine Fett-Auszeichnung am Ende mitwachsen, einen Link aber nicht. Ich behandle vorerst alles wie den Link, tippt man also direkt an die Kante, bleibt es ungeformt. Für meinen Fall reicht das.
  • Winzige, lesbare Transaktionen. Ein Ein-Wort-Edit reist als einzelner Span-Eintrag über die Leitung, als schlichtes JSON, das man im Log mitlesen kann.

Die Identität der Spans muss dafür nicht durch den Editor wandern, denn Tiptap kennt keine Wort-IDs. Stattdessen richtet der Codec beim Commit die neuen Tokens per LCS gegen die eigenen alten Spans aus. Unverändertes behält seine ID, ein korrigiertes Wort wird zur Ersetzung mit gleicher ID und neuem Text, und Einfügungen bekommen frische IDs mit lauf-atomaren Schlüsseln. Dadurch bleiben zwei gleichzeitig eingefügte Wortgruppen zusammenhängend, statt sich zu verschränken.

Erfunden habe ich hier nichts

Damit kein falscher Eindruck entsteht: Das ist keine neue Erfindung, sondern eine Kombination aus Ideen, die es längst gibt. Das Grundmuster, also Blöcke in einer geordneten Map, Properties als Last-Write-Wins-Felder und Reihenfolge über einen fractional index, steckt zum Beispiel in block-basierten Editoren wie BlockSuite. Notion macht im Kern dasselbe, nur eine Ebene gröber: Es mergt offline auf Block-Ebene per Last-Write-Wins, und wenn zwei Leute denselben Absatz bearbeiten, überlebt nur eine Version.

Auch die Idee, Formatierung als eigenes Feld statt am Zeichen zu führen, ist beschrieben. Matthew Weidner skizziert in Designing Data Structures for Collaborative Apps genau so eine Map aus LWW-Registern, und das Peritext-Paper diskutiert die JSON-Variante ausdrücklich, bevor es sich aus guten Gründen für den aufwändigeren Weg auf Zeichenebene entscheidet. Beide Texte lohnen sich, wenn man tiefer einsteigen will.

Mein Beitrag ist deshalb weniger ein Algorithmus als eine Einordnung. Ich habe aus diesen bestehenden Ideen die Variante gewählt, die für meine Rahmenbedingungen am besten passt. Der einzige Punkt, an dem ich von den üblichen Vorbildern abweiche, ist die Granularität: Notion bleibt beim ganzen Block, die klassischen Rich-Text-CRDTs gehen bis aufs einzelne Zeichen. Ich lege die Grenze dazwischen, beim Wort.

Der Trade-off

Umsonst ist das nicht. Tippen Alice und Bob exakt dasselbe Wort gleichzeitig neu, gewinnt einer per Last-Write-Wins. Das ist deterministisch, und die verlorene Version bleibt im revisionssicheren Log rekonstruierbar. Ein zeichenweiser CRDT würde hier nichts verlieren, und Notion trifft dieselbe Entscheidung eine Stufe darüber, nur eben für einen ganzen Absatz statt für ein Wort. Für Notizen aber, die meistens von einer Person bearbeitet werden und nur gelegentlich kollidieren, ist das Wort die passende Granularität. Ich zahle grob das Zwei- bis Dreifache an Metadaten gegenüber reinem Text, und zwar gebunden an die Dokumentgröße, nicht an die Editier-Historie. Wo selbst das zu viel wäre, etwa bei einer importierten E-Mail, die ohnehin meist nur gelesen wird, bleibt ein Absatz zunächst ein einziger Roh-String und zerfällt erst beim ersten Edit in Spans (in der Demo der Füllmodus Email import).

Was ich für mich daraus mitgenommen habe, ist eine simple Faustregel: Die Granularität der gespeicherten Metadaten ist die Granularität der Merge-Qualität. Pro-Block-Metadaten kaufen blockweises Mergen, Yjs’ Pro-Zeichen-IDs kaufen zeichenweises Mergen, meine Pro-Wort-Spans kaufen wortweises Mergen. Ich musste keinen ausgefeilten Sequence-CRDT importieren, um für meinen Fall brauchbar kollaborativ zu werden. Ich musste nur die Granularität wählen, die zu meinen Dokumenten passt, und die Metadaten dafür einplanen. Der Rest ist erfreulich langweilige, auditierbare Last-Write-Wins-Mechanik.

Disclaimer: Dieser Artikel wurde mithilfe von KI erstellt. Ich habe ihn geprüft und korrigiert und muss leider sagen, die KI schreibt besser als ich 😁

Veröffentlicht am 20. September 2026

 
Zurück zur Liste der Beiträge