feat(frontend): gemeinsam am Server-Dokument arbeiten (?live=, Schritt 6)

Der Editor fuehrt ein Dokument des Backends: laden, nach 1,5 s Ruhe das Diff
schicken, ueber einen offenen Abruf fremde Aenderungen einspielen — ohne
Neuladen, mit mitwandernder Schreibmarke. Dazu CORS im Backend; ohne das
blockiert der Browser jeden Aufruf.

Zwei Fehler hat erst der Live-Test gegen das laufende Backend gefunden, beide
an der Naht zwischen Modul und Verdrahtung (D54-Nachtrag 3):

Der Konflikt entstand nie. Mit laufendem Feed zieht die Schattenkopie staendig
nach, die eigene Basis ist also nie veraltet — der Server haette nie 409
geantwortet, und die fremde Zeile waere stillschweigend ueberschrieben worden.
Der Client prueft die Ueberschneidung jetzt selbst gegen den ungesendeten
Text; den kennt der Server nicht.

Die Nummer begann nach jedem Neuladen wieder bei 1, waehrend die Kennung
blieb — der Server hielt die erste echte Aenderung fuer eine Wiederholung und
tat nichts. Beides liegt jetzt im sessionStorage: je Tab, ueberlebt Neuladen.
Je Tab ist zugleich die richtige Aussage, zwei Tabs sind zwei Schreiber.

