feat: Etherpad-Anbindung ausgebaut (D78)

D31 lieh sich fuer die Echtzeit-Zusammenarbeit ein Etherpad: Das Pad war die
Schreibflaeche, Werkbaum die Ansicht. Richtig fuer den Zeitpunkt - die schwere
Arbeit war dort getan, und Werkbaum hatte kein Backend. Jetzt hat es eins
(D76), das dasselbe besser und im Editor selbst kann.

D31 bleibt stehen: Was dort gemessen wurde, ist der Grund, warum die Anbindung
nie gut wurde - Export-Drosselung (10 je 90 s), SameSite=Lax beim
Autoren-Cookie, abgelehnter Socket-Upgrade. Uebrig blieb ein Neu-laden-Knopf
und ein schreibgeschuetztes Textfeld.

Ein alter ?etherpad=-Link meldet sich, statt still nichts zu tun: Der Parameter
bleibt erkannt und ergibt die zeilenlose Warnung padGone, die auf ?live= zeigt
(neun Sprachen). Geholt wird nichts - nachgemessen geht keine Anfrage mehr an
den Pad-Host. Vorhandene Pad-Dokumente bleiben liegen und werden dabei besser:
ohne Schreibschutz sind sie bearbeitbar und sammeln fruehere Staende.

Mit ausgebaut, weil sie nur der Anbindung dienten: remote.js samt Tests, der
Ansichts-Waehler und sein Splitter, der Neu-laden-Knopf samt
Drosselungs-Zaehler, die Warnungen sourceTimeout/padRateLimit, elf i18n-
Schluessel x 9 Sprachen - und src.readOnly ganz, denn nur Pad-Dokumente haben
es gesetzt (mit ihm updateSnapBtn und sieben tote Waechter). Auch #srcArea
faellt weg: den Kasten gab es nur, damit Pad-Rahmen und Spiegel sich einen
Bereich teilen. ?sourceUrl= bleibt unangetastet - beide teilten sich einen
Fetch-Pfad, das war die Stelle, an der nichts verrutschen durfte.

Im Plan wird aus dem #col.pad-Zweig (14 Knoten samt der ganzen
"Update by itself"-Gruppe) ein verworfener Knoten mit Begruendung; der
gemeinsame Zeigefinger wandert als #col.point heraus, denn !!! gilt jedem
geteilten Dokument. 196 Knoten, 0 Warnungen.

Nebengewinn: Der README bekommt endlich den Abschnitt zu ?live= - den gab es
nie, die Einstiegs-Beschreibung stand weiter beim Pad.

Nachgemessen im Browser: ?etherpad= zeigt die Warnung ohne Netz-Anfrage,
?sourceUrl= laedt unveraendert und ist beschreibbar, Textfeld und
Zahlenstreifen liegen buendig, Legenden-Splitter teilt wie zuvor (954+10+300),
Falten und Staende funktionieren. 501 Tests, 946 Zeilen weniger.
This commit is contained in:
mhoennig
2026-08-26 20:31:33 +02:00
parent 911a100df4
commit bfacbe96e5
17 changed files with 267 additions and 934 deletions
+29 -43
View File
@@ -88,60 +88,46 @@ 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) ### Zusammen an einem Plan arbeiten (`?live=`)
Für Zusammenarbeit in Echtzeit braucht Werkbaum kein eigenes Backend — es leiht Liegt der Plan auf einem Werkbaum-Backend, bearbeiten ihn alle **im Editor
sich ein **Etherpad**. Angegeben wird die Pad-Adresse, wie sie im Browser steht selbst** und sehen die Änderungen der anderen, ohne neu zu laden:
(ohne Export-Pfad, den hängt Werkbaum selbst an):
``` ```
https://werkbaum.javagil.de/?etherpad=https://pad.hostsharing.net/p/mein-plan https://werkbaum.javagil.de/?live=https://werkbaum.javagil.de/api/v1/documents/<uuid>
``` ```
Alle bearbeiten den Notationstext **im Pad**; ein Neu-laden-Knopf neben dem Angelegt wird so ein Dokument über den Eintrag **„Auf den Server legen"** im
Pad-Knopf holt den aktuellen Stand. Das Zusammenführen gleichzeitiger Änderungen Dokumenten-Menü: Er lädt den aktiven Plan hoch, schaltet dorthin um und schreibt
macht Etherpad — genau darum geht es. den Link in die Adresszeile und in die Zwischenablage. Diesen Link weitergeben —
die Adresse ist die Einladung, und wer sie kennt, kommt hinein.
Bewusst **kein** Abrufen im Hintergrund: Etherpad drosselt den Export **Das Textfeld bleibt beschreibbar.** Nach 1,5 s Ruhe schickt der Editor die
(serienmäßig 10 Abrufe je 90 s und IP), ein Takt gewinnt dagegen nicht — er Änderung als Zeilen-Diff; ein offener Abruf hält die Gegenrichtung bereit und
*erzeugt* die Drosselung. Nachgemessen: Nach Überschreiten des Budgets hält der spielt fremde Änderungen ein. **Die Schreibmarke wandert mit** — fügt jemand
Server die Verbindung rund zwei Minuten ohne Antwort offen und liefert dann in oberhalb Zeilen ein, bleibt sie an ihrer Stelle im Text.
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**; Der **Name** ist der Titel des Dokuments (alle sehen denselben), die vollständige
ein Knopf in der Editor-Titelzeile öffnet das Pad im neuen Tab. Ohne den Schutz Adresse steht im Tooltip.
verschwände getippter Text beim nächsten Abruf.
Getestet gegen Etherpad: Der Klartext-Export sendet **Überschneiden sich zwei Änderungen wirklich** — dieselben Zeilen —, fragt ein
`Access-Control-Allow-Origin: *`, und die Notation kommt **byte-identisch** Band oben, wessen Fassung gelten soll: *Fremde übernehmen* oder *Eigene
zurück — führende Leerzeichen, `-`/`+`/`|`, Statusboxen und `%%` überleben durchsetzen*. Alles andere führt der Server selbst zusammen, ohne zu fragen.
Etherpads Speichermodell, das `-` wird nicht zur Aufzählung umgedeutet. Verloren geht nichts: Der verworfene Stand bleibt in den früheren Ständen, jede
Version in der Historie des Servers.
Das Pad lässt sich auch **im Editor-Panel einbetten**: Ein Wähler in der **Ein gemeinsamer Zeigefinger:** `!!!` auf einer Zeile hebt diesen Knoten hervor
Titelzeile schaltet reihum zwischen *Pad und Text* (durch einen Splitter frei und holt ihn ins Bild — bei **allen**, die das Dokument ansehen. Das kann ein
geteilt), *nur Pad* und *nur Text*. Der schmal gezogene Textspiegel behält seinen Cursor nicht. Erkannt nur als alleinstehendes Token, `Achtung!!!` bleibt also ein
Zweck — der Sprung zwischen Diagramm und Text arbeitet auf ihm, und in *nur Pad* gewöhnliches Label. Die Marke bleibt stehen, bis jemand sie löscht.
holt ein Sprung ihn selbst zurück. Der Rahmen wird nur geladen, solange er
sichtbar ist, denn ein geladenes Pad verbindet sich und zeigt dich in dessen
Anwesenden-Liste.
**Der gemeinsame Zeigefinger:** Schreib `!!!` in eine Zeile, und dieser Knoten Das Backend einrichten: siehe [backend/README.md](backend/README.md) und
wird hervorgehoben und ins Bild geholt — bei **allen**, die auf das Pad schauen. `docs/DECISIONS.md` D76.
Das kann ein Cursor nicht. Erkannt nur als alleinstehendes Token, `Achtung!!!`
bleibt also ein gewöhnliches Label. Die Marke bleibt stehen, bis jemand sie
löscht.
**Bedenke:** Der Plantext liegt damit auf fremder Infrastruktur, und ein Pad ist > Dafür lieh sich Werkbaum früher ein **Etherpad** (`?etherpad=`). Das ist
für jeden lesbar, der die Adresse kennt. Im eingebetteten Rahmen wird Etherpads > ausgebaut — das Backend kann dasselbe besser und im Editor selbst. Ein alter
Autoren-Cookie (`SameSite=Lax`) nicht mitgesendet — man gilt bei jedem Laden als > `?etherpad=`-Link zeigt heute einen Hinweis hierher, statt still nichts zu
neuer Autor, was nur serverseitig zu beheben ist (`cookie.sameSite: "None"`). > tun; siehe `docs/DECISIONS.md` D78.
Aus demselben Grund ist der eingebettete Rahmen vor allem zum **Mitlesen** gut:
Sollte das Bearbeiten darin einmal aufhören zu funktionieren, den Ansichts-Wähler
einmal durchschalten (das lädt den Rahmen neu) oder über „im Pad bearbeiten" im
eigenen Tab weiterarbeiten, wo das Cookie gilt. Siehe `docs/DECISIONS.md` D31
und D32.
### Lokal ausführen ### Lokal ausführen
+28 -39
View File
@@ -83,57 +83,46 @@ 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) ### Working on one plan together (`?live=`)
For real-time collaboration Werkbaum needs no backend of its own — it borrows an If the plan lives on a Werkbaum backend, everyone edits it **in the editor
**Etherpad**. Pass the pad address as it appears in your browser (no export path, itself** and sees the others' changes without reloading:
Werkbaum appends that itself):
``` ```
https://werkbaum.javagil.de/?etherpad=https://pad.hostsharing.net/p/my-plan https://werkbaum.javagil.de/?live=https://werkbaum.javagil.de/api/v1/documents/<uuid>
``` ```
Everyone edits the notation text **in the pad**; a reload button next to the pad To create such a document, open the document menu and pick **"Put on the
button fetches the current state. Merging simultaneous edits is Etherpad's job — server"**: it uploads the active plan, switches to it, and puts the link in the
that is the whole point. address bar and on your clipboard. Share that link — the address is the
invitation, and knowing it is what grants access.
Deliberately **no background polling**: Etherpad rate-limits the export (10 The text area stays **writable**. After 1.5 s of quiet the editor sends the
fetches per 90 s per IP by default), so a timer does not win against it — it change as a line diff; an open request holds the other direction ready and plays
*causes* the throttling. Measured: after exceeding the budget the server holds foreign changes in. **The caret travels with them** — if someone inserts lines
the connection open with no reply for about two minutes, then answers in 0.4 s. above you, it stays where it was in the text.
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 The document's **title** is its name (everyone sees the same one), the full
button in the editor title bar opens the pad in a new tab. Without that address sits in the tooltip.
protection, anything you typed would vanish on the next fetch.
Verified against Etherpad: the plain-text export sends When two changes really overlap — the same lines — a bar at the top asks whose
`Access-Control-Allow-Origin: *`, and the notation comes back **byte-identical** version should win: *take theirs* or *keep mine*. Everything else the server
leading spaces, `-`/`+`/`|`, status boxes and `%%` survive Etherpad's storage merges without asking. Nothing is lost either way: the discarded state stays in
model, and `-` is not turned into a bullet list. the earlier states, and every version is in the server's history.
The pad can also be **embedded** in the editor panel: a selector in the title bar
cycles through *pad and text* (split by a draggable divider), *pad only* and *text
only*. The narrow text mirror keeps its purpose — the jump between diagram and
text works on it, and in *pad only* a jump brings it back by itself. The frame is
only loaded while it is visible, because a loaded pad connects and shows you in
the pad's list of people.
**A shared pointer:** write `!!!` on a line and that node is highlighted and **A shared pointer:** write `!!!` on a line and that node is highlighted and
scrolled into view — for **everyone** looking at the pad, which is something a scrolled into view — for **everyone** looking at the document, which is
cursor cannot do. Recognised only as a standalone token, so `Careful!!!` stays an something a cursor cannot do. Recognised only as a standalone token, so
ordinary label. It stays in the text until someone deletes it. `Careful!!!` stays an ordinary label. It stays in the text until someone
deletes it.
**Be aware:** your plan text then lives on third-party infrastructure, and a pad Setting up the backend: see [backend/README.md](backend/README.md) and
is readable by anyone who knows its address. In an embedded frame Etherpad's `docs/DECISIONS.md` D76.
author cookie (`SameSite=Lax`) is not sent, so you count as a new author on every
load — fixable only on the server (`cookie.sameSite: "None"`).
For the same reason the **embedded frame is best for reading along**: if editing > Werkbaum used to borrow an **Etherpad** for this (`?etherpad=`). That is
in it ever stops working, cycle the view selector once (that reloads the frame), > removed — the backend does the same job better and in the editor itself. An
or use the "edit in the pad" button and work in the pad's own tab, where the > old `?etherpad=` link now shows a note pointing here rather than silently
cookie does apply. See `docs/DECISIONS.md` D31 and D32. > doing nothing; see `docs/DECISIONS.md` D78.
### Running it locally ### Running it locally
+1
View File
@@ -19,6 +19,7 @@ reverse.
## 2026-08-26 ## 2026-08-26
- The Etherpad integration is gone: collaboration now runs through a Werkbaum backend, and an old `?etherpad=` link says so instead of doing nothing
- Plans can live on a Werkbaum backend now: open `?live=<document URL>` and everyone edits the same text, seeing each other's changes without reloading - Plans can live on a Werkbaum backend now: open `?live=<document URL>` and everyone edits the same text, seeing each other's changes without reloading
- When two people change the same lines, a bar asks whose version should win — everything else the server merges by itself - When two people change the same lines, a bar asks whose version should win — everything else the server merges by itself
- The caret stays where you put it when someone else inserts lines above you - The caret stays where you put it when someone else inserts lines above you
+84
View File
@@ -6660,3 +6660,87 @@ Version), `frontend info` (1.1.162, Commit = HEAD), `backend status`,
`backend restart`, `backend backup` samt lokalem Wiederanlauf, `backend restart`, `backend backup` samt lokalem Wiederanlauf,
`frontend preview` (nichts geschrieben, HEAD unverändert) und die `frontend preview` (nichts geschrieben, HEAD unverändert) und die
Fehlerpfade (Exit-Code 2 bei unbekanntem Ziel und fehlender Aktion). Fehlerpfade (Exit-Code 2 bei unbekanntem Ziel und fehlender Aktion).
## D78 — Die Etherpad-Anbindung ist ausgebaut
D31 lieh sich für die Echtzeit-Zusammenarbeit ein **Etherpad**: Das Pad war die
Schreibfläche, Werkbaum die Ansicht. Das war die richtige Entscheidung für den
Zeitpunkt — die schwere Arbeit (gleichzeitige Änderungen zusammenführen) war
dort seit Jahren getan, und Werkbaum hatte kein Backend. **Jetzt hat es eins**
(D76), und das kann dasselbe besser und im Editor selbst. Also raus damit,
statt zwei Wege zur selben Sache zu pflegen.
**D31 bleibt stehen** — Entscheidungen werden nicht gelöscht, sondern
fortgeschrieben. Was dort gemessen wurde, gilt weiter und ist der Grund, warum
die Anbindung nie gut wurde: Etherpad **drosselt den Export** (serienmäßig 10
Abrufe je 90 s und IP), ein Hintergrund-Takt erzeugt die Drosselung, statt sie
zu umgehen; das Autoren-Cookie ist `SameSite=Lax` und kommt im eingebetteten
Rahmen nicht an, also ist man bei jedem Laden ein neuer Autor; und ein eigener
Socket zum Pad wurde von der Gegenseite abgelehnt (D31-Nachtrag, Code 1006).
Übrig blieb ein Neu-laden-Knopf und ein schreibgeschütztes Textfeld — die
Rückmeldung „funktioniert sowieso nicht gut" ist die ehrliche Zusammenfassung.
**Was `?live=` besser macht**, in derselben Reihenfolge: Es wird **im Editor
geschrieben** statt in einem fremden Rahmen; die Gegenrichtung ist ein offener
Abruf statt eines Takts gegen ein fremdes Limit; der Konflikt wird benannt
statt versteckt; und der Plantext liegt auf **eigener** Infrastruktur statt auf
einem Pad, das jeder lesen kann, der die Adresse kennt.
**Ein alter Link meldet sich, statt still nichts zu tun.** `?etherpad=` bleibt
als **erkannter** Parameter stehen und ergibt die zeilenlose Warnung `padGone`,
die auf `?live=` zeigt — in allen neun Sprachen. Der Parameter war geteilt: Wer
ihn irgendwo stehen hat (in einer Mail, einem Wiki, einem Lesezeichen), bekäme
sonst ein leeres Werkbaum ohne Erklärung. Genau der stille Fehler, den dieses
Projekt sonst überall ablehnt (SPEC §4, D59). Es ist bewusst **kein**
Rest-Feature: Geholt wird nichts, der Pad-Host sieht keine Anfrage mehr
(nachgemessen).
**Vorhandene Pad-Dokumente bleiben liegen — und werden dabei besser.** Sie sind
im localStorage gewöhnliche Dokumente (`{id, name, text, source}`); ohne den
Schreibschutz sind sie ab jetzt **bearbeitbar**, sammeln frühere Stände (D54)
und lassen sich falten. Ihr Text ist der zuletzt geholte. Nichts wird gelöscht,
niemand verliert seinen Plan.
**Der Schreibschutz verschwindet ganz, nicht nur seine Ursache.** `src.readOnly`
wurde ausschließlich von Pad-Dokumenten gesetzt; danach hätten sieben Wächter
in Falten, Kurz-IDs, Autovervollständigung und Ständen auf eine Bedingung
geprüft, die nie mehr wahr wird — mit Kommentaren, die auf D31 zeigen. Tote
Wächter mit veralteter Begründung sind schlechter als keine; ein künftiger
Lesemodus braucht ohnehin seine eigene Entscheidung. Mit ihm fällt
`updateSnapBtn()` weg: Der Knopf ist jetzt immer da.
**Eine Layout-Ebene weniger.** `#srcArea` gab es nur, damit Pad-Rahmen und
Textspiegel sich einen Bereich teilen konnten, ohne die Legenden-Aufteilung
(D26) anzufassen. Ohne Rahmen ist es ein Kasten mit einem Kind. Nachgemessen
nach dem Entfernen: `.editor-body` trägt jetzt direkt `srcWrap` · `hintGutter` ·
`agenda`, und die drei kacheln die Breite exakt (954 + 10 + 300 px) — der
Legenden-Splitter arbeitet unverändert.
**Mit ausgebaut**, weil sie nur der Anbindung dienten: `remote.js` samt seinen
Tests (die Pad-Adressen normalisieren), der Ansichts-Wähler und sein Splitter
(`--pcol`/`--prow`), der Neu-laden-Knopf samt Drosselungs-Zähler, die Warnungen
`sourceTimeout` und `padRateLimit`, elf i18n-Schlüssel × 9 Sprachen und der
Timeout-Parameter von `fetchRemote()`. **`?sourceUrl=` bleibt unangetastet**
(D23) — beide teilten sich einen Fetch-Pfad, und das war die eine Stelle, an der
beim Schneiden nichts verrutschen durfte.
**Der Plan sagt es auch.** Im mitgelieferten `werkbaum.werkbaum` wird aus dem
`#col.pad`-Zweig (14 Knoten samt der ganzen „Update by itself"-Gruppe, die nur
Etherpads Grenze umgehen wollte) **ein** verworfener Knoten `[-] #col.pad` mit
Begründung — dieselbe Form, in der dort schon `#bld.ghpages` und `#not.store`
stehen. Der eine Knoten, der weiterlebt, wandert heraus: `#col.pad.point` wird
`#col.point`, denn der gemeinsame Zeigefinger `!!!` gilt jedem geteilten
Dokument, nicht nur einem Pad. Danach 196 Knoten, 0 Warnungen.
**Nebengewinn: Der README bekommt endlich den Abschnitt zu `?live=`.** Den gab
es nie — D76 ist gebaut und dokumentiert (SPEC §9, DECISIONS), aber die
Einstiegs-Beschreibung stand weiter beim Pad. Das Ausbauen hätte sonst ein Loch
hinterlassen; jetzt steht dort in beiden Sprachen, wie man ein Server-Dokument
anlegt, teilt und was bei einem Konflikt passiert.
**Nachgemessen** im Browser: `?etherpad=…` zeigt die Warnung, und im
Netzwerk-Mitschnitt geht **keine** Anfrage an den Pad-Host; `?sourceUrl=` lädt
unverändert und das Dokument ist beschreibbar (früher: schreibgeschützt); das
Textfeld liegt mit dem Zahlenstreifen bündig (1151 + 20 px), die Zeilennummern
sitzen auf ihren Höhen, Pfad und Stationen werden gezeichnet; der
Legenden-Splitter teilt wie zuvor. 501 Tests (die 24 Pad-Adress-Tests sind mit
`remote.js` gegangen, `padGone` ist dazugekommen).
+1 -1
View File
@@ -112,7 +112,7 @@ Tastendruck ein Commit); und wer bei einem Backend eigentlich was darf
Zu klären ist das Zusammenspiel mit der Update-Anzeige (D45): Sitzt ein Zu klären ist das Zusammenspiel mit der Update-Anzeige (D45): Sitzt ein
Worker vor der Seite, entscheidet **er**, was ausgeliefert wird — „der Worker vor der Seite, entscheidet **er**, was ausgeliefert wird — „der
laufende Build gegen den, den der Server schickt" ist dann nicht mehr laufende Build gegen den, den der Server schickt" ist dann nicht mehr
dasselbe. Dokumente aus `?sourceUrl=`/`?etherpad=` bleiben naturgemäß dasselbe. Dokumente aus `?sourceUrl=`/`?live=` bleiben naturgemäß
online. Im Plan: `#bld.pwa`. online. Im Plan: `#bld.pwa`.
- Schlagworte `&tag` mit einer Filter-/Hervorheben-Linse im Diagramm: Der Baum - Schlagworte `&tag` mit einer Filter-/Hervorheben-Linse im Diagramm: Der Baum
ist genau eine Zerlegung — ein Tag benennt eine Menge von Knoten quer dazu ist genau eine Zerlegung — ein Tag benennt eine Menge von Knoten quer dazu
+17 -61
View File
@@ -155,7 +155,7 @@ ab, auch außerhalb seines eigenen Teilbaums.
**Fokusmarke `!!!`** — „schau hier hin": Der Knoten wird im Diagramm **Fokusmarke `!!!`** — „schau hier hin": Der Knoten wird im Diagramm
hervorgehoben und ins Bild geholt (§9). Gedacht für das gemeinsame Arbeiten an hervorgehoben und ins Bild geholt (§9). Gedacht für das gemeinsame Arbeiten an
einem Pad (§9, `?etherpad=`): Weil dort niemand den Cursor der anderen sieht, einem Dokument (§9, `?live=`): Weil dort niemand den Cursor der anderen sieht,
ist eine Marke **im Text** der einzige Weg, auf eine Stelle zu zeigen — und sie ist eine Marke **im Text** der einzige Weg, auf eine Stelle zu zeigen — und sie
hat etwas, das ein Cursor nicht hat: **alle** sehen dieselbe Stelle. hat etwas, das ein Cursor nicht hat: **alle** sehen dieselbe Stelle.
@@ -770,59 +770,18 @@ 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=`) ### Kein Etherpad mehr (`?etherpad=`, entfallen)
Für Zusammenarbeit in Echtzeit nimmt der Editor die Adresse eines Der Editor konnte den Notationstext aus einem **Etherpad-Pad** beziehen: Alle
**Etherpad-Pads** — die Adresse, die im Browser steht, ohne Export-Pfad: schrieben im Pad, Werkbaum zeigte das Diagramm dazu. Das ist **ausgebaut**
`…?etherpad=https://pad.example.org/p/mein-plan`. Werkbaum hängt den `?live=` (unten) kann dasselbe besser, und zwar im Editor selbst. Ein alter
Klartext-Export (`/export/txt`) selbst an; ein versehentlich mitgegebener `?etherpad=`-Link ergibt heute eine **Warnung**, die auf `?live=` zeigt, statt
Export- oder `/timeslider`-Pfad wird abgeschnitten. still nichts zu tun. Siehe D78; die frühere Ausführung steht in D31.
- **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.
- Das Pad kann **im Editor-Panel eingebettet** werden. Ein Wähler in der
Titelzeile schaltet reihum zwischen drei Ansichten: **Pad und Text** (beide,
durch einen eigenen **Splitter** frei geteilt — Doppelklick setzt zurück),
**nur Pad** und **nur Text**. Die Aufteilung wird für nebeneinander und
gestapelt getrennt gehalten und bleibt erhalten.
- Der schmal gezogene Textspiegel behält seinen Zweck: Der Sprung zwischen
Diagramm und Text (§9) arbeitet auf ihm. In „nur Pad" ist er ausgeblendet —
ein Sprung holt ihn dann selbst zurück, so wie er ein zugeklapptes
Editor-Panel aufklappt.
- Der Rahmen wird **nur geladen, wenn er sichtbar ist**. Ein geladenes Pad
verbindet sich und zeigt dich in dessen Anwesenden-Liste; „nur Text" ist
damit die Ansicht, die nichts von dir verrät.
- Geholt wird **auf Knopfdruck**, nicht selbsttätig: Ein Neu-laden-Knopf neben
dem Pad-Knopf holt den aktuellen Stand. Etherpad **drosselt** den Export
(serienmäßig 10 Abrufe je 90 s und IP); ein Hintergrund-Takt läuft dagegen an
und bekommt am Ende gar nichts mehr. Läuft ein Abruf, dreht das Symbol — bei
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
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 **Abbruch**
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.
### Gemeinsam an einem Server-Dokument arbeiten (`?live=`) ### Gemeinsam an einem Server-Dokument arbeiten (`?live=`)
Liegt der Plan auf einem Werkbaum-Backend, nimmt der Editor dessen Liegt der Plan auf einem Werkbaum-Backend, nimmt der Editor dessen
Dokument-Adresse: `…?live=https://example.org/api/v1/documents/<uuid>`. Dokument-Adresse: `…?live=https://example.org/api/v1/documents/<uuid>`.
Anders als beim Pad (`?etherpad=`) wird hier **im Editor selbst geschrieben**, Geschrieben wird **im Editor selbst**, und alle sehen die Änderungen der
und alle sehen die Änderungen der anderen, ohne neu zu laden. anderen, ohne neu zu laden.
- **Das Textfeld bleibt beschreibbar.** Nach kurzer Ruhe (1,5 s) schickt der - **Das Textfeld bleibt beschreibbar.** Nach kurzer Ruhe (1,5 s) schickt der
Editor die Änderung als Zeilen-Diff; ein offener Abruf hält die Gegenrichtung Editor die Änderung als Zeilen-Diff; ein offener Abruf hält die Gegenrichtung
@@ -852,8 +811,7 @@ 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, per `?sourceUrl=` oder Bei Dokumenten, die von außen kommen (mitgeliefert oder per `?sourceUrl=`), wird
`?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
@@ -1018,8 +976,7 @@ eigene ID der Zeile.
weiter: Sie fängt keine Taste ab, solange sie nichts anzeigt, und Tab rückt weiter: Sie fängt keine Taste ab, solange sie nichts anzeigt, und Tab rückt
bei geschlossener Liste unverändert ein. bei geschlossener Liste unverändert ein.
- **Kein Vorschlag** bei bloßem `#` (das *definiert* eine ID), im Kommentar - **Kein Vorschlag** bei bloßem `#` (das *definiert* eine ID), im Kommentar
(`%%`), im Beschreibungsteil hinter `---` und in schreibgeschützten (`%%`) und im Beschreibungsteil hinter `---`.
Pad-Dokumenten (§9).
- Der Parser sieht nie etwas davon; `llms.md` (§13) bleibt unberührt. Für - Der Parser sieht nie etwas davon; `llms.md` (§13) bleibt unberührt. Für
Screenreader meldet eine höfliche Live-Region die Trefferzahl und den Screenreader meldet eine höfliche Live-Region die Trefferzahl und den
gewählten Eintrag; das Popup selbst ist `aria-hidden`, normales Tippen gewählten Eintrag; das Popup selbst ist `aria-hidden`, normales Tippen
@@ -1099,13 +1056,12 @@ fokussierten Knoten (WAI-ARIA-Baum-Idiom).
angefasst, solange die übrigen Marken den Zustand noch richtig beschreiben — angefasst, solange die übrigen Marken den Zustand noch richtig beschreiben —
ein von Hand gesetztes `<` bleibt also stehen. Trifft es nicht mehr zu, werden ein von Hand gesetztes `<` bleibt also stehen. Trifft es nicht mehr zu, werden
alle Marken neu gesetzt und das `<` dabei aufgelöst. alle Marken neu gesetzt und das `<` dabei aufgelöst.
- Wo der Text **nicht beschreibbar** ist — bei einem Pad-Dokument (§9) —, gilt - Lässt sich ein Zustand in Marken **gar nicht ausdrücken** (etwa weil eine
der Eingriff **je Knoten** (Identität = Label-Pfad, wie bei „Was ist neu?“) Fokusmarke `!!!` ihren Knoten immer wieder hervorholt), wird lieber nichts
und **nur für die Sitzung**; ein Dokumentwechsel setzt ihn zurück. Dasselbe geschrieben, als einen Text zu hinterlassen, der etwas anderes sagt als das
gilt für einen Zustand, der sich in Marken gar nicht ausdrücken lässt (etwa Bild. Der Eingriff gilt dann **je Knoten** (Identität = Label-Pfad, wie bei
weil eine Fokusmarke `!!!` ihren Knoten immer wieder hervorholt): Dann wird „Was ist neu?“) und **nur für die Sitzung**; ein Dokumentwechsel setzt ihn
lieber nichts geschrieben, als einen Text zu hinterlassen, der etwas anderes zurück.
sagt als das Bild.
- **Für den ganzen Baum** gibt es im Diagramm-Kopf einen **Durchschalter**: - **Für den ganzen Baum** gibt es im Diagramm-Kopf einen **Durchschalter**:
Jeder Druck stellt die **nächste** von vier Voreinstellungen her, reihum — Jeder Druck stellt die **nächste** von vier Voreinstellungen her, reihum —
1. **ab Größe M abwärts zugeklappt**: jeder Knoten mit Kindern, dessen 1. **ab Größe M abwärts zugeklappt**: jeder Knoten mit Kindern, dessen
+16 -115
View File
@@ -131,30 +131,10 @@
| [?] #be.auth.token: Single user, one token (S) | [?] #be.auth.token: Single user, one token (S)
| [?] #be.auth.oidc: Log in with OIDC (L) | [?] #be.auth.oidc: Log in with OIDC (L)
- [~] #col: Collaborating on a Diagram (XXL) - [~] #col: Collaborating on a Diagram (XXL)
" The pad merges concurrent edits, Werkbaum renders the plan. " Reading together is solved, changing together too — typing together at the
- [^] #col.pad: Watch a shared Etherpad — it merges, we render (L) " same instant is the expensive one.
- [^] #col.pad.export: Load the pad's plain-text export (XS) - [^] #col.point: A shared pointer: !!! focuses a node for everyone (XS)
- [^] #col.pad.reload: Reload on demand (XS) %% a timer only trips Etherpad's rate limit - [-] #col.pad: Borrow an Etherpad — it merges, we render (L) %% built, then removed, see D78
- [^] #col.pad.embed: Embed the pad, keep the text mirror beside it (M)
- [^] #col.pad.views: Three views: pad and text, pad only, text only (S)
- [^] #col.pad.split: Splitter between pad and mirror (XS)
- [?] #col.pad.cookie: Editing in the frame stops after a while (S) %% SameSite=Lax, see D31
| [?] #col.pad.samesite: Ask the admin for cookie.sameSite = None (XS)
| [ ] #col.pad.owntab: Live with it: edit in the pad's own tab (XS)
- [^] #col.pad.point: A shared pointer: !!! focuses a node for everyone (XS)
- [?] > #col.auto: Update by itself, within about 2 s (M)
| [?] #col.own: Our own Etherpad beside Werkbaum (L) %% then every setting below is ours
- [ ] #col.own.db: Node and a real database in userland (S) %% the flatfile store is not for production
- [ ] #col.own.ram: Book the RAM quota for an own daemon (XS)
- [ ] #col.own.proxy: Reverse proxy in .htaccess, websockets included (S) %% Mattermost does it, wiki shows how
- [ ] #col.own.unit: systemd user unit so it survives a reboot (XS)
- [!] #col.own.patch: Keeping a public service patched (S) %% indefinitely, that is the real cost
| [?] #col.auto.limit: Admin raises the export rate limit (XS) %% default is 10 per 90 s
| [?] #col.auto.plugin: Admin installs a plugin that pushes the text (S) %% postMessage to the page
| [?] #col.auto.relay: Our own backend holds one socket and fans out (XXL) :#be.scaffold
| [ ] #col.auto.poll: Poll inside the budget, at best every 9 s (S) %% legal, but not 2 s
| [-] #col.auto.socket: Our own socket straight to the pad (L) %% measured: upgrade refused
| [-] #col.auto.html: Read the pad's HTML instead of the export (M) %% measured: no CORS header
- [ ] #col.git: Git as the shared store (L) - [ ] #col.git: Git as the shared store (L)
| [ ] #col.git.pr: A file in a repository, changed by pull request (S) %% works today, no code | [ ] #col.git.pr: A file in a repository, changed by pull request (S) %% works today, no code
| [?] #col.git.auto: The backend commits every change (L) :#be.scaffold | [?] #col.git.auto: The backend commits every change (L) :#be.scaffold
@@ -714,8 +694,8 @@
#bld.pwa.sw #bld.pwa.sw
A worker lets the editor start without a network. It is deliberately dumb: A worker lets the editor start without a network. It is deliberately dumb:
navigations go network-first, and the cache only keeps the last version it navigations go network-first, and the cache only keeps the last version it
has seen of the one self-contained file for the offline case. Texts pulled from a URL has seen of the one self-contained file for the offline case. Texts pulled from
or a pad pass through untouched and stay online by their nature. a URL or a server pass through untouched and stay online by their nature.
#bld.pwa.update #bld.pwa.update
The feared migration turned out unnecessary: because the worker only The feared migration turned out unnecessary: because the worker only
@@ -838,96 +818,17 @@
machinery. Reading together is solved, changing together mostly is, and machinery. Reading together is solved, changing together mostly is, and
typing together at the same instant is the expensive one. typing together at the same instant is the expensive one.
#col.point
Nobody sees anyone else's caret, so a mark in the text is the only way to
point at a place — and it has something a caret has not: everyone sees the
same spot.
#col.pad #col.pad
Let an Etherpad do the hard part: it merges concurrent edits, and Werkbaum Removed. An Etherpad did the hard part for a while: it merged concurrent
only renders the result. Borrowed rather than rebuilt. edits and Werkbaum only rendered the result, borrowed rather than rebuilt.
The borrowing is what it cost — a rate-limited export, no writing from here,
#col.pad.export and an author cookie that a foreign frame never gets. The backend does it
Every pad offers its content as plain text, with the header that lets a better and in our own editor, so this went out again.
foreign page read it. That one endpoint is the entire integration.
#col.pad.reload
Fetching happens on a button press, not on a timer. The export is rate
limited, and a timer does nothing but trip it.
#col.pad.embed
The pad in a frame inside the editor, with our own text mirror beside it.
The mirror keeps the jumps between diagram and text working, because a
foreign frame gives up no access to its content.
#col.pad.views
Pad and text, pad only, text only. Text only is the view that tells the pad
nothing about you — a loaded pad announces you in its list of people.
#col.pad.split
A splitter of its own between frame and mirror, remembered separately for
side by side and stacked.
#col.pad.cookie
In a foreign frame the pad's author cookie is not sent, so a session can
turn read-only after a while. Not fixable from this side.
#col.pad.samesite
One server setting resolves it. Presumes somebody who can change that
setting.
#col.pad.owntab
Or live with it: read along in the frame, and write in the pad's own tab
where the cookie works.
#col.pad.point
Nobody sees anybody else's cursor across two applications, so a mark in the
text is the only way to point at something. It has one thing a cursor
hasn't: everyone sees the same spot.
#col.auto
The view should follow the pad by itself, within a couple of seconds. Every
route below was measured rather than guessed at.
#col.own
Running the pad ourselves makes every setting below it ours. It also makes
every one of them our job.
#col.own.db
The default file store is not meant for production, so a real database and a
runtime to host it are the price of admission.
#col.own.ram
A daemon of one's own needs memory booked for it.
#col.own.proxy
Reaching it needs a reverse proxy that passes websockets through as well.
#col.own.unit
And something that starts it again after a reboot.
#col.own.patch
The real cost is not the setup but keeping a public service patched,
indefinitely.
#col.auto.limit
The cheapest effective change: raise the export limit on the pad server. Ten
fetches per ninety seconds is the default, and the reason a timer fails.
#col.auto.plugin
A pad plugin can push the text to the embedding page. Live, no polling, no
limit touched — and nothing to install on our side.
#col.auto.relay
Our own backend holds one connection to the pad and fans out to every
viewer. Solves it for everybody at once, and costs a backend.
#col.auto.poll
Staying inside the budget means one fetch every nine seconds at best, shared
by everyone behind the same address. Legal, but not two seconds.
#col.auto.socket
Measured and rejected: the pad server refuses the websocket upgrade when it
comes from another origin.
#col.auto.html
Measured and rejected: the pad page carries the full text, but sends no
header that would let a foreign page read it.
#col.git #col.git
Keep the plan in a repository. Because it is text, diff, blame, review and Keep the plan in a repository. Because it is text, diff, blame, review and
+21 -53
View File
@@ -76,7 +76,7 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
(verworfene einblenden, Pfad an/aus) als **Parameter** — keine Globals; nur (verworfene einblenden, Pfad an/aus) als **Parameter** — keine Globals; nur
`cheapPathOn` lebt als UI-State in `app.js`. Tests: `tests/*.test.js`. `cheapPathOn` lebt als UI-State in `app.js`. Tests: `tests/*.test.js`.
- **Was entscheidbar ist, gehört in ein eigenes Modul** — auch bei Features, die - **Was entscheidbar ist, gehört in ein eigenes Modul** — auch bei Features, die
wie reine UI aussehen: `remote.js` (Pad-URLs normalisieren, D31), wie reine UI aussehen:
`live.js` (Server-Dokumente: Adressen, Zeilen-Diff, Cursor-Rechnung, wann eine `live.js` (Server-Dokumente: Adressen, Zeilen-Diff, Cursor-Rechnung, wann eine
Feed-Antwort angewendet werden darf — D76), Feed-Antwort angewendet werden darf — D76),
`warnings.js` (Warnung → Text), `snapshots.js` (frühere Stände: wann entsteht `warnings.js` (Warnung → Text), `snapshots.js` (frühere Stände: wann entsteht
@@ -201,47 +201,18 @@ 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 - **Etherpad ist ausgebaut (D78).** `?etherpad=` bleibt als **erkannter**
Eingänge (`?sourceUrl=` und `?etherpad=`), `loadRemoteSource()` holt und legt Parameter stehen und ergibt die zeilenlose Warnung `padGone`, die auf `?live=`
das Dokument an — ein Fetch-Pfad, ein Warnkanal. Die Normalisierung der zeigt — ein geteilter Link soll nicht still ins Leere laufen. Geholt wird
Pad-Adresse steht headless in **`remote.js`** (`padUrls`, Tests in nichts. Mit gegangen sind `remote.js`, der Ansichts-Wähler samt Splitter
`tests/remote.test.js`): Export-/Timeslider-Pfad, Query, Fragment und (`--pcol`/`--prow`), der Neu-laden-Knopf, die Warnungen `sourceTimeout`/
Schrägstriche fallen weg, verlangt wird `/p/<name>` am Ende. Name und id sind `padRateLimit` und **der Schreibschutz**: `src.readOnly` wurde nur von
die **vollständige** Pad-URL (Pad-Namen sind nur pro Instanz eindeutig). Pad-Dokumenten gesetzt, also sind die Wächter in Falten, Kurz-IDs,
`padSource` muss **vor** dem ersten Abruf und vor `loadActiveIntoEditor()` Autovervollständigung und Ständen weg (und mit ihnen `updateSnapBtn()`).
gesetzt werden: Es trägt den Schreibschutz, und scheitert der erste Abruf Wer einen Lesemodus wieder einführt, braucht dafür eine eigene Entscheidung.
(Normalfall bei dieser Gegenseite), bleibt so der Neu-laden-Knopf erreichbar Ebenfalls weg: `#srcArea` — es gab den Kasten nur, damit sich Pad-Rahmen und
statt das Dokument tot liegen zu lassen. Geholt wird **nur auf Knopfdruck** Spiegel einen Bereich teilen; `.editor-body` trägt jetzt direkt
(`refreshPad`) — **kein Hintergrund-Takt wieder einbauen**: Etherpads `srcWrap` · `hintGutter` · `agenda`.
`importExportRateLimiting` lässt serienmäßig 10 Abrufe je 90 s und IP zu, und
jenseits davon hält die Gegenseite die Verbindung ohne Antwort offen (kein
`429`), bis der eigene Abbruch sie abreißt — gemessen: zwei Minuten tot, danach
0,4 s. Ein Takt gewinnt dagegen nicht, er erzeugt die Drosselung. Deshalb auch
`setPadBusy()` (Symbol dreht, bis zu 20 s) und ein **eigener** Warnungstyp
`sourceTimeout` für den Abbruch — `sourceLoad` zeigt auf CORS und schickte hier
auf die falsche Fährte. `deleteDoc()` ruft `stopPad()`, sonst legte ein späterer
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.
- Pad einbetten, drei Ansichten (D31): `padView` ('both'|'pad'|'text') +
`applyPadView()`. Der Rahmen bekommt **nur sichtbar** ein `src`, sonst
`about:blank` — ein geladenes Pad verbindet sich per Socket und zeigt dich in
dessen Anwesenden-Liste. `PAD_VIEWS`/`padView` stehen **oben** bei
`cheapPathOn`, nicht beim übrigen Pad-Code: `saveUI()` liest sie und kann schon
aus `applySplit()` heraus laufen, weiter unten stünden sie noch in der
temporalen Todeszone. Der Splitter Pad|Spiegel (`#padGutter`, `--pcol`/`--prow`)
ist eine Kopie des Legenden-Splitters (D26) samt getrennter Persistenz.
`revealEditor()` schaltet 'pad' → 'both', sonst zeigte der Sprung (D25) ins
Nichts. Das Markup: `#srcArea` umschließt Rahmen + Splitter + `#src`, damit die
Legenden-Aufteilung unberührt bleibt — `.editor-body textarea` ist ein
Nachfahren-Selektor und greift weiter. Nebenwirkung, die als Behelf taugt:
Einmal durch die Ansichten schalten **lädt den Rahmen neu** (über
`about:blank`) — das ist der Ausweg, wenn ein eingebettetes Pad nicht mehr
beschreibbar ist. Nicht in den Neu-laden-Knopf legen: der zieht nach dem Tippen
Spiegel und Diagramm nach, und ein Rahmen-Reload dabei kostete jedes Mal die
Schreibmarke im Pad. **Umhängen im DOM lädt jeden `<iframe>` neu** — wer
Container um `#srcArea` herum neu aufbaut, wirft die Pad-Sitzung weg.
- Fokusmarke `!!!` (D32, SPEC §1): Parser setzt `focus`, Renderer die Klasse - Fokusmarke `!!!` (D32, SPEC §1): Parser setzt `focus`, Renderer die Klasse
`focusmark`, CSS gibt ihr einen **eigenen Strahlenkranz in Petrol** (Nachtrag zu `focusmark`, CSS gibt ihr einen **eigenen Strahlenkranz in Petrol** (Nachtrag zu
D32; der geteilte Ring mit `.current` war zu leise). Schein in **hellem** Teal D32; der geteilte Ring mit `.current` war zu leise). Schein in **hellem** Teal
@@ -253,7 +224,7 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
Lookbehind (Safari erst ab 16.4), sonst verlöre `Achtung!!!` seine Lookbehind (Safari erst ab 16.4), sonst verlöre `Achtung!!!` seine
Ausrufezeichen. Extraktion **nach** den Tags; der Kommentar fällt vorher weg, Ausrufezeichen. Extraktion **nach** den Tags; der Kommentar fällt vorher weg,
eine Marke hinter `%%` wirkt also nicht. `revealFocusMark()` scrollt **nur bei eine Marke hinter `%%` wirkt also nicht. `revealFocusMark()` scrollt **nur bei
Änderung** (Schlüssel = Label-Text, nicht Zeilennummer — Umsortieren im Pad soll Änderung** (Schlüssel = Label-Text, nicht Zeilennummer — Umsortieren soll
nicht neu scrollen), sonst zöge jeder Neubau den Blick zurück. Die Regel braucht nicht neu scrollen), sonst zöge jeder Neubau den Blick zurück. Die Regel braucht
den `#out`-Präfix wie `.current` (`ul.or .node{box-shadow:none}` ist den `#out`-Präfix wie `.current` (`ul.or .node{box-shadow:none}` ist
spezifischer) **und** die Ausnahme spezifischer) **und** die Ausnahme
@@ -317,10 +288,9 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
diesen Versatz (`drop`) nach oben, sonst stehen sie durchgehend zu tief. diesen Versatz (`drop`) nach oben, sonst stehen sie durchgehend zu tief.
Aufgerufen wird aus `render()` (dort kommt auch die Warnungsmenge her) und aus Aufgerufen wird aus `render()` (dort kommt auch die Warnungsmenge her) und aus
einem `ResizeObserver` auf `#src` (Umbruch hängt an der Breite). Markup: einem `ResizeObserver` auf `#src` (Umbruch hängt an der Breite). Markup:
`#srcWrap` umschließt Streifen + Textfeld; die Pad-Regeln (D31) teilen gegen `#srcWrap` umschließt Streifen + Textfeld. `caretLine` steht **oben** bei den
**diese Spalte** (`.src-area.pv-both>.src-wrap`), nicht gegen das nackte übrigen Ansichts-Variablen — der Streifen liest sie und hängt an `render()`
Textfeld. `caretLine` steht deshalb **oben** bei `padView` — der Streifen liest (temporale Todeszone).
sie und hängt an `render()` (temporale Todeszone, siehe D31).
- Touch-Langdruck (D25): Der Timer (500 ms) setzt **nur** die Klasse `armed` - Touch-Langdruck (D25): Der Timer (500 ms) setzt **nur** die Klasse `armed`
(Petrol-Ring); `jumpToLine()` läuft im `touchend`-Handler. Nicht zurück in den (Petrol-Ring); `jumpToLine()` läuft im `touchend`-Handler. Nicht zurück in den
Timer verlegen — **`focus()` aus einem Timer gilt in mobilen Browsern nicht als Timer verlegen — **`focus()` aus einem Timer gilt in mobilen Browsern nicht als
@@ -396,10 +366,8 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
`setRangeText` zerstören die Undo-Historie (gemessen). Zwei Fallen: Ein `setRangeText` zerstören die Undo-Historie (gemessen). Zwei Fallen: Ein
Textfeld mit `display:none`-Vorfahr nimmt **kein** `execCommand` an (Klasse Textfeld mit `display:none`-Vorfahr nimmt **kein** `execCommand` an (Klasse
`writing-fold` schaltet es kurz sichtbar), und der nötige Fokus zieht auf `writing-fold` schaltet es kurz sichtbar), und der nötige Fokus zieht auf
Mobil die Tastatur hoch (`keyboardOnJump(true)`). Bei `src.readOnly` (Pad, Mobil die Tastatur hoch (`keyboardOnJump(true)`). Die Überlagerungen werden
D31) wird nicht geschrieben, dort trägt weiter `foldOverrides` — die nach erfolgreichem Schreiben geleert, sonst maskieren sie den Text. Der **Umschalter** im Diagramm-Kopf (`applyFoldPreset`, D44)
Überlagerungen werden nach erfolgreichem Schreiben geleert, sonst maskieren
sie den Text. Der **Umschalter** im Diagramm-Kopf (`applyFoldPreset`, D44)
geht denselben Weg: Überlagerungen für ALLE faltbaren Knoten setzen geht denselben Weg: Überlagerungen für ALLE faltbaren Knoten setzen
(`atMostM(n)` bzw. `false`), dann `writeAllFoldMarks()` — der aus (`atMostM(n)` bzw. `false`), dann `writeAllFoldMarks()` — der aus
`writeFoldToText()` herausgelöste Voll-Rewrite; einen minimalen Patch gibt es `writeFoldToText()` herausgelöste Voll-Rewrite; einen minimalen Patch gibt es
@@ -551,7 +519,7 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
im Textbereich die Pfad-Linie weg (gemessen). Die Sprünge (D25) schalten den im Textbereich die Pfad-Linie weg (gemessen). Die Sprünge (D25) schalten den
Bereich selbst um: `revealEditor()` auf Text, `focusNodeOfCaret()` aufs Bereich selbst um: `revealEditor()` auf Text, `focusNodeOfCaret()` aufs
Diagramm — **vor** dem Zentrieren. Zustand in `werkbaum-ui` (`mobilePane`), Diagramm — **vor** dem Zentrieren. Zustand in `werkbaum-ui` (`mobilePane`),
Variable oben bei `padView` deklariert (saveUI liest sie). Variable oben deklariert (saveUI liest sie).
`applyLayout` ruft auf Mobil **kein** `applySplit`. `applyLayout` ruft auf Mobil **kein** `applySplit`.
**Inhalte ~25 % kleiner** (D17-Nachtrag 2): `MOBILE_ZOOM = 0.75` als **Faktor** **Inhalte ~25 % kleiner** (D17-Nachtrag 2): `MOBILE_ZOOM = 0.75` als **Faktor**
auf den Nutzer-Zoom — `effZoom()` ist die Wahrheit, die `applyZoom()` setzt und auf den Nutzer-Zoom — `effZoom()` ist die Wahrheit, die `applyZoom()` setzt und
@@ -647,7 +615,7 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
**aktuellen** Dokuments geht deshalb über `execCommand('insertText')` **aktuellen** Dokuments geht deshalb über `execCommand('insertText')`
(`replaceTextUndoable()` für ganze Texte, `writeAt()` für Bereiche). (`replaceTextUndoable()` für ganze Texte, `writeAt()` für Bereiche).
`src.value =` ist nur beim **Laden eines anderen** Dokuments richtig `src.value =` ist nur beim **Laden eines anderen** Dokuments richtig
(`loadActiveIntoEditor`, Dokumentwechsel, Pad-Abruf) — dorthin gibt es nichts (`loadActiveIntoEditor`, Dokumentwechsel, URL-Abruf) — dorthin gibt es nichts
zurückzunehmen. zurückzunehmen.
- **ID-Vorschläge `:#` (D63):** Die Regeln stehen headless in `autocomplete.js` - **ID-Vorschläge `:#` (D63):** Die Regeln stehen headless in `autocomplete.js`
(`depFragment`/`collectIds`/`matchIds`, Tests); app.js verdrahtet nur Popup, (`depFragment`/`collectIds`/`matchIds`, Tests); app.js verdrahtet nur Popup,
+1 -18
View File
@@ -103,15 +103,6 @@
<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>
</button> </button>
</span> </span>
<button type="button" class="copybtn padview" id="padViewBtn" data-view="text" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect class="frame" x="2.5" y="5" width="19" height="14" rx="2"/><line class="ic-both" x1="12" y1="5" x2="12" y2="19"/><rect class="ic-pad" x="3.5" y="6" width="8" height="12" rx="1" fill="currentColor" stroke="none"/><rect class="ic-text" x="12.5" y="6" width="8" height="12" rx="1" fill="currentColor" stroke="none"/></svg>
</button>
<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 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>
@@ -126,14 +117,7 @@
</div> </div>
</div> </div>
<div class="editor-body"> <div class="editor-body">
<!-- Pad-Rahmen + interner Spiegel teilen sich einen eigenen Bereich, damit die <!-- Zeilennummern + Textfeld sind eine Spalte (D33). -->
Legenden-Aufteilung (D26) unberührt bleibt. Reihenfolge: Pad, Splitter,
Spiegel — gezogen wird von der Spiegelseite aus, wie bei der Legende. -->
<div class="src-area pv-text" id="srcArea">
<iframe class="padframe" id="padFrame" title="Etherpad" hidden></iframe>
<div class="pad-gutter" id="padGutter" role="separator" data-i18n-title="gutterTooltip" data-i18n-aria="padGutterAria" title="Ziehen zum Verschieben, Doppelklick setzt zurück" aria-label="Pad und Texteditor größenverändern" hidden></div>
<!-- Zeilennummern + Textfeld sind eine Spalte: der Splitter oben teilt den
Rahmen gegen diese Spalte, nicht gegen das nackte Textfeld. -->
<div class="src-wrap" id="srcWrap"> <div class="src-wrap" id="srcWrap">
<div class="lineno" id="lineNos" aria-hidden="true"><div class="lineno-inner"></div></div> <div class="lineno" id="lineNos" aria-hidden="true"><div class="lineno-inner"></div></div>
<!-- `wrap="off"`: Zeilen werden nicht umbrochen, lange Zeilen bekommen <!-- `wrap="off"`: Zeilen werden nicht umbrochen, lange Zeilen bekommen
@@ -142,7 +126,6 @@
Rand und lässt die Ebene verschwinden. --> Rand und lässt die Ebene verschwinden. -->
<textarea id="src" wrap="off" spellcheck="false" data-i18n-aria="editorTitle" aria-label="Struktur (Text)"></textarea> <textarea id="src" wrap="off" spellcheck="false" data-i18n-aria="editorTitle" aria-label="Struktur (Text)"></textarea>
</div> </div>
</div>
<div class="hint-gutter" id="hintGutter" role="separator" data-i18n-title="gutterTooltip" data-i18n-aria="hintGutterAria" title="Ziehen zum Verschieben, Doppelklick setzt zurück" aria-label="Editor und Legende größenverändern" hidden></div> <div class="hint-gutter" id="hintGutter" role="separator" data-i18n-title="gutterTooltip" data-i18n-aria="hintGutterAria" title="Ziehen zum Verschieben, Doppelklick setzt zurück" aria-label="Editor und Legende größenverändern" hidden></div>
<div class="agenda" id="agenda"> <div class="agenda" id="agenda">
<button type="button" class="agenda-summary" id="agendaToggle" aria-expanded="false" aria-controls="hint" data-i18n="agenda">Agenda</button> <button type="button" class="agenda-summary" id="agendaToggle" aria-expanded="false" aria-controls="hint" data-i18n="agenda">Agenda</button>
+2 -1
View File
@@ -10,7 +10,8 @@ Files are UTF-8 with LF line endings; the extension `.werkbaum` is a
convention (plain `.txt` works too). The editor at convention (plain `.txt` works too). The editor at
https://werkbaum.javagil.de renders the notation as a diagram; https://werkbaum.javagil.de renders the notation as a diagram;
`?sourceUrl=<url>` loads any CORS-readable http(s) text file, and `?sourceUrl=<url>` loads any CORS-readable http(s) text file, and
`?etherpad=<pad-url>` follows a live Etherpad. `?live=<document-url>` opens a document on a Werkbaum backend that several
people edit at once.
## Line format ## Line format
+52 -427
View File
@@ -3,7 +3,6 @@ import { parse, setFoldMark, expandShortIds, shortIdClosed } from './parser.js';
import { computeCheapPlan, overloadedAssignee, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, lineTargets } from './model.js'; import { computeCheapPlan, overloadedAssignee, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, lineTargets } from './model.js';
import { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { esc, renderTreeHtml, TIP_RULE } from './render.js';
import { formatWarning, warningText } from './warnings.js'; import { formatWarning, warningText } from './warnings.js';
import { padUrls } from './remote.js';
import * as live from './live.js'; import * as live from './live.js';
import { depFragment, collectIds, matchIds, depIdAt, idLine } from './autocomplete.js'; import { depFragment, collectIds, matchIds, depIdAt, idLine } from './autocomplete.js';
import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel } import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel }
@@ -69,18 +68,14 @@ let showIds = false; /* Knoten-IDs im Diagramm einblenden (D56) */
Der Faltzustand steht im Text (D38), die Position ist nur der Zeiger darin. */ Der Faltzustand steht im Text (D38), die Position ist nur der Zeiger darin. */
const FOLD_CYCLE = ['small', 'path', 'closed', 'open']; const FOLD_CYCLE = ['small', 'path', 'closed', 'open'];
let foldCycleNext = 0, foldCycleApplied = -1; let foldCycleNext = 0, foldCycleApplied = -1;
/* Ansicht bei einem Pad-Dokument (D31): beide | nur Pad | nur Spiegel. Hier oben
deklariert, weil saveUI() sie liest und schon aus applySplit() heraus laufen
kann weiter unten stünde sie dann noch in der temporalen Todeszone. */
const PAD_VIEWS = ['both', 'pad', 'text'];
let padView = 'both';
/* Sichtbarer Bereich auf kleinem Bildschirm (D17-Nachtrag): dort ist immer /* Sichtbarer Bereich auf kleinem Bildschirm (D17-Nachtrag): dort ist immer
genau einer zu sehen. Aus demselben Grund hier oben deklariert wie `padView` genau einer zu sehen. Hier oben deklariert, weil saveUI() ihn liest und schon
saveUI() liest ihn und läuft schon aus applySplit() heraus. */ aus applySplit() heraus laufen kann weiter unten stünde er dann noch in der
temporalen Todeszone. */
let mobilePane = 'diagram'; let mobilePane = 'diagram';
/* Cursor-Zeile (D25) bleibt `null`, bis der Cursor das erste Mal bewegt wurde, /* Cursor-Zeile (D25) bleibt `null`, bis der Cursor das erste Mal bewegt wurde,
sonst wäre nach dem Laden ungefragt die Wurzel markiert. Hier oben deklariert sonst wäre nach dem Laden ungefragt die Wurzel markiert. Aus demselben Grund
wie `padView`: der Zeilennummern-Streifen (D33) liest sie, und der hängt an hier oben: der Zeilennummern-Streifen (D33) liest sie, und der hängt an
render(). */ render(). */
let caretLine = null, currentNodeEl = null; let caretLine = null, currentNodeEl = null;
/* Warnung des ?sourceUrl-Ladens (D23) — zeilenlos und persistent, siehe render(). */ /* Warnung des ?sourceUrl-Ladens (D23) — zeilenlos und persistent, siehe render(). */
@@ -242,7 +237,7 @@ function render(){
/* Fokusmarke `!!!` (SPEC §1): Der erste markierte Knoten wird ins Bild geholt /* Fokusmarke `!!!` (SPEC §1): Der erste markierte Knoten wird ins Bild geholt
aber nur, wenn sich die Marke **geändert** hat. Sonst zöge jeder Neubau des aber nur, wenn sich die Marke **geändert** hat. Sonst zöge jeder Neubau des
Baums den Blick zurück und man käme nicht weg. Verglichen wird das Label des Baums den Blick zurück und man käme nicht weg. Verglichen wird das Label des
Knotens, nicht die Zeilennummer: Umsortieren im Pad soll nicht als neue Marke Knotens, nicht die Zeilennummer: Umsortieren soll nicht als neue Marke
gelten. Die Hervorhebung selbst macht die Klasse `focusmark` aus dem Renderer, gelten. Die Hervorhebung selbst macht die Klasse `focusmark` aus dem Renderer,
hier geht es nur ums Scrollen. */ hier geht es nur ums Scrollen. */
let lastFocusMark = null; let lastFocusMark = null;
@@ -1074,14 +1069,6 @@ function scrollEditorToOffset(offset){
/* Ist das Editor-Panel zugeklappt, muss der Sprung es erst öffnen. */ /* Ist das Editor-Panel zugeklappt, muss der Sprung es erst öffnen. */
function revealEditor(){ function revealEditor(){
/* In der Ansicht nur Pad" (D31) ist der Spiegel ausgeblendet dann gibt es
keine Zeile zum Markieren. Der Sprung holt ihn deshalb zurück, genau wie er
ein zugeklapptes Editor-Panel aufklappt (D25). */
if(padView === 'pad' && padSource && activeId === padSource.id){
padView = 'both';
applyPadView();
saveUI();
}
if(isMobile()){ if(isMobile()){
/* Auf Mobil ist der Text womöglich gar nicht vorn dann holt der Sprung /* Auf Mobil ist der Text womöglich gar nicht vorn dann holt der Sprung
ihn nach vorn, so wie er auf dem Desktop ein zugeklapptes Panel aufklappt ihn nach vorn, so wie er auf dem Desktop ein zugeklapptes Panel aufklappt
@@ -1133,7 +1120,7 @@ function nodeFromEvent(e){
Umklappen im Diagramm ändert den Notationstext. Der Text ist damit auch für Umklappen im Diagramm ändert den Notationstext. Der Text ist damit auch für
die Faltung die eine Quelle der Wahrheit (D14) die Sitzungs-Überlagerung die Faltung die eine Quelle der Wahrheit (D14) die Sitzungs-Überlagerung
`foldOverrides` bleibt nur für Dokumente, in die nicht geschrieben werden `foldOverrides` bleibt nur für Dokumente, in die nicht geschrieben werden
kann (Pad, schreibgeschützt nach D31). */ kann. */
/* Der Sollzustand als Menge von Label-Pfad-Schlüsseln: heutiger Anfangszustand /* Der Sollzustand als Menge von Label-Pfad-Schlüsseln: heutiger Anfangszustand
aus dem Text, überlagert von den Eingriffen dieselbe Rechnung wie in aus dem Text, überlagert von den Eingriffen dieselbe Rechnung wie in
@@ -1222,7 +1209,6 @@ function withEditorWritable(fn){
/* Schreibt den Faltzustand in den Text. Liefert false, wenn nicht geschrieben /* Schreibt den Faltzustand in den Text. Liefert false, wenn nicht geschrieben
werden konnte dann bleibt die Sitzungs-Überlagerung stehen. */ werden konnte dann bleibt die Sitzungs-Überlagerung stehen. */
function writeFoldToText(line, collapsed){ function writeFoldToText(line, collapsed){
if(src.readOnly) return false; /* Pad-Dokument (D31) */
const roots = parse(src.value).roots; const roots = parse(src.value).roots;
if(!roots.length) return false; if(!roots.length) return false;
const want = desiredFoldKeys(roots); const want = desiredFoldKeys(roots);
@@ -1273,8 +1259,8 @@ function applyFoldPreset(mode){
if(!n.children.length) return; /* nur faltbare Knoten */ if(!n.children.length) return; /* nur faltbare Knoten */
foldOverrides.set(key, want.has(n)); foldOverrides.set(key, want.has(n));
}); });
if(!src.readOnly && writeAllFoldMarks(roots, desiredFoldKeys(roots))) foldOverrides.clear(); if(writeAllFoldMarks(roots, desiredFoldKeys(roots))) foldOverrides.clear();
else render(); /* Pad o. nicht ausdrückbar: Überlagerung trägt */ else render(); /* nicht ausdrückbar: Überlagerung trägt */
} }
/* Faltung umklappen (SPEC §9, D38). Gelingt das Zurückschreiben, ist der Text /* Faltung umklappen (SPEC §9, D38). Gelingt das Zurückschreiben, ist der Text
@@ -1620,7 +1606,7 @@ function resolveShortId(line){
writeShortId(line); writeShortId(line);
} }
function writeShortId(line){ function writeShortId(line){
if(src.readOnly || line == null) return; if(line == null) return;
/* **Nicht** sofort schreiben: Beide Wege hierher hängen am /* **Nicht** sofort schreiben: Beide Wege hierher hängen am
`input`-Ereignis der Doppelpunkt unmittelbar, der Zeilenwechsel über die `input`-Ereignis der Doppelpunkt unmittelbar, der Zeilenwechsel über die
Enter-Taste , und `execCommand` verweigert den Dienst, wenn es Enter-Taste , und `execCommand` verweigert den Dienst, wenn es
@@ -1751,7 +1737,7 @@ function closeAc(){
acCtx = null; acCtx = null;
} }
function updateAc(){ function updateAc(){
if(src.readOnly || document.activeElement !== src || if(document.activeElement !== src ||
src.selectionStart !== src.selectionEnd){ closeAc(); return; } src.selectionStart !== src.selectionEnd){ closeAc(); return; }
const ctx = depFragment(src.value, src.selectionStart); const ctx = depFragment(src.value, src.selectionStart);
if(!ctx){ acSuppress = null; closeAc(); return; } if(!ctx){ acSuppress = null; closeAc(); return; }
@@ -2221,21 +2207,11 @@ const I18N = {
acHint:"{n} ID-Vorschläge ↑/↓ wählt, Enter übernimmt", acHint:"{n} ID-Vorschläge ↑/↓ wählt, Enter übernimmt",
tipClose:"Schließen", tipClose:"Schließen",
tipOpenLink:"Link öffnen", tipOpenLink:"Link öffnen",
padReadonly:"Wird im Pad bearbeitet — hier nur lesen.",
padEdit:"Pad zum Bearbeiten öffnen",
padRefresh:"Vom Pad neu laden",
liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.", liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.",
liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?", liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?",
liveConflictTheirs:"Fremde übernehmen", liveConflictTheirs:"Fremde übernehmen",
liveConflictMine:"Eigene durchsetzen", liveConflictMine:"Eigene durchsetzen",
padWait:"Noch {seconds} s — Etherpad begrenzt die Abrufe",
padRateLimitWarn:"Noch nicht neu geladen: Etherpad erlaubt nur wenige Abrufe je Zeitfenster (serienmäßig 10 pro 90 s), so häufiges Nachladen ist leider nicht möglich. In {seconds} s geht es wieder.",
padViewTooltip:"Ansicht: {state} — klicken zum Wechseln",
padView_both:"Pad und Text",
padView_pad:"nur Pad",
padView_text:"nur Text",
padGutterAria:"Pad und Texteditor größenverändern",
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",
@@ -2307,7 +2283,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.", padGoneWarn:"Dieser Link zeigt auf ein Etherpad-Pad. Die Etherpad-Anbindung gibt es nicht mehr — gemeinsames Arbeiten läuft jetzt über ein Werkbaum-Backend (?live=…). Der Text des Pads lässt sich dort einmal einfügen und dann zu zweit bearbeiten.",
a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: mindestens {size} (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yFolded:"eingeklappt, {n} verborgen", a11yEffective:"effektiv: {status}", heldTooltip:"effektiv {eff} — selbst schon {own}, wartet auf Abhängigkeiten", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt", a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: mindestens {size} (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yFolded:"eingeklappt, {n} verborgen", a11yEffective:"effektiv: {status}", heldTooltip:"effektiv {eff} — selbst schon {own}, wartet auf Abhängigkeiten", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", 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",
@@ -2343,21 +2319,11 @@ const I18N = {
acHint:"{n} id suggestions ↑/↓ to choose, Enter to insert", acHint:"{n} id suggestions ↑/↓ to choose, Enter to insert",
tipClose:"Close", tipClose:"Close",
tipOpenLink:"Open link", tipOpenLink:"Open link",
padReadonly:"Edited in the pad — read-only here.",
padEdit:"Open the pad to edit",
padRefresh:"Reload from the pad",
liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.", liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.",
liveConflictText:"Someone changed the same lines. Whose version should win?", liveConflictText:"Someone changed the same lines. Whose version should win?",
liveConflictTheirs:"Take theirs", liveConflictTheirs:"Take theirs",
liveConflictMine:"Keep mine", liveConflictMine:"Keep mine",
padWait:"{seconds} s to go — Etherpad limits how often we may fetch",
padRateLimitWarn:"Not reloaded yet: Etherpad only allows a few fetches per time window (10 per 90 s by default), so syncing this often is unfortunately not possible. Try again in {seconds} s.",
padViewTooltip:"View: {state} — click to switch",
padView_both:"pad and text",
padView_pad:"pad only",
padView_text:"text only",
padGutterAria:"Resize pad and text editor",
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",
@@ -2428,7 +2394,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.", padGoneWarn:"This link points at an Etherpad pad. The Etherpad connection is gone — collaboration now runs through a Werkbaum backend (?live=…). Paste the pads text there once, then edit it together.",
a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: at least {size} (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yFolded:"collapsed, {n} hidden", a11yEffective:"effective: {status}", heldTooltip:"effectively {eff} — itself already {own}, waiting on dependencies", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link", a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: at least {size} (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yFolded:"collapsed, {n} hidden", a11yEffective:"effective: {status}", heldTooltip:"effectively {eff} — itself already {own}, waiting on dependencies", a11yOptional:"optional", a11yFocusMark:"look here", 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",
@@ -2464,21 +2430,11 @@ const I18N = {
acHint:"{n} sugerencias de ID ↑/↓ elige, Intro inserta", acHint:"{n} sugerencias de ID ↑/↓ elige, Intro inserta",
tipClose:"Cerrar", tipClose:"Cerrar",
tipOpenLink:"Abrir enlace", tipOpenLink:"Abrir enlace",
padReadonly:"Se edita en el pad — aquí solo lectura.",
padEdit:"Abrir el pad para editar",
padRefresh:"Recargar desde el pad",
liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.", liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.",
liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?", liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?",
liveConflictTheirs:"Tomar la ajena", liveConflictTheirs:"Tomar la ajena",
liveConflictMine:"Mantener la mía", liveConflictMine:"Mantener la mía",
padWait:"Faltan {seconds} s — Etherpad limita la frecuencia",
padRateLimitWarn:"Aún no se ha recargado: Etherpad solo permite unas pocas descargas por ventana de tiempo (10 por 90 s de forma predeterminada), así que sincronizar tan a menudo no es posible. Vuelve a intentarlo en {seconds} s.",
padViewTooltip:"Vista: {state} — clic para cambiar",
padView_both:"pad y texto",
padView_pad:"solo pad",
padView_text:"solo texto",
padGutterAria:"Redimensionar pad y editor de texto",
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",
@@ -2549,7 +2505,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.", padGoneWarn:"Este enlace apunta a un pad de Etherpad. La conexión con Etherpad ya no existe: ahora se colabora a través de un backend de Werkbaum (?live=…). Pega allí el texto del pad una vez y editadlo juntos.",
a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: al menos {size} (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yFolded:"plegado, {n} ocultos", a11yEffective:"efectivo: {status}", heldTooltip:"efectivamente {eff} — por sí mismo ya {own}, espera dependencias", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace", a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: al menos {size} (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yFolded:"plegado, {n} ocultos", a11yEffective:"efectivo: {status}", heldTooltip:"efectivamente {eff} — por sí mismo ya {own}, espera dependencias", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", 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",
@@ -2585,21 +2541,11 @@ const I18N = {
acHint:"{n} suggestions d'ID ↑/↓ pour choisir, Entrée pour insérer", acHint:"{n} suggestions d'ID ↑/↓ pour choisir, Entrée pour insérer",
tipClose:"Fermer", tipClose:"Fermer",
tipOpenLink:"Ouvrir le lien", tipOpenLink:"Ouvrir le lien",
padReadonly:"Modifié dans le pad — lecture seule ici.",
padEdit:"Ouvrir le pad pour modifier",
padRefresh:"Recharger depuis le pad",
liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/&lt;uuid&gt;) ?", liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/&lt;uuid&gt;) ?",
liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.", liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.",
liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?", liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?",
liveConflictTheirs:"Prendre la sienne", liveConflictTheirs:"Prendre la sienne",
liveConflictMine:"Garder la mienne", liveConflictMine:"Garder la mienne",
padWait:"Encore {seconds} s — Etherpad limite la fréquence",
padRateLimitWarn:"Pas encore rechargé : Etherpad nautorise que quelques récupérations par fenêtre de temps (10 par 90 s par défaut), une synchronisation aussi fréquente nest donc pas possible. Réessaie dans {seconds} s.",
padViewTooltip:"Vue : {state} — cliquer pour changer",
padView_both:"pad et texte",
padView_pad:"pad seul",
padView_text:"texte seul",
padGutterAria:"Redimensionner le pad et l’éditeur de texte",
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",
@@ -2670,7 +2616,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.", padGoneWarn:"Ce lien pointe vers un pad Etherpad. La connexion Etherpad nexiste plus — la collaboration passe désormais par un backend Werkbaum (?live=…). Collez-y une fois le texte du pad, puis modifiez-le à plusieurs.",
a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : au moins {size} (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yFolded:"replié, {n} masqués", a11yEffective:"effectif : {status}", heldTooltip:"effectivement {eff} — lui-même déjà {own}, en attente de dépendances", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien", a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : au moins {size} (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yFolded:"replié, {n} masqués", a11yEffective:"effectif : {status}", heldTooltip:"effectivement {eff} — lui-même déjà {own}, en attente de dépendances", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", 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",
@@ -2706,21 +2652,11 @@ const I18N = {
acHint:"{n} podpowiedzi ID ↑/↓ wybiera, Enter wstawia", acHint:"{n} podpowiedzi ID ↑/↓ wybiera, Enter wstawia",
tipClose:"Zamknij", tipClose:"Zamknij",
tipOpenLink:"Otwórz link", tipOpenLink:"Otwórz link",
padReadonly:"Edytowane w padzie — tu tylko do czytania.",
padEdit:"Otwórz pad do edycji",
padRefresh:"Wczytaj ponownie z padu",
liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.", liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.",
liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?", liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?",
liveConflictTheirs:"Przyjmij cudzą", liveConflictTheirs:"Przyjmij cudzą",
liveConflictMine:"Zachowaj własną", liveConflictMine:"Zachowaj własną",
padWait:"Jeszcze {seconds} s — Etherpad ogranicza częstość",
padRateLimitWarn:"Jeszcze nie wczytano ponownie: Etherpad dopuszcza tylko kilka pobrań w okresie (domyślnie 10 na 90 s), więc tak częsta synchronizacja nie jest możliwa. Spróbuj za {seconds} s.",
padViewTooltip:"Widok: {state} — kliknij, aby zmienić",
padView_both:"pad i tekst",
padView_pad:"tylko pad",
padView_text:"tylko tekst",
padGutterAria:"Zmień rozmiar padu i edytora tekstu",
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",
@@ -2791,7 +2727,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.", padGoneWarn:"Ten link prowadzi do pada Etherpad. Połączenia z Etherpadem już nie ma — wspólna praca odbywa się teraz przez backend Werkbaum (?live=…). Wklej tam raz tekst pada i edytujcie go razem.",
a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: co najmniej {size} (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yFolded:"zwinięte, ukrytych: {n}", a11yEffective:"efektywnie: {status}", heldTooltip:"efektywnie {eff} — sam już {own}, czeka na zależności", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem", a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: co najmniej {size} (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yFolded:"zwinięte, ukrytych: {n}", a11yEffective:"efektywnie: {status}", heldTooltip:"efektywnie {eff} — sam już {own}, czeka na zależności", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", 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ą",
@@ -2827,21 +2763,11 @@ const I18N = {
acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет", acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет",
tipClose:"Закрыть", tipClose:"Закрыть",
tipOpenLink:"Открыть ссылку", tipOpenLink:"Открыть ссылку",
padReadonly:"Редактируется в паде — здесь только чтение.",
padEdit:"Открыть пад для редактирования",
padRefresh:"Обновить из пада",
liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.", liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.",
liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?", liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?",
liveConflictTheirs:"Принять чужую", liveConflictTheirs:"Принять чужую",
liveConflictMine:"Оставить свою", liveConflictMine:"Оставить свою",
padWait:"Ещё {seconds} с — Etherpad ограничивает частоту",
padRateLimitWarn:"Пока не обновлено: Etherpad разрешает лишь несколько загрузок за окно времени (по умолчанию 10 за 90 с), поэтому столь частая синхронизация невозможна. Повторите через {seconds} с.",
padViewTooltip:"Вид: {state} — нажмите для переключения",
padView_both:"пад и текст",
padView_pad:"только пад",
padView_text:"только текст",
padGutterAria:"Изменить размер пада и текстового редактора",
riskTooltip:"Высокий риск – оценка ещё не ясна.", riskTooltip:"Высокий риск – оценка ещё не ясна.",
discardedTooltip:"Показать/скрыть отклонённые узлы вместе с поддеревом", discardedTooltip:"Показать/скрыть отклонённые узлы вместе с поддеревом",
cheapTooltip:"Выделить самый дешёвый путь — ненужные альтернативы приглушаются", cheapTooltip:"Выделить самый дешёвый путь — ненужные альтернативы приглушаются",
@@ -2912,7 +2838,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 с); подождите немного и обновите снова.", padGoneWarn:"Эта ссылка ведёт на пад Etherpad. Подключения к Etherpad больше нет — совместная работа теперь идёт через бэкенд Werkbaum (?live=…). Вставьте туда текст пада один раз и редактируйте вместе.",
a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: не меньше {size} (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yFolded:"свёрнуто, скрыто: {n}", a11yEffective:"фактически: {status}", heldTooltip:"фактически {eff} — сам уже {own}, ждёт зависимости", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой", a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: не меньше {size} (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yFolded:"свёрнуто, скрыто: {n}", a11yEffective:"фактически: {status}", heldTooltip:"фактически {eff} — сам уже {own}, ждёт зависимости", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой",
hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.", hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну", hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
@@ -2948,21 +2874,11 @@ const I18N = {
acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें", acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें",
tipClose:"बंद करें", tipClose:"बंद करें",
tipOpenLink:"लिंक खोलें", tipOpenLink:"लिंक खोलें",
padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।",
padEdit:"संपादित करने के लिए पैड खोलें",
padRefresh:"पैड से फिर लोड करें",
liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।", liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।",
liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?", liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?",
liveConflictTheirs:"दूसरे का लें", liveConflictTheirs:"दूसरे का लें",
liveConflictMine:"अपना रखें", liveConflictMine:"अपना रखें",
padWait:"{seconds} स॰ बाकी — Etherpad बार-बार लेने की सीमा रखता है",
padRateLimitWarn:"अभी दोबारा लोड नहीं किया: Etherpad प्रति समय-खिड़की केवल कुछ ही बार लेने देता है (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार), इसलिए इतनी बार सिंक करना संभव नहीं है। {seconds} स॰ में फिर कोशिश करें।",
padViewTooltip:"दृश्य: {state} — बदलने के लिए क्लिक करें",
padView_both:"पैड और टेक्स्ट",
padView_pad:"केवल पैड",
padView_text:"केवल टेक्स्ट",
padGutterAria:"पैड और टेक्स्ट संपादक का आकार बदलें",
riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।", riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।",
discardedTooltip:"अस्वीकृत नोड्स और उनके उप-वृक्ष दिखाएँ/छिपाएँ", discardedTooltip:"अस्वीकृत नोड्स और उनके उप-वृक्ष दिखाएँ/छिपाएँ",
cheapTooltip:"सबसे किफ़ायती पथ को उजागर करें – अनावश्यक विकल्प मंद हो जाते हैं", cheapTooltip:"सबसे किफ़ायती पथ को उजागर करें – अनावश्यक विकल्प मंद हो जाते हैं",
@@ -3033,7 +2949,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 बार); कुछ क्षण रुकें, फिर दोबारा लोड करें।", padGoneWarn:"यह लिंक एक Etherpad पैड की ओर इशारा करता है। Etherpad कनेक्शन अब नहीं है — साझा काम अब Werkbaum बैकएंड (?live=…) से होता है। पैड का टेक्स्ट वहाँ एक बार चिपकाएँ और मिलकर संपादित करें।",
a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: कम से कम {size} (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yFolded:"समेटा हुआ, {n} छिपे", a11yEffective:"प्रभावी: {status}", heldTooltip:"प्रभावी रूप से {eff} — स्वयं {own} है, निर्भरताओं की प्रतीक्षा में", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित", a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: कम से कम {size} (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yFolded:"समेटा हुआ, {n} छिपे", a11yEffective:"प्रभावी: {status}", heldTooltip:"प्रभावी रूप से {eff} — स्वयं {own} है, निर्भरताओं की प्रतीक्षा में", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित",
hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।", hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें", hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
@@ -3080,21 +2996,11 @@ const I18N = {
acHint:"{n} 个 ID 建议 ↑/↓ 选择,Enter 插入", acHint:"{n} 个 ID 建议 ↑/↓ 选择,Enter 插入",
tipClose:"关闭", tipClose:"关闭",
tipOpenLink:"打开链接", tipOpenLink:"打开链接",
padReadonly:"在 Pad 中编辑 — 此处只读。",
padEdit:"打开 Pad 进行编辑",
padRefresh:"从 Pad 重新加载",
liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?", liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?",
liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。", liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。",
liveConflictText:"有人改动了同样的行。以谁的版本为准?", liveConflictText:"有人改动了同样的行。以谁的版本为准?",
liveConflictTheirs:"采用对方的", liveConflictTheirs:"采用对方的",
liveConflictMine:"保留我的", liveConflictMine:"保留我的",
padWait:"还需 {seconds} 秒 — Etherpad 限制获取频率",
padRateLimitWarn:"尚未重新加载:Etherpad 每个时间窗口只允许少量获取(默认每 90 秒 10 次),因此无法如此频繁地同步。请在 {seconds} 秒后再试。",
padViewTooltip:"视图:{state} — 点击切换",
padView_both:"Pad 和文本",
padView_pad:"仅 Pad",
padView_text:"仅文本",
padGutterAria:"调整 Pad 与文本编辑器大小",
riskTooltip:"高风险 – 工作量尚不明确。", riskTooltip:"高风险 – 工作量尚不明确。",
editorTitle:"结构(文本)", diagramTitle:"图表", editorTitle:"结构(文本)", diagramTitle:"图表",
docSwitchTooltip:"选择或管理文档", docMenuAria:"文档", docSwitchTooltip:"选择或管理文档", docMenuAria:"文档",
@@ -3154,7 +3060,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 次);请稍候再重新加载。", padGoneWarn:"此链接指向一个 Etherpad pad。Etherpad 连接已移除——协作现在通过 Werkbaum 后端(?live=…)进行。把 pad 的文本粘贴过去一次,然后一起编辑。",
a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:至少 {size}(假定)", a11yTags:"负责人:{names}", a11yId:"ID#{id}", a11yDeps:"依赖:{ids}", a11yFolded:"已折叠,隐藏 {n} 项", a11yEffective:"实际:{status}", heldTooltip:"实际为 {eff}——自身已是 {own},等待依赖完成", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接", a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:至少 {size}(假定)", a11yTags:"负责人:{names}", a11yId:"ID#{id}", a11yDeps:"依赖:{ids}", a11yFolded:"已折叠,隐藏 {n} 项", a11yEffective:"实际:{status}", heldTooltip:"实际为 {eff}——自身已是 {own},等待依赖完成", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接",
hint_indent:"缩进(2 个空格或制表符)定义层级。", hint_indent:"缩进(2 个空格或制表符)定义层级。",
hint_all:"子任务,全部必需", hint_any:"备选项,择其一", hint_all:"子任务,全部必需", hint_any:"备选项,择其一",
@@ -3201,21 +3107,11 @@ const I18N = {
acHint:"ID候補 {n} 件 ↑/↓で選択、Enterで挿入", acHint:"ID候補 {n} 件 ↑/↓で選択、Enterで挿入",
tipClose:"閉じる", tipClose:"閉じる",
tipOpenLink:"リンクを開く", tipOpenLink:"リンクを開く",
padReadonly:"パッドで編集します — ここでは読み取り専用です。",
padEdit:"編集するにはパッドを開く",
padRefresh:"パッドから再読み込み",
liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。", liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。",
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。", liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。", liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
liveConflictTheirs:"相手の版", liveConflictTheirs:"相手の版",
liveConflictMine:"自分の版", liveConflictMine:"自分の版",
padWait:"あと {seconds} 秒 — Etherpad は取得頻度を制限します",
padRateLimitWarn:"まだ再読み込みしていません: Etherpad は一定時間内の取得回数を制限します(既定で 90 秒あたり 10 回)。これほど頻繁な同期はできません。{seconds} 秒後にもう一度お試しください。",
padViewTooltip:"表示: {state} — クリックで切り替え",
padView_both:"パッドとテキスト",
padView_pad:"パッドのみ",
padView_text:"テキストのみ",
padGutterAria:"パッドとテキストエディターのサイズを変更",
riskTooltip:"高リスク – 規模はまだ不明。", riskTooltip:"高リスク – 規模はまだ不明。",
editorTitle:"構造(テキスト)", diagramTitle:"ダイアグラム", editorTitle:"構造(テキスト)", diagramTitle:"ダイアグラム",
docSwitchTooltip:"ドキュメントを選択・管理", docMenuAria:"ドキュメント", docSwitchTooltip:"ドキュメントを選択・管理", docMenuAria:"ドキュメント",
@@ -3275,7 +3171,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 回)。少し待ってから再読み込みしてください。", padGoneWarn:"このリンクは Etherpad のパッドを指しています。Etherpad 連携は廃止されました。共同編集は Werkbaum バックエンド(?live=…)で行います。パッドの本文を一度貼り付ければ、複数人で編集できます。",
a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: 少なくとも {size}(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yFolded:"折りたたみ中、{n} 件非表示", a11yEffective:"実効: {status}", heldTooltip:"実効では {eff} — 自身は既に {own}、依存待ち", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり", a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: 少なくとも {size}(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yFolded:"折りたたみ中、{n} 件非表示", a11yEffective:"実効: {status}", heldTooltip:"実効では {eff} — 自身は既に {own}、依存待ち", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり",
hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。", hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。",
hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ", hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ",
@@ -3555,11 +3451,6 @@ function saveUI(){
agenda: !!document.querySelector('.agenda.open'), agenda: !!document.querySelector('.agenda.open'),
hcol: app.style.getPropertyValue('--hcol') || null, hcol: app.style.getPropertyValue('--hcol') || null,
hrow: app.style.getPropertyValue('--hrow') || null, hrow: app.style.getPropertyValue('--hrow') || null,
/* Pad-Ansicht + Aufteilung Pad|Spiegel (D31). Global über alle Dokumente,
wie der übrige Ansichts-Zustand (D22). */
padView: padView,
pcol: app.style.getPropertyValue('--pcol') || null,
prow: app.style.getPropertyValue('--prow') || null,
/* Sichtbarer Bereich auf kleinem Bildschirm (D17-Nachtrag) ebenfalls /* Sichtbarer Bereich auf kleinem Bildschirm (D17-Nachtrag) ebenfalls
global über alle Dokumente wie der übrige Ansichts-Zustand. */ global über alle Dokumente wie der übrige Ansichts-Zustand. */
mobilePane: mobilePane, mobilePane: mobilePane,
@@ -3609,9 +3500,6 @@ function restoreState(){
if(ui && ui.hcol) app.style.setProperty('--hcol', ui.hcol); if(ui && ui.hcol) app.style.setProperty('--hcol', ui.hcol);
if(ui && ui.hrow) app.style.setProperty('--hrow', ui.hrow); if(ui && ui.hrow) app.style.setProperty('--hrow', ui.hrow);
if(ui && (ui.mobilePane === 'diagram' || ui.mobilePane === 'text')) mobilePane = ui.mobilePane; if(ui && (ui.mobilePane === 'diagram' || ui.mobilePane === 'text')) mobilePane = ui.mobilePane;
if(ui && PAD_VIEWS.indexOf(ui.padView) >= 0) padView = ui.padView;
if(ui && ui.pcol) app.style.setProperty('--pcol', ui.pcol);
if(ui && ui.prow) app.style.setProperty('--prow', ui.prow);
setAgendaOpen(!!(ui && ui.agenda)); setAgendaOpen(!!(ui && ui.agenda));
applyZoom(); applyZoom();
restoring = false; restoring = false;
@@ -3633,7 +3521,6 @@ 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) */
/* Kam das Umbenennen von einem frisch angelegten Dokument? Dann geht es danach /* Kam das Umbenennen von einem frisch angelegten Dokument? Dann geht es danach
@@ -3741,9 +3628,8 @@ function flushActive(){ const d = activeDoc(); if(d) d.text = src.value; }
wer weiter zurück will, hat Git. wer weiter zurück will, hat Git.
Die Regeln stehen in snapshots.js und sind dort getestet; hier bleibt nur, Die Regeln stehen in snapshots.js und sind dort getestet; hier bleibt nur,
was DOM oder Speicher berührt: welches Dokument aktiv ist, ob sein Text was DOM oder Speicher berührt: welches Dokument aktiv ist und das
beschreibbar ist (Pad-Dokumente, D31, bleiben außen vor ein alter Stand Nachzeichnen des Menüs. */
ließe sich dort gar nicht einsetzen), und das Nachzeichnen des Menüs. */
let snaps = {}; /* {docId: [{t, text}, …]} — ältester zuerst */ let snaps = {}; /* {docId: [{t, text}, …]} — ältester zuerst */
let snapBase = ''; /* Text bei Dokumentwechsel; Vergleich, solange es keinen Stand gibt */ let snapBase = ''; /* Text bei Dokumentwechsel; Vergleich, solange es keinen Stand gibt */
@@ -3751,7 +3637,7 @@ function loadSnaps(){ snaps = parseSnaps(localStorage.getItem(LS_SNAPS)); }
function snapshotNow(manuell){ function snapshotNow(manuell){
const d = activeDoc(); const d = activeDoc();
if(!d || src.readOnly) return false; if(!d) return false;
const text = src.value; const text = src.value;
if(!addSnapshot(snaps, d.id, text, Date.now(), {base: snapBase, manual: manuell})) return false; if(!addSnapshot(snaps, d.id, text, Date.now(), {base: snapBase, manual: manuell})) return false;
snapBase = text; snapBase = text;
@@ -3796,7 +3682,6 @@ function renderSnapMenu(){
keine Rückfrage. Der aktuelle Stand wird vorher weggelegt, falls er noch keine Rückfrage. Der aktuelle Stand wird vorher weggelegt, falls er noch
nicht drin ist sonst wäre er das Einzige, was der Griff verlöre. */ nicht drin ist sonst wäre er das Einzige, was der Griff verlöre. */
function loadSnapshot(s){ function loadSnapshot(s){
if(src.readOnly) return;
snapshotNow(true); /* bewusstes Wegleg-Ereignis wie der Knopf, nicht der Takt */ snapshotNow(true); /* bewusstes Wegleg-Ereignis wie der Knopf, nicht der Takt */
closeSnapMenu(); closeSnapMenu();
if(!replaceTextUndoable(s.text)) render(); if(!replaceTextUndoable(s.text)) render();
@@ -3811,10 +3696,6 @@ function closeSnapMenu(){
snapMenu.hidden = true; snapMenu.hidden = true;
snapBtn.setAttribute('aria-expanded', 'false'); snapBtn.setAttribute('aria-expanded', 'false');
} }
function updateSnapBtn(){
snapBtn.hidden = snapAddBtn.hidden = src.readOnly;
if(src.readOnly) closeSnapMenu();
}
snapBtn.addEventListener('click', e => { snapBtn.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
snapMenu.hidden ? openSnapMenu() : closeSnapMenu(); snapMenu.hidden ? openSnapMenu() : closeSnapMenu();
@@ -4010,7 +3891,7 @@ document.addEventListener('pointerdown', e => {
function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : ''; function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : '';
/* Vergleichsstand für den nächsten Snapshot (D54): Ohne ihn legte der /* Vergleichsstand für den nächsten Snapshot (D54): Ohne ihn legte der
erste Takt nach dem Öffnen auch ein unverändertes Dokument weg. */ erste Takt nach dem Öffnen auch ein unverändertes Dokument weg. */
snapBase = src.value; closeSnapMenu(); updateSnapBtn(); snapBase = src.value; closeSnapMenu();
render(); updateDocName(); updateFreshBtn(); } render(); updateDocName(); updateFreshBtn(); }
function switchDoc(id){ function switchDoc(id){
if(id === activeId) return; if(id === activeId) return;
@@ -4072,7 +3953,6 @@ 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) */
if(liveState && liveState.id === d.id) stopLive(); /* dito fürs Server-Dokument (D76) */ if(liveState && liveState.id === d.id) stopLive(); /* dito fürs Server-Dokument (D76) */
if(fileHandles.has(d.id)){ fileHandles.delete(d.id); idbDeleteHandle(d.id); } /* mit dem Dokument geht sein Datei-Handle (D72) */ if(fileHandles.has(d.id)){ fileHandles.delete(d.id); idbDeleteHandle(d.id); } /* mit dem Dokument geht sein Datei-Handle (D72) */
docs = docs.filter(x => x.id !== d.id); docs = docs.filter(x => x.id !== d.id);
@@ -4336,24 +4216,21 @@ function initDocs(){
src.value = d ? d.text : ''; src.value = d ? d.text : '';
snapBase = src.value; snapBase = src.value;
restoring = false; restoring = false;
updateSnapBtn();
persistDocs(); /* migrierte/geladene Liste festschreiben (stabil über Reload) */ persistDocs(); /* migrierte/geladene Liste festschreiben (stabil über Reload) */
updateDocName(); updateDocName();
render(); render();
} }
/* ---------- Text von außen: ?sourceUrl= (D23) und ?etherpad= (D31) ---------- /* ---------- Text von außen: ?sourceUrl= (D23) ----------
Beide holen einen Notationstext über http(s) und führen ihn als eigenes Holt einen Notationstext über http(s) und führt ihn als eigenes Dokument. Die
Dokument. Die id leitet sich aus der URL ab: derselbe Link aktualisiert dieses id leitet sich aus der URL ab: derselbe Link aktualisiert dieses Dokument,
Dokument, statt bei jedem Aufruf ein neues anzulegen. Eigene Dokumente des statt bei jedem Aufruf ein neues anzulegen. Eigene Dokumente des Nutzers
Nutzers bleiben unberührt. Scheitert das Laden (häufigster Fall: das Ziel bleiben unberührt. Scheitert das Laden (häufigster Fall: das Ziel sendet
sendet keinen CORS-Header), bleibt der bisherige Stand stehen und es keinen CORS-Header), bleibt der bisherige Stand stehen und es erscheint eine
erscheint eine Warnung. Warnung. Eine statische Datei, einmal pro Laden geholt.
Unterschied: `sourceUrl` ist eine statische Datei, einmal pro Laden geholt `?etherpad=` gab es hier einmal daneben (D31) und ist ausgebaut (D78) ein
(D23, unverändert). `etherpad` ist ein lebendes Pad wiederholt geholt und alter Link meldet sich, statt still nichts zu tun. */
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';
const ETHERPAD_PARAM = 'etherpad'; const ETHERPAD_PARAM = 'etherpad';
function urlParam(name){ function urlParam(name){
@@ -4361,16 +4238,14 @@ function urlParam(name){
} }
function sourceUrlParam(){ return urlParam(SOURCE_PARAM); } function sourceUrlParam(){ return urlParam(SOURCE_PARAM); }
/* Woher kommt der Text? null = kein Parameter, {bad,error} = unbrauchbare /* Woher kommt der Text? null = kein Parameter, {gone} = ausgebauter Eingang,
Angabe, sonst der Beschreiber für loadRemoteSource(). Die Normalisierung der {bad,error} = unbrauchbare Angabe, sonst der Beschreiber für
Pad-Adresse steht headless in remote.js dort auch ihre Begründung. */ loadRemoteSource(). */
function remoteSource(){ function remoteSource(){
const padRaw = urlParam(ETHERPAD_PARAM); /* Ein alter `?etherpad=`-Link soll nicht stillschweigend ein leeres
if(padRaw){ Werkbaum zeigen: Wer ihn irgendwo stehen hat, erfährt hier, wohin die
const p = padUrls(padRaw, location.href); Zusammenarbeit gezogen ist (D78). */
if(!p) return {bad: padRaw, error: 'not an Etherpad URL'}; if(urlParam(ETHERPAD_PARAM)) return {gone: true};
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 null; if(!raw) return null;
let url; let url;
@@ -4380,25 +4255,15 @@ function remoteSource(){
(Failed to fetch", „HTTP 404") der Rahmentext ist lokalisiert. */ (Failed to fetch", „HTTP 404") der Rahmentext ist lokalisiert. */
try{ url = new URL(raw, location.href); }catch(_){ return {bad: raw, error: 'invalid URL'}; } try{ url = new URL(raw, location.href); }catch(_){ return {bad: raw, error: 'invalid URL'}; }
if(url.protocol !== 'http:' && url.protocol !== 'https:') return {bad: url.href, error: url.protocol}; if(url.protocol !== 'http:' && url.protocol !== 'https:') return {bad: url.href, error: url.protocol};
return {fetchUrl: url.href, id: 'url:' + url.href, name: url.href, source: url.href, live: false}; return {fetchUrl: url.href, id: 'url:' + url.href, name: url.href, source: url.href};
} }
/* `timeoutMs` nur beim Pad-Takt (D31): Hängt die Gegenseite, muss der Abruf async function fetchRemote(url){
abbrechen, sonst bliebe der Riegel `padBusy` für immer zu und es käme nie const resp = await fetch(url, {cache:'no-store', credentials:'omit'});
wieder etwas. Der erste Ladeversuch wartet unbegrenzt wie bisher (D23). */ if(!resp.ok) throw new Error('HTTP ' + resp.status);
async function fetchRemote(url, timeoutMs){ return await resp.text();
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); }
} }
/* Geholten Text als Dokument übernehmen und aktivieren. Gemeinsam genutzt vom /* Geholten Text als Dokument übernehmen und aktivieren. */
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){ function adoptRemote(s, text){
flushActive(); /* laufende Bearbeitung nicht verlieren */ flushActive(); /* laufende Bearbeitung nicht verlieren */
let d = docs.find(x => x.id === s.id); let d = docs.find(x => x.id === s.id);
@@ -4414,28 +4279,23 @@ function adoptRemote(s, text){
async function loadRemoteSource(){ async function loadRemoteSource(){
const s = remoteSource(); const s = remoteSource();
if(!s) return; if(!s) return;
if(s.gone){
sourceWarning = {type:'padGone'};
render();
return;
}
if(s.bad !== undefined){ if(s.bad !== undefined){
sourceWarning = {type:'sourceLoad', url: s.bad, error: s.error}; sourceWarning = {type:'sourceLoad', url: s.bad, error: s.error};
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{
adoptRemote(s, await fetchRemote(s.fetchUrl)); adoptRemote(s, await fetchRemote(s.fetchUrl));
}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;
} }
} }
@@ -4886,204 +4746,6 @@ async function putOnServer(){
} }
} }
/* ---------- Pad: auf Knopfdruck neu holen (D31) ----------
Kein Hintergrund-Takt. Der erste Entwurf holte alle 2,5 s selbsttätig und
lief damit in Etherpads **Drosselung**: `importExportRateLimiting` ist
serienmäßig an und lässt 10 Abrufe je 90 s und IP zu, der Takt wollte 36.
Danach hält die Gegenseite die Verbindung einfach offen (kein `429`, keine
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 padBusy = false; /* ein Abruf unterwegs — schützt vor Doppelklick */
let padNextAllowed = 0; /* Zeitpunkt (ms), ab dem wieder geholt werden darf */
let padCoolTimer = null;
/* Wer das Pad-Dokument löscht, meint es: danach gibt es nichts mehr zu holen. */
function stopPad(){ padSource = null; }
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);
}
/* Sperre sichtbar machen, aber den Knopf **klickbar** lassen: Ein `disabled`
erklärt nichts. So kann der Klick den Grund melden (Live-Region `#warn`). */
function updatePadRefreshLabel(){
if(!padRefreshBtn || padRefreshBtn.hidden) return;
const left = Math.ceil(padCoolLeft()/1000);
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;
if(!s || padBusy) return;
if(padCoolLeft() > 0){
/* Nicht heimlich nichts tun: Der Grund ist eine fremde Grenze, und die
gehört gesagt sonst wirkt der Knopf kaputt. Die Meldung landet im
Warnbereich, der eine Live-Region ist und deshalb angesagt wird. */
sourceWarning = {type:'padRateLimit', seconds: Math.ceil(padCoolLeft()/1000)};
render();
return;
}
padBusy = true;
setPadBusy(true);
setPadCooldown(PAD_MIN_GAP_MS);
let text;
try{ text = await fetchRemote(s.fetchUrl, PAD_FETCH_TIMEOUT_MS); }
catch(err){
/* Anders als ein Hintergrund-Takt ist das eine bewusste Handlung sie
braucht eine Antwort, auch wenn sie schiefgeht. Ein Abbruch bekommt einen
**eigenen** Warnungstyp: `sourceLoad` zeigt auf CORS, und bei einer
Drosselung schickte das den Leser auf die falsche Fährte. */
if(err && err.name === 'AbortError'){
/* Abbruch heißt: Wir sind schon in der Drosselung. Weiterklicken hilft
nicht, also das ganze Fenster abwarten und das dem Nutzer sagen. */
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;
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();
}
/* ---------- Ansicht bei einem Pad-Dokument: beide | nur Pad | nur Spiegel ----
Das Pad lässt sich einbetten (nachgemessen: kein `X-Frame-Options`, keine CSP
mit `frame-ancestors`), doch es ersetzt nicht einfach das Textfeld: Alt+Klick
und die Cursor-Zeile (D25) arbeiten auf **unserem** `<textarea>`, und in einen
fremdstämmigen Rahmen kommt kein DOM-Zugriff. Deshalb drei Ansichten statt
einer Entscheidung in beide" bleibt der Spiegel schmal ziehbar und trägt
weiter die Sprünge.
Ein **Wähler** statt dreier Knöpfe: Die Editor-Titelzeile ist schon voll
(Dokument, Pad, Neu laden, Kopieren, Legende, Fenster), und auf kleinem
Bildschirm (D17) ist sie es dreifach. Derselbe Reihum-Griff wie beim
Modus-Wähler dort.
Der Rahmen wird **nur geladen, wenn er sichtbar ist** (`about:blank` sonst).
Das ist kein Geiz: Ein geladenes Pad verbindet sich per Socket und macht dich
in der Anwesenden-Liste sichtbar. Nur Spiegel" ist damit die Ansicht, die
nichts von dir verrät. */
const padFrame = document.getElementById('padFrame');
const srcArea = document.getElementById('srcArea');
const padGutter = document.getElementById('padGutter');
const padViewBtn = document.getElementById('padViewBtn');
function applyPadView(){
const d = activeDoc();
const isPad = !!(d && padSource && d.id === padSource.id);
const show = isPad ? padView : 'text';
if(srcArea) srcArea.className = 'src-area pv-' + show;
if(padViewBtn){
padViewBtn.hidden = !isPad;
padViewBtn.dataset.view = show;
const tip = t('padViewTooltip', {state: t('padView_' + show)});
padViewBtn.title = tip;
padViewBtn.setAttribute('aria-label', tip);
}
if(padGutter) padGutter.hidden = (show !== 'both');
if(!padFrame) return;
const wantFrame = isPad && show !== 'text';
padFrame.hidden = !wantFrame;
const wantSrc = wantFrame ? padViewUrl(padSource.source) : 'about:blank';
if(padFrame.getAttribute('src') !== wantSrc) padFrame.setAttribute('src', wantSrc);
}
/* Zeilennummern und Monospace sind für diese Notation die richtige Darstellung;
der Chat kostet in einem schmalen Rahmen nur Platz. */
function padViewUrl(padUrl){
return padUrl + '?showChat=false&showLineNumbers=true&useMonospaceFont=true';
}
function cyclePadView(){
padView = PAD_VIEWS[(PAD_VIEWS.indexOf(padView) + 1) % PAD_VIEWS.length];
applyPadView();
saveUI();
}
if(padViewBtn) padViewBtn.addEventListener('click', cyclePadView);
/* 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');
const padRefreshBtn = document.getElementById('padRefresh');
function updatePadLink(){
const d = activeDoc();
const isPad = !!(d && padSource && d.id === padSource.id);
src.readOnly = isPad;
updateSnapBtn(); /* Pad-Dokumente sammeln keine Stände (D54) */
src.classList.toggle('readonly', isPad);
if(srcWrap) srcWrap.classList.toggle('readonly', isPad); /* Streifen mit abtönen */
if(isPad) src.title = t('padReadonly'); else src.removeAttribute('title');
if(padLink){
padLink.hidden = !isPad;
if(isPad){
padLink.href = padSource.source;
padLink.title = t('padEdit');
padLink.setAttribute('aria-label', t('padEdit'));
}
}
/* 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 */
}
applyPadView();
}
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. */
@@ -5228,43 +4890,6 @@ hintGutter.addEventListener('dblclick', () => {
(D26): gezogen wird von der Spiegelseite aus, Doppelklick setzt zurück, (D26): gezogen wird von der Spiegelseite aus, Doppelklick setzt zurück,
Ausrichtung folgt der Panel-Anordnung. Beide Werte werden getrennt gehalten, Ausrichtung folgt der Panel-Anordnung. Beide Werte werden getrennt gehalten,
damit ein Moduswechsel die jeweils andere Aufteilung nicht zerstört. */ damit ein Moduswechsel die jeweils andere Aufteilung nicht zerstört. */
const PAD_MIN = 120; /* schmal darf er werden — aber lesbar bleiben */
const PAD_MAX_SHARE = 0.85;
let padDragging = false;
padGutter.addEventListener('pointerdown', e => {
padDragging = true;
padGutter.classList.add('dragging');
padGutter.setPointerCapture(e.pointerId);
document.body.style.userSelect = 'none';
e.preventDefault();
});
padGutter.addEventListener('pointermove', e => {
if(!padDragging) return;
const b = srcArea.getBoundingClientRect();
if(editorStacked()){
const h = Math.min(Math.max(b.bottom - e.clientY, PAD_MIN), b.height * PAD_MAX_SHARE);
app.style.setProperty('--prow', Math.round(h) + 'px');
} else {
const w = Math.min(Math.max(b.right - e.clientX, PAD_MIN), b.width * PAD_MAX_SHARE);
app.style.setProperty('--pcol', Math.round(w) + 'px');
}
});
function endPadDrag(e){
if(!padDragging) return;
padDragging = false;
padGutter.classList.remove('dragging');
document.body.style.userSelect = '';
try{ padGutter.releasePointerCapture(e.pointerId); }catch(_){}
saveUI();
}
padGutter.addEventListener('pointerup', endPadDrag);
padGutter.addEventListener('pointercancel', endPadDrag);
padGutter.addEventListener('dblclick', () => {
app.style.removeProperty('--pcol');
app.style.removeProperty('--prow');
saveUI();
});
/* Modus-Wähler auf kleinem Bildschirm: es ist nur das aktive Icon sichtbar, /* Modus-Wähler auf kleinem Bildschirm: es ist nur das aktive Icon sichtbar,
Tippen schaltet zum nächsten Modus (reihum). Auf normaler Größe bleibt es Tippen schaltet zum nächsten Modus (reihum). Auf normaler Größe bleibt es
der Dreier-Umschalter dort kehrt der Handler sofort zurück. */ der Dreier-Umschalter dort kehrt der Handler sofort zurück. */
-28
View File
@@ -1,28 +0,0 @@
/* 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'};
}
+1 -1
View File
@@ -6,7 +6,7 @@
dafür ist reine Logik sie hing nur an `localStorage`, `Date.now()` und dafür ist reine Logik sie hing nur an `localStorage`, `Date.now()` und
dem `<textarea>` fest und war deshalb nicht prüfbar. dem `<textarea>` fest und war deshalb nicht prüfbar.
Aufteilung nach dem Vorbild von remote.js (D31): Hier steht, WAS gilt; in Aufteilung wie bei den übrigen Modulen: Hier steht, WAS gilt; in
app.js bleibt, WOHER die Werte kommen (aktives Dokument, Schreibschutz, app.js bleibt, WOHER die Werte kommen (aktives Dokument, Schreibschutz,
Menü) und WOHIN sie gehen (`localStorage`). Der Speicher wird als Menü) und WOHIN sie gehen (`localStorage`). Der Speicher wird als
`{setItem, removeItem}` hereingereicht, die Uhr als Zahl beides lässt sich `{setItem, removeItem}` hereingereicht, die Uhr als Zahl beides lässt sich
+3 -54
View File
@@ -270,29 +270,8 @@
Horizontal nebeneinander, vertikal (.side) untereinander. */ Horizontal nebeneinander, vertikal (.side) untereinander. */
.editor-body{display:flex;align-items:stretch;flex:1 1 auto;min-height:0} .editor-body{display:flex;align-items:stretch;flex:1 1 auto;min-height:0}
.editor-body textarea{flex:1 1 auto;min-width:0;min-height:0} .editor-body textarea{flex:1 1 auto;min-width:0;min-height:0}
/* Pad-Rahmen | interner Spiegel (D31). Eigener Bereich, damit die /* Zeilennummern + Textfeld sind eine Spalte (`.src-wrap`, D33). */
Legenden-Aufteilung (D26) unangetastet bleibt. Nur in der Ansicht beide"
bekommt der Spiegel eine feste Größe (`--pcol`/`--prow`) sonst füllt er
wie bisher den Bereich. Die 85 %-Grenze steht zusätzlich hier im CSS, weil
der gespeicherte Wert ein px-Wert ist und der Bereich später schrumpfen
kann (dieselbe Falle wie bei der Legende, D26). */
.src-area{display:flex;align-items:stretch;flex:1 1 auto;min-width:0;min-height:0}
.padframe{flex:1 1 auto;min-width:0;min-height:0;border:0;background:var(--card)}
.padframe[hidden]{display:none}
/* Zeilennummern + Textfeld sind eine Spalte (`.src-wrap`). Der Pad-Splitter
teilt gegen diese Spalte nicht gegen das nackte Textfeld, sonst stünde der
Zahlenstreifen außerhalb der geteilten Breite. */
.src-wrap{display:flex;align-items:stretch;flex:1 1 auto;min-width:0;min-height:0} .src-wrap{display:flex;align-items:stretch;flex:1 1 auto;min-width:0;min-height:0}
.src-area.pv-both>.src-wrap{
flex:0 0 var(--pcol,300px);max-width:85%;
border-left:1px dashed rgba(36,52,71,.18);
}
.src-area.pv-pad>.src-wrap{display:none}
.side .src-area,body.mobile .src-area{flex-direction:column}
.side .src-area.pv-both>.src-wrap,body.mobile .src-area.pv-both>.src-wrap{
flex:0 0 var(--prow,180px);max-width:none;max-height:85%;
border-left:0;border-top:1px dashed rgba(36,52,71,.18);
}
/* Legende: bewusst KEIN <details> mehr. Chrome legt den Details-Inhalt in /* Legende: bewusst KEIN <details> mehr. Chrome legt den Details-Inhalt in
`::details-content`; dadurch war `.hint` kein Flex-Kind von `.agenda` mehr, `::details-content`; dadurch war `.hint` kein Flex-Kind von `.agenda` mehr,
`flex:1/min-height:0` liefen ins Leere und der Inhalt wurde geclippt statt `flex:1/min-height:0` liefen ins Leere und der Inhalt wurde geclippt statt
@@ -343,17 +322,6 @@
.side .agenda.open{width:auto;max-width:none;height:var(--hrow,220px);max-height:85%} .side .agenda.open{width:auto;max-width:none;height:var(--hrow,220px);max-height:85%}
.side .hint-gutter{cursor:row-resize} .side .hint-gutter{cursor:row-resize}
.side .hint-gutter::before{width:38px;height:3px} .side .hint-gutter::before{width:38px;height:3px}
/* Splitter Pad|Spiegel — gleiches Idiom wie der Legenden-Splitter. */
.pad-gutter{
flex:0 0 10px;align-self:stretch;
cursor:col-resize;touch-action:none;
display:flex;align-items:center;justify-content:center;
}
.pad-gutter[hidden]{display:none}
.pad-gutter::before{content:'';width:3px;height:38px;border-radius:2px;background:rgba(36,52,71,.18)}
.pad-gutter:hover::before,.pad-gutter.dragging::before{background:var(--or)}
.side .pad-gutter,body.mobile .pad-gutter{cursor:row-resize}
.side .pad-gutter::before,body.mobile .pad-gutter::before{width:38px;height:3px}
.panel{ .panel{
background:var(--card); background:var(--card);
@@ -431,22 +399,9 @@
kopieren, aber dieselbe Erfolgs-Rückmeldung geben (Stand sichern, D54). */ kopieren, aber dieselbe Erfolgs-Rückmeldung geben (Stand sichern, D54). */
.copybtn.done .ic-copy,.copybtn.done .ic-main{display:none} .copybtn.done .ic-copy,.copybtn.done .ic-main{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],.padrefresh[hidden],.padview[hidden]{display:none}
/* Der Ansichts-Wähler zeigt seinen Zustand als Position im Rahmen: geteilt,
links gefüllt (Pad) oder rechts gefüllt (Spiegel). Zwei Klassen, damit die
Regel die allgemeinere `.copybtn svg{display:block}` sicher schlägt. */
.copybtn.padview .ic-both,
.copybtn.padview .ic-pad,
.copybtn.padview .ic-text{display:none}
.copybtn.padview[data-view="both"] .ic-both,
.copybtn.padview[data-view="pad"] .ic-pad,
.copybtn.padview[data-view="text"] .ic-text{display:block}
/* Falt-Durchschalter (SPEC §9/D75): vier Icons, sichtbar ist das des /* Falt-Durchschalter (SPEC §9/D75): vier Icons, sichtbar ist das des
NÄCHSTEN Schritts `data-next` setzt updateFoldBtn(). Zwei Klassen wie NÄCHSTEN Schritts `data-next` setzt updateFoldBtn(). Zwei Klassen, damit
beim Pad-Wähler, damit die Regel `.copybtn svg{display:block}` schlägt. */ die Regel `.copybtn svg{display:block}` schlägt. */
.copybtn.foldbtn .fc-small, .copybtn.foldbtn .fc-small,
.copybtn.foldbtn .fc-path, .copybtn.foldbtn .fc-path,
.copybtn.foldbtn .fc-closed, .copybtn.foldbtn .fc-closed,
@@ -596,11 +551,6 @@
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}
/* Zeilennummern (D33). Die Warnungen nennen Zeilennummern ohne Streifen /* Zeilennummern (D33). Die Warnungen nennen Zeilennummern ohne Streifen
muss man sie im Textfeld abzählen. Jede Zahl steht auf einer **gemessenen** muss man sie im Textfeld abzählen. Jede Zahl steht auf einer **gemessenen**
Höhe (`renderLineNos()` in app.js), nicht auf Zeilenhöhe × n"; seit D49 Höhe (`renderLineNos()` in app.js), nicht auf Zeilenhöhe × n"; seit D49
@@ -617,7 +567,6 @@
color:var(--muted); color:var(--muted);
-webkit-user-select:none;user-select:none; -webkit-user-select:none;user-select:none;
} }
.src-wrap.readonly .lineno{background:#F1F4F7}
.lineno-inner{position:absolute;top:0;left:0;right:0} .lineno-inner{position:absolute;top:0;left:0;right:0}
.lineno-inner span{ .lineno-inner span{
position:absolute;right:6px; position:absolute;right:6px;
+6 -12
View File
@@ -39,18 +39,14 @@
exakte Suche (D42); zeilenlos exakte Suche (D42); zeilenlos
- 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 - padGone { } ein alter ?etherpad=-Link: die Anbindung
erst geholt, um Etherpads Grenze nicht ist ausgebaut (D78), der Text zeigt auf
auszulösen (D31) ?live=; zeilenlos
- liveLoad { url, error } Server-Dokument (?live=, D76) nicht - liveLoad { url, error } Server-Dokument (?live=, D76) nicht
erreichbar oder keine Dokument-Adresse erreichbar oder keine Dokument-Adresse
- liveStale { error } eigene Änderung nicht anwendbar - liveStale { error } eigene Änderung nicht anwendbar
(Prüfsumme, Index, Basis verdichtet); (Prüfsumme, Index, Basis verdichtet);
der Client hat einmal neu geladen (D76) der Client hat einmal neu geladen (D76) */
- sourceTimeout { url, seconds } Abruf abgebrochen, Gegenseite zu langsam
(D31). Eigener Typ, weil `sourceLoad` auf
CORS zeigt bei einem Zeitablauf schickt
das den Leser auf die falsche Fährte */
import { esc } from './render.js'; import { esc } from './render.js';
@@ -95,14 +91,12 @@ function build(w, t, esc){
return t('cheapApproxWarn'); return t('cheapApproxWarn');
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': case 'padGone':
return t('padRateLimitWarn', {seconds: w.seconds}); return t('padGoneWarn');
case 'liveLoad': case 'liveLoad':
return t('liveLoadWarn', {url: esc(w.url), error: esc(w.error)}); return t('liveLoadWarn', {url: esc(w.url), error: esc(w.error)});
case 'liveStale': case 'liveStale':
return t('liveStaleWarn', {error: esc(w.error)}); return t('liveStaleWarn', {error: esc(w.error)});
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 ?? '?'})`;
} }
-69
View File
@@ -1,69 +0,0 @@
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();
});
});
+5 -12
View File
@@ -21,18 +21,11 @@ describe('formatWarning — vereinheitlichtes Warnungs-Modell', () => {
expect(out).toBe('sourceLoadWarn|{"url":"https://a/?x=1&amp;y=2","error":"HTTP 404"}'); 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 /* Ein alter ?etherpad=-Link soll nicht still ins Leere laufen: Die Anbindung
(10 Abrufe je 90 s) schickte das den Leser auf die falsche Fährte (D31). */ ist ausgebaut (D78), der Text zeigt auf ?live=. Ohne Platzhalter die
it('sourceTimeout ist ein eigener Typ und nennt die Sekunden', () => { Meldung hat nichts einzusetzen. */
const out = formatWarning({type: 'sourceTimeout', url: 'https://p/x&y', seconds: 20}, t); it('padGone meldet den ausgebauten Eingang ohne Platzhalter', () => {
expect(out).toBe('sourceTimeoutWarn|{"url":"https://p/x&amp;y","seconds":20}'); expect(formatWarning({type: 'padGone'}, t)).toBe('padGoneWarn|undefined');
});
/* 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', () => {