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
+26
View File
@@ -91,6 +91,32 @@ GitLab-Raw-Links tun das; ein beliebiger Webserver oft nicht. Scheitert das
Laden, bleibt der bisherige Stand stehen und eine Warnung nennt die Ursache.
Zugelassen sind nur `http`/`https`.
### Zusammen an einem Plan arbeiten (Etherpad)
Für Zusammenarbeit in Echtzeit braucht Werkbaum kein eigenes Backend — es leiht
sich ein **Etherpad**. Angegeben wird die Pad-Adresse, wie sie im Browser steht
(ohne Export-Pfad, den hängt Werkbaum selbst an):
```
https://werkbaum.javagil.de/?etherpad=https://pad.hostsharing.net/p/mein-plan
```
Alle bearbeiten den Notationstext **im Pad**, jeder Betrachter sieht das Diagramm
mitwachsen (Abruf alle 2,5 s; nicht, während der Tab im Hintergrund liegt). Das
Zusammenführen gleichzeitiger Änderungen macht Etherpad — genau darum geht es.
Weil das Pad die Schreibfläche ist, ist das Textfeld hier **schreibgeschützt**;
ein Knopf in der Editor-Titelzeile öffnet das Pad im neuen Tab. Ohne den Schutz
verschwände getippter Text beim nächsten Abruf.
Getestet gegen Etherpad: Der Klartext-Export sendet
`Access-Control-Allow-Origin: *`, und die Notation kommt **byte-identisch**
zurück — führende Leerzeichen, `-`/`+`/`|`, Statusboxen und `%%` überleben
Etherpads Speichermodell, das `-` wird nicht zur Aufzählung umgedeutet.
**Bedenke:** Der Plantext liegt damit auf fremder Infrastruktur, und ein Pad ist
für jeden lesbar, der die Adresse kennt. Siehe `docs/DECISIONS.md` D31.
### Lokal ausführen
Die Editor-Quelle liegt jetzt als ES-Module unter `frontend/src/`, gebündelt mit
+26
View File
@@ -86,6 +86,32 @@ http(s) regardless of extension or content type.
do; an arbitrary web server often does not. If loading fails the previous
content stays and a warning explains why. Only `http`/`https` are allowed.
### Working on one plan together (Etherpad)
For real-time collaboration Werkbaum needs no backend of its own — it borrows an
**Etherpad**. Pass the pad address as it appears in your browser (no export path,
Werkbaum appends that itself):
```
https://werkbaum.javagil.de/?etherpad=https://pad.hostsharing.net/p/my-plan
```
Everyone edits the notation text **in the pad**, and every viewer watches the
diagram grow (fetched every 2.5 s; not while the tab is in the background).
Merging simultaneous edits is Etherpad's job — that is the whole point.
Because the pad is the writing surface, the text area here is **read-only**; a
button in the editor title bar opens the pad in a new tab. Without that
protection, anything you typed would vanish on the next fetch.
Verified against Etherpad: the plain-text export sends
`Access-Control-Allow-Origin: *`, and the notation comes back **byte-identical**
leading spaces, `-`/`+`/`|`, status boxes and `%%` survive Etherpad's storage
model, and `-` is not turned into a bullet list.
**Be aware:** your plan text then lives on third-party infrastructure, and a pad
is readable by anyone who knows its address. See `docs/DECISIONS.md` D31.
### Running it locally
The editor source now lives as ES modules under `frontend/src/`, bundled by
+105
View File
@@ -979,3 +979,108 @@ einem absichtlich ausgeweiteten `sed` in einem Wegwerf-Worktree.
bleibt eine bewusste Handlung. `deploy-prod.sh` warnt stattdessen, wenn HEAD
noch nicht auf `origin` liegt: der Footer-Versionslink zeigt sonst auf einen
Commit, den GitHub nicht kennt.
## D31 — Echtzeit-Zusammenarbeit über ein Etherpad, eigener Parameter `?etherpad=`
Werkbaum hat kein Backend (D13 ist Plan, nicht Bestand), und der Plan setzt
Zusammenarbeit als `[?] Live editing, several people at once (XL)` an — mit
`[!] Merging simultaneous edits (L)` als der eigentlichen Arbeit. Genau diese
Arbeit ist in Etherpad seit Jahren getan. Also wird sie geliehen statt
nachgebaut: **Das Pad ist die Schreibfläche, Werkbaum die Ansicht.**
**Das Fundament war schon da.** Etherpad liefert pro Pad einen Klartext-Export
(`/p/<pad>/export/txt`), und `?sourceUrl=` (D23) lädt jede Textdatei über
http(s), ohne Endung oder `Content-Type` zu prüfen (D24). Nachgemessen an
`pad.hostsharing.net`:
- `Access-Control-Allow-Origin: *` und `Content-Type: text/plain; charset=utf-8`
— die eigentliche Hürde aus D23 (CORS) fällt also weg;
- das kanonische Beispiel aus SPEC §10 kommt **byte-identisch** zurück
(führende Leerzeichen, `-`/`+`/`|`, Statusboxen, `%%`, UTF-8);
- der HTML-Export zeigt **kein** Listen-Markup: Etherpad speichert die
Einrückung als echte Leerzeichen und deutet das `-` nicht zur Aufzählung um.
Das war das Risiko, das die Idee hätte erledigen können.
(Gemessen wurde Import → Speicher → Export. Das *Tippen* im Etherpad-Editor —
Tab-Einrückung, mögliches Auto-Bullet — ist damit **nicht** geprüft; dafür
braucht es einen echten Browser. Vgl. die Lehre aus D25: synthetische Ereignisse
beweisen nur die eigene Logik.)
**Eigener Parameter statt `?sourceUrl=`.** Drei Gründe, der erste ist der
schwächste:
1. Die URL, die ein Mensch in der Hand hat, ist die **Pad**-URL — die aus der
Adresszeile. `/export/txt` ist eine Implementierungseinzelheit und gehört
nicht in die Schnittstelle; Werkbaum hängt sie selbst an.
2. Der Parameter **lizenziert anderes Verhalten**. `sourceUrl` heißt „statische
Datei, einmal pro Laden geholt" — das ist D23 wörtlich und bleibt
unangetastet. `etherpad` heißt „lebendes Pad", und daran hängt das
regelmäßige Abrufen. Ohne die Trennung müsste D23 seine Semantik ändern und
bestehende Links bekämen ungefragt Polling.
3. Die **Pad**-URL ist mehr wert als die Export-URL: nur mit ihr sind der
„im Pad bearbeiten"-Knopf und ein späteres Einbetten (siehe unten) ohne
weiteren Parameter erreichbar, und Identität/Name des Dokuments werden aus
ihr gebildet — derselbe Pad ergibt so genau **ein** Dokument, auch wenn
jemand versehentlich die Export-URL einträgt (sie wird normalisiert).
**Der Dokumentname ist die vollständige Pad-URL**, nicht der bloße Pad-Name.
Kurz wäre schöner (`mein-plan` statt 45 Zeichen in einer schmalen Titelzeile),
aber Pad-Namen sind nur **pro Instanz** eindeutig, nicht global: zwei Hosts mit
je einem Pad `plan` ergäben zwei gleichnamige Dokumente im Wähler, ohne
Möglichkeit sie zu unterscheiden. Damit gilt dieselbe Regel wie in D23 — und der
Ellipsen-Schnitt in der Titelzeile samt vollständiger URL im Tooltip ist dafür
schon eingerichtet.
Der Name `?etherpad=` statt des neutraleren `?pad=`: Das Anhängen von
`/export/txt` **ist** produktspezifisch. Das ehrlich zu benennen ist besser, als
Allgemeinheit vorzutäuschen, die beim nächsten Werkzeug (HedgeDoc, CryptPad —
andere Export-Pfade) doch einen Typ-Diskriminator bräuchte.
**Das Textfeld ist schreibgeschützt.** Ohne das verschwände getippter Text beim
nächsten Abruf — Datenverlust, und zwar überraschend, weil nichts darauf
hindeutet. Der Schutz ist zugleich die ehrliche Aussage: Werkbaum kann
gleichzeitige Änderungen nicht zusammenführen, das Pad kann es. Deshalb
erscheint in der Editor-Titelzeile ein Knopf, der das Pad im neuen Tab öffnet
(nur bei solchen Dokumenten sichtbar).
**Stabilitäts-Verzögerung statt rohem Polling.** Beim Abrufen sieht man die
anderen mitten im Tippen; eine halb geschriebene Zeile ist eine kaputte Zeile,
das Diagramm zuckt und die Warnungen flackern. Übernommen wird ein neuer Text
deshalb erst, wenn **zwei** Abrufe hintereinander denselben liefern. Das kostet
im Mittel einen Takt Verzögerung und macht die Ansicht ruhig. Bei `?sourceUrl=`
gibt es das Problem nicht (man lädt bewusst neu) — es ist also spezifisch für
diesen Parameter, wie das Polling selbst.
Getaktet wird mit 2,5 s und **nicht**, solange der Tab im Hintergrund liegt: Der
Export rendert bei jedem Abruf das ganze Pad, und die Gegenseite ist fremde
Infrastruktur. Ein Fehlschlag beim Abrufen bleibt **stumm** und lässt den
letzten Stand stehen (nur der erste Ladeversuch warnt) — sonst flutete ein
Netzaussetzer den Warnbereich.
**Verhältnis zu D20 („keine externen Requests").** Unverändert wie bei D23: Die
App lädt von sich aus nichts; der Request entsteht nur, weil der Nutzer eine
URL angibt, und geht nur an genau diesen Host (`credentials:'omit'`, nur
`http`/`https`). Neu und ausdrücklich zu benennen ist die andere Richtung:
**Der Plantext liegt jetzt auf fremder Infrastruktur**, und ein Pad ist für
jeden lesbar, der die Adresse kennt. Das ist bei einem Projektplan etwas
anderes als bei einer Schriftart. Es bleibt die Entscheidung dessen, der den
Link baut — Werkbaum legt von sich aus kein Pad an.
**Verworfene und aufgeschobene Alternativen:**
- **`?sourceUrl=` um Polling erweitern** — hätte bestehenden Links ungefragt
wiederholte Requests verpasst und D23 seine klare Semantik gekostet.
- **Das Pad als `<iframe>` einbetten** (aufgeschoben, nicht verworfen): Bringt
Cursor, Namen, Farben und Chat mit. Kostet aber **beide** Richtungen von D25
— in einem cross-origin-iframe gibt es keinen DOM-Zugriff, also kein
Alt+Klick → Zeile und keine Cursor-Zeile → Knoten. Bei 75 Knoten ist das die
Orientierung. Der Knopf „im Pad bearbeiten" ist die kleine Lösung desselben
Bedürfnisses; das Einbetten bleibt möglich, weil die Pad-URL vorliegt.
- **Echter Etherpad-Client** (socket.io + Easysync-Changesets): Rettet D25 und
erlaubt Schreiben aus Werkbaum heraus, kostet aber zwei **Laufzeit**-
Abhängigkeiten und damit „eine self-contained Datei ohne
Laufzeit-Abhängigkeiten" (D11/D19/D20) — und die Diff-Hälfte müsste man
selbst schreiben. Das ist der XL-Knoten aus dem Plan, nur mit fremdem
Protokoll statt fremdem CRDT. Wenn, dann zusammen mit dem eigenen Backend
(D13) und dann besser mit einem Text-CRDT, wie der Plan es vorsieht.
- **Etherpads HTTP-API** (`/api/1/getText?apikey=…`) — der API-Schlüssel ist ein
Administrationsschlüssel für **alle** Pads der Instanz und hat in einer
Client-Anwendung nichts zu suchen. Der Export-Endpunkt braucht ihn nicht.
+33 -1
View File
@@ -276,6 +276,37 @@ Scheitert das Laden (häufigster Fall: das Ziel sendet keinen
`Access-Control-Allow-Origin`-Header, außerdem 404/Netzfehler), bleibt der
bisherige Stand stehen und es erscheint eine **Warnung**. Siehe D23.
### Gemeinsam an einem Pad arbeiten (`?etherpad=`)
Für Zusammenarbeit in Echtzeit nimmt der Editor die Adresse eines
**Etherpad-Pads** — die Adresse, die im Browser steht, ohne Export-Pfad:
`…?etherpad=https://pad.example.org/p/mein-plan`. Werkbaum hängt den
Klartext-Export (`/export/txt`) selbst an; ein versehentlich mitgegebener
Export- oder `/timeslider`-Pfad wird abgeschnitten.
- **Das Pad ist die Schreibfläche, Werkbaum die Ansicht.** Alle bearbeiten den
Notationstext im Pad, jeder Betrachter sieht das Diagramm mitwachsen. Das
Zusammenführen gleichzeitiger Änderungen macht Etherpad; Werkbaum tut es
nicht.
- Deshalb ist das Textfeld für ein solches Dokument **schreibgeschützt** — ein
Knopf in der Editor-Titelzeile öffnet das Pad im neuen Tab. Ohne den Schutz
verschwände getippter Text beim nächsten Abruf.
- Geholt wird **regelmäßig** (Voreinstellung 2,5 s; nicht, solange der Tab im
Hintergrund liegt). Übernommen wird eine Änderung erst, wenn zwei Abrufe
hintereinander **denselben** neuen Text liefern — sonst sähe man die anderen
mitten im Tippen, und halb geschriebene Zeilen ließen Diagramm und Warnungen
flackern.
- **Name ist die vollständige Pad-URL** (nicht der bloße Pad-Name — zwei Pads
gleichen Namens auf verschiedenen Hosts wären sonst nicht zu unterscheiden),
wie bei `?sourceUrl=`. Identität und Name leiten sich von der **Pad**-Adresse
ab, nicht von der Export-Adresse — derselbe Pad ergibt damit genau ein
Dokument, gleich in welcher Schreibweise der Link kam.
- `?sourceUrl=` bleibt unverändert: statische Datei, einmal pro Laden geholt.
Der eigene Parameter trägt gerade den Unterschied.
- Fehler (CORS, 404, Netz) melden sich wie bei `?sourceUrl=`; ein einzelner
Aussetzer beim Abrufen bleibt stumm und lässt den letzten Stand stehen.
Siehe D31.
### Legende im Editor-Panel
Neben dem Textfeld steht eine aufklappbare **Legende** (Notation in Kurzform,
abschließend eine Bedienungs-Zeile). Sie ist **scrollbar**, wenn ihr Inhalt
@@ -287,7 +318,8 @@ Ausrichtungen getrennt erhalten. Die Legende belegt höchstens 85 % des Panels,
damit das Textfeld nie ganz verschwindet. Siehe D26.
### Was ist neu? (Dokumente von außen)
Bei Dokumenten, die von außen kommen (mitgeliefert oder per `?sourceUrl=`), wird
Bei Dokumenten, die von außen kommen (mitgeliefert, per `?sourceUrl=` oder
`?etherpad=`), wird
gezeigt, was sich seit dem letzten Besuch getan hat. **„Neu" heißt: neu in
Produktion** — ein Knoten trägt jetzt `[^]` und tat es in der zuletzt gesehenen
Fassung nicht. Solche Knoten bekommen einen **gelben Strahlenkranz** nach außen
+5 -1
View File
@@ -61,7 +61,11 @@
- [?] Accounts and permissions (L)
| [?] Single user, one token (S)
| [?] Log in with OIDC (L)
- [?] Working together on one plan (XL)
- [~] Working together on one plan (XL)
- [x] Watch a shared Etherpad — it merges, we render (S)
- [x] Load the pad's plain-text export (XS)
- [x] Keep up while the others type (XS)
+ [?] Embed the pad beside the diagram (M) %% costs the jump to the line
- [ ] Git as the shared store (L)
| [ ] A file in a repository, changed by pull request (S) %% works today, no code
| [?] The backend commits every change (L)
+20
View File
@@ -135,6 +135,26 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
Endung `.werkbaum` ist reine Konvention (D24, SPEC §12). Beispieldateien zum
Ausprobieren: `docs/examples/*.werkbaum` (nacheinander geöffnet ergeben sie
mehrere Dokumente im Wähler).
- `?etherpad=` (D31): `remoteSource()` liefert **einen** Beschreiber für beide
Eingänge (`?sourceUrl=` und `?etherpad=`), `loadRemoteSource()` holt und legt
das Dokument an — ein Fetch-Pfad, ein Warnkanal. Die Normalisierung der
Pad-Adresse steht headless in **`remote.js`** (`padUrls`, Tests in
`tests/remote.test.js`): Export-/Timeslider-Pfad, Query, Fragment und
Schrägstriche fallen weg, verlangt wird `/p/<name>` am Ende. Name und id sind
die **vollständige** Pad-URL (Pad-Namen sind nur pro Instanz eindeutig).
`padSource` muss **vor** `loadActiveIntoEditor()` gesetzt werden — daran hängt
der Schreibschutz. `pollPad()` hat drei Riegel, jeder aus einem echten Fehler:
`padBusy` (höchstens ein Abruf unterwegs — sonst stapeln sich Anfragen und eine
spät eintreffende alte Antwort überschreibt neueren Text, im
Netzwerk-Mitschnitt beobachtet), `PAD_FETCH_TIMEOUT_MS` (ohne Abbruch bliebe
`padBusy` bei hängender Gegenseite für immer zu) und `visibilityState`
(+ `visibilitychange`-Handler, der bei Rückkehr sofort holt). Fehlschläge im
Takt bleiben **stumm**; nur der erste Ladeversuch warnt. Der Stabilitätstakt
(`padPending`) übernimmt erst beim zweiten gleichen Abruf — sonst sieht man die
anderen mitten im Tippen. Beim Prüfen im Vorschau-Browser: `visibilityState`
ist dort `hidden` (Polling also aus) und HMR lädt bei jeder Quelländerung neu —
ein Reload sieht wie eine geglückte Übernahme aus. Marker auf `window` setzen
und hinterher prüfen, sonst beweist der Test nichts.
- Sprung Diagramm ↔ Text (D25): `render.js` schreibt die Parser-Zeilennummer als
`data-line` an jeden Knoten (Geister-Knoten bekommen keine). `jumpToLine()` in
`app.js` klappt bei Bedarf das Editor-Panel auf (`revealEditor()`), markiert die
+3
View File
@@ -58,6 +58,9 @@
<button type="button" class="docact docdel" id="docDelete" data-i18n="docDelete">Löschen</button>
</div>
</div>
<a class="copybtn padlink" id="padLink" href="#" target="_blank" rel="noopener" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</a>
<button type="button" class="copybtn" id="copy" data-i18n-title="copyTooltip" data-i18n-aria="copyTooltip" title="Text in die Zwischenablage kopieren" aria-label="Text in die Zwischenablage kopieren">
<svg class="ic-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<svg class="ic-done" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>
+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;
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { padUrls } from '../src/remote.js';
const PAD = 'https://pad.example.org/p/mein-plan';
describe('padUrls — Pad-Adresse normalisieren (D31)', () => {
it('hängt den Klartext-Export an', () => {
expect(padUrls(PAD)).toEqual({ pad: PAD, text: PAD + '/export/txt' });
});
/* Derselbe Pad soll genau EIN Dokument ergeben die Identität leitet sich
aus `pad` ab, also müssen alle Schreibweisen darauf zusammenfallen. */
it.each([
['Schrägstrich am Ende', PAD + '/'],
['mehrere Schrägstriche', PAD + '///'],
['Export-Pfad mitgegeben', PAD + '/export/txt'],
['anderer Export', PAD + '/export/html'],
['Timeslider', PAD + '/timeslider'],
['Query dran', PAD + '?showChat=false'],
['Fragment dran', PAD + '#anker'],
['Query und Schrägstrich', PAD + '/?showControls=false'],
])('fällt auf dieselbe Pad-URL zusammen: %s', (_name, input) => {
expect(padUrls(input)).toEqual({ pad: PAD, text: PAD + '/export/txt' });
});
it('erlaubt eine Montage unter einem Unterpfad', () => {
const sub = 'https://example.org/etherpad/p/plan';
expect(padUrls(sub)).toEqual({ pad: sub, text: sub + '/export/txt' });
});
it('erlaubt http neben https', () => {
const h = 'http://pad.example.org/p/plan';
expect(padUrls(h).pad).toBe(h);
});
it('behält den Port', () => {
const h = 'https://pad.example.org:9001/p/plan';
expect(padUrls(h)).toEqual({ pad: h, text: h + '/export/txt' });
});
/* Zwei Pads gleichen Namens auf verschiedenen Hosts müssen unterscheidbar
bleiben deshalb ist der Name die vollständige URL, nicht der Pad-Name. */
it('unterscheidet gleichnamige Pads verschiedener Hosts', () => {
expect(padUrls('https://a.example/p/plan').pad)
.not.toBe(padUrls('https://b.example/p/plan').pad);
});
it.each([
['kein /p/-Pfad', 'https://pad.example.org/mein-plan'],
['nur der Host', 'https://pad.example.org'],
['/p/ ohne Namen', 'https://pad.example.org/p/'],
['fremdes Schema', 'file:///tmp/plan.txt'],
['javascript:', 'javascript:alert(1)'],
['data:', 'data:text/plain,foo'],
['gar keine URL', 'nicht mal eine URL'],
['leer', ''],
])('weist ab: %s', (_name, input) => {
expect(padUrls(input)).toBeNull();
});
it('löst relative Angaben gegen die Seite auf, wenn eine Basis da ist', () => {
expect(padUrls('/p/plan', 'https://pad.example.org/x/y').pad)
.toBe('https://pad.example.org/p/plan');
});
it('ohne Basis bleibt eine relative Angabe unbrauchbar', () => {
expect(padUrls('/p/plan')).toBeNull();
});
});