Nachgemessen im Browser: fremde Aenderung erscheint ohne Neuladen, Getipptes
erreicht den Server, beide Konflikt-Knoepfe tun was sie sagen, und die
Schreibmarke steht nach zwei fremd eingefuegten Zeilen darueber unveraendert
bei Zeile+2, Spalte 8. 518 Frontend-Tests, 135 im Backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 17:48:17 +02:00
co-authored by Claude Opus 5
parent 5881be1a2c
commit 08965157a1
12 changed files with 666 additions and 7 deletions
+10 -6
View File
@@ -1,9 +1,10 @@
# Live-Editing über HTTP (Variante „Simpel") # Live-Editing über HTTP (Variante „Simpel")
Status: **Konzept entschieden** (D76), **Schritte 15 der Umsetzungsreihenfolge Status: **umgesetzt** — alle sechs Schritte der Umsetzungsreihenfolge stehen
gebaut** (Zeilen-Diff, zweistufige Historie, `PATCH /content`, Änderungsfeed, (Zeilen-Diff, zweistufige Historie, `PATCH /content`, Änderungsfeed,
Master-Passwort); der Client steht aus, ebenso das Umbenennen per Master-Passwort, Client). Offen bleiben das Umbenennen per `PATCH /title`
`PATCH /title` und damit das Ereignis `RENAMED`. Die offenen (und damit das Ereignis `RENAMED`), ein Eingabefeld für den Anzeigenamen und
die Präsenz-Anzeige. Die offenen
Punkte des ersten Entwurfs sind beantwortet; die Begründungen stehen in Punkte des ersten Entwurfs sind beantwortet; die Begründungen stehen in
`docs/DECISIONS.md` unter D76 und werden hier nicht wiederholt, sondern nur `docs/DECISIONS.md` unter D76 und werden hier nicht wiederholt, sondern nur
verwiesen. Was beim Bauen zusätzlich zu entscheiden war, steht dort in verwiesen. Was beim Bauen zusätzlich zu entscheiden war, steht dort in
@@ -24,7 +25,9 @@ Nachtrag 4.
deshalb ausschließlich auf **physischen Zeilen** und braucht keine IDs. deshalb ausschließlich auf **physischen Zeilen** und braucht keine IDs.
- Bei echtem Gleichzeitig-Konflikt: Update ablehnen, **der Client - Bei echtem Gleichzeitig-Konflikt: Update ablehnen, **der Client
entscheidet** (rebase, neu laden, verwerfen). Das Dokument darf nie entscheidet** (rebase, neu laden, verwerfen). Das Dokument darf nie
kaputtgehen. kaputtgehen. **Der Client erkennt den Konflikt zusätzlich selbst**, wenn eine
fremde Änderung die Zeilen trifft, an denen gerade getippt wird — der Server
kann das nicht sehen, er kennt den ungesendeten Text nicht (D76-Nachtrag 7).
### Zugriff und Identität ### Zugriff und Identität
@@ -433,7 +436,8 @@ verworfen.
4. ~~`GET /changes` mit Long Polling, Volltext-Fall und Ereignistypen 4. ~~`GET /changes` mit Long Polling, Volltext-Fall und Ereignistypen
(Spec + Cucumber)~~ — gebaut, `ChangeNotifier` + `LiveEditingService` (Spec + Cucumber)~~ — gebaut, `ChangeNotifier` + `LiveEditingService`
5. ~~Master-Passwort für `GET /documents` (Spring Security)~~ — gebaut 5. ~~Master-Passwort für `GET /documents` (Spring Security)~~ — gebaut
6. Client-Anpassung (Feed-Schleife, lokales Anwenden, Konfliktdialog) 6. ~~Client-Anpassung (Feed-Schleife, lokales Anwenden, Konfliktdialog)~~ —
gebaut, `frontend/src/live.js` + `app.js`; Befunde in D76-Nachtrag 7
**Vor Schritt 4** steht die Vermessung der Zielumgebung (siehe „Betrieb") — **Vor Schritt 4** steht die Vermessung der Zielumgebung (siehe „Betrieb") —
sie bestimmt den `wait`-Wert und im Extremfall, ob Long Polling dort sie bestimmt den `wait`-Wert und im Extremfall, ob Long Polling dort
@@ -0,0 +1,46 @@
package de.werkbaum.api
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
/**
* Von welchen Herkünften darf der Editor die API ansprechen?
*
* Voreinstellung `*`. Das ist hier keine Nachlässigkeit, sondern das
* Zugriffsmodell (D76): Wer die unerratbare UUID hat, darf; wer sie nicht hat,
* findet sie auch über eine erlaubte Herkunft nicht. CORS schützt Cookies und
* mitgesendete Anmeldedaten — beides gibt es hier nicht (`credentials: omit`).
* Der Editor wiederum läuft je nach Installation überall: GitHub Pages, eigene
* Domain, Dev-Server, `file://`.
*
* Wer es enger will, setzt `werkbaum.cors.allowed-origins` auf die eigenen
* Adressen.
*/
@ConfigurationProperties(prefix = "werkbaum.cors")
data class CorsProperties(
val allowedOrigins: List<String> = listOf("*"),
)
@Configuration
class CorsConfiguration(private val properties: CorsProperties) {
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val config = CorsConfiguration().apply {
allowedOriginPatterns = properties.allowedOrigins
allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
// Authorization fuer das Master-Passwort, Content-Type fuer JSON.
allowedHeaders = listOf("Authorization", "Content-Type")
// Nichts Vertrauliches im Spiel; Cookies werden nie mitgesendet.
allowCredentials = false
maxAge = 3600
}
return UrlBasedCorsConfigurationSource().apply {
registerCorsConfiguration("/api/**", config)
}
}
}
@@ -75,6 +75,10 @@ class SecurityConfiguration {
properties: MasterPasswordProperties, properties: MasterPasswordProperties,
): SecurityFilterChain = ): SecurityFilterChain =
http http
// CORS-Regeln gelten auch fuer die Preflight-Anfrage - die traegt
// keine Anmeldedaten und darf deshalb nicht an der Kette haengen
// bleiben.
.cors { }
// Zustandslose API: keine Sitzung, kein CSRF-Token. Der Schutz // Zustandslose API: keine Sitzung, kein CSRF-Token. Der Schutz
// hängt am Passwort, nicht an einem Cookie ein CSRF-Token // hängt am Passwort, nicht an einem Cookie ein CSRF-Token
// schützte hier nichts und bräche jeden Client. // schützte hier nichts und bräche jeden Client.
@@ -37,6 +37,12 @@ werkbaum:
# haelt einen Long-Poll 30 s durch, seine Zeitgrenzen liegen bei 300 s. # haelt einen Long-Poll 30 s durch, seine Zeitgrenzen liegen bei 300 s.
max-wait: 25s max-wait: 25s
# Von welchen Herkuenften darf der Editor die API ansprechen? "*" ist die
# Voreinstellung: Zugriff regelt die unerratbare UUID, nicht die Herkunft,
# und Cookies werden nie mitgesendet. Enger geht ueber eine Liste.
cors:
allowed-origins: "*"
# Schutz der Dokumentenliste. BCrypt-Hash, NIE im Repository - er kommt aus # Schutz der Dokumentenliste. BCrypt-Hash, NIE im Repository - er kommt aus
# der Umgebung. Ohne ihn bleibt GET /documents gesperrt. # der Umgebung. Ohne ihn bleibt GET /documents gesperrt.
master-password: master-password:
+6
View File
@@ -17,6 +17,12 @@ the git history of `docs/examples/werkbaum.werkbaum`. A day can therefore carry
a link without having a note (someone forgot to write one) — but never the a link without having a note (someone forgot to write one) — but never the
reverse. reverse.
## 2026-08-26
- 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
- The caret stays where you put it when someone else inserts lines above you
## 2026-08-25 ## 2026-08-25
- The fold button cycles through four presets now: size M and smaller, everything off the cheapest path, everything, all open - The fold button cycles through four presets now: size M and smaller, everything off the cheapest path, everything, all open
+66
View File
@@ -6196,3 +6196,69 @@ gehasht wurde, ein Wechsel des Verfahrens bricht nichts — und die Tests dürfe
ausdrücklich vorgesehen („geprüft über Spring Security") und damit von der ausdrücklich vorgesehen („geprüft über Spring Security") und damit von der
Rückfragepflicht der Wurzel-CLAUDE.md gedeckt. Sie ist zugleich der Platz für Rückfragepflicht der Wurzel-CLAUDE.md gedeckt. Sie ist zugleich der Platz für
die spätere richtige Authentifizierung. die spätere richtige Authentifizierung.
**Nachtrag 7 — der Client: was der Live-Test gefunden hat (2026-08-26).**
Schritt 6 steht: `?live=<Dokument-URL>` führt ein Server-Dokument, schickt nach
1,5 s Ruhe das Diff und hält einen Feed offen. Die entscheidbare Hälfte liegt
headless in `frontend/src/live.js`, die I/O in `app.js` (Hausregel,
D54-Nachtrag 3). Fünf Festlegungen und zwei Funde:
**Der Name ist der Titel des Servers, nicht die URL.** Das weicht vom
Haupttext ab („der Name ist die URL", wie bei `?sourceUrl=` und `?etherpad=`),
und zwar mit Grund: Anders als eine Datei oder ein Pad **hat** ein
Server-Dokument einen Namen, und alle sehen denselben. Die vollständige
Adresse steht wie dort im Tooltip. Identität und Wiederfinden hängen
unverändert an der URL.
**Konflikte entstehen beim Tippen, nicht erst beim Senden — und das war der
erste Fund.** Der Server sieht nur, was eingereicht wird; den ungesendeten
Text im Editor kennt er nicht. Mit laufendem Feed zieht die Schattenkopie
ständig nach, die eigene Basis ist also nie veraltet — ein 409 käme praktisch
nie zustande, und die fremde Zeile wäre **stillschweigend überschrieben**.
Deshalb prüft der Client beim Einblenden fremder Änderungen selbst, ob sie
sich mit dem gerade Getippten überschneiden, und stellt dann dieselbe Frage.
Ein Zustand, zwei Wege hinein: lokal erkannt oder vom Server gemeldet.
**Die Frage lautet „wessen Fassung", nicht „welche Zeile".** Zwei Knöpfe,
ganzes Dokument: *Fremde übernehmen* setzt den Text auf den Server-Stand,
*Eigene durchsetzen* zieht nur die Schattenkopie nach und schickt den eigenen
Text darauf. Zeilenweises Zusammenführen im Konfliktfall wäre eine eigene
Oberfläche; verloren geht dabei ohnehin nichts — der verworfene Stand liegt in
den früheren Ständen (D54) und jede Version in der Historie des Servers.
Solange die Frage offen ist, ruhen Senden **und** Feed: Sonst zöge der Stand
unter der Frage weg, die gerade gestellt ist.
**Kennung und laufende Nummer liegen im sessionStorage, also je Tab — das war
der zweite Fund.** Erst lag beides im localStorage. Nach einem Neuladen begann
`seq` wieder bei 1, die Kennung blieb — und der Server hielt die erste echte
Änderung für die Wiederholung der letzten von vorhin und tat **nichts**. Im
Live-Test sofort sichtbar, in keinem Unit-Test: Genau die Naht zwischen Modul
und Verdrahtung, vor der D54-Nachtrag 3 warnt. Je Tab ist zugleich die
richtige Aussage: Zwei Tabs sind zwei Schreiber; mit gemeinsamer Nummer
schickte der eine bald eine kleinere `seq` als der andere.
**Fremde Änderungen werden nicht undo-fähig eingespielt.** Ein Strg+Z, das den
Beitrag eines anderen zurücknimmt, wäre eine Lüge über die Herkunft. Die
Schreibmarke wandert dagegen mit (`mapLine`) — ohne das spränge sie bei jeder
fremden Änderung weiter oben im Dokument, und „kein Neuladen" wäre nichts
wert.
**CORS steht auf `*`, und das ist hier keine Nachlässigkeit.** Der Editor läuft
je nach Installation überall (Pages, eigene Domain, Dev-Server); Zugriff regelt
die unerratbare UUID, nicht die Herkunft, und Cookies werden nie mitgesendet
(`credentials: omit`). CORS schützt Anmeldedaten — die es hier nicht gibt. Wer
es enger will, setzt `werkbaum.cors.allowed-origins`.
**Nachgemessen im Browser gegen das laufende Backend**, weil das der einzige
Ort ist, an dem sich das beweisen lässt: Ein Server-Dokument lädt und rendert;
eine fremde Änderung erscheint ohne Neuladen; Getipptes erreicht nach 1,5 s
den Server; zwei Änderungen an derselben Zeile öffnen das Konflikt-Band, und
beide Knöpfe tun, was sie sagen; die Schreibmarke steht nach zwei fremd
eingefügten Zeilen darüber unverändert bei Zeile+2, Spalte 8. **Werkzeuggrenze,
wie in D25 und D17-Nachtrag 4:** Der Automatisierungs-Tab meldet sich dauerhaft
als `document.hidden` — die Sichtbarkeits-Sperre des Feeds (Nachtrag 1) greift
also nachweislich, ließ sich aber nur mit gestellter Sichtbarkeit umgehen, um
alles Übrige zu sehen.
**Offen bleibt** `PATCH /title` (und damit das Ereignis `RENAMED`), ein
Eingabefeld für den Anzeigenamen und die Präsenz-Anzeige.
+23
View File
@@ -818,6 +818,29 @@ Export- oder `/timeslider`-Pfad wird abgeschnitten.
Siehe D31. Siehe D31.
### Gemeinsam an einem Server-Dokument arbeiten (`?live=`)
Liegt der Plan auf einem Werkbaum-Backend, nimmt der Editor dessen
Dokument-Adresse: `…?live=https://example.org/api/v1/documents/<uuid>`.
Anders als beim Pad (`?etherpad=`) wird hier **im Editor selbst geschrieben**,
und alle sehen die Änderungen der anderen, ohne neu zu laden.
- **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
bereit und spielt fremde Änderungen ein. Die **Schreibmarke wandert mit**
fügt jemand oberhalb Zeilen ein, bleibt sie an ihrer Stelle im Text.
- **Der Name ist der Titel des Dokuments** (alle sehen denselben), die
vollständige Adresse steht im Tooltip.
- **Überschneiden sich zwei Änderungen wirklich** — dieselben Zeilen —, fragt
ein Band oben, wessen Fassung gelten soll: *Fremde übernehmen* oder *Eigene
durchsetzen*. Alles andere führt der Server selbst zusammen, ohne zu fragen.
Verloren geht nichts: Der verworfene Stand bleibt in den früheren Ständen,
jede Version in der Historie des Servers.
- Der Abruf läuft **nur im sichtbaren Tab**; im Hintergrund ruht er und holt
beim Zurückkommen den Rückstand.
Siehe D76 (Protokoll und Begründung) und
`backend/docs/live-editing-proposal.md`.
### Legende im Editor-Panel ### Legende im Editor-Panel
Neben dem Textfeld steht eine aufklappbare **Legende** (Notation in Kurzform, Neben dem Textfeld steht eine aufklappbare **Legende** (Notation in Kurzform,
abschließend eine Bedienungs-Zeile). Sie ist **scrollbar**, wenn ihr Inhalt abschließend eine Bedienungs-Zeile). Sie ist **scrollbar**, wenn ihr Inhalt
+12 -1
View File
@@ -114,7 +114,18 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
Knoten-Eigenschaften dort in `nodeAria()` mitpflegen und dafür a11y-i18n-Keys Knoten-Eigenschaften dort in `nodeAria()` mitpflegen und dafür a11y-i18n-Keys
(`a11y*`) in **allen 9 Sprachen** anlegen. Knoten sind `tabindex="0"` (`a11y*`) in **allen 9 Sprachen** anlegen. Knoten sind `tabindex="0"`
(Fokus = Lesereihenfolge), `#warn` ist eine Live-Region. (Fokus = Lesereihenfolge), `#warn` ist eine Live-Region.
- Zustand wird im `localStorage` gehalten (noch kein Backend): `werkbaum-lang` - **Server-Dokumente (`?live=`, D76):** `live.js` hält die entscheidbare Hälfte
(Adressen, Zeilen-Diff, Rebasen, Cursor-Rechnung, Feed-Regel), `app.js` die
Verdrahtung: `loadLive()` holt Text und Version und merkt beides als
**Schattenkopie**, `scheduleLivePush()` schickt nach 1,5 s Ruhe das Diff,
`runFeed()` hält einen Abruf offen (nur im sichtbaren Tab). Zwei Fallen, beide
im Live-Test gefunden (D76-Nachtrag 7): `clientId` **und** `seq` gehören in den
`sessionStorage` (je Tab, überlebt Neuladen — sonst hält der Server die erste
Änderung nach einem Reload für eine Wiederholung und tut nichts), und der
**Konflikt entsteht beim Tippen**, nicht erst beim Senden — der Server kennt
den ungesendeten Text nicht. Fremde Änderungen werden bewusst **nicht**
undo-fähig eingespielt.
- Zustand wird im `localStorage` gehalten (Server-Dokumente ausgenommen): `werkbaum-lang`
(Sprache), `werkbaum-docs` (JSON-Array der Dokumente `[{id,name,text}]`), (Sprache), `werkbaum-docs` (JSON-Array der Dokumente `[{id,name,text}]`),
`werkbaum-active` (id des aktiven Dokuments), `werkbaum-src` (Spiegel des `werkbaum-active` (id des aktiven Dokuments), `werkbaum-src` (Spiegel des
aktiven Texts, Abwärtskompatibilität), `werkbaum-ui` (JSON: Modus, verworfene, aktiven Texts, Abwärtskompatibilität), `werkbaum-ui` (JSON: Modus, verworfene,
+418
View File
@@ -4,6 +4,7 @@ import { computeCheapPlan, overloadedAssignee, freshProdSet, initialCollapsed, n
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 { padUrls } from './remote.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 }
from './snapshots.js'; from './snapshots.js';
@@ -913,6 +914,7 @@ src.addEventListener('keydown', e => {
src.addEventListener('input', render); src.addEventListener('input', render);
src.addEventListener('input', saveSrc); src.addEventListener('input', saveSrc);
src.addEventListener('input', scheduleLivePush); /* Server-Dokument: Diff nach 1,5 s Ruhe (D76) */
/* ---------- Sprung zwischen Diagramm und Text (D25) ---------- /* ---------- Sprung zwischen Diagramm und Text (D25) ----------
Jeder Knoten trägt seine Zeilennummer als `data-line` (render.js). Jeder Knoten trägt seine Zeilennummer als `data-line` (render.js).
@@ -2222,6 +2224,11 @@ const I18N = {
padReadonly:"Wird im Pad bearbeitet — hier nur lesen.", padReadonly:"Wird im Pad bearbeitet — hier nur lesen.",
padEdit:"Pad zum Bearbeiten öffnen", padEdit:"Pad zum Bearbeiten öffnen",
padRefresh:"Vom Pad neu laden", 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;)?",
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?",
liveConflictTheirs:"Fremde übernehmen",
liveConflictMine:"Eigene durchsetzen",
padWait:"Noch {seconds} s — Etherpad begrenzt die Abrufe", 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.", 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", padViewTooltip:"Ansicht: {state} — klicken zum Wechseln",
@@ -2335,6 +2342,11 @@ const I18N = {
padReadonly:"Edited in the pad — read-only here.", padReadonly:"Edited in the pad — read-only here.",
padEdit:"Open the pad to edit", padEdit:"Open the pad to edit",
padRefresh:"Reload from the pad", 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;)?",
liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.",
liveConflictText:"Someone changed the same lines. Whose version should win?",
liveConflictTheirs:"Take theirs",
liveConflictMine:"Keep mine",
padWait:"{seconds} s to go — Etherpad limits how often we may fetch", 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.", 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", padViewTooltip:"View: {state} — click to switch",
@@ -2447,6 +2459,11 @@ const I18N = {
padReadonly:"Se edita en el pad — aquí solo lectura.", padReadonly:"Se edita en el pad — aquí solo lectura.",
padEdit:"Abrir el pad para editar", padEdit:"Abrir el pad para editar",
padRefresh:"Recargar desde el pad", 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;)?",
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?",
liveConflictTheirs:"Tomar la ajena",
liveConflictMine:"Mantener la mía",
padWait:"Faltan {seconds} s — Etherpad limita la frecuencia", 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.", 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", padViewTooltip:"Vista: {state} — clic para cambiar",
@@ -2559,6 +2576,11 @@ const I18N = {
padReadonly:"Modifié dans le pad — lecture seule ici.", padReadonly:"Modifié dans le pad — lecture seule ici.",
padEdit:"Ouvrir le pad pour modifier", padEdit:"Ouvrir le pad pour modifier",
padRefresh:"Recharger depuis le pad", 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;) ?",
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 ?",
liveConflictTheirs:"Prendre la sienne",
liveConflictMine:"Garder la mienne",
padWait:"Encore {seconds} s — Etherpad limite la fréquence", 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.", 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", padViewTooltip:"Vue : {state} — cliquer pour changer",
@@ -2671,6 +2693,11 @@ const I18N = {
padReadonly:"Edytowane w padzie — tu tylko do czytania.", padReadonly:"Edytowane w padzie — tu tylko do czytania.",
padEdit:"Otwórz pad do edycji", padEdit:"Otwórz pad do edycji",
padRefresh:"Wczytaj ponownie z padu", 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;)?",
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ć?",
liveConflictTheirs:"Przyjmij cudzą",
liveConflictMine:"Zachowaj własną",
padWait:"Jeszcze {seconds} s — Etherpad ogranicza częstość", 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.", 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ć", padViewTooltip:"Widok: {state} — kliknij, aby zmienić",
@@ -2783,6 +2810,11 @@ const I18N = {
padReadonly:"Редактируется в паде — здесь только чтение.", padReadonly:"Редактируется в паде — здесь только чтение.",
padEdit:"Открыть пад для редактирования", padEdit:"Открыть пад для редактирования",
padRefresh:"Обновить из пада", padRefresh:"Обновить из пада",
liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.",
liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?",
liveConflictTheirs:"Принять чужую",
liveConflictMine:"Оставить свою",
padWait:"Ещё {seconds} с — Etherpad ограничивает частоту", padWait:"Ещё {seconds} с — Etherpad ограничивает частоту",
padRateLimitWarn:"Пока не обновлено: Etherpad разрешает лишь несколько загрузок за окно времени (по умолчанию 10 за 90 с), поэтому столь частая синхронизация невозможна. Повторите через {seconds} с.", padRateLimitWarn:"Пока не обновлено: Etherpad разрешает лишь несколько загрузок за окно времени (по умолчанию 10 за 90 с), поэтому столь частая синхронизация невозможна. Повторите через {seconds} с.",
padViewTooltip:"Вид: {state} — нажмите для переключения", padViewTooltip:"Вид: {state} — нажмите для переключения",
@@ -2895,6 +2927,11 @@ const I18N = {
padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।", padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।",
padEdit:"संपादित करने के लिए पैड खोलें", padEdit:"संपादित करने के लिए पैड खोलें",
padRefresh:"पैड से फिर लोड करें", padRefresh:"पैड से फिर लोड करें",
liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।",
liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?",
liveConflictTheirs:"दूसरे का लें",
liveConflictMine:"अपना रखें",
padWait:"{seconds} स॰ बाकी — Etherpad बार-बार लेने की सीमा रखता है", padWait:"{seconds} स॰ बाकी — Etherpad बार-बार लेने की सीमा रखता है",
padRateLimitWarn:"अभी दोबारा लोड नहीं किया: Etherpad प्रति समय-खिड़की केवल कुछ ही बार लेने देता है (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार), इसलिए इतनी बार सिंक करना संभव नहीं है। {seconds} स॰ में फिर कोशिश करें।", padRateLimitWarn:"अभी दोबारा लोड नहीं किया: Etherpad प्रति समय-खिड़की केवल कुछ ही बार लेने देता है (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार), इसलिए इतनी बार सिंक करना संभव नहीं है। {seconds} स॰ में फिर कोशिश करें।",
padViewTooltip:"दृश्य: {state} — बदलने के लिए क्लिक करें", padViewTooltip:"दृश्य: {state} — बदलने के लिए क्लिक करें",
@@ -3018,6 +3055,11 @@ const I18N = {
padReadonly:"在 Pad 中编辑 — 此处只读。", padReadonly:"在 Pad 中编辑 — 此处只读。",
padEdit:"打开 Pad 进行编辑", padEdit:"打开 Pad 进行编辑",
padRefresh:"从 Pad 重新加载", padRefresh:"从 Pad 重新加载",
liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?",
liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。",
liveConflictText:"有人改动了同样的行。以谁的版本为准?",
liveConflictTheirs:"采用对方的",
liveConflictMine:"保留我的",
padWait:"还需 {seconds} 秒 — Etherpad 限制获取频率", padWait:"还需 {seconds} 秒 — Etherpad 限制获取频率",
padRateLimitWarn:"尚未重新加载:Etherpad 每个时间窗口只允许少量获取(默认每 90 秒 10 次),因此无法如此频繁地同步。请在 {seconds} 秒后再试。", padRateLimitWarn:"尚未重新加载:Etherpad 每个时间窗口只允许少量获取(默认每 90 秒 10 次),因此无法如此频繁地同步。请在 {seconds} 秒后再试。",
padViewTooltip:"视图:{state} — 点击切换", padViewTooltip:"视图:{state} — 点击切换",
@@ -3130,6 +3172,11 @@ const I18N = {
padReadonly:"パッドで編集します — ここでは読み取り専用です。", padReadonly:"パッドで編集します — ここでは読み取り専用です。",
padEdit:"編集するにはパッドを開く", padEdit:"編集するにはパッドを開く",
padRefresh:"パッドから再読み込み", padRefresh:"パッドから再読み込み",
liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。",
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
liveConflictTheirs:"相手の版",
liveConflictMine:"自分の版",
padWait:"あと {seconds} 秒 — Etherpad は取得頻度を制限します", padWait:"あと {seconds} 秒 — Etherpad は取得頻度を制限します",
padRateLimitWarn:"まだ再読み込みしていません: Etherpad は一定時間内の取得回数を制限します(既定で 90 秒あたり 10 回)。これほど頻繁な同期はできません。{seconds} 秒後にもう一度お試しください。", padRateLimitWarn:"まだ再読み込みしていません: Etherpad は一定時間内の取得回数を制限します(既定で 90 秒あたり 10 回)。これほど頻繁な同期はできません。{seconds} 秒後にもう一度お試しください。",
padViewTooltip:"表示: {state} — クリックで切り替え", padViewTooltip:"表示: {state} — クリックで切り替え",
@@ -3970,6 +4017,7 @@ function deleteDoc(){
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(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(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);
if(snaps[d.id]){ delete snaps[d.id]; persistSnaps(snaps, localStorage); } /* mit dem Dokument gehen seine Stände (D54) */ if(snaps[d.id]){ delete snaps[d.id]; persistSnaps(snaps, localStorage); } /* mit dem Dokument gehen seine Stände (D54) */
@@ -4335,6 +4383,375 @@ async function loadRemoteSource(){
} }
} }
/* ---------- Gemeinsam am selben Dokument arbeiten: ?live= (D76) ----------
Der dritte Eingang für Text von außen und der einzige, in den auch
zurückgeschrieben wird. Adressiert wird die Dokument-URL des Backends
(`…/api/v1/documents/<uuid>`); Identität und Endpunkte leitet live.js daraus
ab, dort steht auch die Begründung.
Der Ablauf in drei Sätzen: Beim Laden holen wir Text und Version und merken
uns beides als **Schattenkopie** den Stand, den der Server kennt. Wer tippt,
schickt nach 1,5 s Ruhe das **Diff** von der Schattenkopie zum jetzigen Text.
Ein **Feed** hält die Gegenrichtung offen und wendet fremde Änderungen an,
ohne das Dokument neu zu laden dafür wandert die Schreibmarke mit
(live.js, `mapLine`).
Was hier NICHT passiert: Zusammenführen. Überschneiden sich zwei Änderungen
wirklich, entscheidet der Mensch (Konflikt-Band unten). Alles andere
verschiebt der Server selbst. */
const LIVE_PARAM = 'live';
const LIVE_DEBOUNCE_MS = 1500; /* Ruhe vor dem Senden; D76 */
const LIVE_WAIT_S = 25; /* Wartezeit des Feeds; der Server klemmt sie ohnehin */
const LIVE_RETRY_MS = 5000; /* nach einem Netzfehler, bevor der Feed erneut fragt */
/* Zufällige, pseudonyme Kennung dieses Clients samt laufender Nummer.
Beide zusammen machen das Einreichen wiederholbar: Geht die Antwort
unterwegs verloren, erkennt der Server die Wiederholung und wendet sie nicht
ein zweites Mal an (D76).
Beide liegen im **sessionStorage**, also je Tab:
- Über den Tab hinaus geteilt wären sie falsch. Zwei Tabs desselben Browsers
sind zwei Schreiber; mit gemeinsamer Nummer schickte der eine bald eine
kleinere `seq` als der andere für den Server eine veraltete Nummer (422).
- Innerhalb des Tabs müssen sie das Neuladen überleben. Sonst finge die
Nummer wieder bei 1 an, und der Server hielte die erste echte Änderung für
die Wiederholung der letzten von vorhin und täte **nichts** im Live-Test
genau so passiert. */
const SS_CLIENT = 'werkbaum-client';
const SS_SEQ = 'werkbaum-seq';
function clientId(){
let id = null;
try{ id = sessionStorage.getItem(SS_CLIENT); }catch(_){}
if(!id){
id = 'c-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
try{ sessionStorage.setItem(SS_CLIENT, id); }catch(_){}
}
return id;
}
function nextSeq(){
let n = 0;
try{ n = parseInt(sessionStorage.getItem(SS_SEQ) || '0', 10) || 0; }catch(_){}
n++;
try{ sessionStorage.setItem(SS_SEQ, String(n)); }catch(_){}
return n;
}
let liveState = null; /* {urls, id, version, shadow, seq, pushTimer, feedAbort, busy} */
let liveConflict = null; /* offener Konflikt: bis der Mensch entscheidet, ruht alles */
function liveActive(){ return !!liveState && activeId === liveState.id; }
/* Wer das Live-Dokument löscht, meint es (wie beim Pad, D31). */
function stopLive(){
if(!liveState) return;
if(liveState.pushTimer) clearTimeout(liveState.pushTimer);
if(liveState.feedAbort) liveState.feedAbort.abort();
liveState = null;
liveConflict = null;
hideConflictBanner();
}
async function loadLive(){
const raw = urlParam(LIVE_PARAM);
if(!raw) return;
const urls = live.liveUrls(raw, location.href);
if(!urls){
sourceWarning = {type:'liveLoad', url: raw, error: 'not a Werkbaum document URL'};
render();
return;
}
liveState = {urls, id: 'live:' + urls.doc, version: 0, shadow: [''],
pushTimer: null, feedAbort: null, busy: false};
try{
const doc = await fetchJson(urls.doc);
adoptLive(doc);
runFeed();
}catch(err){
sourceWarning = {type:'liveLoad', url: urls.doc, error: (err && err.message) || String(err)};
render();
}
}
async function fetchJson(url, options){
const resp = await fetch(url, Object.assign({cache:'no-store', credentials:'omit'}, options));
if(resp.status === 204) return null;
if(!resp.ok){
const err = new Error('HTTP ' + resp.status);
err.status = resp.status;
try{ err.body = await resp.json(); }catch(_){}
throw err;
}
return await resp.json();
}
/* Server-Stand übernehmen: Text ins Dokument, Schattenkopie und Version merken.
Der Name ist der **Titel des Servers**, nicht die URL wie bei ?sourceUrl= und
?etherpad= anders als eine Datei oder ein Pad hat ein Server-Dokument einen
Namen, und alle sehen denselben. Die volle Adresse steht im Tooltip
(D76-Nachtrag 7). */
function adoptLive(doc){
const content = live.normalize(doc.content || '');
liveState.version = doc.version;
liveState.shadow = live.lines(content);
flushActive();
let d = docs.find(x => x.id === liveState.id);
if(d){ d.text = content; d.name = doc.title || liveState.urls.doc; }
else { d = {id: liveState.id, name: doc.title || liveState.urls.doc, text: content}; docs.push(d); }
d.source = liveState.urls.doc;
activeId = liveState.id;
sourceWarning = null;
computeFresh(liveState.id, content);
loadActiveIntoEditor();
persistDocs();
}
/* ---------- Hinschicken ---------- */
/* Nach jeder Eingabe neu gestartet: Gesendet wird erst, wenn 1,5 s Ruhe ist.
Ohne den Takt entstünde je Tastendruck eine Version die Historie wäre ein
Transaktionslog, und das Netz hätte zu tun. */
function scheduleLivePush(){
if(!liveActive() || liveConflict) return;
if(liveState.pushTimer) clearTimeout(liveState.pushTimer);
liveState.pushTimer = setTimeout(() => { liveState.pushTimer = null; pushLive(); },
LIVE_DEBOUNCE_MS);
}
async function pushLive(){
if(!liveActive() || liveConflict || liveState.busy) return;
const now = live.lines(live.normalize(src.value));
const ops = live.computeOps(liveState.shadow, now);
if(!ops.length) return;
liveState.busy = true;
const seq = nextSeq();
try{
const body = {
baseVersion: liveState.version,
checksum: await live.checksum(live.text(liveState.shadow)),
clientId: clientId(),
displayName: displayName(),
seq,
ops,
};
const result = await fetchJson(liveState.urls.content, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
/* Angenommen. Hat der Server verschoben, stehen die fremden Operationen in
`opsSinceBase`: Die Schattenkopie zieht erst darüber nach, dann kommt
unsere eigene Änderung darauf verschoben um die fremde. Genau diese
Rechnung hat der Server auch gemacht; wir kommen deshalb auf denselben
Text, ohne ihn abholen zu müssen. */
const foreign = (result.opsSinceBase || []);
const alt = liveState.shadow;
const meine = foreign.length ? live.rebaseOps(ops, foreign) : ops;
if(meine == null){ await reloadLive(); return; } /* kann nicht sein - dann lieber neu */
liveState.shadow = live.applyOps(
foreign.length ? live.applyOps(alt, foreign) : alt, meine);
liveState.version = result.version;
if(foreign.length) applyForeign(alt, foreign, liveState.shadow, liveState.version);
}catch(err){
handlePushError(err);
}finally{
liveState.busy = false;
}
}
function handlePushError(err){
if(err && err.status === 409 && err.body){
/* Der andere Weg in denselben Zustand: Unser Senden hat das Rennen gegen
den Feed gewonnen, und der Server hat die Überschneidung gesehen. */
let serverLines;
try{ serverLines = live.applyOps(liveState.shadow, err.body.opsSinceBase || []); }
catch(_){ reloadLive(); return; }
openConflict(err.body.currentVersion, serverLines);
return;
}
if(err && (err.status === 422 || err.status === 404)){
/* Nicht anwendbar oder weg: einmal neu laden ist der vereinbarte Ausweg. */
sourceWarning = {type:'liveStale', error: (err.body && err.body.detail) || err.message};
render();
reloadLive();
return;
}
sourceWarning = {type:'liveLoad', url: liveState.urls.doc,
error: (err && err.message) || String(err)};
render();
}
async function reloadLive(){
if(!liveState) return;
try{ adoptLive(await fetchJson(liveState.urls.doc)); }catch(_){ /* der Feed versucht es weiter */ }
}
/* ---------- Herbekommen: der Feed ---------- */
/* Eine offene Anfrage, die der Server beantwortet, sobald sich etwas tut.
Läuft **nur im sichtbaren Tab** (D76-Nachtrag 1): Ein Hintergrund-Tab braucht
keinen Live-Feed, niemand schaut hin, und ohne HTTP/2 belegt jeder Tab eine
der sechs Verbindungen zur Herkunft. Beim Zurückkommen holt ein einziger
Abruf den Rückstand. */
async function runFeed(){
while(liveState){
if(document.hidden || liveConflict){ await sleep(500); continue; }
const ctl = new AbortController();
liveState.feedAbort = ctl;
try{
const url = liveState.urls.changes +
'?since=' + liveState.version + '&wait=' + LIVE_WAIT_S;
const feed = await fetchJson(url, {signal: ctl.signal});
if(feed) applyFeed(feed);
}catch(err){
if(!liveState) return;
if(err && err.name === 'AbortError') continue;
if(err && err.status === 404){ stopLive(); return; }
await sleep(LIVE_RETRY_MS);
}finally{
if(liveState) liveState.feedAbort = null;
}
}
}
function sleep(ms){ return new Promise(r => setTimeout(r, ms)); }
/* Beim Wegschalten die offene Verbindung **schließen**, nicht bloß die Antwort
ignorieren: Ohne HTTP/2 belegt jeder Tab eine der sechs Verbindungen zur
Herkunft, und ein Hintergrund-Tab braucht keinen Feed (D76-Nachtrag 1). Beim
Zurückkommen holt der nächste Durchlauf den Rückstand in einem Zug. */
document.addEventListener('visibilitychange', () => {
if(document.hidden && liveState && liveState.feedAbort) liveState.feedAbort.abort();
});
function applyFeed(feed){
const what = live.feedAction(feed, liveState.version);
if(what === 'skip') return;
if(what === 'replace'){
/* Volltext: die Basis ist verdichtet, ein Diff gibt es nicht mehr. */
liveState.version = feed.currentVersion;
liveState.shadow = live.lines(live.normalize(feed.content));
setLiveText(live.text(liveState.shadow), null);
return;
}
const alt = liveState.shadow;
liveState.shadow = live.applyOps(alt, feed.ops);
liveState.version = feed.currentVersion;
applyForeign(alt, feed.ops, liveState.shadow, liveState.version);
}
/* Fremde Operationen auf den **sichtbaren** Text anwenden.
Hat der Nutzer inzwischen selbst getippt, ist sein Text nicht mehr die alte
Schattenkopie: Dann wird die fremde Änderung auf seinen Stand angewendet und
sein eigenes Diff beim nächsten Senden neu gebildet. Passt das nicht (die
Zeilen sind unter ihm weggezogen), bleibt sein Text stehen das nächste
Senden klärt es, notfalls über einen Konflikt. */
function applyForeign(oldShadow, ops, serverLines, version){
const editor = live.lines(live.normalize(src.value));
/* Was der Server noch nicht kennt: alles, was seit der Schattenkopie
getippt wurde. Die fremden Operationen zählen gegen die Schattenkopie -
um sie auf den getippten Text anzuwenden, müssen sie daran vorbei. */
const ungesendet = live.computeOps(oldShadow, editor);
const verschoben = live.rebaseOps(ops, ungesendet);
if(verschoben == null){
/* Überschneidung mit dem, was gerade getippt wird **hier** entsteht der
Konflikt, nicht erst beim Senden. Ohne diese Stelle bliebe er praktisch
aus: Der Feed zieht die Schattenkopie nach, das nächste Senden hätte
eine aktuelle Basis, und die fremde Zeile wäre stillschweigend
überschrieben. Der Server kann das nicht sehen er kennt den
ungesendeten Text nicht. */
openConflict(version, serverLines);
return;
}
let after;
try{ after = live.applyOps(editor, verschoben); }catch(_){ return; }
const caret = live.caretToLineCol(src.value, src.selectionStart);
const line = live.mapLine(caret.line, verschoben);
setLiveText(live.text(after), live.lineColToCaret(after, line, caret.col));
}
/* Text setzen, ohne die eigene Änderungslogik erneut auszulösen.
Bewusst **nicht** undo-fähig geschrieben: Eine fremde Änderung ist nicht
meine Eingabe, und ein Strg+Z, das den Beitrag eines anderen zurücknimmt,
wäre eine Lüge über die Herkunft. */
function setLiveText(text, caret){
src.value = text;
if(caret != null){ src.selectionStart = src.selectionEnd = caret; }
const d = activeDoc();
if(d) d.text = text;
persistDocs();
render();
}
/* ---------- Konflikt ---------- */
/* Echte Überschneidung: Der Server hat abgelehnt, und jetzt entscheidet der
Mensch. Bis dahin ruhen Senden und Feed sonst zöge der Stand unter der
Frage weg, die gerade gestellt ist. */
function openConflict(version, serverLines){
if(liveConflict) return; /* eine Frage zur Zeit */
liveConflict = {version, serverLines};
showConflictBanner();
}
/* Fremde Fassung übernehmen: Der eigene Text weicht. Verloren ist er nicht
er steht in den früheren Ständen (D54), und jede Version steht in der
Historie des Servers. */
function takeTheirs(){
const c = liveConflict;
liveConflict = null;
hideConflictBanner();
liveState.version = c.version;
liveState.shadow = c.serverLines;
setLiveText(live.text(c.serverLines), null);
}
/* Eigene durchsetzen: Die Schattenkopie zieht auf den Server-Stand nach, der
eigene Text bleibt stehen. Das nächste Senden bildet das Diff dagegen es
überschneidet sich dann per Konstruktion nicht mehr. */
function keepMine(){
const c = liveConflict;
liveConflict = null;
hideConflictBanner();
liveState.version = c.version;
liveState.shadow = c.serverLines;
pushLive();
}
function showConflictBanner(){
hideConflictBanner();
const bar = document.createElement('div');
bar.id = 'liveConflict';
bar.className = 'live-conflict';
bar.setAttribute('role', 'alertdialog');
bar.innerHTML = '<span></span>' +
'<div class="live-conflict-actions">' +
'<button type="button" class="theirs"></button>' +
'<button type="button" class="mine"></button></div>';
bar.querySelector('span').textContent = t('liveConflictText');
const theirs = bar.querySelector('.theirs');
const mine = bar.querySelector('.mine');
theirs.textContent = t('liveConflictTheirs');
mine.textContent = t('liveConflictMine');
theirs.addEventListener('click', takeTheirs);
mine.addEventListener('click', keepMine);
document.body.appendChild(bar);
theirs.focus();
}
function hideConflictBanner(){
const el = document.getElementById('liveConflict');
if(el) el.remove();
}
/* Anzeigename: ohne Anmeldung eine Behauptung (D76) die Oberfläche darf ihn
nicht wie einen Nachweis aussehen lassen. Vorerst der gemerkte Name oder
nichts; ein Eingabefeld dafür kommt mit der Präsenz-Anzeige. */
const LS_NAME = 'werkbaum-name';
function displayName(){
try{ return localStorage.getItem(LS_NAME) || undefined; }catch(_){ return undefined; }
}
/* ---------- Pad: auf Knopfdruck neu holen (D31) ---------- /* ---------- Pad: auf Knopfdruck neu holen (D31) ----------
Kein Hintergrund-Takt. Der erste Entwurf holte alle 2,5 s selbsttätig und Kein Hintergrund-Takt. Der erste Entwurf holte alle 2,5 s selbsttätig und
lief damit in Etherpads **Drosselung**: `importExportRateLimiting` ist lief damit in Etherpads **Drosselung**: `importExportRateLimiting` ist
@@ -4796,6 +5213,7 @@ if('launchQueue' in window){
} }
applyMobile(); /* Mobil-Verhalten (nach Sprache/Restore) anwenden */ applyMobile(); /* Mobil-Verhalten (nach Sprache/Restore) anwenden */
loadRemoteSource(); /* ?sourceUrl= / ?etherpad= nachladen (asynchron, D23/D31) */ loadRemoteSource(); /* ?sourceUrl= / ?etherpad= nachladen (asynchron, D23/D31) */
loadLive(); /* ?live= — Server-Dokument samt Feed (asynchron, D76) */
/* ---------- PWA: Service Worker (D73) ---------- /* ---------- PWA: Service Worker (D73) ----------
Ein reiner Offline-Mantel (public/sw.js): Navigationen network-first, der Ein reiner Offline-Mantel (public/sw.js): Navigationen network-first, der
+42
View File
@@ -144,6 +144,48 @@ export function applyOps(base, ops){
return out; return out;
} }
/* ------------------------------------------------------------ Rebasen */
/* Überschneiden sich zwei Operationen derselben Basis? Dieselbe Regel wie im
Backend: `replace`/`delete` belegen `[index, index+count)`, `insert` ist ein
Punkt **zwischen** den Zeilen und kollidiert nur mit dem Inneren eines
fremden Bereichs. Zwei Einfügungen an derselben Stelle vertragen sich. */
export function conflicts(a, b){
const ai = a.index, ae = ai + removedCount(a);
const bi = b.index, be = bi + removedCount(b);
if(a.op === 'insert' && b.op === 'insert') return false;
if(a.op === 'insert') return bi < ai && ai < be;
if(b.op === 'insert') return ai < bi && bi < ae;
return ai < be && bi < ae;
}
/* Verschiebt [ops] auf den Stand, der durch [onto] aus derselben Basis
entstanden ist. `null` heißt echte Überschneidung — dann darf nichts
angewendet werden, und der Mensch entscheidet.
Der Client braucht das an zwei Stellen: um die eigene Schattenkopie
nachzuziehen, nachdem der Server verschoben hat, und um fremde Änderungen in
einen Text einzublenden, in dem schon ungesendet getippt wurde. */
export function rebaseOps(ops, onto){
if(!onto || !onto.length || !ops.length) return ops;
for(const mine of ops){
for(const theirs of onto){
if(conflicts(mine, theirs)) return null;
}
}
return ops.map(op => {
let delta = 0;
for(const other of onto){
/* Eine fremde Einfügung an derselben Stelle zählt dazu: Sie ist bereits
bestätigt und steht deshalb oben. */
if(other.index + removedCount(other) <= op.index){
delta += insertedLines(other).length - removedCount(other);
}
}
return delta ? Object.assign({}, op, {index: op.index + delta}) : op;
});
}
/* ------------------------------------------------------------ Cursor */ /* ------------------------------------------------------------ Cursor */
/* Wohin wandert eine Zeile, wenn fremde Operationen angewendet werden? /* Wohin wandert eine Zeile, wenn fremde Operationen angewendet werden?
+24
View File
@@ -1544,3 +1544,27 @@
.node:not(.folded) .fold{display:none!important} .node:not(.folded) .fold{display:none!important}
@page{margin:12mm} @page{margin:12mm}
} }
/* ---------- Konflikt beim gemeinsamen Bearbeiten (D76) ----------
Band oben, wie der Update-Hinweis: Es unterbricht nicht die Arbeit, steht
aber über allem, bis jemand entscheidet. In `--warn`, nicht in Petrol — es
ist keine Einladung, sondern eine Sache, die geklärt werden muss. */
.live-conflict{
position: fixed; inset: 0 0 auto 0; z-index: 1200;
display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding: 10px 16px;
background: var(--warn, #B45309); color: #fff;
font: 500 14px/1.4 'IBM Plex Sans', system-ui, sans-serif;
box-shadow: 0 2px 8px rgba(0,0,0,.25);
}
.live-conflict-actions{ display: flex; gap: 8px; flex: 0 0 auto; }
.live-conflict button{
font: 500 13px/1 'IBM Plex Sans', system-ui, sans-serif;
padding: 6px 10px; border-radius: 4px; cursor: pointer;
border: 1px solid #fff; background: transparent; color: #fff;
}
/* „Eigene durchsetzen" ist die Antwort, die nichts von der eigenen Arbeit
kostet - sie steht deshalb gefüllt da, ohne die andere zu verstecken. */
.live-conflict button.mine{ background: #fff; color: var(--warn, #B45309); border-color: #fff; }
.live-conflict button:focus-visible{ outline: 2px solid #fff; outline-offset: 2px; }
@media print{ .live-conflict{ display: none; } }
+9
View File
@@ -42,6 +42,11 @@
- padRateLimit { seconds } — zu früh nachgeladen; Werkbaum hat gar nicht - padRateLimit { seconds } — zu früh nachgeladen; Werkbaum hat gar nicht
erst geholt, um Etherpads Grenze nicht erst geholt, um Etherpads Grenze nicht
auszulösen (D31) auszulösen (D31)
- liveLoad { url, error } — Server-Dokument (?live=, D76) nicht
erreichbar oder keine Dokument-Adresse
- liveStale { error } — eigene Änderung nicht anwendbar
(Prüfsumme, Index, Basis verdichtet);
der Client hat einmal neu geladen (D76)
- sourceTimeout { url, seconds } — Abruf abgebrochen, Gegenseite zu langsam - sourceTimeout { url, seconds } — Abruf abgebrochen, Gegenseite zu langsam
(D31). Eigener Typ, weil `sourceLoad` auf (D31). Eigener Typ, weil `sourceLoad` auf
CORS zeigt — bei einem Zeitablauf schickt CORS zeigt — bei einem Zeitablauf schickt
@@ -92,6 +97,10 @@ function build(w, t, esc){
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 'padRateLimit':
return t('padRateLimitWarn', {seconds: w.seconds}); return t('padRateLimitWarn', {seconds: w.seconds});
case 'liveLoad':
return t('liveLoadWarn', {url: esc(w.url), error: esc(w.error)});
case 'liveStale':
return t('liveStaleWarn', {error: esc(w.error)});
case 'sourceTimeout': case 'sourceTimeout':
return t('sourceTimeoutWarn', {url: esc(w.url), seconds: w.seconds}); return t('sourceTimeoutWarn', {url: esc(w.url), seconds: w.seconds});
default: default: