frontend: Pad auf Knopfdruck holen statt zu takten — Etherpad drosselt

Der Hintergrund-Takt aus dem letzten Commit funktionierte in der Praxis nicht:
im Netzwerk-Mitschnitt stapelten sich Anfragen und wurden abgebrochen
("cancelled"). Die Ursache ist nicht Langsamkeit, sondern ein Rate Limit —
Etherpads `importExportRateLimiting` ist serienmäßig an und lässt 10 Abrufe je
90 s und IP zu; der Takt wollte 36. Jenseits davon antwortet die Gegenseite
nicht mit 429, sondern hält die Verbindung ohne Antwort offen. Nachgemessen:

  12:48:12  Abbruch nach 25 s (0 Bytes)
            Abbruch nach 25 s (0 Bytes)
  12:50:23  HTTP 200 nach 0,436 s   <- nach ~40 s Pause

Zwei Minuten totgestellt, dann sofort in 0,4 s da. Gegen eine Drosselung kann
ein Takt nicht gewinnen, er erzeugt sie. Also ein Knopf. Damit entfallen
Stabilitätstakt, Sichtbarkeits-Wächter, Anti-Stapel-Riegel und Wiederanlauf.
Der Knopf greift zudem gut mit "Was ist neu?" (D28) zusammen: drücken, und was
seither in Produktion ging, leuchtet auf.

Verhindern UND melden, beides:
- Mindestabstand 10 s zwischen zwei Abrufen. Das ergibt höchstens 9 je 90 s und
  bleibt damit beweisbar unter Etherpads Budget — die Drosselung wird gar nicht
  erst ausgelöst. Nach einem Abbruch sind wir schon drüber: dann das ganze
  Fenster (90 s) abwarten statt weiter dagegen zu rennen.
- Der Knopf bleibt in der Sperre **klickbar** (nur `aria-disabled`, nicht
  `disabled`): Ein Klick meldet den Grund und nennt die Grenze samt Restzeit.
  Die Meldung landet in `#warn`, das eine Live-Region ist, wird also angesagt.
  Ein `disabled` erklärt nichts.
- Eigener Warnungstyp `sourceTimeout` für den Abbruch: `sourceLoad` zeigt auf
  CORS und schickte hier auf die falsche Fährte.
- `setPadBusy()` dreht das Symbol während des Abrufs — das können bis zu 20 s
  sein, und ohne Zeichen wirkt der Knopf kaputt.

Zwei Fehler des Vorgänger-Commits fielen dabei auf:
- `padSource` und der Takt standen HINTER dem `await` des ersten Abrufs. Ein
  einziger Fehlschlag — bei dieser Gegenseite der Normalfall — ließ das Dokument
  tot liegen, ohne Knopf und ohne Wiederversuch, bis zum Neuladen der Seite.
  Genau das war die vom Nutzer gemeldete Fehlermeldung.
- Kein Erfolg räumte die Warnung des ersten Versuchs weg; sie hätte gestanden,
  während der Text längst da war.
Dazu: `deleteDoc()` beendet die Pad-Quelle, sonst legte ein späterer Abruf das
gelöschte Dokument wieder an und aktivierte es auch gleich.

Nebenbefunde, beide nachgemessen und in D31 festgehalten:
- Das *Tippen* im Etherpad-Editor war der letzte offene Punkt. Der Nutzer hat
  `    - [ ] Layout` eingerückt eingetippt; der Export gibt es zeichengenau
  zurück (vier echte Leerzeichen, kein Tab, kein Listen-Markup).
- Einbetten verweigert der Server nicht: kein `X-Frame-Options`, keine CSP mit
  `frame-ancestors`, und im Versuch baute das Pad in einem fremdstämmigen Rahmen
  seine Editorframes auf. Preis ist `SameSite=Lax` am Autoren-Cookie (jedes
  Laden ein neuer Autor; nur serverseitig zu beheben) und der Verlust beider
  Richtungen von D25, falls das Textpanel ersetzt wird.

86 Tests (85 -> 86); Schreibschutz zeigt den Text jetzt in Tinte statt grau —
hier wird gelesen, der Plantext ist der Hauptinhalt.
This commit is contained in:
mhoennig
2026-07-30 12:59:14 +02:00
parent 3310cab7be
commit 793561c873
11 changed files with 357 additions and 111 deletions
+10 -3
View File
@@ -101,9 +101,16 @@ sich ein **Etherpad**. Angegeben wird die Pad-Adresse, wie sie im Browser steht
https://werkbaum.javagil.de/?etherpad=https://pad.hostsharing.net/p/mein-plan https://werkbaum.javagil.de/?etherpad=https://pad.hostsharing.net/p/mein-plan
``` ```
Alle bearbeiten den Notationstext **im Pad**, jeder Betrachter sieht das Diagramm Alle bearbeiten den Notationstext **im Pad**; ein Neu-laden-Knopf neben dem
mitwachsen (Abruf alle 2,5 s; nicht, während der Tab im Hintergrund liegt). Das Pad-Knopf holt den aktuellen Stand. Das Zusammenführen gleichzeitiger Änderungen
Zusammenführen gleichzeitiger Änderungen macht Etherpad — genau darum geht es. macht Etherpad — genau darum geht es.
Bewusst **kein** Abrufen im Hintergrund: Etherpad drosselt den Export
(serienmäßig 10 Abrufe je 90 s und IP), ein Takt gewinnt dagegen nicht — er
*erzeugt* die Drosselung. Nachgemessen: Nach Überschreiten des Budgets hält der
Server die Verbindung rund zwei Minuten ohne Antwort offen und liefert dann in
0,4 s. Der Knopf greift gut mit „Was ist neu?" zusammen: drücken, und was seither
in Produktion ging, leuchtet auf.
Weil das Pad die Schreibfläche ist, ist das Textfeld hier **schreibgeschützt**; 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 ein Knopf in der Editor-Titelzeile öffnet das Pad im neuen Tab. Ohne den Schutz
+10 -3
View File
@@ -96,9 +96,16 @@ Werkbaum appends that itself):
https://werkbaum.javagil.de/?etherpad=https://pad.hostsharing.net/p/my-plan 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 Everyone edits the notation text **in the pad**; a reload button next to the pad
diagram grow (fetched every 2.5 s; not while the tab is in the background). button fetches the current state. Merging simultaneous edits is Etherpad's job —
Merging simultaneous edits is Etherpad's job — that is the whole point. that is the whole point.
Deliberately **no background polling**: Etherpad rate-limits the export (10
fetches per 90 s per IP by default), so a timer does not win against it — it
*causes* the throttling. Measured: after exceeding the budget the server holds
the connection open with no reply for about two minutes, then answers in 0.4 s.
The button pairs well with "what's new": press it, and whatever went into
production since your last look lights up.
Because the pad is the writing surface, the text area here is **read-only**; a 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 button in the editor title bar opens the pad in a new tab. Without that
+83 -26
View File
@@ -1000,10 +1000,14 @@ http(s), ohne Endung oder `Content-Type` zu prüfen (D24). Nachgemessen an
Einrückung als echte Leerzeichen und deutet das `-` nicht zur Aufzählung um. 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. Das war das Risiko, das die Idee hätte erledigen können.
(Gemessen wurde Import → Speicher → Export. Das *Tippen* im Etherpad-Editor — Das war zunächst nur Import → Speicher → Export, also **nicht** das Tippen — und
Tab-Einrückung, mögliches Auto-Bullet — ist damit **nicht** geprüft; dafür genau das war der offene Punkt (Tab-Einrückung, mögliches Auto-Bullet), im Geist
braucht es einen echten Browser. Vgl. die Lehre aus D25: synthetische Ereignisse der Lehre aus D25: synthetische Ereignisse beweisen nur die eigene Logik.
beweisen nur die eigene Logik.) **Nachgeholt an echter Eingabe:** Der Nutzer hat im Pad ` - [ ] Layout`
eingerückt eingetippt und eine Kommentarzeile geändert; der Export gibt beides
zeichengenau zurück (`> - [ ] Layout$` — vier echte Leerzeichen, kein Tab,
kein Listen-Markup). Der Etherpad-Editor fasst die Notation beim Tippen also
nicht an.
**Eigener Parameter statt `?sourceUrl=`.** Drei Gründe, der erste ist der **Eigener Parameter statt `?sourceUrl=`.** Drei Gründe, der erste ist der
schwächste: schwächste:
@@ -1013,9 +1017,10 @@ schwächste:
nicht in die Schnittstelle; Werkbaum hängt sie selbst an. nicht in die Schnittstelle; Werkbaum hängt sie selbst an.
2. Der Parameter **lizenziert anderes Verhalten**. `sourceUrl` heißt „statische 2. Der Parameter **lizenziert anderes Verhalten**. `sourceUrl` heißt „statische
Datei, einmal pro Laden geholt" — das ist D23 wörtlich und bleibt Datei, einmal pro Laden geholt" — das ist D23 wörtlich und bleibt
unangetastet. `etherpad` heißt „lebendes Pad", und daran hängt das unangetastet. `etherpad` heißt „lebendes Pad", und daran hängen der
regelmäßige Abrufen. Ohne die Trennung müsste D23 seine Semantik ändern und Schreibschutz, der „im Pad bearbeiten"-Knopf und der Neu-laden-Knopf. Ohne
bestehende Links bekämen ungefragt Polling. die Trennung müsste D23 seine Semantik ändern und bestehende Links bekämen
Verhalten, um das niemand gebeten hat.
3. Die **Pad**-URL ist mehr wert als die Export-URL: nur mit ihr sind der 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 „im Pad bearbeiten"-Knopf und ein späteres Einbetten (siehe unten) ohne
weiteren Parameter erreichbar, und Identität/Name des Dokuments werden aus weiteren Parameter erreichbar, und Identität/Name des Dokuments werden aus
@@ -1042,19 +1047,51 @@ gleichzeitige Änderungen nicht zusammenführen, das Pad kann es. Deshalb
erscheint in der Editor-Titelzeile ein Knopf, der das Pad im neuen Tab öffnet erscheint in der Editor-Titelzeile ein Knopf, der das Pad im neuen Tab öffnet
(nur bei solchen Dokumenten sichtbar). (nur bei solchen Dokumenten sichtbar).
**Stabilitäts-Verzögerung statt rohem Polling.** Beim Abrufen sieht man die **Geholt wird auf Knopfdruck, nicht selbsttätig — Etherpad drosselt.** Die erste
anderen mitten im Tippen; eine halb geschriebene Zeile ist eine kaputte Zeile, Fassung holte alle 2,5 s im Hintergrund, mit einem Stabilitätstakt (erst
das Diagramm zuckt und die Warnungen flackern. Übernommen wird ein neuer Text übernehmen, wenn zwei Abrufe denselben Text liefern) gegen das Mitlesen halb
deshalb erst, wenn **zwei** Abrufe hintereinander denselben liefern. Das kostet getippter Zeilen. In der Praxis kam damit fast nichts an: im Netzwerk-Mitschnitt
im Mittel einen Takt Verzögerung und macht die Ansicht ruhig. Bei `?sourceUrl=` stapelten sich Anfragen und wurden abgebrochen („cancelled").
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 Die Ursache ist nicht Langsamkeit, sondern ein **Rate Limit**:
Export rendert bei jedem Abruf das ganze Pad, und die Gegenseite ist fremde `importExportRateLimiting` ist in Etherpad serienmäßig an und lässt **10 Abrufe
Infrastruktur. Ein Fehlschlag beim Abrufen bleibt **stumm** und lässt den je 90 s und IP** zu — der Takt wollte 36. Danach antwortet die Gegenseite nicht
letzten Stand stehen (nur der erste Ladeversuch warnt) — sonst flutete ein mit `429`, sondern **hält die Verbindung offen** (keine Kopfzeilen, keine
Netzaussetzer den Warnbereich. Antwort), bis der eigene Abbruch sie abreißt. Nachgemessen:
```
12:48:12 Abbruch nach 25 s (0 Bytes)
Abbruch nach 25 s (0 Bytes)
12:50:23 HTTP 200 nach 0,436 s <- nach ~40 s Pause
```
Zwei Minuten totgestellt, dann sofort in 0,4 s da. Gegen eine Drosselung kann ein
Takt nicht gewinnen — er *erzeugt* sie. Also: **ein Knopf**. Damit entfallen
Stabilitätstakt, Sichtbarkeits-Wächter, Anti-Stapel-Riegel und Wiederanlauf; was
bleibt, ist ein Abruf, wenn jemand ihn will. Das ist auch die ehrlichere Haltung
gegenüber fremder Infrastruktur, und es greift gut mit „Was ist neu?" (D28)
zusammen: drücken, und was seither in Produktion ging, leuchtet auf.
Zwei Dinge, die der Knopf braucht und ein stiller Takt nicht:
- **Rückmeldung während des Abrufs** (das Symbol dreht). Bei gedrosselter
Gegenseite sind das bis zu 20 s; ohne Zeichen wirkt der Knopf kaputt.
- **Eine Antwort im Fehlerfall.** Ein Hintergrund-Takt durfte stumm scheitern,
eine bewusste Handlung nicht. Der Abbruch bekommt dafür einen **eigenen**
Warnungstyp `sourceTimeout`: Die `sourceLoad`-Meldung zeigt auf CORS und
schickte hier auf die falsche Fährte — richtig ist „warte einen Moment".
**Der erste Abruf darf scheitern, ohne alles zu verlieren.** In der ersten Fassung
standen `padSource` und der Takt *hinter* dem `await` des ersten Abrufs: Ein
einziger Fehlschlag — bei dieser Gegenseite der Normalfall — ließ das Dokument
tot liegen, ohne Knopf und ohne Wiederversuch, bis zum Neuladen der Seite.
Jetzt wird `padSource` **vor** dem Abruf gesetzt; der Knopf erscheint auch, wenn
es das Dokument noch nicht gibt, und legt es beim ersten Erfolg an.
**Wer das Pad-Dokument löscht, meint es.** `deleteDoc()` beendet die Pad-Quelle.
Ohne das hätte der (damalige) Takt es wieder angelegt — und beim Anlegen auch
gleich aktiviert, den Nutzer also aus dem Dokument gerissen, in das er gewechselt
war.
**Verhältnis zu D20 („keine externen Requests").** Unverändert wie bei D23: Die **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 App lädt von sich aus nichts; der Request entsteht nur, weil der Nutzer eine
@@ -1066,13 +1103,33 @@ anderes als bei einer Schriftart. Es bleibt die Entscheidung dessen, der den
Link baut — Werkbaum legt von sich aus kein Pad an. Link baut — Werkbaum legt von sich aus kein Pad an.
**Verworfene und aufgeschobene Alternativen:** **Verworfene und aufgeschobene Alternativen:**
- **`?sourceUrl=` um Polling erweitern** — hätte bestehenden Links ungefragt - **`?sourceUrl=` um wiederholtes Abrufen erweitern** — hätte bestehenden Links
wiederholte Requests verpasst und D23 seine klare Semantik gekostet. ungefragt Requests verpasst und D23 seine klare Semantik gekostet.
- **Das Pad als `<iframe>` einbetten** (aufgeschoben, nicht verworfen): Bringt - **Das Pad als `<iframe>` einbetten** (aufgeschoben, nicht verworfen). Ob der
Cursor, Namen, Farben und Chat mit. Kostet aber **beide** Richtungen von D25 Server das überhaupt zulässt, ist **nachgemessen**: `pad.hostsharing.net`
— in einem cross-origin-iframe gibt es keinen DOM-Zugriff, also kein sendet **kein** `X-Frame-Options` und keine CSP mit `frame-ancestors`, und im
Alt+Klick → Zeile und keine Cursor-Zeile → Knoten. Bei 75 Knoten ist das die Versuch hat das Pad in einem fremdstämmigen Rahmen seine inneren Editorframes
Orientierung. Der Knopf „im Pad bearbeiten" ist die kleine Lösung desselben aufgebaut (`contentWindow.length === 2`, das sind `ace_outer`/`ace_inner`),
samt Werkzeugleiste, heiler Einrückung und lebender Socket-Verbindung
(Anwesenden-Zähler). Technisch geht es also.
Zwei Preise. Erstens **`Set-Cookie: token=…; SameSite=Lax`**: Dieses Cookie ist
Etherpads Autoren-Identität und wird in einem fremdstämmigen Rahmen **nicht
mitgesendet** (Safari blockt Dritt-Cookies grundsätzlich, Chrome je nach
Einstellung). Bearbeiten geht, aber man ist bei jedem Laden ein neuer Autor —
Name und Farbe halten nicht. Reparieren lässt sich das nur serverseitig
(`cookie.sameSite: "None"` in Etherpads `settings.json`), nicht in Werkbaum.
Zweitens: Einbetten heißt das **Textpanel ersetzen**, und damit fallen **beide**
Richtungen von D25 weg — kein Alt+Klick → Zeile, keine Cursor-Zeile → Knoten,
weil ein cross-origin-iframe keinen DOM-Zugriff erlaubt. Bei 75 Knoten ist das
die Orientierung.
Der Ausweg, der sich beim Messen zeigte: das schreibgeschützte Textfeld
**behalten** und das Pad daneben stellen. Dann bleibt der Spiegel die Fläche
zum Hinschauen (Alt+Klick und Cursor-Synchronisierung arbeiten auf unserem
eigenen `<textarea>`), das Pad ist die Fläche zum Schreiben. Preis ist
Bildschirmfläche; auf kleinem Bildschirm (D17) sind es dann drei Bereiche.
Der Knopf „im Pad bearbeiten" ist bis dahin die kleine Lösung desselben
Bedürfnisses; das Einbetten bleibt möglich, weil die Pad-URL vorliegt. Bedürfnisses; das Einbetten bleibt möglich, weil die Pad-URL vorliegt.
- **Echter Etherpad-Client** (socket.io + Easysync-Changesets): Rettet D25 und - **Echter Etherpad-Client** (socket.io + Easysync-Changesets): Rettet D25 und
erlaubt Schreiben aus Werkbaum heraus, kostet aber zwei **Laufzeit**- erlaubt Schreiben aus Werkbaum heraus, kostet aber zwei **Laufzeit**-
+12 -7
View File
@@ -290,11 +290,13 @@ Export- oder `/timeslider`-Pfad wird abgeschnitten.
- Deshalb ist das Textfeld für ein solches Dokument **schreibgeschützt** — ein - 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 Knopf in der Editor-Titelzeile öffnet das Pad im neuen Tab. Ohne den Schutz
verschwände getippter Text beim nächsten Abruf. verschwände getippter Text beim nächsten Abruf.
- Geholt wird **regelmäßig** (Voreinstellung 2,5 s; nicht, solange der Tab im - Geholt wird **auf Knopfdruck**, nicht selbsttätig: Ein Neu-laden-Knopf neben
Hintergrund liegt). Übernommen wird eine Änderung erst, wenn zwei Abrufe dem Pad-Knopf holt den aktuellen Stand. Etherpad **drosselt** den Export
hintereinander **denselben** neuen Text liefern — sonst sähe man die anderen (serienmäßig 10 Abrufe je 90 s und IP); ein Hintergrund-Takt läuft dagegen an
mitten im Tippen, und halb geschriebene Zeilen ließen Diagramm und Warnungen und bekommt am Ende gar nichts mehr. Läuft ein Abruf, dreht das Symbol — bei
flackern. gedrosselter Gegenseite kann das bis zum Abbruch (20 s) dauern.
- Der Knopf greift gut mit „Was ist neu?" (§9) zusammen: drücken, und was seither
in Produktion ging, leuchtet auf.
- **Name ist die vollständige Pad-URL** (nicht der bloße Pad-Name — zwei Pads - **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), gleichen Namens auf verschiedenen Hosts wären sonst nicht zu unterscheiden),
wie bei `?sourceUrl=`. Identität und Name leiten sich von der **Pad**-Adresse wie bei `?sourceUrl=`. Identität und Name leiten sich von der **Pad**-Adresse
@@ -302,8 +304,11 @@ Export- oder `/timeslider`-Pfad wird abgeschnitten.
Dokument, gleich in welcher Schreibweise der Link kam. Dokument, gleich in welcher Schreibweise der Link kam.
- `?sourceUrl=` bleibt unverändert: statische Datei, einmal pro Laden geholt. - `?sourceUrl=` bleibt unverändert: statische Datei, einmal pro Laden geholt.
Der eigene Parameter trägt gerade den Unterschied. Der eigene Parameter trägt gerade den Unterschied.
- Fehler (CORS, 404, Netz) melden sich wie bei `?sourceUrl=`; ein einzelner - Fehler (CORS, 404, Netz) melden sich wie bei `?sourceUrl=`. Ein **Abbruch**
Aussetzer beim Abrufen bleibt stumm und lässt den letzten Stand stehen. wegen Zeitablauf bekommt eine eigene Meldung, die die Drosselung benennt —
die `?sourceUrl=`-Meldung zeigt auf CORS und schickte hier auf die falsche
Fährte. Scheitert schon der erste Abruf, bleibt der Neu-laden-Knopf sichtbar
und holt es nach; ein Neuladen der Seite ist nicht nötig.
Siehe D31. Siehe D31.
+1 -1
View File
@@ -64,7 +64,7 @@
- [~] Working together on one plan (XL) - [~] Working together on one plan (XL)
- [x] Watch a shared Etherpad — it merges, we render (S) - [x] Watch a shared Etherpad — it merges, we render (S)
- [x] Load the pad's plain-text export (XS) - [x] Load the pad's plain-text export (XS)
- [x] Keep up while the others type (XS) - [x] Reload on demand (XS) %% a timer only trips Etherpad's rate limit
+ [?] Embed the pad beside the diagram (M) %% costs the jump to the line + [?] 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
+16 -13
View File
@@ -142,19 +142,22 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
`tests/remote.test.js`): Export-/Timeslider-Pfad, Query, Fragment und `tests/remote.test.js`): Export-/Timeslider-Pfad, Query, Fragment und
Schrägstriche fallen weg, verlangt wird `/p/<name>` am Ende. Name und id sind 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). die **vollständige** Pad-URL (Pad-Namen sind nur pro Instanz eindeutig).
`padSource` muss **vor** `loadActiveIntoEditor()` gesetzt werden — daran hängt `padSource` muss **vor** dem ersten Abruf und vor `loadActiveIntoEditor()`
der Schreibschutz. `pollPad()` hat drei Riegel, jeder aus einem echten Fehler: gesetzt werden: Es trägt den Schreibschutz, und scheitert der erste Abruf
`padBusy` (höchstens ein Abruf unterwegs — sonst stapeln sich Anfragen und eine (Normalfall bei dieser Gegenseite), bleibt so der Neu-laden-Knopf erreichbar
spät eintreffende alte Antwort überschreibt neueren Text, im statt das Dokument tot liegen zu lassen. Geholt wird **nur auf Knopfdruck**
Netzwerk-Mitschnitt beobachtet), `PAD_FETCH_TIMEOUT_MS` (ohne Abbruch bliebe (`refreshPad`) — **kein Hintergrund-Takt wieder einbauen**: Etherpads
`padBusy` bei hängender Gegenseite für immer zu) und `visibilityState` `importExportRateLimiting` lässt serienmäßig 10 Abrufe je 90 s und IP zu, und
(+ `visibilitychange`-Handler, der bei Rückkehr sofort holt). Fehlschläge im jenseits davon hält die Gegenseite die Verbindung ohne Antwort offen (kein
Takt bleiben **stumm**; nur der erste Ladeversuch warnt. Der Stabilitätstakt `429`), bis der eigene Abbruch sie abreißt — gemessen: zwei Minuten tot, danach
(`padPending`) übernimmt erst beim zweiten gleichen Abruf — sonst sieht man die 0,4 s. Ein Takt gewinnt dagegen nicht, er erzeugt die Drosselung. Deshalb auch
anderen mitten im Tippen. Beim Prüfen im Vorschau-Browser: `visibilityState` `setPadBusy()` (Symbol dreht, bis zu 20 s) und ein **eigener** Warnungstyp
ist dort `hidden` (Polling also aus) und HMR lädt bei jeder Quelländerung neu — `sourceTimeout` für den Abbruch — `sourceLoad` zeigt auf CORS und schickte hier
ein Reload sieht wie eine geglückte Übernahme aus. Marker auf `window` setzen auf die falsche Fährte. `deleteDoc()` ruft `stopPad()`, sonst legte ein späterer
und hinterher prüfen, sonst beweist der Test nichts. Abruf das gelöschte Dokument wieder an und aktivierte es auch gleich.
Beim Prüfen im Vorschau-Browser: HMR lädt bei jeder Quelländerung neu, und ein
Reload holt den Text beim Erstabruf — 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
+3
View File
@@ -61,6 +61,9 @@
<a class="copybtn padlink" id="padLink" href="#" target="_blank" rel="noopener" hidden> <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> <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> </a>
<button type="button" class="copybtn padrefresh" id="padRefresh" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>
</button>
<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>
+177 -56
View File
@@ -894,6 +894,9 @@ const I18N = {
jumpHint:"Alt+Klick: zur Zeile im Text", jumpHint:"Alt+Klick: zur Zeile im Text",
padReadonly:"Wird im Pad bearbeitet — hier nur lesen.", padReadonly:"Wird im Pad bearbeitet — hier nur lesen.",
padEdit:"Pad zum Bearbeiten öffnen", padEdit:"Pad zum Bearbeiten öffnen",
padRefresh:"Vom Pad neu laden",
padWait:"Noch {seconds} s — Etherpad begrenzt die Abrufe",
padRateLimitWarn:"Noch nicht neu geladen: Etherpad erlaubt nur wenige Abrufe je Zeitfenster (serienmäßig 10 pro 90 s), so häufiges Nachladen ist leider nicht möglich. In {seconds} s geht es wieder.",
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",
@@ -926,6 +929,7 @@ const I18N = {
st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen", st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen",
unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.", unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.",
sourceLoadWarn:"„{url}“ konnte nicht geladen werden ({error}). Die Datei muss per http(s) erreichbar sein und CORS erlauben (Access-Control-Allow-Origin).", sourceLoadWarn:"„{url}“ konnte nicht geladen werden ({error}). Die Datei muss per http(s) erreichbar sein und CORS erlauben (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"„{url}“ hat innerhalb von {seconds} s nicht geantwortet — der Abruf wurde abgebrochen. Etherpad begrenzt, wie oft der Export geholt werden darf (serienmäßig 10-mal pro 90 s); warte einen Moment und lade dann erneut.",
a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yOptional:"optional", a11yLink:"verlinkt", a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yOptional:"optional", a11yLink:"verlinkt",
hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.", hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.",
hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen", hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen",
@@ -948,6 +952,9 @@ const I18N = {
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.", padReadonly:"Edited in the pad — read-only here.",
padEdit:"Open the pad to edit", padEdit:"Open the pad to edit",
padRefresh:"Reload from the pad",
padWait:"{seconds} s to go — Etherpad limits how often we may fetch",
padRateLimitWarn:"Not reloaded yet: Etherpad only allows a few fetches per time window (10 per 90 s by default), so syncing this often is unfortunately not possible. Try again in {seconds} s.",
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",
@@ -980,6 +987,7 @@ const I18N = {
st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded", st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded",
unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.", unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.",
sourceLoadWarn:"Could not load “{url}” ({error}). The file must be reachable via http(s) and allow CORS (Access-Control-Allow-Origin).", sourceLoadWarn:"Could not load “{url}” ({error}). The file must be reachable via http(s) and allow CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"“{url}” did not answer within {seconds} s — the request was aborted. Etherpad limits how often the export may be fetched (10 times per 90 s by default); wait a moment, then reload.",
a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yOptional:"optional", a11yLink:"has link", a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yOptional:"optional", a11yLink:"has link",
hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.", hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.",
hint_all:"sub-task, all required", hint_any:"alternative, choose one", hint_all:"sub-task, all required", hint_any:"alternative, choose one",
@@ -1002,6 +1010,9 @@ const I18N = {
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.", padReadonly:"Se edita en el pad — aquí solo lectura.",
padEdit:"Abrir el pad para editar", padEdit:"Abrir el pad para editar",
padRefresh:"Recargar desde el pad",
padWait:"Faltan {seconds} s — Etherpad limita la frecuencia",
padRateLimitWarn:"Aún no se ha recargado: Etherpad solo permite unas pocas descargas por ventana de tiempo (10 por 90 s de forma predeterminada), así que sincronizar tan a menudo no es posible. Vuelve a intentarlo en {seconds} s.",
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",
@@ -1034,6 +1045,7 @@ const I18N = {
st_fertig:"terminado", st_prod:"en producción", st_highrisk:"alto riesgo", st_verworfen:"descartado", st_fertig:"terminado", st_prod:"en producción", st_highrisk:"alto riesgo", st_verworfen:"descartado",
unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.", unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.",
sourceLoadWarn:"No se pudo cargar «{url}» ({error}). El archivo debe ser accesible por http(s) y permitir CORS (Access-Control-Allow-Origin).", sourceLoadWarn:"No se pudo cargar «{url}» ({error}). El archivo debe ser accesible por http(s) y permitir CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"«{url}» no respondió en {seconds} s — se canceló la petición. Etherpad limita la frecuencia de descarga del export (10 veces por 90 s de forma predeterminada); espera un momento y vuelve a cargar.",
a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yOptional:"opcional", a11yLink:"con enlace", a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yOptional:"opcional", a11yLink:"con enlace",
hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.", hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.",
hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una", hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una",
@@ -1056,6 +1068,9 @@ const I18N = {
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.", padReadonly:"Modifié dans le pad — lecture seule ici.",
padEdit:"Ouvrir le pad pour modifier", padEdit:"Ouvrir le pad pour modifier",
padRefresh:"Recharger depuis le pad",
padWait:"Encore {seconds} s — Etherpad limite la fréquence",
padRateLimitWarn:"Pas encore rechargé : Etherpad nautorise que quelques récupérations par fenêtre de temps (10 par 90 s par défaut), une synchronisation aussi fréquente nest donc pas possible. Réessaie dans {seconds} s.",
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",
@@ -1088,6 +1103,7 @@ const I18N = {
st_fertig:"terminé", st_prod:"en production", st_highrisk:"risque élevé", st_verworfen:"abandonné", st_fertig:"terminé", st_prod:"en production", st_highrisk:"risque élevé", st_verworfen:"abandonné",
unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.", unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.",
sourceLoadWarn:"Impossible de charger « {url} » ({error}). Le fichier doit être accessible en http(s) et autoriser CORS (Access-Control-Allow-Origin).", sourceLoadWarn:"Impossible de charger « {url} » ({error}). Le fichier doit être accessible en http(s) et autoriser CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"« {url} » na pas répondu en {seconds} s — la requête a été interrompue. Etherpad limite la fréquence de récupération de lexport (10 fois par 90 s par défaut) ; attends un instant, puis recharge.",
a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yOptional:"facultatif", a11yLink:"avec lien", a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yOptional:"facultatif", a11yLink:"avec lien",
hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.", hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.",
hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une", hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une",
@@ -1110,6 +1126,9 @@ const I18N = {
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.", padReadonly:"Edytowane w padzie — tu tylko do czytania.",
padEdit:"Otwórz pad do edycji", padEdit:"Otwórz pad do edycji",
padRefresh:"Wczytaj ponownie z padu",
padWait:"Jeszcze {seconds} s — Etherpad ogranicza częstość",
padRateLimitWarn:"Jeszcze nie wczytano ponownie: Etherpad dopuszcza tylko kilka pobrań w okresie (domyślnie 10 na 90 s), więc tak częsta synchronizacja nie jest możliwa. Spróbuj za {seconds} s.",
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",
@@ -1142,6 +1161,7 @@ const I18N = {
st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone", st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone",
unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.", unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.",
sourceLoadWarn:"Nie udało się wczytać „{url}” ({error}). Plik musi być dostępny przez http(s) i zezwalać na CORS (Access-Control-Allow-Origin).", sourceLoadWarn:"Nie udało się wczytać „{url}” ({error}). Plik musi być dostępny przez http(s) i zezwalać na CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"„{url}” nie odpowiedział w ciągu {seconds} s — żądanie przerwano. Etherpad ogranicza częstość pobierania eksportu (domyślnie 10 razy na 90 s); odczekaj chwilę i wczytaj ponownie.",
a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yOptional:"opcjonalny", a11yLink:"z linkiem", a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yOptional:"opcjonalny", a11yLink:"z linkiem",
hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.", hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.",
hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną", hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną",
@@ -1164,6 +1184,9 @@ const I18N = {
jumpHint:"Alt+клик: перейти к строке в тексте", jumpHint:"Alt+клик: перейти к строке в тексте",
padReadonly:"Редактируется в паде — здесь только чтение.", padReadonly:"Редактируется в паде — здесь только чтение.",
padEdit:"Открыть пад для редактирования", padEdit:"Открыть пад для редактирования",
padRefresh:"Обновить из пада",
padWait:"Ещё {seconds} с — Etherpad ограничивает частоту",
padRateLimitWarn:"Пока не обновлено: Etherpad разрешает лишь несколько загрузок за окно времени (по умолчанию 10 за 90 с), поэтому столь частая синхронизация невозможна. Повторите через {seconds} с.",
riskTooltip:"Высокий риск – оценка ещё не ясна.", riskTooltip:"Высокий риск – оценка ещё не ясна.",
discardedTooltip:"Показать/скрыть отклонённые узлы вместе с поддеревом", discardedTooltip:"Показать/скрыть отклонённые узлы вместе с поддеревом",
cheapTooltip:"Выделить самый дешёвый путь — ненужные альтернативы приглушаются", cheapTooltip:"Выделить самый дешёвый путь — ненужные альтернативы приглушаются",
@@ -1196,6 +1219,7 @@ const I18N = {
st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено", st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено",
unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.", unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.",
sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).", sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"«{url}» не ответил за {seconds} с — запрос прерван. Etherpad ограничивает частоту загрузки экспорта (по умолчанию 10 раз за 90 с); подождите немного и обновите снова.",
a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yOptional:"необязательно", a11yLink:"со ссылкой", a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yOptional:"необязательно", a11yLink:"со ссылкой",
hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.", hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну", hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
@@ -1218,6 +1242,9 @@ const I18N = {
jumpHint:"Alt+क्लिक: टेक्स्ट में उस पंक्ति पर जाएँ", jumpHint:"Alt+क्लिक: टेक्स्ट में उस पंक्ति पर जाएँ",
padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।", padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।",
padEdit:"संपादित करने के लिए पैड खोलें", padEdit:"संपादित करने के लिए पैड खोलें",
padRefresh:"पैड से फिर लोड करें",
padWait:"{seconds} स॰ बाकी — Etherpad बार-बार लेने की सीमा रखता है",
padRateLimitWarn:"अभी दोबारा लोड नहीं किया: Etherpad प्रति समय-खिड़की केवल कुछ ही बार लेने देता है (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार), इसलिए इतनी बार सिंक करना संभव नहीं है। {seconds} स॰ में फिर कोशिश करें।",
riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।", riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।",
discardedTooltip:"अस्वीकृत नोड्स और उनके उप-वृक्ष दिखाएँ/छिपाएँ", discardedTooltip:"अस्वीकृत नोड्स और उनके उप-वृक्ष दिखाएँ/छिपाएँ",
cheapTooltip:"सबसे किफ़ायती पथ को उजागर करें – अनावश्यक विकल्प मंद हो जाते हैं", cheapTooltip:"सबसे किफ़ायती पथ को उजागर करें – अनावश्यक विकल्प मंद हो जाते हैं",
@@ -1250,6 +1277,7 @@ const I18N = {
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत", st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत",
unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।", unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।",
sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।", sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।",
sourceTimeoutWarn:"„{url}“ ने {seconds} स॰ में उत्तर नहीं दिया — अनुरोध रद्द कर दिया गया। Etherpad सीमित करता है कि एक्सपोर्ट कितनी बार लिया जा सके (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार); कुछ क्षण रुकें, फिर दोबारा लोड करें।",
a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yOptional:"वैकल्पिक", a11yLink:"लिंक सहित", a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yOptional:"वैकल्पिक", a11yLink:"लिंक सहित",
hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।", hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें", hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
@@ -1277,6 +1305,9 @@ const I18N = {
jumpHint:"Alt+点击:跳转到文本中的该行", jumpHint:"Alt+点击:跳转到文本中的该行",
padReadonly:"在 Pad 中编辑 — 此处只读。", padReadonly:"在 Pad 中编辑 — 此处只读。",
padEdit:"打开 Pad 进行编辑", padEdit:"打开 Pad 进行编辑",
padRefresh:"从 Pad 重新加载",
padWait:"还需 {seconds} 秒 — Etherpad 限制获取频率",
padRateLimitWarn:"尚未重新加载:Etherpad 每个时间窗口只允许少量获取(默认每 90 秒 10 次),因此无法如此频繁地同步。请在 {seconds} 秒后再试。",
riskTooltip:"高风险 – 工作量尚不明确。", riskTooltip:"高风险 – 工作量尚不明确。",
editorTitle:"结构(文本)", diagramTitle:"图表", editorTitle:"结构(文本)", diagramTitle:"图表",
docSwitchTooltip:"选择或管理文档", docMenuAria:"文档", docSwitchTooltip:"选择或管理文档", docMenuAria:"文档",
@@ -1304,6 +1335,7 @@ const I18N = {
st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃", st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃",
unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。", unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。",
sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORSAccess-Control-Allow-Origin)。", sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORSAccess-Control-Allow-Origin)。",
sourceTimeoutWarn:"“{url}” 在 {seconds} 秒内没有响应 — 请求已中止。Etherpad 会限制导出的获取频率(默认每 90 秒 10 次);请稍候再重新加载。",
a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yOptional:"可选", a11yLink:"含链接", a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yOptional:"可选", a11yLink:"含链接",
hint_indent:"缩进(2 个空格或制表符)定义层级。", hint_indent:"缩进(2 个空格或制表符)定义层级。",
hint_all:"子任务,全部必需", hint_any:"备选项,择其一", hint_all:"子任务,全部必需", hint_any:"备选项,择其一",
@@ -1331,6 +1363,9 @@ const I18N = {
jumpHint:"Alt+クリック:テキストの該当行へ移動", jumpHint:"Alt+クリック:テキストの該当行へ移動",
padReadonly:"パッドで編集します — ここでは読み取り専用です。", padReadonly:"パッドで編集します — ここでは読み取り専用です。",
padEdit:"編集するにはパッドを開く", padEdit:"編集するにはパッドを開く",
padRefresh:"パッドから再読み込み",
padWait:"あと {seconds} 秒 — Etherpad は取得頻度を制限します",
padRateLimitWarn:"まだ再読み込みしていません: Etherpad は一定時間内の取得回数を制限します(既定で 90 秒あたり 10 回)。これほど頻繁な同期はできません。{seconds} 秒後にもう一度お試しください。",
riskTooltip:"高リスク – 規模はまだ不明。", riskTooltip:"高リスク – 規模はまだ不明。",
editorTitle:"構造(テキスト)", diagramTitle:"ダイアグラム", editorTitle:"構造(テキスト)", diagramTitle:"ダイアグラム",
docSwitchTooltip:"ドキュメントを選択・管理", docMenuAria:"ドキュメント", docSwitchTooltip:"ドキュメントを選択・管理", docMenuAria:"ドキュメント",
@@ -1358,6 +1393,7 @@ const I18N = {
st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄", st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄",
unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。", unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。",
sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORSAccess-Control-Allow-Origin)を許可する必要があります。", sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORSAccess-Control-Allow-Origin)を許可する必要があります。",
sourceTimeoutWarn:"「{url}」が {seconds} 秒以内に応答しませんでした — 要求を中止しました。Etherpad はエクスポートの取得回数を制限します(既定で 90 秒あたり 10 回)。少し待ってから再読み込みしてください。",
a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yOptional:"任意", a11yLink:"リンクあり", a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yOptional:"任意", a11yLink:"リンクあり",
hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。", hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。",
hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ", hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ",
@@ -1781,6 +1817,7 @@ function deleteDoc(){
const d = activeDoc(); const d = activeDoc();
if(!d) return; if(!d) return;
if(!window.confirm(t('docDeleteConfirm', {name: d.name}))) return; if(!window.confirm(t('docDeleteConfirm', {name: d.name}))) return;
if(padSource && padSource.id === d.id) stopPad(); /* danach gibt es nichts mehr zu holen (D31) */
docs = docs.filter(x => x.id !== d.id); docs = docs.filter(x => x.id !== d.id);
if(!docs.length) docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: INITIAL }]; if(!docs.length) docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: INITIAL }];
activeId = docs[0].id; activeId = docs[0].id;
@@ -1814,7 +1851,6 @@ function initDocs(){
Etherpads Aufgabe ist. Ein Fetch-Pfad, zwei Eingänge. */ Etherpads Aufgabe ist. Ein Fetch-Pfad, zwei Eingänge. */
const SOURCE_PARAM = 'sourceUrl'; const SOURCE_PARAM = 'sourceUrl';
const ETHERPAD_PARAM = 'etherpad'; const ETHERPAD_PARAM = 'etherpad';
const PAD_POLL_MS = 2500;
function urlParam(name){ function urlParam(name){
try{ return new URLSearchParams(location.search).get(name); }catch(_){ return null; } try{ return new URLSearchParams(location.search).get(name); }catch(_){ return null; }
} }
@@ -1855,6 +1891,21 @@ async function fetchRemote(url, timeoutMs){
return await resp.text(); return await resp.text();
} finally { if(timer) clearTimeout(timer); } } finally { if(timer) clearTimeout(timer); }
} }
/* Geholten Text als Dokument übernehmen und aktivieren. Gemeinsam genutzt vom
ersten Ladeversuch und vom Pad-Takt, wenn dieser einen gescheiterten ersten
Versuch nachholt — sonst gäbe es zwei Fassungen derselben sechs Zeilen. */
function adoptRemote(s, text){
flushActive(); /* laufende Bearbeitung nicht verlieren */
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(s.id, text); /* was ist seit dem letzten Ansehen in Produktion? (D28) */
loadActiveIntoEditor();
persistDocs();
}
async function loadRemoteSource(){ async function loadRemoteSource(){
const s = remoteSource(); const s = remoteSource();
if(!s) return; if(!s) return;
@@ -1863,67 +1914,126 @@ async function loadRemoteSource(){
render(); render();
return; return;
} }
/* `padSource` **vor** dem ersten Abruf setzen: Es trägt den Schreibschutz
(muss also vor loadActiveIntoEditor() stehen) und macht den Neu-laden-Knopf
erreichbar. Scheitert der erste Abruf — die Gegenseite fällt beobachtbar mal
aus —, bleibt der Knopf also da und man kommt ohne Neuladen weiter. */
if(s.live){ padSource = s; padBusy = true; setPadCooldown(PAD_MIN_GAP_MS); }
try{ try{
const text = await fetchRemote(s.fetchUrl); adoptRemote(s, await fetchRemote(s.fetchUrl));
flushActive(); /* laufende Bearbeitung nicht verlieren */
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;
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){ }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.
Bei einem Pad ist das nicht das Ende: der Knopf holt es nach und räumt die
Warnung weg, sobald ein Abruf gelingt. */
sourceWarning = {type:'sourceLoad', url: s.fetchUrl, error: (err && err.message) || String(err)}; sourceWarning = {type:'sourceLoad', url: s.fetchUrl, error: (err && err.message) || String(err)};
updatePadLink(); /* Knopf zeigen, obwohl kein Dokument entstanden ist */
render(); render();
}finally{
if(s.live) padBusy = false;
} }
} }
/* ---------- Pad: regelmäßig neu holen (D31) ---------- /* ---------- Pad: auf Knopfdruck neu holen (D31) ----------
Stumm bei Fehlschlägen — der erste Ladeversuch hat bereits gewarnt, ein Kein Hintergrund-Takt. Der erste Entwurf holte alle 2,5 s selbsttätig — und
Netzaussetzer soll den Warnbereich nicht fluten. Nicht abrufen, solange der lief damit in Etherpads **Drosselung**: `importExportRateLimiting` ist
Tab im Hintergrund liegt: der Export rendert jedes Mal das ganze Pad, und die serienmäßig an und lässt 10 Abrufe je 90 s und IP zu, der Takt wollte 36.
Gegenseite ist fremde Infrastruktur. */ Danach hält die Gegenseite die Verbindung einfach offen (kein `429`, keine
const PAD_FETCH_TIMEOUT_MS = 10000; Kopfzeilen), der Abbruch reißt sie ab — im Netzwerk-Mitschnitt „cancelled",
und es kommt kaum je etwas an. Ein Knopf ist ohnehin ehrlicher gegenüber
fremder Infrastruktur: geholt wird, wenn jemand es will. Zusammen mit
„Was ist neu?" (D28) ergibt das die bessere Geschichte — Knopf drücken, und
was seither in Produktion ging, leuchtet auf. */
const PAD_FETCH_TIMEOUT_MS = 20000;
/* Mindestabstand zwischen zwei Abrufen. 10 s ergibt höchstens **9** Abrufe je
90 s und bleibt damit beweisbar unter Etherpads Voreinstellung (10 je 90 s) —
die Drosselung wird also gar nicht erst ausgelöst. Nach einem Abbruch sind wir
schon drüber: dann das ganze Fenster abwarten, statt weiter dagegen zu rennen. */
const PAD_MIN_GAP_MS = 10000;
const PAD_BACKOFF_MS = 90000;
let padSource = null; /* Beschreiber der aktiven Pad-Quelle, oder null */ let padSource = null; /* Beschreiber der aktiven Pad-Quelle, oder null */
let padTimer = null; let padBusy = false; /* ein Abruf unterwegs — schützt vor Doppelklick */
let padPending = null; /* geholter, noch nicht übernommener Text (Stabilitätstakt) */ let padNextAllowed = 0; /* Zeitpunkt (ms), ab dem wieder geholt werden darf */
let padBusy = false; /* höchstens ein Abruf unterwegs (siehe pollPad) */ let padCoolTimer = null;
function startPadPolling(){ /* Wer das Pad-Dokument löscht, meint es: danach gibt es nichts mehr zu holen. */
if(padTimer) clearInterval(padTimer); function stopPad(){ padSource = null; }
padTimer = setInterval(pollPad, PAD_POLL_MS); function padCoolLeft(){ return Math.max(0, padNextAllowed - Date.now()); }
function setPadCooldown(ms){ padNextAllowed = Date.now() + ms; tickPadCooldown(); }
/* Der Sekundenzähler läuft **nur** während der Sperre und hält sich selbst an. */
function tickPadCooldown(){
if(padCoolTimer){ clearInterval(padCoolTimer); padCoolTimer = null; }
updatePadRefreshLabel();
if(padCoolLeft() <= 0) return;
padCoolTimer = setInterval(() => {
updatePadRefreshLabel();
if(padCoolLeft() <= 0){ clearInterval(padCoolTimer); padCoolTimer = null; }
}, 1000);
} }
/* Bei Rückkehr in den sichtbaren Zustand sofort holen: nach einer langen Pause /* Sperre sichtbar machen, aber den Knopf **klickbar** lassen: Ein `disabled`
stünde sonst bis zum nächsten Takt ein veralteter Stand da. */ erklärt nichts. So kann der Klick den Grund melden (Live-Region `#warn`). */
document.addEventListener('visibilitychange', () => { function updatePadRefreshLabel(){
if(document.visibilityState === 'visible' && padSource) pollPad(); if(!padRefreshBtn || padRefreshBtn.hidden) return;
}); const left = Math.ceil(padCoolLeft()/1000);
async function pollPad(){ padRefreshBtn.classList.toggle('cooling', left > 0);
padRefreshBtn.setAttribute('aria-disabled', left > 0 ? 'true' : 'false');
const tip = left > 0 ? t('padWait', {seconds: left}) : t('padRefresh');
padRefreshBtn.title = tip;
padRefreshBtn.setAttribute('aria-label', tip);
}
/* Rückmeldung während des Abrufs: Bei einer gedrosselten Gegenseite können das
die vollen 20 s sein — ohne Zeichen wirkt der Knopf kaputt. */
function setPadBusy(on){
if(!padRefreshBtn) return;
padRefreshBtn.classList.toggle('busy', on);
padRefreshBtn.setAttribute('aria-busy', on ? 'true' : 'false');
}
async function refreshPad(){
const s = padSource; const s = padSource;
/* `padBusy`: höchstens ein Abruf unterwegs. Ist die Gegenseite langsamer als if(!s || padBusy) return;
der Takt, stapelten sich sonst die Anfragen — und eine spät eintreffende if(padCoolLeft() > 0){
alte Antwort überschriebe neueren Text (im Netzwerk-Mitschnitt beobachtet). */ /* Nicht heimlich nichts tun: Der Grund ist eine fremde Grenze, und die
if(!s || padBusy || document.visibilityState === 'hidden') return; gehört gesagt — sonst wirkt der Knopf kaputt. Die Meldung landet im
const d = docs.find(x => x.id === s.id); Warnbereich, der eine Live-Region ist und deshalb angesagt wird. */
if(!d) return; /* Dokument gelöscht — nichts nachtragen */ sourceWarning = {type:'padRateLimit', seconds: Math.ceil(padCoolLeft()/1000)};
let text; render();
return;
}
padBusy = true; padBusy = true;
setPadBusy(true);
setPadCooldown(PAD_MIN_GAP_MS);
let text;
try{ text = await fetchRemote(s.fetchUrl, PAD_FETCH_TIMEOUT_MS); } try{ text = await fetchRemote(s.fetchUrl, PAD_FETCH_TIMEOUT_MS); }
catch(_){ return; } /* stumm; `finally` gibt den Riegel frei */ catch(err){
finally{ padBusy = false; } /* Anders als ein Hintergrund-Takt ist das eine bewusste Handlung — sie
if(text === d.text){ padPending = null; return; } braucht eine Antwort, auch wenn sie schiefgeht. Ein Abbruch bekommt einen
/* Stabilitätstakt: erst übernehmen, wenn zwei Abrufe hintereinander denselben **eigenen** Warnungstyp: `sourceLoad` zeigt auf CORS, und bei einer
neuen Text liefern. Sonst sieht man die anderen mitten im Tippen, und eine Drosselung schickte das den Leser auf die falsche Fährte. */
halb geschriebene Zeile ist eine kaputte Zeile — Diagramm und Warnungen if(err && err.name === 'AbortError'){
flackerten. Kostet im Mittel einen Takt. */ /* Abbruch heißt: Wir sind schon in der Drosselung. Weiterklicken hilft
if(padPending !== text){ padPending = text; return; } nicht, also das ganze Fenster abwarten — und das dem Nutzer sagen. */
padPending = null; setPadCooldown(PAD_BACKOFF_MS);
sourceWarning = {type:'sourceTimeout', url: s.fetchUrl,
seconds: Math.round(PAD_FETCH_TIMEOUT_MS/1000)};
} else {
sourceWarning = {type:'sourceLoad', url: s.fetchUrl, error: (err && err.message) || String(err)};
}
render();
return;
}
finally{ padBusy = false; setPadBusy(false); }
if(padRefreshBtn) flashBtn(padRefreshBtn);
const d = docs.find(x => x.id === s.id);
if(!d){
/* Kein Dokument: der erste Ladeversuch ist gescheitert. Jetzt nachholen —
anlegen und aktivieren wie beim Laden. */
adoptRemote(s, text);
return;
}
/* Ein geglückter Abruf räumt die Warnung des ersten Versuchs weg — sonst
stünde „konnte nicht geladen werden", während der Text längst da ist. */
const hadWarning = !!sourceWarning;
sourceWarning = null;
if(text === d.text){ if(hadWarning) render(); return; }
d.text = text; d.text = text;
persistDocs(); persistDocs();
if(activeId !== d.id) return; /* im Hintergrund still aktualisiert */ if(activeId !== d.id) return; /* im Hintergrund still aktualisiert */
@@ -1942,20 +2052,31 @@ async function pollPad(){
schreibgeschützt, Knopf in der Titelzeile öffnet das Pad im neuen Tab. Ohne schreibgeschützt, Knopf in der Titelzeile öffnet das Pad im neuen Tab. Ohne
den Schutz verschwände getippter Text beim nächsten Abruf. */ den Schutz verschwände getippter Text beim nächsten Abruf. */
const padLink = document.getElementById('padLink'); const padLink = document.getElementById('padLink');
const padRefreshBtn = document.getElementById('padRefresh');
function updatePadLink(){ function updatePadLink(){
const d = activeDoc(); const d = activeDoc();
const isPad = !!(d && padSource && d.id === padSource.id); const isPad = !!(d && padSource && d.id === padSource.id);
src.readOnly = isPad; src.readOnly = isPad;
src.classList.toggle('readonly', isPad); src.classList.toggle('readonly', isPad);
if(isPad) src.title = t('padReadonly'); else src.removeAttribute('title'); if(isPad) src.title = t('padReadonly'); else src.removeAttribute('title');
if(!padLink) return; if(padLink){
padLink.hidden = !isPad; padLink.hidden = !isPad;
if(!isPad) return; if(isPad){
padLink.href = padSource.source; padLink.href = padSource.source;
const tip = t('padEdit'); padLink.title = t('padEdit');
padLink.title = tip; padLink.setAttribute('aria-label', t('padEdit'));
padLink.setAttribute('aria-label', tip); }
}
/* Der Neu-laden-Knopf erscheint auch, wenn es das Pad-Dokument noch **nicht**
gibt: Dann ist der erste Abruf gescheitert, und genau dieser Knopf ist der
Weg zurück — ohne ihn bliebe nur Neuladen der Seite. */
if(padRefreshBtn){
const pending = !!(padSource && !docs.some(x => x.id === padSource.id));
padRefreshBtn.hidden = !(isPad || pending);
updatePadRefreshLabel(); /* trägt auch den laufenden Sperr-Zähler */
}
} }
if(padRefreshBtn) padRefreshBtn.addEventListener('click', refreshPad);
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. */
+14 -1
View File
@@ -332,7 +332,20 @@
/* im Pad bearbeiten" (D31) nur bei einem ?etherpad=-Dokument sichtbar. /* im Pad bearbeiten" (D31) nur bei einem ?etherpad=-Dokument sichtbar.
Das [hidden] braucht die eigene Regel, weil .copybtn ein display setzt. */ Das [hidden] braucht die eigene Regel, weil .copybtn ein display setzt. */
.padlink{text-decoration:none} .padlink{text-decoration:none}
.padlink[hidden]{display:none} .padlink[hidden],.padrefresh[hidden]{display:none}
/* Läuft ein Abruf, dreht das Symbol: bei gedrosselter Gegenseite (D31) können
das die vollen 20 s sein, und ein Knopf ohne Rückmeldung wirkt kaputt. */
/* Sperre nach einem Abruf (D31): sichtbar gedämpft, aber **klickbar** der
Klick meldet den Grund, ein `disabled` erklärte nichts. */
.padrefresh.cooling{opacity:.45;cursor:not-allowed}
.padrefresh.busy{color:var(--or);border-color:var(--or);pointer-events:none}
.padrefresh.busy.cooling{opacity:1;cursor:default}
.padrefresh.busy svg{animation:padspin 1.1s linear infinite}
@keyframes padspin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){
.padrefresh.busy svg{animation:none}
.padrefresh.busy{opacity:.55}
}
.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.
+12 -1
View File
@@ -10,7 +10,14 @@
- mixedGate { line, label } Geschwister mit gemischtem Gate (SPEC §3) - mixedGate { line, label } Geschwister mit gemischtem Gate (SPEC §3)
- unknownStatus { line, code } unbekanntes Statuszeichen (Phase 2) - unknownStatus { line, code } unbekanntes Statuszeichen (Phase 2)
- sourceLoad { url, error } ?sourceUrl= nicht ladbar (D23); ohne - sourceLoad { url, error } ?sourceUrl= nicht ladbar (D23); ohne
Zeilennummer, erscheint dadurch zuoberst */ Zeilennummer, erscheint dadurch zuoberst
- padRateLimit { seconds } zu früh nachgeladen; Werkbaum hat gar nicht
erst geholt, um Etherpads Grenze nicht
auszulösen (D31)
- sourceTimeout { url, seconds } Abruf abgebrochen, Gegenseite zu langsam
(D31). Eigener Typ, weil `sourceLoad` auf
CORS zeigt bei einem Zeitablauf schickt
das den Leser auf die falsche Fährte */
import { esc } from './render.js'; import { esc } from './render.js';
@@ -24,6 +31,10 @@ export function formatWarning(w, t){
return t('unknownStatusWarn', {line: w.line, code: esc(w.code)}); return t('unknownStatusWarn', {line: w.line, code: esc(w.code)});
case 'sourceLoad': case 'sourceLoad':
return t('sourceLoadWarn', {url: esc(w.url), error: esc(w.error)}); return t('sourceLoadWarn', {url: esc(w.url), error: esc(w.error)});
case 'padRateLimit':
return t('padRateLimitWarn', {seconds: w.seconds});
case 'sourceTimeout':
return t('sourceTimeoutWarn', {url: esc(w.url), seconds: w.seconds});
default: default:
return `${esc(String(w.type))} (${w.line ?? '?'})`; return `${esc(String(w.type))} (${w.line ?? '?'})`;
} }
+19
View File
@@ -16,6 +16,25 @@ describe('formatWarning — vereinheitlichtes Warnungs-Modell', () => {
expect(out).toBe('unknownStatusWarn|{"line":3,"code":"&lt;"}'); expect(out).toBe('unknownStatusWarn|{"line":3,"code":"&lt;"}');
}); });
it('sourceLoad escaped die URL und reicht das technische Detail durch', () => {
const out = formatWarning({type: 'sourceLoad', url: 'https://a/?x=1&y=2', error: 'HTTP 404'}, t);
expect(out).toBe('sourceLoadWarn|{"url":"https://a/?x=1&amp;y=2","error":"HTTP 404"}');
});
/* Eigener Typ, weil sourceLoadWarn auf CORS zeigt: Bei Etherpads Drosselung
(10 Abrufe je 90 s) schickte das den Leser auf die falsche Fährte (D31). */
it('sourceTimeout ist ein eigener Typ und nennt die Sekunden', () => {
const out = formatWarning({type: 'sourceTimeout', url: 'https://p/x&y', seconds: 20}, t);
expect(out).toBe('sourceTimeoutWarn|{"url":"https://p/x&amp;y","seconds":20}');
});
/* Kein `url` im Objekt: Der Nutzer hat gar nichts angefordert, was scheitern
konnte Werkbaum hat den Abruf verhindert (D31). */
it('padRateLimit nennt die Restzeit und braucht keine URL', () => {
expect(formatWarning({type: 'padRateLimit', seconds: 7}, t))
.toBe('padRateLimitWarn|{"seconds":7}');
});
it('unbekannter Typ fällt auf eine generische, escapte Meldung zurück', () => { it('unbekannter Typ fällt auf eine generische, escapte Meldung zurück', () => {
expect(formatWarning({type: 'was?', line: 9}, t)).toBe('was? (9)'); expect(formatWarning({type: 'was?', line: 9}, t)).toBe('was? (9)');
expect(formatWarning({type: 'x'}, t)).toBe('x (?)'); expect(formatWarning({type: 'x'}, t)).toBe('x (?)');