feat(frontend): gemeinsam am Server-Dokument arbeiten (?live=, Schritt 6)

Der Editor fuehrt ein Dokument des Backends: laden, nach 1,5 s Ruhe das Diff
schicken, ueber einen offenen Abruf fremde Aenderungen einspielen — ohne
Neuladen, mit mitwandernder Schreibmarke. Dazu CORS im Backend; ohne das
blockiert der Browser jeden Aufruf.

Zwei Fehler hat erst der Live-Test gegen das laufende Backend gefunden, beide
an der Naht zwischen Modul und Verdrahtung (D54-Nachtrag 3):

Der Konflikt entstand nie. Mit laufendem Feed zieht die Schattenkopie staendig
nach, die eigene Basis ist also nie veraltet — der Server haette nie 409
geantwortet, und die fremde Zeile waere stillschweigend ueberschrieben worden.
Der Client prueft die Ueberschneidung jetzt selbst gegen den ungesendeten
Text; den kennt der Server nicht.

Die Nummer begann nach jedem Neuladen wieder bei 1, waehrend die Kennung
blieb — der Server hielt die erste echte Aenderung fuer eine Wiederholung und
tat nichts. Beides liegt jetzt im sessionStorage: je Tab, ueberlebt Neuladen.
Je Tab ist zugleich die richtige Aussage, zwei Tabs sind zwei Schreiber.

Nachgemessen im Browser: fremde Aenderung erscheint ohne Neuladen, Getipptes
erreicht den Server, beide Konflikt-Knoepfe tun was sie sagen, und die
Schreibmarke steht nach zwei fremd eingefuegten Zeilen darueber unveraendert
bei Zeile+2, Spalte 8. 518 Frontend-Tests, 135 im Backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 17:48:17 +02:00
co-authored by Claude Opus 5
parent 5881be1a2c
commit 08965157a1
12 changed files with 666 additions and 7 deletions
+42
View File
@@ -144,6 +144,48 @@ export function applyOps(base, ops){
return out;
}
/* ------------------------------------------------------------ Rebasen */
/* Überschneiden sich zwei Operationen derselben Basis? Dieselbe Regel wie im
Backend: `replace`/`delete` belegen `[index, index+count)`, `insert` ist ein
Punkt **zwischen** den Zeilen und kollidiert nur mit dem Inneren eines
fremden Bereichs. Zwei Einfügungen an derselben Stelle vertragen sich. */
export function conflicts(a, b){
const ai = a.index, ae = ai + removedCount(a);
const bi = b.index, be = bi + removedCount(b);
if(a.op === 'insert' && b.op === 'insert') return false;
if(a.op === 'insert') return bi < ai && ai < be;
if(b.op === 'insert') return ai < bi && bi < ae;
return ai < be && bi < ae;
}
/* Verschiebt [ops] auf den Stand, der durch [onto] aus derselben Basis
entstanden ist. `null` heißt echte Überschneidung — dann darf nichts
angewendet werden, und der Mensch entscheidet.
Der Client braucht das an zwei Stellen: um die eigene Schattenkopie
nachzuziehen, nachdem der Server verschoben hat, und um fremde Änderungen in
einen Text einzublenden, in dem schon ungesendet getippt wurde. */
export function rebaseOps(ops, onto){
if(!onto || !onto.length || !ops.length) return ops;
for(const mine of ops){
for(const theirs of onto){
if(conflicts(mine, theirs)) return null;
}
}
return ops.map(op => {
let delta = 0;
for(const other of onto){
/* Eine fremde Einfügung an derselben Stelle zählt dazu: Sie ist bereits
bestätigt und steht deshalb oben. */
if(other.index + removedCount(other) <= op.index){
delta += insertedLines(other).length - removedCount(other);
}
}
return delta ? Object.assign({}, op, {index: op.index + delta}) : op;
});
}
/* ------------------------------------------------------------ Cursor */
/* Wohin wandert eine Zeile, wenn fremde Operationen angewendet werden?