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:
@@ -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.
|
Laden, bleibt der bisherige Stand stehen und eine Warnung nennt die Ursache.
|
||||||
Zugelassen sind nur `http`/`https`.
|
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
|
### Lokal ausführen
|
||||||
|
|
||||||
Die Editor-Quelle liegt jetzt als ES-Module unter `frontend/src/`, gebündelt mit
|
Die Editor-Quelle liegt jetzt als ES-Module unter `frontend/src/`, gebündelt mit
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
### Running it locally
|
||||||
|
|
||||||
The editor source now lives as ES modules under `frontend/src/`, bundled by
|
The editor source now lives as ES modules under `frontend/src/`, bundled by
|
||||||
|
|||||||
@@ -979,3 +979,108 @@ einem absichtlich ausgeweiteten `sed` in einem Wegwerf-Worktree.
|
|||||||
bleibt eine bewusste Handlung. `deploy-prod.sh` warnt stattdessen, wenn HEAD
|
bleibt eine bewusste Handlung. `deploy-prod.sh` warnt stattdessen, wenn HEAD
|
||||||
noch nicht auf `origin` liegt: der Footer-Versionslink zeigt sonst auf einen
|
noch nicht auf `origin` liegt: der Footer-Versionslink zeigt sonst auf einen
|
||||||
Commit, den GitHub nicht kennt.
|
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
@@ -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
|
`Access-Control-Allow-Origin`-Header, außerdem 404/Netzfehler), bleibt der
|
||||||
bisherige Stand stehen und es erscheint eine **Warnung**. Siehe D23.
|
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
|
### Legende im Editor-Panel
|
||||||
Neben dem Textfeld steht eine aufklappbare **Legende** (Notation in Kurzform,
|
Neben dem Textfeld steht eine aufklappbare **Legende** (Notation in Kurzform,
|
||||||
abschließend eine Bedienungs-Zeile). Sie ist **scrollbar**, wenn ihr Inhalt
|
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.
|
damit das Textfeld nie ganz verschwindet. Siehe D26.
|
||||||
|
|
||||||
### Was ist neu? (Dokumente von außen)
|
### 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
|
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
|
Produktion** — ein Knoten trägt jetzt `[^]` und tat es in der zuletzt gesehenen
|
||||||
Fassung nicht. Solche Knoten bekommen einen **gelben Strahlenkranz** nach außen
|
Fassung nicht. Solche Knoten bekommen einen **gelben Strahlenkranz** nach außen
|
||||||
|
|||||||
@@ -61,7 +61,11 @@
|
|||||||
- [?] Accounts and permissions (L)
|
- [?] Accounts and permissions (L)
|
||||||
| [?] Single user, one token (S)
|
| [?] Single user, one token (S)
|
||||||
| [?] Log in with OIDC (L)
|
| [?] 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)
|
- [ ] Git as the shared store (L)
|
||||||
| [ ] A file in a repository, changed by pull request (S) %% works today, no code
|
| [ ] A file in a repository, changed by pull request (S) %% works today, no code
|
||||||
| [?] The backend commits every change (L)
|
| [?] The backend commits every change (L)
|
||||||
|
|||||||
@@ -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
|
Endung `.werkbaum` ist reine Konvention (D24, SPEC §12). Beispieldateien zum
|
||||||
Ausprobieren: `docs/examples/*.werkbaum` (nacheinander geöffnet ergeben sie
|
Ausprobieren: `docs/examples/*.werkbaum` (nacheinander geöffnet ergeben sie
|
||||||
mehrere Dokumente im Wähler).
|
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
|
- Sprung Diagramm ↔ Text (D25): `render.js` schreibt die Parser-Zeilennummer als
|
||||||
`data-line` an jeden Knoten (Geister-Knoten bekommen keine). `jumpToLine()` in
|
`data-line` an jeden Knoten (Geister-Knoten bekommen keine). `jumpToLine()` in
|
||||||
`app.js` klappt bei Bedarf das Editor-Panel auf (`revealEditor()`), markiert die
|
`app.js` klappt bei Bedarf das Editor-Panel auf (`revealEditor()`), markiert die
|
||||||
|
|||||||
@@ -58,6 +58,9 @@
|
|||||||
<button type="button" class="docact docdel" id="docDelete" data-i18n="docDelete">Löschen</button>
|
<button type="button" class="docact docdel" id="docDelete" data-i18n="docDelete">Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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-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>
|
<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>
|
||||||
|
|||||||
+152
-27
@@ -3,6 +3,7 @@ import { parse } from './parser.js';
|
|||||||
import { computeCheapSet, freshProdSet } from './model.js';
|
import { computeCheapSet, freshProdSet } from './model.js';
|
||||||
import { esc, renderTreeHtml } from './render.js';
|
import { esc, renderTreeHtml } from './render.js';
|
||||||
import { formatWarning } from './warnings.js';
|
import { formatWarning } from './warnings.js';
|
||||||
|
import { padUrls } from './remote.js';
|
||||||
/* Werkbaum, mit Werkbaum geplant — als mitgeliefertes Dokument „Werkbank" (D27).
|
/* Werkbaum, mit Werkbaum geplant — als mitgeliefertes Dokument „Werkbank" (D27).
|
||||||
Dieselbe Datei, die auch per ?sourceUrl= geladen werden kann; `?raw` bettet
|
Dieselbe Datei, die auch per ?sourceUrl= geladen werden kann; `?raw` bettet
|
||||||
sie beim Build in die eine Ausgabedatei ein (D19), es wird nichts nachgeladen
|
sie beim Build in die eine Ausgabedatei ein (D19), es wird nichts nachgeladen
|
||||||
@@ -891,6 +892,8 @@ const I18N = {
|
|||||||
legendTooltip:"Legende ein-/ausblenden",
|
legendTooltip:"Legende ein-/ausblenden",
|
||||||
ghostTooltip:"Ab Größe M sollte ein Element weiter untergliedert werden.",
|
ghostTooltip:"Ab Größe M sollte ein Element weiter untergliedert werden.",
|
||||||
jumpHint:"Alt+Klick: zur Zeile im Text",
|
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.",
|
riskTooltip:"High Risk – Aufwand noch unklar.",
|
||||||
discardedTooltip:"Verworfene Knoten samt Teilbaum ein-/ausblenden",
|
discardedTooltip:"Verworfene Knoten samt Teilbaum ein-/ausblenden",
|
||||||
cheapTooltip:"Günstigsten Pfad hervorheben – nicht benötigte Alternativen treten zurück",
|
cheapTooltip:"Günstigsten Pfad hervorheben – nicht benötigte Alternativen treten zurück",
|
||||||
@@ -943,6 +946,8 @@ const I18N = {
|
|||||||
legendTooltip:"Show/hide legend",
|
legendTooltip:"Show/hide legend",
|
||||||
ghostTooltip:"From size M upward, an item should be broken down further.",
|
ghostTooltip:"From size M upward, an item should be broken down further.",
|
||||||
jumpHint:"Alt+click: jump to the line in the text",
|
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.",
|
riskTooltip:"High risk – effort still unclear.",
|
||||||
discardedTooltip:"Show/hide discarded nodes and their subtree",
|
discardedTooltip:"Show/hide discarded nodes and their subtree",
|
||||||
cheapTooltip:"Highlight the cheapest path – unneeded alternatives recede",
|
cheapTooltip:"Highlight the cheapest path – unneeded alternatives recede",
|
||||||
@@ -995,6 +1000,8 @@ const I18N = {
|
|||||||
legendTooltip:"Mostrar u ocultar la leyenda",
|
legendTooltip:"Mostrar u ocultar la leyenda",
|
||||||
ghostTooltip:"A partir de la talla M, un elemento debería desglosarse más.",
|
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",
|
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.",
|
riskTooltip:"Alto riesgo – esfuerzo aún incierto.",
|
||||||
discardedTooltip:"Mostrar u ocultar los nodos descartados y su subárbol",
|
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",
|
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",
|
legendTooltip:"Afficher/masquer la légende",
|
||||||
ghostTooltip:"À partir de la taille M, un élément devrait être décomposé davantage.",
|
ghostTooltip:"À partir de la taille M, un élément devrait être décomposé davantage.",
|
||||||
jumpHint:"Alt+clic : aller à la ligne dans le texte",
|
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.",
|
riskTooltip:"Risque élevé – effort encore incertain.",
|
||||||
discardedTooltip:"Afficher/masquer les nœuds abandonnés et leur sous-arbre",
|
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",
|
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ę",
|
legendTooltip:"Pokaż/ukryj legendę",
|
||||||
ghostTooltip:"Od rozmiaru M element powinien być dalej podzielony.",
|
ghostTooltip:"Od rozmiaru M element powinien być dalej podzielony.",
|
||||||
jumpHint:"Alt+kliknięcie: przejdź do wiersza w tekście",
|
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.",
|
riskTooltip:"Wysokie ryzyko – nakład jeszcze niejasny.",
|
||||||
discardedTooltip:"Pokaż/ukryj odrzucone węzły wraz z poddrzewem",
|
discardedTooltip:"Pokaż/ukryj odrzucone węzły wraz z poddrzewem",
|
||||||
cheapTooltip:"Wyróżnij najtańszą ścieżkę – niepotrzebne alternatywy są przygaszone",
|
cheapTooltip:"Wyróżnij najtańszą ścieżkę – niepotrzebne alternatywy są przygaszone",
|
||||||
@@ -1151,6 +1162,8 @@ const I18N = {
|
|||||||
legendTooltip:"Показать/скрыть легенду",
|
legendTooltip:"Показать/скрыть легенду",
|
||||||
ghostTooltip:"Начиная с размера M элемент следует далее декомпозировать.",
|
ghostTooltip:"Начиная с размера M элемент следует далее декомпозировать.",
|
||||||
jumpHint:"Alt+клик: перейти к строке в тексте",
|
jumpHint:"Alt+клик: перейти к строке в тексте",
|
||||||
|
padReadonly:"Редактируется в паде — здесь только чтение.",
|
||||||
|
padEdit:"Открыть пад для редактирования",
|
||||||
riskTooltip:"Высокий риск – оценка ещё не ясна.",
|
riskTooltip:"Высокий риск – оценка ещё не ясна.",
|
||||||
discardedTooltip:"Показать/скрыть отклонённые узлы вместе с поддеревом",
|
discardedTooltip:"Показать/скрыть отклонённые узлы вместе с поддеревом",
|
||||||
cheapTooltip:"Выделить самый дешёвый путь — ненужные альтернативы приглушаются",
|
cheapTooltip:"Выделить самый дешёвый путь — ненужные альтернативы приглушаются",
|
||||||
@@ -1203,6 +1216,8 @@ const I18N = {
|
|||||||
legendTooltip:"लेजेंड दिखाएँ/छिपाएँ",
|
legendTooltip:"लेजेंड दिखाएँ/छिपाएँ",
|
||||||
ghostTooltip:"आकार M से ऊपर किसी तत्व को और अधिक उप-विभाजित करना चाहिए।",
|
ghostTooltip:"आकार M से ऊपर किसी तत्व को और अधिक उप-विभाजित करना चाहिए।",
|
||||||
jumpHint:"Alt+क्लिक: टेक्स्ट में उस पंक्ति पर जाएँ",
|
jumpHint:"Alt+क्लिक: टेक्स्ट में उस पंक्ति पर जाएँ",
|
||||||
|
padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।",
|
||||||
|
padEdit:"संपादित करने के लिए पैड खोलें",
|
||||||
riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।",
|
riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।",
|
||||||
discardedTooltip:"अस्वीकृत नोड्स और उनके उप-वृक्ष दिखाएँ/छिपाएँ",
|
discardedTooltip:"अस्वीकृत नोड्स और उनके उप-वृक्ष दिखाएँ/छिपाएँ",
|
||||||
cheapTooltip:"सबसे किफ़ायती पथ को उजागर करें – अनावश्यक विकल्प मंद हो जाते हैं",
|
cheapTooltip:"सबसे किफ़ायती पथ को उजागर करें – अनावश्यक विकल्प मंद हो जाते हैं",
|
||||||
@@ -1260,6 +1275,8 @@ const I18N = {
|
|||||||
implicitSizeTooltip:"未指定尺寸——成本估算时按 M 计",
|
implicitSizeTooltip:"未指定尺寸——成本估算时按 M 计",
|
||||||
ghostTooltip:"从 M 号起,元素应进一步细分。",
|
ghostTooltip:"从 M 号起,元素应进一步细分。",
|
||||||
jumpHint:"Alt+点击:跳转到文本中的该行",
|
jumpHint:"Alt+点击:跳转到文本中的该行",
|
||||||
|
padReadonly:"在 Pad 中编辑 — 此处只读。",
|
||||||
|
padEdit:"打开 Pad 进行编辑",
|
||||||
riskTooltip:"高风险 – 工作量尚不明确。",
|
riskTooltip:"高风险 – 工作量尚不明确。",
|
||||||
editorTitle:"结构(文本)", diagramTitle:"图表",
|
editorTitle:"结构(文本)", diagramTitle:"图表",
|
||||||
docSwitchTooltip:"选择或管理文档", docMenuAria:"文档",
|
docSwitchTooltip:"选择或管理文档", docMenuAria:"文档",
|
||||||
@@ -1312,6 +1329,8 @@ const I18N = {
|
|||||||
implicitSizeTooltip:"サイズ未指定 – コスト見積もりのため M として扱う",
|
implicitSizeTooltip:"サイズ未指定 – コスト見積もりのため M として扱う",
|
||||||
ghostTooltip:"サイズ M 以上の要素はさらに分解すべきです。",
|
ghostTooltip:"サイズ M 以上の要素はさらに分解すべきです。",
|
||||||
jumpHint:"Alt+クリック:テキストの該当行へ移動",
|
jumpHint:"Alt+クリック:テキストの該当行へ移動",
|
||||||
|
padReadonly:"パッドで編集します — ここでは読み取り専用です。",
|
||||||
|
padEdit:"編集するにはパッドを開く",
|
||||||
riskTooltip:"高リスク – 規模はまだ不明。",
|
riskTooltip:"高リスク – 規模はまだ不明。",
|
||||||
editorTitle:"構造(テキスト)", diagramTitle:"ダイアグラム",
|
editorTitle:"構造(テキスト)", diagramTitle:"ダイアグラム",
|
||||||
docSwitchTooltip:"ドキュメントを選択・管理", docMenuAria:"ドキュメント",
|
docSwitchTooltip:"ドキュメントを選択・管理", docMenuAria:"ドキュメント",
|
||||||
@@ -1638,6 +1657,7 @@ function updateDocName(){
|
|||||||
? d.source + '\n' + t('docSwitchTooltip')
|
? d.source + '\n' + t('docSwitchTooltip')
|
||||||
: 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) */
|
let renamingId = null; /* id des gerade inline umbenannten Dokuments (oder null) */
|
||||||
function renderDocMenu(){
|
function renderDocMenu(){
|
||||||
@@ -1780,57 +1800,162 @@ function initDocs(){
|
|||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- ?sourceUrl= — Notationstext von einer URL laden (D23) ----------
|
/* ---------- Text von außen: ?sourceUrl= (D23) und ?etherpad= (D31) ----------
|
||||||
Die Datei wird geholt und als eigenes Dokument geführt, dessen Name die URL
|
Beide holen einen Notationstext über http(s) und führen ihn als eigenes
|
||||||
ist. Die id leitet sich aus der URL ab: derselbe Link aktualisiert dieses
|
Dokument. Die id leitet sich aus der URL ab: derselbe Link aktualisiert dieses
|
||||||
Dokument, statt bei jedem Aufruf ein neues anzulegen. Eigene Dokumente des
|
Dokument, statt bei jedem Aufruf ein neues anzulegen. Eigene Dokumente des
|
||||||
Nutzers bleiben unberührt. Scheitert das Laden (häufigster Fall: das Ziel
|
Nutzers bleiben unberührt. Scheitert das Laden (häufigster Fall: das Ziel
|
||||||
sendet keinen CORS-Header), bleibt der bisherige Stand stehen und es
|
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';
|
const SOURCE_PARAM = 'sourceUrl';
|
||||||
function sourceUrlParam(){
|
const ETHERPAD_PARAM = 'etherpad';
|
||||||
try{ return new URLSearchParams(location.search).get(SOURCE_PARAM); }catch(_){ return null; }
|
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();
|
const raw = sourceUrlParam();
|
||||||
if(!raw) return;
|
if(!raw) return null;
|
||||||
let url;
|
let url;
|
||||||
/* Relative Angaben gegen die Seite auflösen; nur http(s) zulassen (kein
|
/* Relative Angaben gegen die Seite auflösen; nur http(s) zulassen (kein
|
||||||
file:/data:/javascript: — die Notation selbst erlaubt ohnehin nur http(s)). */
|
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
|
/* Fehlerdetail bewusst technisch/englisch wie die Browser-Meldungen
|
||||||
(„Failed to fetch", „HTTP 404") — der Rahmentext ist lokalisiert. */
|
(„Failed to fetch", „HTTP 404") — der Rahmentext ist lokalisiert. */
|
||||||
sourceWarning = {type:'sourceLoad', url: raw, error: 'invalid URL'};
|
try{ url = new URL(raw, location.href); }catch(_){ return {bad: raw, error: 'invalid URL'}; }
|
||||||
render();
|
if(url.protocol !== 'http:' && url.protocol !== 'https:') return {bad: url.href, error: url.protocol};
|
||||||
return;
|
return {fetchUrl: url.href, id: 'url:' + url.href, name: url.href, source: url.href, live: false};
|
||||||
}
|
}
|
||||||
if(url.protocol !== 'http:' && url.protocol !== 'https:'){
|
|
||||||
sourceWarning = {type:'sourceLoad', url: url.href, error: url.protocol};
|
/* `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();
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try{
|
try{
|
||||||
const resp = await fetch(url.href, {cache:'no-store', credentials:'omit'});
|
const text = await fetchRemote(s.fetchUrl);
|
||||||
if(!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
||||||
const text = await resp.text();
|
|
||||||
const id = 'url:' + url.href;
|
|
||||||
flushActive(); /* laufende Bearbeitung nicht verlieren */
|
flushActive(); /* laufende Bearbeitung nicht verlieren */
|
||||||
let d = docs.find(x => x.id === id);
|
let d = docs.find(x => x.id === s.id);
|
||||||
if(d){ d.text = text; d.name = url.href; }
|
if(d){ d.text = text; d.name = s.name; }
|
||||||
else { d = {id, name: url.href, text, source: url.href}; docs.push(d); }
|
else { d = {id: s.id, name: s.name, text}; docs.push(d); }
|
||||||
d.source = url.href;
|
d.source = s.source;
|
||||||
activeId = id;
|
activeId = s.id;
|
||||||
sourceWarning = null;
|
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();
|
loadActiveIntoEditor();
|
||||||
persistDocs();
|
persistDocs();
|
||||||
|
if(s.live) startPadPolling();
|
||||||
}catch(err){
|
}catch(err){
|
||||||
/* CORS-Fehler melden sich als „TypeError: Failed to fetch" ohne Details —
|
/* CORS-Fehler melden sich als „TypeError: Failed to fetch" ohne Details —
|
||||||
der Warntext nennt CORS daher ausdrücklich als wahrscheinliche Ursache. */
|
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();
|
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 => {
|
docTrigger.addEventListener('click', e => {
|
||||||
/* Desktop: ist der Editor minimiert, stellt ein Klick ihn wieder her
|
/* Desktop: ist der Editor minimiert, stellt ein Klick ihn wieder her
|
||||||
(Bubbling zur Titelzeile) statt das Menü zu öffnen. */
|
(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 */
|
applyLang(I18N[startLang] ? startLang : 'de'); /* setzt Texte + rendert */
|
||||||
initDocs(); /* Dokumente laden + aktiven Text in den Editor (nach Sprache) */
|
initDocs(); /* Dokumente laden + aktiven Text in den Editor (nach Sprache) */
|
||||||
applyMobile(); /* Mobil-Verhalten (nach Sprache/Restore) anwenden */
|
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") ----------
|
/* ---------- Build-Hinweis (Vorschau/Dev + „latest build") ----------
|
||||||
Kennzeichnet einen nicht-produktiven Build mit einem kleinen Symbol samt
|
Kennzeichnet einen nicht-produktiven Build mit einem kleinen Symbol samt
|
||||||
|
|||||||
@@ -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'};
|
||||||
|
}
|
||||||
@@ -329,6 +329,10 @@
|
|||||||
.copybtn .ic-done{display:none}
|
.copybtn .ic-done{display:none}
|
||||||
.copybtn.done .ic-copy{display:none}
|
.copybtn.done .ic-copy{display:none}
|
||||||
.copybtn.done .ic-done{display:block}
|
.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}
|
.dlbtn{gap:3px}
|
||||||
.dl-label{font-family:'IBM Plex Mono',monospace;font-size:.62rem;font-weight:600;letter-spacing:.02em}
|
.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.
|
/* Download-Gruppe: Desktop zeigt SVG/PNG inline, der Trigger ist versteckt.
|
||||||
@@ -398,6 +402,11 @@
|
|||||||
color:var(--ink);background:var(--card);
|
color:var(--ink);background:var(--card);
|
||||||
tab-size:2;
|
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{
|
.hint{
|
||||||
padding:12px 16px;border-top:1px dashed rgba(36,52,71,.18);
|
padding:12px 16px;border-top:1px dashed rgba(36,52,71,.18);
|
||||||
font-size:.8rem;color:var(--muted);line-height:1.7;
|
font-size:.8rem;color:var(--muted);line-height:1.7;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user