frontend: ?etherpad= — Echtzeit-Zusammenarbeit über ein geliehenes Pad

Werkbaum hat kein Backend, und das eigentlich Schwere an gemeinsamem
Bearbeiten ist das Zusammenführen gleichzeitiger Änderungen — im Plan als
`[!] Merging simultaneous edits` markiert. Etherpad hat das gelöst. Also
geliehen statt nachgebaut: das Pad ist die Schreibfläche, Werkbaum die
Ansicht.

Nachgemessen an pad.hostsharing.net, bevor irgendwas gebaut wurde:
- der Klartext-Export sendet `Access-Control-Allow-Origin: *`,
- das kanonische Beispiel (SPEC §10) kommt byte-identisch zurück,
- der HTML-Export zeigt kein Listen-Markup — Etherpad deutet `-` nicht zur
  Aufzählung um und behält die führenden Leerzeichen.
Das war das Risiko, das die Idee hätte erledigen können. Nicht geprüft ist
das Tippen im Etherpad-Editor selbst (Tab-Einrückung, mögliches
Auto-Bullet); dafür braucht es einen echten Browser.

Eigener Parameter statt `?sourceUrl=`: Die URL, die ein Mensch in der Hand
hat, ist die Pad-URL — `/export/txt` hängt Werkbaum selbst an. Vor allem
aber lizenziert der eigene Parameter das andere Verhalten, sodass D23
unangetastet bleibt: `sourceUrl` heißt weiter „statische Datei, einmal pro
Laden geholt", bestehende Links bekommen kein Polling. Name und id sind die
vollständige Pad-URL (Pad-Namen sind nur pro Instanz eindeutig).

Textfeld schreibgeschützt, Knopf öffnet das Pad: ohne das verschwände
getippter Text beim nächsten Abruf. Schrift bleibt Tinte statt grau — hier
wird gelesen, der Plantext ist der Hauptinhalt.

Drei Riegel im Takt, jeder aus einem echten Fehler:
- `padBusy` — im Netzwerk-Mitschnitt stapelten sich die Abrufe, weil die
  Gegenseite langsamer war als der Takt; eine spät eintreffende alte
  Antwort hätte neueren Text überschrieben,
- Abbruch nach 10 s — sonst bliebe der Riegel bei hängender Gegenseite für
  immer zu,
- `visibilityState` + `visibilitychange` — nicht im Hintergrund abrufen,
  aber bei Rückkehr sofort.
Der Stabilitätstakt übernimmt erst beim zweiten gleichen Abruf, sonst sieht
man die anderen mitten im Tippen.

Die Normalisierung der Pad-Adresse liegt headless in `remote.js`, damit sie
testbar ist (23 neue Tests, 60 -> 83). Im Vorschau-Browser meldet
`visibilityState` „hidden" und HMR lädt bei jeder Quelländerung neu — ein
Reload sieht wie eine geglückte Übernahme aus; nachgewiesen wurde die
Übernahme deshalb mit einem Marker auf `window`, der einen Reload nicht
überlebt.

SPEC §9 zuerst, dann D31, dann Code. Der Plan bekommt den Knoten nach D30
mit `[x]`, nicht `[^]`.
This commit is contained in:
mhoennig
2026-07-30 12:04:22 +02:00
parent cfbbfab54b
commit 3310cab7be
11 changed files with 478 additions and 31 deletions
+154 -29
View File
@@ -3,6 +3,7 @@ import { parse } from './parser.js';
import { computeCheapSet, freshProdSet } from './model.js';
import { esc, renderTreeHtml } from './render.js';
import { formatWarning } from './warnings.js';
import { padUrls } from './remote.js';
/* Werkbaum, mit Werkbaum geplant — als mitgeliefertes Dokument „Werkbank" (D27).
Dieselbe Datei, die auch per ?sourceUrl= geladen werden kann; `?raw` bettet
sie beim Build in die eine Ausgabedatei ein (D19), es wird nichts nachgeladen
@@ -891,6 +892,8 @@ const I18N = {
legendTooltip:"Legende ein-/ausblenden",
ghostTooltip:"Ab Größe M sollte ein Element weiter untergliedert werden.",
jumpHint:"Alt+Klick: zur Zeile im Text",
padReadonly:"Wird im Pad bearbeitet — hier nur lesen.",
padEdit:"Pad zum Bearbeiten öffnen",
riskTooltip:"High Risk Aufwand noch unklar.",
discardedTooltip:"Verworfene Knoten samt Teilbaum ein-/ausblenden",
cheapTooltip:"Günstigsten Pfad hervorheben nicht benötigte Alternativen treten zurück",
@@ -943,6 +946,8 @@ const I18N = {
legendTooltip:"Show/hide legend",
ghostTooltip:"From size M upward, an item should be broken down further.",
jumpHint:"Alt+click: jump to the line in the text",
padReadonly:"Edited in the pad — read-only here.",
padEdit:"Open the pad to edit",
riskTooltip:"High risk effort still unclear.",
discardedTooltip:"Show/hide discarded nodes and their subtree",
cheapTooltip:"Highlight the cheapest path unneeded alternatives recede",
@@ -995,6 +1000,8 @@ const I18N = {
legendTooltip:"Mostrar u ocultar la leyenda",
ghostTooltip:"A partir de la talla M, un elemento debería desglosarse más.",
jumpHint:"Alt+clic: ir a la línea en el texto",
padReadonly:"Se edita en el pad — aquí solo lectura.",
padEdit:"Abrir el pad para editar",
riskTooltip:"Alto riesgo esfuerzo aún incierto.",
discardedTooltip:"Mostrar u ocultar los nodos descartados y su subárbol",
cheapTooltip:"Resaltar la ruta más económica: las alternativas no necesarias se atenúan",
@@ -1047,6 +1054,8 @@ const I18N = {
legendTooltip:"Afficher/masquer la légende",
ghostTooltip:"À partir de la taille M, un élément devrait être décomposé davantage.",
jumpHint:"Alt+clic : aller à la ligne dans le texte",
padReadonly:"Modifié dans le pad — lecture seule ici.",
padEdit:"Ouvrir le pad pour modifier",
riskTooltip:"Risque élevé effort encore incertain.",
discardedTooltip:"Afficher/masquer les nœuds abandonnés et leur sous-arbre",
cheapTooltip:"Mettre en évidence le chemin le moins coûteux les alternatives inutiles s'estompent",
@@ -1099,6 +1108,8 @@ const I18N = {
legendTooltip:"Pokaż/ukryj legendę",
ghostTooltip:"Od rozmiaru M element powinien być dalej podzielony.",
jumpHint:"Alt+kliknięcie: przejdź do wiersza w tekście",
padReadonly:"Edytowane w padzie — tu tylko do czytania.",
padEdit:"Otwórz pad do edycji",
riskTooltip:"Wysokie ryzyko nakład jeszcze niejasny.",
discardedTooltip:"Pokaż/ukryj odrzucone węzły wraz z poddrzewem",
cheapTooltip:"Wyróżnij najtańszą ścieżkę niepotrzebne alternatywy są przygaszone",
@@ -1151,6 +1162,8 @@ const I18N = {
legendTooltip:"Показать/скрыть легенду",
ghostTooltip:"Начиная с размера M элемент следует далее декомпозировать.",
jumpHint:"Alt+клик: перейти к строке в тексте",
padReadonly:"Редактируется в паде — здесь только чтение.",
padEdit:"Открыть пад для редактирования",
riskTooltip:"Высокий риск – оценка ещё не ясна.",
discardedTooltip:"Показать/скрыть отклонённые узлы вместе с поддеревом",
cheapTooltip:"Выделить самый дешёвый путь — ненужные альтернативы приглушаются",
@@ -1203,6 +1216,8 @@ const I18N = {
legendTooltip:"लेजेंड दिखाएँ/छिपाएँ",
ghostTooltip:"आकार M से ऊपर किसी तत्व को और अधिक उप-विभाजित करना चाहिए।",
jumpHint:"Alt+क्लिक: टेक्स्ट में उस पंक्ति पर जाएँ",
padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।",
padEdit:"संपादित करने के लिए पैड खोलें",
riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।",
discardedTooltip:"अस्वीकृत नोड्स और उनके उप-वृक्ष दिखाएँ/छिपाएँ",
cheapTooltip:"सबसे किफ़ायती पथ को उजागर करें – अनावश्यक विकल्प मंद हो जाते हैं",
@@ -1260,6 +1275,8 @@ const I18N = {
implicitSizeTooltip:"未指定尺寸——成本估算时按 M 计",
ghostTooltip:"从 M 号起,元素应进一步细分。",
jumpHint:"Alt+点击:跳转到文本中的该行",
padReadonly:"在 Pad 中编辑 — 此处只读。",
padEdit:"打开 Pad 进行编辑",
riskTooltip:"高风险 – 工作量尚不明确。",
editorTitle:"结构(文本)", diagramTitle:"图表",
docSwitchTooltip:"选择或管理文档", docMenuAria:"文档",
@@ -1312,6 +1329,8 @@ const I18N = {
implicitSizeTooltip:"サイズ未指定 – コスト見積もりのため M として扱う",
ghostTooltip:"サイズ M 以上の要素はさらに分解すべきです。",
jumpHint:"Alt+クリック:テキストの該当行へ移動",
padReadonly:"パッドで編集します — ここでは読み取り専用です。",
padEdit:"編集するにはパッドを開く",
riskTooltip:"高リスク – 規模はまだ不明。",
editorTitle:"構造(テキスト)", diagramTitle:"ダイアグラム",
docSwitchTooltip:"ドキュメントを選択・管理", docMenuAria:"ドキュメント",
@@ -1638,6 +1657,7 @@ function updateDocName(){
? d.source + '\n' + t('docSwitchTooltip')
: t('docSwitchTooltip');
}
updatePadLink(); /* Schreibschutz + Pad-Knopf hängen am aktiven Dokument (D31) */
}
let renamingId = null; /* id des gerade inline umbenannten Dokuments (oder null) */
function renderDocMenu(){
@@ -1780,57 +1800,162 @@ function initDocs(){
render();
}
/* ---------- ?sourceUrl= — Notationstext von einer URL laden (D23) ----------
Die Datei wird geholt und als eigenes Dokument geführt, dessen Name die URL
ist. Die id leitet sich aus der URL ab: derselbe Link aktualisiert dieses
/* ---------- Text von außen: ?sourceUrl= (D23) und ?etherpad= (D31) ----------
Beide holen einen Notationstext über http(s) und führen ihn als eigenes
Dokument. Die id leitet sich aus der URL ab: derselbe Link aktualisiert dieses
Dokument, statt bei jedem Aufruf ein neues anzulegen. Eigene Dokumente des
Nutzers bleiben unberührt. Scheitert das Laden (häufigster Fall: das Ziel
sendet keinen CORS-Header), bleibt der bisherige Stand stehen und es
erscheint eine Warnung. */
erscheint eine Warnung.
Unterschied: `sourceUrl` ist eine statische Datei, einmal pro Laden geholt
(D23, unverändert). `etherpad` ist ein lebendes Pad — wiederholt geholt und
hier schreibgeschützt, weil das Zusammenführen gleichzeitiger Änderungen
Etherpads Aufgabe ist. Ein Fetch-Pfad, zwei Eingänge. */
const SOURCE_PARAM = 'sourceUrl';
function sourceUrlParam(){
try{ return new URLSearchParams(location.search).get(SOURCE_PARAM); }catch(_){ return null; }
const ETHERPAD_PARAM = 'etherpad';
const PAD_POLL_MS = 2500;
function urlParam(name){
try{ return new URLSearchParams(location.search).get(name); }catch(_){ return null; }
}
async function loadFromSourceUrl(){
function sourceUrlParam(){ return urlParam(SOURCE_PARAM); }
/* Woher kommt der Text? null = kein Parameter, {bad,error} = unbrauchbare
Angabe, sonst der Beschreiber für loadRemoteSource(). Die Normalisierung der
Pad-Adresse steht headless in remote.js — dort auch ihre Begründung. */
function remoteSource(){
const padRaw = urlParam(ETHERPAD_PARAM);
if(padRaw){
const p = padUrls(padRaw, location.href);
if(!p) return {bad: padRaw, error: 'not an Etherpad URL'};
return {fetchUrl: p.text, id: 'url:' + p.pad, name: p.pad, source: p.pad, live: true};
}
const raw = sourceUrlParam();
if(!raw) return;
if(!raw) return null;
let url;
/* Relative Angaben gegen die Seite auflösen; nur http(s) zulassen (kein
file:/data:/javascript: — die Notation selbst erlaubt ohnehin nur http(s)). */
try{ url = new URL(raw, location.href); }catch(_){
/* Fehlerdetail bewusst technisch/englisch wie die Browser-Meldungen
(„Failed to fetch", „HTTP 404") — der Rahmentext ist lokalisiert. */
sourceWarning = {type:'sourceLoad', url: raw, error: 'invalid URL'};
render();
return;
}
if(url.protocol !== 'http:' && url.protocol !== 'https:'){
sourceWarning = {type:'sourceLoad', url: url.href, error: url.protocol};
/* Fehlerdetail bewusst technisch/englisch wie die Browser-Meldungen
(„Failed to fetch", „HTTP 404") — der Rahmentext ist lokalisiert. */
try{ url = new URL(raw, location.href); }catch(_){ return {bad: raw, error: 'invalid URL'}; }
if(url.protocol !== 'http:' && url.protocol !== 'https:') return {bad: url.href, error: url.protocol};
return {fetchUrl: url.href, id: 'url:' + url.href, name: url.href, source: url.href, live: false};
}
/* `timeoutMs` nur beim Pad-Takt (D31): Hängt die Gegenseite, muss der Abruf
abbrechen, sonst bliebe der Riegel `padBusy` für immer zu und es käme nie
wieder etwas. Der erste Ladeversuch wartet unbegrenzt wie bisher (D23). */
async function fetchRemote(url, timeoutMs){
const ctl = timeoutMs ? new AbortController() : null;
const timer = ctl ? setTimeout(() => ctl.abort(), timeoutMs) : null;
try{
const resp = await fetch(url, {cache:'no-store', credentials:'omit',
signal: ctl ? ctl.signal : undefined});
if(!resp.ok) throw new Error('HTTP ' + resp.status);
return await resp.text();
} finally { if(timer) clearTimeout(timer); }
}
async function loadRemoteSource(){
const s = remoteSource();
if(!s) return;
if(s.bad !== undefined){
sourceWarning = {type:'sourceLoad', url: s.bad, error: s.error};
render();
return;
}
try{
const resp = await fetch(url.href, {cache:'no-store', credentials:'omit'});
if(!resp.ok) throw new Error('HTTP ' + resp.status);
const text = await resp.text();
const id = 'url:' + url.href;
const text = await fetchRemote(s.fetchUrl);
flushActive(); /* laufende Bearbeitung nicht verlieren */
let d = docs.find(x => x.id === id);
if(d){ d.text = text; d.name = url.href; }
else { d = {id, name: url.href, text, source: url.href}; docs.push(d); }
d.source = url.href;
activeId = id;
let d = docs.find(x => x.id === s.id);
if(d){ d.text = text; d.name = s.name; }
else { d = {id: s.id, name: s.name, text}; docs.push(d); }
d.source = s.source;
activeId = s.id;
sourceWarning = null;
computeFresh(id, text); /* was ist seit dem letzten Ansehen in Produktion? (D28) */
if(s.live) padSource = s; /* vor loadActiveIntoEditor: setzt den Schreibschutz */
computeFresh(s.id, text); /* was ist seit dem letzten Ansehen in Produktion? (D28) */
loadActiveIntoEditor();
persistDocs();
if(s.live) startPadPolling();
}catch(err){
/* CORS-Fehler melden sich als „TypeError: Failed to fetch" ohne Details —
der Warntext nennt CORS daher ausdrücklich als wahrscheinliche Ursache. */
sourceWarning = {type:'sourceLoad', url: url.href, error: (err && err.message) || String(err)};
sourceWarning = {type:'sourceLoad', url: s.fetchUrl, error: (err && err.message) || String(err)};
render();
}
}
/* ---------- Pad: regelmäßig neu holen (D31) ----------
Stumm bei Fehlschlägen — der erste Ladeversuch hat bereits gewarnt, ein
Netzaussetzer soll den Warnbereich nicht fluten. Nicht abrufen, solange der
Tab im Hintergrund liegt: der Export rendert jedes Mal das ganze Pad, und die
Gegenseite ist fremde Infrastruktur. */
const PAD_FETCH_TIMEOUT_MS = 10000;
let padSource = null; /* Beschreiber der aktiven Pad-Quelle, oder null */
let padTimer = null;
let padPending = null; /* geholter, noch nicht übernommener Text (Stabilitätstakt) */
let padBusy = false; /* höchstens ein Abruf unterwegs (siehe pollPad) */
function startPadPolling(){
if(padTimer) clearInterval(padTimer);
padTimer = setInterval(pollPad, PAD_POLL_MS);
}
/* Bei Rückkehr in den sichtbaren Zustand sofort holen: nach einer langen Pause
stünde sonst bis zum nächsten Takt ein veralteter Stand da. */
document.addEventListener('visibilitychange', () => {
if(document.visibilityState === 'visible' && padSource) pollPad();
});
async function pollPad(){
const s = padSource;
/* `padBusy`: höchstens ein Abruf unterwegs. Ist die Gegenseite langsamer als
der Takt, stapelten sich sonst die Anfragen — und eine spät eintreffende
alte Antwort überschriebe neueren Text (im Netzwerk-Mitschnitt beobachtet). */
if(!s || padBusy || document.visibilityState === 'hidden') return;
const d = docs.find(x => x.id === s.id);
if(!d) return; /* Dokument gelöscht — nichts nachtragen */
let text;
padBusy = true;
try{ text = await fetchRemote(s.fetchUrl, PAD_FETCH_TIMEOUT_MS); }
catch(_){ return; } /* stumm; `finally` gibt den Riegel frei */
finally{ padBusy = false; }
if(text === d.text){ padPending = null; return; }
/* Stabilitätstakt: erst übernehmen, wenn zwei Abrufe hintereinander denselben
neuen Text liefern. Sonst sieht man die anderen mitten im Tippen, und eine
halb geschriebene Zeile ist eine kaputte Zeile — Diagramm und Warnungen
flackerten. Kostet im Mittel einen Takt. */
if(padPending !== text){ padPending = text; return; }
padPending = null;
d.text = text;
persistDocs();
if(activeId !== d.id) return; /* im Hintergrund still aktualisiert */
/* Auswahl und Scrollstand erhalten — der Sprung aus dem Diagramm (D25) und
die Cursor-Zeile sollen einen Abruf überleben. */
const top = src.scrollTop, a = src.selectionStart, b = src.selectionEnd;
src.value = text;
try{ src.setSelectionRange(a, b); }catch(_){}
src.scrollTop = top;
computeFresh(d.id, text); /* Basis bleibt die zuletzt bestätigte Fassung */
render();
updateFreshBtn();
}
/* Ein Pad-Dokument wird im Pad bearbeitet, nicht hier (D31): Textfeld
schreibgeschützt, Knopf in der Titelzeile öffnet das Pad im neuen Tab. Ohne
den Schutz verschwände getippter Text beim nächsten Abruf. */
const padLink = document.getElementById('padLink');
function updatePadLink(){
const d = activeDoc();
const isPad = !!(d && padSource && d.id === padSource.id);
src.readOnly = isPad;
src.classList.toggle('readonly', isPad);
if(isPad) src.title = t('padReadonly'); else src.removeAttribute('title');
if(!padLink) return;
padLink.hidden = !isPad;
if(!isPad) return;
padLink.href = padSource.source;
const tip = t('padEdit');
padLink.title = tip;
padLink.setAttribute('aria-label', tip);
}
docTrigger.addEventListener('click', e => {
/* Desktop: ist der Editor minimiert, stellt ein Klick ihn wieder her
(Bubbling zur Titelzeile) statt das Menü zu öffnen. */
@@ -1995,7 +2120,7 @@ try{ startLang = localStorage.getItem('werkbaum-lang') || detectLang(); }catch(_
applyLang(I18N[startLang] ? startLang : 'de'); /* setzt Texte + rendert */
initDocs(); /* Dokumente laden + aktiven Text in den Editor (nach Sprache) */
applyMobile(); /* Mobil-Verhalten (nach Sprache/Restore) anwenden */
loadFromSourceUrl(); /* ?sourceUrl= nachladen (asynchron, D23) */
loadRemoteSource(); /* ?sourceUrl= / ?etherpad= nachladen (asynchron, D23/D31) */
/* ---------- Build-Hinweis (Vorschau/Dev + „latest build") ----------
Kennzeichnet einen nicht-produktiven Build mit einem kleinen Symbol samt
+28
View File
@@ -0,0 +1,28 @@
/* Werkbaum — Adressen für Dokumente von außen (headless, D31).
Hier steht nur reine Logik, damit sie testbar ist; das Holen selbst und der
ganze DOM-Kram bleiben in app.js. */
/* Pad-Adresse normalisieren (D31). Eingabe ist die Adresse aus der Browser-
Adresszeile — ohne Export-Pfad, den hängt Werkbaum selbst an. Abgeschnitten
werden ein versehentlich mitgegebener Export- oder /timeslider-Pfad, Query,
Fragment und Schrägstriche am Ende: derselbe Pad soll genau ein Dokument
ergeben, gleich in welcher Schreibweise der Link kam.
Verlangt wird `/p/<name>` am Ende (auch unter einem Unterpfad montiert) — das
ist die Prüfung, ob überhaupt eine Pad-Adresse vorliegt. Erlaubt sind nur
http(s), wie bei ?sourceUrl= (D23).
Rückgabe {pad, text} oder null. `pad` ist zugleich Name und Identität des
Dokuments — die **vollständige** URL, nicht der bloße Pad-Name: Pad-Namen sind
nur pro Instanz eindeutig, zwei Hosts mit je einem Pad `plan` wären sonst im
Wähler nicht zu unterscheiden. */
export function padUrls(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(/\/+$/, '').replace(/\/(export\/[^/]+|timeslider)$/, '');
if(!/\/p\/[^/]+$/.test(path)) return null;
u.pathname = path; u.search = ''; u.hash = '';
const pad = u.href;
return {pad, text: pad + '/export/txt'};
}
+9
View File
@@ -329,6 +329,10 @@
.copybtn .ic-done{display:none}
.copybtn.done .ic-copy{display:none}
.copybtn.done .ic-done{display:block}
/* „im Pad bearbeiten" (D31) — nur bei einem ?etherpad=-Dokument sichtbar.
Das [hidden] braucht die eigene Regel, weil .copybtn ein display setzt. */
.padlink{text-decoration:none}
.padlink[hidden]{display:none}
.dlbtn{gap:3px}
.dl-label{font-family:'IBM Plex Mono',monospace;font-size:.62rem;font-weight:600;letter-spacing:.02em}
/* Download-Gruppe: Desktop zeigt SVG/PNG inline, der Trigger ist versteckt.
@@ -398,6 +402,11 @@
color:var(--ink);background:var(--card);
tab-size:2;
}
/* Pad-Dokument (D31): hier nur lesen, geschrieben wird im Pad. Abgesetzt über
den Hintergrund, damit der Schreibschutz nicht als hängendes Textfeld
erscheint — die Schrift bleibt aber Tinte: hier wird gelesen, der Plantext
ist der Hauptinhalt und kein ausgegrautes Formularfeld. */
textarea.readonly{background:#F1F4F7;cursor:default}
.hint{
padding:12px 16px;border-top:1px dashed rgba(36,52,71,.18);
font-size:.8rem;color:var(--muted);line-height:1.7;