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
+12 -1
View File
@@ -114,7 +114,18 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
Knoten-Eigenschaften dort in `nodeAria()` mitpflegen und dafür a11y-i18n-Keys
(`a11y*`) in **allen 9 Sprachen** anlegen. Knoten sind `tabindex="0"`
(Fokus = Lesereihenfolge), `#warn` ist eine Live-Region.
- Zustand wird im `localStorage` gehalten (noch kein Backend): `werkbaum-lang`
- **Server-Dokumente (`?live=`, D76):** `live.js` hält die entscheidbare Hälfte
(Adressen, Zeilen-Diff, Rebasen, Cursor-Rechnung, Feed-Regel), `app.js` die
Verdrahtung: `loadLive()` holt Text und Version und merkt beides als
**Schattenkopie**, `scheduleLivePush()` schickt nach 1,5 s Ruhe das Diff,
`runFeed()` hält einen Abruf offen (nur im sichtbaren Tab). Zwei Fallen, beide
im Live-Test gefunden (D76-Nachtrag 7): `clientId` **und** `seq` gehören in den
`sessionStorage` (je Tab, überlebt Neuladen — sonst hält der Server die erste
Änderung nach einem Reload für eine Wiederholung und tut nichts), und der
**Konflikt entsteht beim Tippen**, nicht erst beim Senden — der Server kennt
den ungesendeten Text nicht. Fremde Änderungen werden bewusst **nicht**
undo-fähig eingespielt.
- Zustand wird im `localStorage` gehalten (Server-Dokumente ausgenommen): `werkbaum-lang`
(Sprache), `werkbaum-docs` (JSON-Array der Dokumente `[{id,name,text}]`),
`werkbaum-active` (id des aktiven Dokuments), `werkbaum-src` (Spiegel des
aktiven Texts, Abwärtskompatibilität), `werkbaum-ui` (JSON: Modus, verworfene,
+418
View File
@@ -4,6 +4,7 @@ import { computeCheapPlan, overloadedAssignee, freshProdSet, initialCollapsed, n
import { esc, renderTreeHtml, TIP_RULE } from './render.js';
import { formatWarning, warningText } from './warnings.js';
import { padUrls } from './remote.js';
import * as live from './live.js';
import { depFragment, collectIds, matchIds, depIdAt, idLine } from './autocomplete.js';
import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel }
from './snapshots.js';
@@ -913,6 +914,7 @@ src.addEventListener('keydown', e => {
src.addEventListener('input', render);
src.addEventListener('input', saveSrc);
src.addEventListener('input', scheduleLivePush); /* Server-Dokument: Diff nach 1,5 s Ruhe (D76) */
/* ---------- Sprung zwischen Diagramm und Text (D25) ----------
Jeder Knoten trägt seine Zeilennummer als `data-line` (render.js).
@@ -2222,6 +2224,11 @@ const I18N = {
padReadonly:"Wird im Pad bearbeitet — hier nur lesen.",
padEdit:"Pad zum Bearbeiten öffnen",
padRefresh:"Vom Pad neu laden",
liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.",
liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?",
liveConflictTheirs:"Fremde übernehmen",
liveConflictMine:"Eigene durchsetzen",
padWait:"Noch {seconds} s — Etherpad begrenzt die Abrufe",
padRateLimitWarn:"Noch nicht neu geladen: Etherpad erlaubt nur wenige Abrufe je Zeitfenster (serienmäßig 10 pro 90 s), so häufiges Nachladen ist leider nicht möglich. In {seconds} s geht es wieder.",
padViewTooltip:"Ansicht: {state} — klicken zum Wechseln",
@@ -2335,6 +2342,11 @@ const I18N = {
padReadonly:"Edited in the pad — read-only here.",
padEdit:"Open the pad to edit",
padRefresh:"Reload from the pad",
liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.",
liveConflictText:"Someone changed the same lines. Whose version should win?",
liveConflictTheirs:"Take theirs",
liveConflictMine:"Keep mine",
padWait:"{seconds} s to go — Etherpad limits how often we may fetch",
padRateLimitWarn:"Not reloaded yet: Etherpad only allows a few fetches per time window (10 per 90 s by default), so syncing this often is unfortunately not possible. Try again in {seconds} s.",
padViewTooltip:"View: {state} — click to switch",
@@ -2447,6 +2459,11 @@ const I18N = {
padReadonly:"Se edita en el pad — aquí solo lectura.",
padEdit:"Abrir el pad para editar",
padRefresh:"Recargar desde el pad",
liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.",
liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?",
liveConflictTheirs:"Tomar la ajena",
liveConflictMine:"Mantener la mía",
padWait:"Faltan {seconds} s — Etherpad limita la frecuencia",
padRateLimitWarn:"Aún no se ha recargado: Etherpad solo permite unas pocas descargas por ventana de tiempo (10 por 90 s de forma predeterminada), así que sincronizar tan a menudo no es posible. Vuelve a intentarlo en {seconds} s.",
padViewTooltip:"Vista: {state} — clic para cambiar",
@@ -2559,6 +2576,11 @@ const I18N = {
padReadonly:"Modifié dans le pad — lecture seule ici.",
padEdit:"Ouvrir le pad pour modifier",
padRefresh:"Recharger depuis le pad",
liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/&lt;uuid&gt;) ?",
liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.",
liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?",
liveConflictTheirs:"Prendre la sienne",
liveConflictMine:"Garder la mienne",
padWait:"Encore {seconds} s — Etherpad limite la fréquence",
padRateLimitWarn:"Pas encore rechargé : Etherpad nautorise que quelques récupérations par fenêtre de temps (10 par 90 s par défaut), une synchronisation aussi fréquente nest donc pas possible. Réessaie dans {seconds} s.",
padViewTooltip:"Vue : {state} — cliquer pour changer",
@@ -2671,6 +2693,11 @@ const I18N = {
padReadonly:"Edytowane w padzie — tu tylko do czytania.",
padEdit:"Otwórz pad do edycji",
padRefresh:"Wczytaj ponownie z padu",
liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.",
liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?",
liveConflictTheirs:"Przyjmij cudzą",
liveConflictMine:"Zachowaj własną",
padWait:"Jeszcze {seconds} s — Etherpad ogranicza częstość",
padRateLimitWarn:"Jeszcze nie wczytano ponownie: Etherpad dopuszcza tylko kilka pobrań w okresie (domyślnie 10 na 90 s), więc tak częsta synchronizacja nie jest możliwa. Spróbuj za {seconds} s.",
padViewTooltip:"Widok: {state} — kliknij, aby zmienić",
@@ -2783,6 +2810,11 @@ const I18N = {
padReadonly:"Редактируется в паде — здесь только чтение.",
padEdit:"Открыть пад для редактирования",
padRefresh:"Обновить из пада",
liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.",
liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?",
liveConflictTheirs:"Принять чужую",
liveConflictMine:"Оставить свою",
padWait:"Ещё {seconds} с — Etherpad ограничивает частоту",
padRateLimitWarn:"Пока не обновлено: Etherpad разрешает лишь несколько загрузок за окно времени (по умолчанию 10 за 90 с), поэтому столь частая синхронизация невозможна. Повторите через {seconds} с.",
padViewTooltip:"Вид: {state} — нажмите для переключения",
@@ -2895,6 +2927,11 @@ const I18N = {
padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।",
padEdit:"संपादित करने के लिए पैड खोलें",
padRefresh:"पैड से फिर लोड करें",
liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।",
liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?",
liveConflictTheirs:"दूसरे का लें",
liveConflictMine:"अपना रखें",
padWait:"{seconds} स॰ बाकी — Etherpad बार-बार लेने की सीमा रखता है",
padRateLimitWarn:"अभी दोबारा लोड नहीं किया: Etherpad प्रति समय-खिड़की केवल कुछ ही बार लेने देता है (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार), इसलिए इतनी बार सिंक करना संभव नहीं है। {seconds} स॰ में फिर कोशिश करें।",
padViewTooltip:"दृश्य: {state} — बदलने के लिए क्लिक करें",
@@ -3018,6 +3055,11 @@ const I18N = {
padReadonly:"在 Pad 中编辑 — 此处只读。",
padEdit:"打开 Pad 进行编辑",
padRefresh:"从 Pad 重新加载",
liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?",
liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。",
liveConflictText:"有人改动了同样的行。以谁的版本为准?",
liveConflictTheirs:"采用对方的",
liveConflictMine:"保留我的",
padWait:"还需 {seconds} 秒 — Etherpad 限制获取频率",
padRateLimitWarn:"尚未重新加载:Etherpad 每个时间窗口只允许少量获取(默认每 90 秒 10 次),因此无法如此频繁地同步。请在 {seconds} 秒后再试。",
padViewTooltip:"视图:{state} — 点击切换",
@@ -3130,6 +3172,11 @@ const I18N = {
padReadonly:"パッドで編集します — ここでは読み取り専用です。",
padEdit:"編集するにはパッドを開く",
padRefresh:"パッドから再読み込み",
liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。",
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
liveConflictTheirs:"相手の版",
liveConflictMine:"自分の版",
padWait:"あと {seconds} 秒 — Etherpad は取得頻度を制限します",
padRateLimitWarn:"まだ再読み込みしていません: Etherpad は一定時間内の取得回数を制限します(既定で 90 秒あたり 10 回)。これほど頻繁な同期はできません。{seconds} 秒後にもう一度お試しください。",
padViewTooltip:"表示: {state} — クリックで切り替え",
@@ -3970,6 +4017,7 @@ function deleteDoc(){
if(!d) return;
if(!window.confirm(t('docDeleteConfirm', {name: d.name}))) return;
if(padSource && padSource.id === d.id) stopPad(); /* danach gibt es nichts mehr zu holen (D31) */
if(liveState && liveState.id === d.id) stopLive(); /* dito fürs Server-Dokument (D76) */
if(fileHandles.has(d.id)){ fileHandles.delete(d.id); idbDeleteHandle(d.id); } /* mit dem Dokument geht sein Datei-Handle (D72) */
docs = docs.filter(x => x.id !== d.id);
if(snaps[d.id]){ delete snaps[d.id]; persistSnaps(snaps, localStorage); } /* mit dem Dokument gehen seine Stände (D54) */
@@ -4335,6 +4383,375 @@ async function loadRemoteSource(){
}
}
/* ---------- Gemeinsam am selben Dokument arbeiten: ?live= (D76) ----------
Der dritte Eingang für Text von außen und der einzige, in den auch
zurückgeschrieben wird. Adressiert wird die Dokument-URL des Backends
(`…/api/v1/documents/<uuid>`); Identität und Endpunkte leitet live.js daraus
ab, dort steht auch die Begründung.
Der Ablauf in drei Sätzen: Beim Laden holen wir Text und Version und merken
uns beides als **Schattenkopie** den Stand, den der Server kennt. Wer tippt,
schickt nach 1,5 s Ruhe das **Diff** von der Schattenkopie zum jetzigen Text.
Ein **Feed** hält die Gegenrichtung offen und wendet fremde Änderungen an,
ohne das Dokument neu zu laden dafür wandert die Schreibmarke mit
(live.js, `mapLine`).
Was hier NICHT passiert: Zusammenführen. Überschneiden sich zwei Änderungen
wirklich, entscheidet der Mensch (Konflikt-Band unten). Alles andere
verschiebt der Server selbst. */
const LIVE_PARAM = 'live';
const LIVE_DEBOUNCE_MS = 1500; /* Ruhe vor dem Senden; D76 */
const LIVE_WAIT_S = 25; /* Wartezeit des Feeds; der Server klemmt sie ohnehin */
const LIVE_RETRY_MS = 5000; /* nach einem Netzfehler, bevor der Feed erneut fragt */
/* Zufällige, pseudonyme Kennung dieses Clients samt laufender Nummer.
Beide zusammen machen das Einreichen wiederholbar: Geht die Antwort
unterwegs verloren, erkennt der Server die Wiederholung und wendet sie nicht
ein zweites Mal an (D76).
Beide liegen im **sessionStorage**, also je Tab:
- Über den Tab hinaus geteilt wären sie falsch. Zwei Tabs desselben Browsers
sind zwei Schreiber; mit gemeinsamer Nummer schickte der eine bald eine
kleinere `seq` als der andere für den Server eine veraltete Nummer (422).
- Innerhalb des Tabs müssen sie das Neuladen überleben. Sonst finge die
Nummer wieder bei 1 an, und der Server hielte die erste echte Änderung für
die Wiederholung der letzten von vorhin und täte **nichts** im Live-Test
genau so passiert. */
const SS_CLIENT = 'werkbaum-client';
const SS_SEQ = 'werkbaum-seq';
function clientId(){
let id = null;
try{ id = sessionStorage.getItem(SS_CLIENT); }catch(_){}
if(!id){
id = 'c-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
try{ sessionStorage.setItem(SS_CLIENT, id); }catch(_){}
}
return id;
}
function nextSeq(){
let n = 0;
try{ n = parseInt(sessionStorage.getItem(SS_SEQ) || '0', 10) || 0; }catch(_){}
n++;
try{ sessionStorage.setItem(SS_SEQ, String(n)); }catch(_){}
return n;
}
let liveState = null; /* {urls, id, version, shadow, seq, pushTimer, feedAbort, busy} */
let liveConflict = null; /* offener Konflikt: bis der Mensch entscheidet, ruht alles */
function liveActive(){ return !!liveState && activeId === liveState.id; }
/* Wer das Live-Dokument löscht, meint es (wie beim Pad, D31). */
function stopLive(){
if(!liveState) return;
if(liveState.pushTimer) clearTimeout(liveState.pushTimer);
if(liveState.feedAbort) liveState.feedAbort.abort();
liveState = null;
liveConflict = null;
hideConflictBanner();
}
async function loadLive(){
const raw = urlParam(LIVE_PARAM);
if(!raw) return;
const urls = live.liveUrls(raw, location.href);
if(!urls){
sourceWarning = {type:'liveLoad', url: raw, error: 'not a Werkbaum document URL'};
render();
return;
}
liveState = {urls, id: 'live:' + urls.doc, version: 0, shadow: [''],
pushTimer: null, feedAbort: null, busy: false};
try{
const doc = await fetchJson(urls.doc);
adoptLive(doc);
runFeed();
}catch(err){
sourceWarning = {type:'liveLoad', url: urls.doc, error: (err && err.message) || String(err)};
render();
}
}
async function fetchJson(url, options){
const resp = await fetch(url, Object.assign({cache:'no-store', credentials:'omit'}, options));
if(resp.status === 204) return null;
if(!resp.ok){
const err = new Error('HTTP ' + resp.status);
err.status = resp.status;
try{ err.body = await resp.json(); }catch(_){}
throw err;
}
return await resp.json();
}
/* Server-Stand übernehmen: Text ins Dokument, Schattenkopie und Version merken.
Der Name ist der **Titel des Servers**, nicht die URL wie bei ?sourceUrl= und
?etherpad= anders als eine Datei oder ein Pad hat ein Server-Dokument einen
Namen, und alle sehen denselben. Die volle Adresse steht im Tooltip
(D76-Nachtrag 7). */
function adoptLive(doc){
const content = live.normalize(doc.content || '');
liveState.version = doc.version;
liveState.shadow = live.lines(content);
flushActive();
let d = docs.find(x => x.id === liveState.id);
if(d){ d.text = content; d.name = doc.title || liveState.urls.doc; }
else { d = {id: liveState.id, name: doc.title || liveState.urls.doc, text: content}; docs.push(d); }
d.source = liveState.urls.doc;
activeId = liveState.id;
sourceWarning = null;
computeFresh(liveState.id, content);
loadActiveIntoEditor();
persistDocs();
}
/* ---------- Hinschicken ---------- */
/* Nach jeder Eingabe neu gestartet: Gesendet wird erst, wenn 1,5 s Ruhe ist.
Ohne den Takt entstünde je Tastendruck eine Version die Historie wäre ein
Transaktionslog, und das Netz hätte zu tun. */
function scheduleLivePush(){
if(!liveActive() || liveConflict) return;
if(liveState.pushTimer) clearTimeout(liveState.pushTimer);
liveState.pushTimer = setTimeout(() => { liveState.pushTimer = null; pushLive(); },
LIVE_DEBOUNCE_MS);
}
async function pushLive(){
if(!liveActive() || liveConflict || liveState.busy) return;
const now = live.lines(live.normalize(src.value));
const ops = live.computeOps(liveState.shadow, now);
if(!ops.length) return;
liveState.busy = true;
const seq = nextSeq();
try{
const body = {
baseVersion: liveState.version,
checksum: await live.checksum(live.text(liveState.shadow)),
clientId: clientId(),
displayName: displayName(),
seq,
ops,
};
const result = await fetchJson(liveState.urls.content, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
/* Angenommen. Hat der Server verschoben, stehen die fremden Operationen in
`opsSinceBase`: Die Schattenkopie zieht erst darüber nach, dann kommt
unsere eigene Änderung darauf verschoben um die fremde. Genau diese
Rechnung hat der Server auch gemacht; wir kommen deshalb auf denselben
Text, ohne ihn abholen zu müssen. */
const foreign = (result.opsSinceBase || []);
const alt = liveState.shadow;
const meine = foreign.length ? live.rebaseOps(ops, foreign) : ops;
if(meine == null){ await reloadLive(); return; } /* kann nicht sein - dann lieber neu */
liveState.shadow = live.applyOps(
foreign.length ? live.applyOps(alt, foreign) : alt, meine);
liveState.version = result.version;
if(foreign.length) applyForeign(alt, foreign, liveState.shadow, liveState.version);
}catch(err){
handlePushError(err);
}finally{
liveState.busy = false;
}
}
function handlePushError(err){
if(err && err.status === 409 && err.body){
/* Der andere Weg in denselben Zustand: Unser Senden hat das Rennen gegen
den Feed gewonnen, und der Server hat die Überschneidung gesehen. */
let serverLines;
try{ serverLines = live.applyOps(liveState.shadow, err.body.opsSinceBase || []); }
catch(_){ reloadLive(); return; }
openConflict(err.body.currentVersion, serverLines);
return;
}
if(err && (err.status === 422 || err.status === 404)){
/* Nicht anwendbar oder weg: einmal neu laden ist der vereinbarte Ausweg. */
sourceWarning = {type:'liveStale', error: (err.body && err.body.detail) || err.message};
render();
reloadLive();
return;
}
sourceWarning = {type:'liveLoad', url: liveState.urls.doc,
error: (err && err.message) || String(err)};
render();
}
async function reloadLive(){
if(!liveState) return;
try{ adoptLive(await fetchJson(liveState.urls.doc)); }catch(_){ /* der Feed versucht es weiter */ }
}
/* ---------- Herbekommen: der Feed ---------- */
/* Eine offene Anfrage, die der Server beantwortet, sobald sich etwas tut.
Läuft **nur im sichtbaren Tab** (D76-Nachtrag 1): Ein Hintergrund-Tab braucht
keinen Live-Feed, niemand schaut hin, und ohne HTTP/2 belegt jeder Tab eine
der sechs Verbindungen zur Herkunft. Beim Zurückkommen holt ein einziger
Abruf den Rückstand. */
async function runFeed(){
while(liveState){
if(document.hidden || liveConflict){ await sleep(500); continue; }
const ctl = new AbortController();
liveState.feedAbort = ctl;
try{
const url = liveState.urls.changes +
'?since=' + liveState.version + '&wait=' + LIVE_WAIT_S;
const feed = await fetchJson(url, {signal: ctl.signal});
if(feed) applyFeed(feed);
}catch(err){
if(!liveState) return;
if(err && err.name === 'AbortError') continue;
if(err && err.status === 404){ stopLive(); return; }
await sleep(LIVE_RETRY_MS);
}finally{
if(liveState) liveState.feedAbort = null;
}
}
}
function sleep(ms){ return new Promise(r => setTimeout(r, ms)); }
/* Beim Wegschalten die offene Verbindung **schließen**, nicht bloß die Antwort
ignorieren: Ohne HTTP/2 belegt jeder Tab eine der sechs Verbindungen zur
Herkunft, und ein Hintergrund-Tab braucht keinen Feed (D76-Nachtrag 1). Beim
Zurückkommen holt der nächste Durchlauf den Rückstand in einem Zug. */
document.addEventListener('visibilitychange', () => {
if(document.hidden && liveState && liveState.feedAbort) liveState.feedAbort.abort();
});
function applyFeed(feed){
const what = live.feedAction(feed, liveState.version);
if(what === 'skip') return;
if(what === 'replace'){
/* Volltext: die Basis ist verdichtet, ein Diff gibt es nicht mehr. */
liveState.version = feed.currentVersion;
liveState.shadow = live.lines(live.normalize(feed.content));
setLiveText(live.text(liveState.shadow), null);
return;
}
const alt = liveState.shadow;
liveState.shadow = live.applyOps(alt, feed.ops);
liveState.version = feed.currentVersion;
applyForeign(alt, feed.ops, liveState.shadow, liveState.version);
}
/* Fremde Operationen auf den **sichtbaren** Text anwenden.
Hat der Nutzer inzwischen selbst getippt, ist sein Text nicht mehr die alte
Schattenkopie: Dann wird die fremde Änderung auf seinen Stand angewendet und
sein eigenes Diff beim nächsten Senden neu gebildet. Passt das nicht (die
Zeilen sind unter ihm weggezogen), bleibt sein Text stehen das nächste
Senden klärt es, notfalls über einen Konflikt. */
function applyForeign(oldShadow, ops, serverLines, version){
const editor = live.lines(live.normalize(src.value));
/* Was der Server noch nicht kennt: alles, was seit der Schattenkopie
getippt wurde. Die fremden Operationen zählen gegen die Schattenkopie -
um sie auf den getippten Text anzuwenden, müssen sie daran vorbei. */
const ungesendet = live.computeOps(oldShadow, editor);
const verschoben = live.rebaseOps(ops, ungesendet);
if(verschoben == null){
/* Überschneidung mit dem, was gerade getippt wird **hier** entsteht der
Konflikt, nicht erst beim Senden. Ohne diese Stelle bliebe er praktisch
aus: Der Feed zieht die Schattenkopie nach, das nächste Senden hätte
eine aktuelle Basis, und die fremde Zeile wäre stillschweigend
überschrieben. Der Server kann das nicht sehen er kennt den
ungesendeten Text nicht. */
openConflict(version, serverLines);
return;
}
let after;
try{ after = live.applyOps(editor, verschoben); }catch(_){ return; }
const caret = live.caretToLineCol(src.value, src.selectionStart);
const line = live.mapLine(caret.line, verschoben);
setLiveText(live.text(after), live.lineColToCaret(after, line, caret.col));
}
/* Text setzen, ohne die eigene Änderungslogik erneut auszulösen.
Bewusst **nicht** undo-fähig geschrieben: Eine fremde Änderung ist nicht
meine Eingabe, und ein Strg+Z, das den Beitrag eines anderen zurücknimmt,
wäre eine Lüge über die Herkunft. */
function setLiveText(text, caret){
src.value = text;
if(caret != null){ src.selectionStart = src.selectionEnd = caret; }
const d = activeDoc();
if(d) d.text = text;
persistDocs();
render();
}
/* ---------- Konflikt ---------- */
/* Echte Überschneidung: Der Server hat abgelehnt, und jetzt entscheidet der
Mensch. Bis dahin ruhen Senden und Feed sonst zöge der Stand unter der
Frage weg, die gerade gestellt ist. */
function openConflict(version, serverLines){
if(liveConflict) return; /* eine Frage zur Zeit */
liveConflict = {version, serverLines};
showConflictBanner();
}
/* Fremde Fassung übernehmen: Der eigene Text weicht. Verloren ist er nicht
er steht in den früheren Ständen (D54), und jede Version steht in der
Historie des Servers. */
function takeTheirs(){
const c = liveConflict;
liveConflict = null;
hideConflictBanner();
liveState.version = c.version;
liveState.shadow = c.serverLines;
setLiveText(live.text(c.serverLines), null);
}
/* Eigene durchsetzen: Die Schattenkopie zieht auf den Server-Stand nach, der
eigene Text bleibt stehen. Das nächste Senden bildet das Diff dagegen es
überschneidet sich dann per Konstruktion nicht mehr. */
function keepMine(){
const c = liveConflict;
liveConflict = null;
hideConflictBanner();
liveState.version = c.version;
liveState.shadow = c.serverLines;
pushLive();
}
function showConflictBanner(){
hideConflictBanner();
const bar = document.createElement('div');
bar.id = 'liveConflict';
bar.className = 'live-conflict';
bar.setAttribute('role', 'alertdialog');
bar.innerHTML = '<span></span>' +
'<div class="live-conflict-actions">' +
'<button type="button" class="theirs"></button>' +
'<button type="button" class="mine"></button></div>';
bar.querySelector('span').textContent = t('liveConflictText');
const theirs = bar.querySelector('.theirs');
const mine = bar.querySelector('.mine');
theirs.textContent = t('liveConflictTheirs');
mine.textContent = t('liveConflictMine');
theirs.addEventListener('click', takeTheirs);
mine.addEventListener('click', keepMine);
document.body.appendChild(bar);
theirs.focus();
}
function hideConflictBanner(){
const el = document.getElementById('liveConflict');
if(el) el.remove();
}
/* Anzeigename: ohne Anmeldung eine Behauptung (D76) die Oberfläche darf ihn
nicht wie einen Nachweis aussehen lassen. Vorerst der gemerkte Name oder
nichts; ein Eingabefeld dafür kommt mit der Präsenz-Anzeige. */
const LS_NAME = 'werkbaum-name';
function displayName(){
try{ return localStorage.getItem(LS_NAME) || undefined; }catch(_){ return undefined; }
}
/* ---------- Pad: auf Knopfdruck neu holen (D31) ----------
Kein Hintergrund-Takt. Der erste Entwurf holte alle 2,5 s selbsttätig und
lief damit in Etherpads **Drosselung**: `importExportRateLimiting` ist
@@ -4796,6 +5213,7 @@ if('launchQueue' in window){
}
applyMobile(); /* Mobil-Verhalten (nach Sprache/Restore) anwenden */
loadRemoteSource(); /* ?sourceUrl= / ?etherpad= nachladen (asynchron, D23/D31) */
loadLive(); /* ?live= — Server-Dokument samt Feed (asynchron, D76) */
/* ---------- PWA: Service Worker (D73) ----------
Ein reiner Offline-Mantel (public/sw.js): Navigationen network-first, der
+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?
+24
View File
@@ -1544,3 +1544,27 @@
.node:not(.folded) .fold{display:none!important}
@page{margin:12mm}
}
/* ---------- Konflikt beim gemeinsamen Bearbeiten (D76) ----------
Band oben, wie der Update-Hinweis: Es unterbricht nicht die Arbeit, steht
aber über allem, bis jemand entscheidet. In `--warn`, nicht in Petrol — es
ist keine Einladung, sondern eine Sache, die geklärt werden muss. */
.live-conflict{
position: fixed; inset: 0 0 auto 0; z-index: 1200;
display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding: 10px 16px;
background: var(--warn, #B45309); color: #fff;
font: 500 14px/1.4 'IBM Plex Sans', system-ui, sans-serif;
box-shadow: 0 2px 8px rgba(0,0,0,.25);
}
.live-conflict-actions{ display: flex; gap: 8px; flex: 0 0 auto; }
.live-conflict button{
font: 500 13px/1 'IBM Plex Sans', system-ui, sans-serif;
padding: 6px 10px; border-radius: 4px; cursor: pointer;
border: 1px solid #fff; background: transparent; color: #fff;
}
/* „Eigene durchsetzen" ist die Antwort, die nichts von der eigenen Arbeit
kostet - sie steht deshalb gefüllt da, ohne die andere zu verstecken. */
.live-conflict button.mine{ background: #fff; color: var(--warn, #B45309); border-color: #fff; }
.live-conflict button:focus-visible{ outline: 2px solid #fff; outline-offset: 2px; }
@media print{ .live-conflict{ display: none; } }
+9
View File
@@ -42,6 +42,11 @@
- padRateLimit { seconds } — zu früh nachgeladen; Werkbaum hat gar nicht
erst geholt, um Etherpads Grenze nicht
auszulösen (D31)
- liveLoad { url, error } — Server-Dokument (?live=, D76) nicht
erreichbar oder keine Dokument-Adresse
- liveStale { error } — eigene Änderung nicht anwendbar
(Prüfsumme, Index, Basis verdichtet);
der Client hat einmal neu geladen (D76)
- sourceTimeout { url, seconds } — Abruf abgebrochen, Gegenseite zu langsam
(D31). Eigener Typ, weil `sourceLoad` auf
CORS zeigt — bei einem Zeitablauf schickt
@@ -92,6 +97,10 @@ function build(w, t, esc){
return t('sourceLoadWarn', {url: esc(w.url), error: esc(w.error)});
case 'padRateLimit':
return t('padRateLimitWarn', {seconds: w.seconds});
case 'liveLoad':
return t('liveLoadWarn', {url: esc(w.url), error: esc(w.error)});
case 'liveStale':
return t('liveStaleWarn', {error: esc(w.error)});
case 'sourceTimeout':
return t('sourceTimeoutWarn', {url: esc(w.url), seconds: w.seconds});
default: