Files
werkbaum/frontend/src/live.js
T
mhoennigandClaude Opus 5 08965157a1 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>
2026-08-26 17:48:17 +02:00

244 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Werkbaum — gemeinsam am selben Dokument arbeiten (D76, headless).
Hier steht die entscheidbare Hälfte des Clients (Hausregel, D54-Nachtrag 3):
Adressen, das Zeilen-Diff, die Cursor-Rechnung und die Regel, wann eine
Feed-Antwort überhaupt angewendet werden darf. Das Holen selbst, der Takt
und der ganze DOM-Kram bleiben in app.js.
Das Diff-Modell ist dasselbe wie im Backend (`de.werkbaum.diff.LineDiff`):
Operationen gegen eine Basisversion, 0-basierte Zeilenindizes, aufsteigend
sortiert und überschneidungsfrei. Beide Seiten müssen Text **gleich in
Zeilen zerlegen**, sonst zeigen die Indizes auseinander — deshalb liegt
`lines()`/`text()` hier und nicht verstreut in app.js. */
/* ------------------------------------------------------------------ Adressen */
/* Adresse eines Server-Dokuments normalisieren (?live=…). Eingabe ist die
Dokument-URL, so wie sie jemand weitergibt:
`https://host/api/v1/documents/<uuid>`. Query, Fragment und Schrägstriche am
Ende fallen weg, damit derselbe Link genau ein Dokument ergibt — dieselbe
Regel wie bei ?etherpad= (D31).
Verlangt wird `/documents/<uuid>` am Ende; das ist die Prüfung, ob überhaupt
eine Dokument-Adresse vorliegt. Erlaubt sind nur http(s), wie bei
?sourceUrl= (D23).
Rückgabe {doc, content, changes, id} oder null. `doc` ist zugleich Name und
Identität des Dokuments — die vollständige URL. */
export function liveUrls(raw, base){
let u;
try{ u = base ? new URL(raw, base) : new URL(raw); }catch(_){ return null; }
if(u.protocol !== 'http:' && u.protocol !== 'https:') return null;
const path = u.pathname.replace(/\/+$/, '');
const m = /\/documents\/([0-9a-fA-F-]{36})$/.exec(path);
if(!m) return null;
u.pathname = path; u.search = ''; u.hash = '';
const doc = u.href;
return {doc, content: doc + '/content', changes: doc + '/changes', id: m[1].toLowerCase()};
}
/* ------------------------------------------------------------------ Zeilen */
/* Zeilenenden auf LF (SPEC §12). Der Server normalisiert beim Speichern
autoritativ, der Client beim Laden — nur so hashen beide denselben Text. */
export function normalize(text){
return String(text == null ? '' : text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
}
/* Es gilt `text(lines(t)) === normalize(t)`: ein abschließendes LF ergibt eine
leere letzte Zeile, der leere Text ist genau eine leere Zeile. Identisch zur
Server-Seite. */
export function lines(text){ return normalize(text).split('\n'); }
export function text(ls){ return ls.join('\n'); }
/* Prüfsumme des Basistexts, `sha256:<hex>` — Pflichtfeld jedes Patches: Die
Versionsnummer bestätigt nur, dass die Basis dieselbe *Version* ist, nicht
dass beide Seiten sie *gleich lesen*.
Braucht `crypto.subtle`, und das gibt es nur im sicheren Kontext (https oder
localhost). Auf `file://` schlägt es fehl — dort ist Live-Editing ohnehin
keine Frage, aber der Fehler soll benannt sein statt still. */
export async function checksum(t){
const subtle = globalThis.crypto && globalThis.crypto.subtle;
if(!subtle) throw new Error('crypto.subtle nicht verfügbar (kein sicherer Kontext)');
const bytes = new TextEncoder().encode(normalize(t));
const digest = await subtle.digest('SHA-256', bytes);
const hex = Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('');
return 'sha256:' + hex;
}
/* ------------------------------------------------------- Diff berechnen */
/* Obergrenze für die LCS-Tabelle; darüber wird der abweichende Abschnitt zu
einem einzigen `replace`. Bei einem so großen Unterschied ist das ohnehin
die ehrliche Beschreibung. Gleicher Wert wie im Backend. */
const LCS_LIMIT = 1000000;
/* Zeilen-Diff zwischen zwei Ständen; es gilt `applyOps(from, computeOps(from,
to)) == to`. Gemeinsamer Anfang und gemeinsames Ende fallen zuerst weg — der
übliche Fall (ein paar Zeichen in einer Zeile) kostet danach fast nichts. */
export function computeOps(from, to){
let head = 0;
const shortest = Math.min(from.length, to.length);
while(head < shortest && from[head] === to[head]) head++;
let tail = 0;
while(tail < shortest - head && from[from.length - 1 - tail] === to[to.length - 1 - tail]) tail++;
const a = from.slice(head, from.length - tail);
const b = to.slice(head, to.length - tail);
if(!a.length && !b.length) return [];
if(!a.length) return [{op: 'insert', index: head, lines: b}];
if(!b.length) return [{op: 'delete', index: head, count: a.length}];
if(a.length * b.length > LCS_LIMIT) return [{op: 'replace', index: head, count: a.length, lines: b}];
return lcsOps(a, b, head);
}
function lcsOps(a, b, offset){
const n = a.length, m = b.length;
/* lcs[i][j] = Länge der längsten gemeinsamen Teilfolge von a[i..] und b[j..] */
const lcs = Array.from({length: n + 1}, () => new Int32Array(m + 1));
for(let i = n - 1; i >= 0; i--){
for(let j = m - 1; j >= 0; j--){
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
}
}
const ops = [];
let i = 0, j = 0;
while(i < n || j < m){
if(i < n && j < m && a[i] === b[j]){ i++; j++; continue; }
const removedFrom = i;
const inserted = [];
while(i < n || j < m){
if(i < n && j < m && a[i] === b[j]) break;
if(j < m && (i === n || lcs[i][j + 1] >= lcs[i + 1][j])){ inserted.push(b[j]); j++; }
else i++;
}
const removed = i - removedFrom;
if(removed > 0 && inserted.length) ops.push({op: 'replace', index: offset + removedFrom, count: removed, lines: inserted});
else if(removed > 0) ops.push({op: 'delete', index: offset + removedFrom, count: removed});
else ops.push({op: 'insert', index: offset + removedFrom, lines: inserted});
}
return ops;
}
/* ---------------------------------------------------------- Diff anwenden */
function removedCount(op){ return op.op === 'insert' ? 0 : (op.count || 0); }
function insertedLines(op){ return op.op === 'delete' ? [] : (op.lines || []); }
/* Wendet Operationen auf Zeilen an. Wirft, wenn sie nicht passen — ein
halb angewendetes Diff wäre schlimmer als ein Neuladen. */
export function applyOps(base, ops){
const out = [];
let cursor = 0;
for(const op of ops){
const end = op.index + removedCount(op);
if(op.index < cursor || op.index < 0 || end > base.length){
throw new Error('Diff passt nicht auf diesen Stand');
}
out.push(...base.slice(cursor, op.index), ...insertedLines(op));
cursor = end;
}
out.push(...base.slice(cursor));
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?
Ohne diese Rechnung spränge die Schreibmarke bei jeder fremden Änderung
weiter oben im Dokument — genau das, was „kein Neuladen" verhindern soll.
Zeilen **innerhalb** eines ersetzten oder gelöschten Bereichs haben kein
Gegenüber; sie landen am Anfang des Bereichs. Das ist die verlässlichste
Antwort: dort, wo die fremde Änderung eingegriffen hat. */
export function mapLine(lineIndex, ops){
let delta = 0;
for(const op of ops){
const start = op.index;
const end = start + removedCount(op);
if(end <= lineIndex) delta += insertedLines(op).length - removedCount(op);
else if(start <= lineIndex) return start + delta; /* mitten im Eingriff */
else break; /* Ops sind sortiert */
}
return lineIndex + delta;
}
/* Zeichenposition → {line, col}; die Umkehrung braucht die neuen Zeilen. */
export function caretToLineCol(t, offset){
const before = normalize(t).slice(0, Math.max(0, offset));
const line = before.split('\n').length - 1;
const col = before.length - (before.lastIndexOf('\n') + 1);
return {line, col};
}
export function lineColToCaret(ls, line, col){
const clampedLine = Math.max(0, Math.min(line, ls.length - 1));
let offset = 0;
for(let i = 0; i < clampedLine; i++) offset += ls[i].length + 1;
return offset + Math.max(0, Math.min(col, ls[clampedLine].length));
}
/* --------------------------------------------------------------- Feed */
/* Darf diese Feed-Antwort angewendet werden?
Eine gepufferte Antwort darf **nur** angewendet werden, wenn ihr
`fromVersion` zur aktuellen Schattenkopie passt (D76). Sonst wendet der
Client dieselben Operationen doppelt an — der Fall tritt ein, wenn Feed und
409-Antwort beide dasselbe fremde Diff liefern.
'apply' Operationen anwenden
'replace' Volltext übernehmen (Basis verdichtet oder Erstkontakt)
'skip' nichts tun (schon gesehen oder passt nicht auf unseren Stand) */
export function feedAction(feed, shadowVersion){
if(!feed || typeof feed.currentVersion !== 'number') return 'skip';
if(feed.currentVersion <= shadowVersion) return 'skip';
if(typeof feed.content === 'string' && feed.fromVersion == null) return 'replace';
if(Array.isArray(feed.ops) && feed.fromVersion === shadowVersion) return 'apply';
return 'skip';
}