feat: Adresszeile folgt dem Dokument, Live-Sitzung ebenso (D80)
Wer bei offenem `?live=` umschaltet, behielt die alte Adresse — optisch falsch, und ein Neuladen holte das falsche Dokument zurück. Der Parameter gehört jetzt zum aktiven Dokument, für `?live=` wie für `?sourceUrl=`; `?etherpad=` wird nur noch weggeräumt. Fremde Parameter (`?server=`) bleiben wörtlich stehen, damit die URL lesbar bleibt. Dabei gefunden: Die Live-Sitzung lief weiter, während ein anderes Dokument vorn stand — `setLiveText()` schrieb fremde Änderungen in dessen Text. Die Sitzung gehört jetzt dem sichtbaren Dokument: Umschalten beendet sie, Umschalten auf ein Server-Dokument nimmt sie auf. Was noch im Debounce steckt, geht vorher raus, und `pushLive()` hält seine Sitzung fest statt anzunehmen, dass sich über ein `await` hinweg nichts ändert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
627f6feca8
commit
fdca934cf0
@@ -110,6 +110,12 @@ oberhalb Zeilen ein, bleibt sie an ihrer Stelle im Text.
|
|||||||
Der **Name** ist der Titel des Dokuments (alle sehen denselben), die vollständige
|
Der **Name** ist der Titel des Dokuments (alle sehen denselben), die vollständige
|
||||||
Adresse steht im Tooltip.
|
Adresse steht im Tooltip.
|
||||||
|
|
||||||
|
**Die Adresszeile folgt dem Dokument, das vorn ist.** Umschalten auf einen
|
||||||
|
lokalen Plan räumt `?live=` weg, Umschalten auf ein anderes Server-Dokument
|
||||||
|
trägt dessen Adresse ein — ein Neuladen bringt also zurück, was man vor sich
|
||||||
|
hatte. Wer ein Server-Dokument im Menü auswählt, arbeitet darin auch wieder
|
||||||
|
gemeinsam.
|
||||||
|
|
||||||
**Überschneiden sich zwei Änderungen wirklich** — dieselben Zeilen —, fragt ein
|
**Überschneiden sich zwei Änderungen wirklich** — dieselben Zeilen —, fragt ein
|
||||||
Band oben, wessen Fassung gelten soll: *Fremde übernehmen* oder *Eigene
|
Band oben, wessen Fassung gelten soll: *Fremde übernehmen* oder *Eigene
|
||||||
durchsetzen*. Alles andere führt der Server selbst zusammen, ohne zu fragen.
|
durchsetzen*. Alles andere führt der Server selbst zusammen, ohne zu fragen.
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ above you, it stays where it was in the text.
|
|||||||
The document's **title** is its name (everyone sees the same one), the full
|
The document's **title** is its name (everyone sees the same one), the full
|
||||||
address sits in the tooltip.
|
address sits in the tooltip.
|
||||||
|
|
||||||
|
**The address bar follows the document you are on.** Switch to a local plan and
|
||||||
|
`?live=` goes away; switch to another server document and its address takes its
|
||||||
|
place — so a reload brings back what you were looking at. Picking a server
|
||||||
|
document from the menu also puts you back into the shared session.
|
||||||
|
|
||||||
When two changes really overlap — the same lines — a bar at the top asks whose
|
When two changes really overlap — the same lines — a bar at the top asks whose
|
||||||
version should win: *take theirs* or *keep mine*. Everything else the server
|
version should win: *take theirs* or *keep mine*. Everything else the server
|
||||||
merges without asking. Nothing is lost either way: the discarded state stays in
|
merges without asking. Nothing is lost either way: the discarded state stays in
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ reverse.
|
|||||||
|
|
||||||
## 2026-08-26
|
## 2026-08-26
|
||||||
|
|
||||||
|
- The address bar follows the document you switch to: `?live=` and `?sourceUrl=` name what is in front of you, so a reload brings back the same plan
|
||||||
|
- Switching to a server document in the picker now really opens it live — before it only showed its last state
|
||||||
|
- Fix: a foreign change to a server document could land in the text of a local document you had switched to
|
||||||
- Changes in a shared document now reach the others after 0.6 s instead of 1.5 s — the wait before sending was almost the whole delay
|
- Changes in a shared document now reach the others after 0.6 s instead of 1.5 s — the wait before sending was almost the whole delay
|
||||||
|
|
||||||
- The Etherpad integration is gone: collaboration now runs through a Werkbaum backend, and an old `?etherpad=` link says so instead of doing nothing
|
- The Etherpad integration is gone: collaboration now runs through a Werkbaum backend, and an old `?etherpad=` link says so instead of doing nothing
|
||||||
|
|||||||
@@ -6819,3 +6819,85 @@ die 600 ms sind gesetzt, und was daneben liegt (Server 39 ms, Rundlauf 130 ms,
|
|||||||
PATCH → sichtbar 46 ms) ist einzeln gemessen. Dieselbe Lehre wie D25
|
PATCH → sichtbar 46 ms) ist einzeln gemessen. Dieselbe Lehre wie D25
|
||||||
(synthetische `TouchEvent`s), D17-Nachtrag 4 (Bildschirmtastatur) und D53
|
(synthetische `TouchEvent`s), D17-Nachtrag 4 (Bildschirmtastatur) und D53
|
||||||
(synthetisches Strg+Z): Was die Umgebung stellt, stellt der Emulator nicht.
|
(synthetisches Strg+Z): Was die Umgebung stellt, stellt der Emulator nicht.
|
||||||
|
|
||||||
|
## D80 — Die Adresszeile beschreibt das aktive Dokument, und die Live-Sitzung folgt ihm
|
||||||
|
Gemeldet: Wer bei offenem `?live=…` auf ein anderes Dokument umschaltet, behält
|
||||||
|
die alte Adresse — „das sieht optisch falsch aus, und beim Neuladen würde wohl
|
||||||
|
auch das Dokument aus `live=` wieder geladen". Beides stimmt, und beim
|
||||||
|
Nachsehen kam ein dritter, schwererer Befund dazu.
|
||||||
|
|
||||||
|
**Die Adresse ist kein Andenken an den Aufruf, sondern der Stand.** Sie ist der
|
||||||
|
Link, den man weitergibt, und das, was ein Neuladen wiederherstellt. Zeigt sie
|
||||||
|
auf etwas anderes als der Bildschirm, ist eines von beiden gelogen — und beim
|
||||||
|
Neuladen entscheidet die Adresse. Die Regel lautet deshalb: **Der Parameter
|
||||||
|
gehört zum aktiven Dokument.** Umschalten auf ein lokales Dokument räumt ihn
|
||||||
|
weg, Umschalten auf ein anderes Server-Dokument tauscht ihn aus.
|
||||||
|
|
||||||
|
**Sie gilt für beide Eingänge, nicht nur für `?live=`.** `?sourceUrl=` (D23)
|
||||||
|
hatte dasselbe Problem, und eine Regel, die nur für einen der beiden gilt, ist
|
||||||
|
keine. Beide Eingänge sind ohnehin schon die **Identität** des Dokuments
|
||||||
|
(`live:<url>`, `url:<href>`) — der Parameter lässt sich also aus der id
|
||||||
|
zurückrechnen, statt nebenher geführt zu werden. `?etherpad=` ist ausgebaut
|
||||||
|
(D78) und wird nur noch weggeräumt.
|
||||||
|
|
||||||
|
**Für `?sourceUrl=` ist das keine neue Gefahr**, obwohl der Parameter beim
|
||||||
|
Zurückschalten wiederkommt und ein Neuladen den Text dann erneut holt (D23:
|
||||||
|
„lokale Änderungen daran überleben ein Neuladen nicht"). Bisher stand er
|
||||||
|
**immer** da, unabhängig davon, was vorn war — es wird also nicht mehr
|
||||||
|
überschrieben als vorher, sondern weniger.
|
||||||
|
|
||||||
|
**Fremde Parameter bleiben wörtlich stehen — auch ihre Schreibweise.** Der
|
||||||
|
naheliegende Weg über `URLSearchParams` schriebe jedes `:` und `/` als
|
||||||
|
`%3A`/`%2F` und machte damit gerade die URL unleserlich, um die es hier geht;
|
||||||
|
`?server=` (D76-Nachtrag 8) fiele bei einem Neubau der Adresse ganz weg.
|
||||||
|
Maskiert wird nur, was den Query-String sonst zerrisse (`&`, `#`). Die
|
||||||
|
entscheidbare Hälfte steht als reine Funktion in `docurl.js`
|
||||||
|
(Hausregel D54-Nachtrag 3), die `history.replaceState`-Seite in app.js.
|
||||||
|
|
||||||
|
**Der dritte Befund: Die Live-Sitzung lief weiter, während ein anderes Dokument
|
||||||
|
vorn stand.** `switchDoc()` hat bisher nur `activeId` gewechselt; `liveState`
|
||||||
|
blieb, der Feed lief, und `setLiveText()` schreibt in `src.value` **und** in
|
||||||
|
`activeDoc().text` — eine fremde Änderung am Server-Dokument landete also im
|
||||||
|
Text des Dokuments, das man gerade ansieht. Nachgemessen war die Lücke echt:
|
||||||
|
Die Meldung des Nutzers ist die Tür dazu.
|
||||||
|
|
||||||
|
**Also gehört die Sitzung dem sichtbaren Dokument.** Umschalten beendet sie;
|
||||||
|
Umschalten auf ein Server-Dokument nimmt sie auf (`startLive()`, aus
|
||||||
|
`loadLive()` herausgelöst — derselbe Weg, nur mit der URL aus dem Dokument
|
||||||
|
statt aus dem Parameter). Der Nebengewinn ist der eigentliche: Ein
|
||||||
|
Server-Dokument, das man im Wähler auswählt, ist danach wirklich live. Vorher
|
||||||
|
zeigte es stumm seinen letzten Stand — die Adresse hätte also nicht nur
|
||||||
|
optisch, sondern der Sache nach gelogen, wenn man sie einfach mitgeführt hätte.
|
||||||
|
|
||||||
|
**Verdrahtet an genau einer Stelle:** `loadActiveIntoEditor()` — jeder Weg zu
|
||||||
|
einem anderen aktiven Dokument führt dort durch (Umschalten, Anlegen, Löschen,
|
||||||
|
Datei öffnen, Server-Dokument laden). Während des Starts ruht die Regel
|
||||||
|
(`bootDone`): `loadRemoteSource()` und `loadLive()` lesen ihre Parameter erst,
|
||||||
|
nachdem das zuletzt aktive Dokument wiederhergestellt ist — ein vorschnelles
|
||||||
|
Aufräumen nähme ihnen die Vorlage.
|
||||||
|
|
||||||
|
**Was noch im Debounce steckt, wird beim Umschalten losgeschickt.** Sonst
|
||||||
|
verlöre ein Wechsel innerhalb von 600 ms nach dem letzten Tastendruck genau
|
||||||
|
diese Änderung an den Server. Gesendet wird, **bevor** `activeId` wechselt —
|
||||||
|
`pushLive()` liest `src.value` synchron, danach zeigt das Feld schon den
|
||||||
|
anderen Text.
|
||||||
|
|
||||||
|
**Und `pushLive()` hält jetzt seine Sitzung fest, nicht nur deren Felder.**
|
||||||
|
Wer während des Sendens umschaltet, beendet sie; die Fortsetzung nach dem
|
||||||
|
`await` dürfte danach weder schreiben noch in ein `null` greifen (das
|
||||||
|
`finally` hätte es getan). Dieselbe Sorte Annahme, die D76-Nachtrag 9 schon
|
||||||
|
einmal an dieser Funktion korrigiert hat: dass sich über ein `await` hinweg
|
||||||
|
nichts ändert.
|
||||||
|
|
||||||
|
**Nachgemessen** im Browser gegen ein lokales Backend, mit zwei
|
||||||
|
Server-Dokumenten: Umschalten auf ein lokales Dokument räumt `?live=` weg,
|
||||||
|
Umschalten auf das andere Server-Dokument tauscht die URL aus, `?sourceUrl=`
|
||||||
|
verhält sich symmetrisch und bleibt unmaskiert lesbar. Eine fremde Änderung
|
||||||
|
erreicht das per Wähler geöffnete Server-Dokument ohne Neuladen (die Sitzung
|
||||||
|
läuft also wirklich); dieselbe Änderung, während ein lokales Dokument vorn
|
||||||
|
steht, lässt dessen Text unangetastet (vorher hätte sie ihn überschrieben);
|
||||||
|
beim Zurückschalten steht der Server-Stand da. Eine Zeile, im selben Zug
|
||||||
|
getippt und umgeschaltet, kommt beim Server an. 514 Tests, davon 13 neue in
|
||||||
|
`tests/docurl.test.js`; Gegenproben: fremde Parameter mitwerfen → genau die
|
||||||
|
zwei danach benannten Zusicherungen fallen, `encodeURIComponent` statt der
|
||||||
|
sparsamen Maskierung → genau die sieben, die die Lesbarkeit festhalten.
|
||||||
|
|||||||
@@ -766,6 +766,9 @@ Seite aufgelöst; zugelassen sind nur `http`/`https`). Der Text wird als
|
|||||||
Tooltip); derselbe Link aktualisiert dieses Dokument, statt ein neues anzulegen.
|
Tooltip); derselbe Link aktualisiert dieses Dokument, statt ein neues anzulegen.
|
||||||
Ist der Parameter gesetzt, wird bei **jedem** Laden neu geholt — die URL ist die
|
Ist der Parameter gesetzt, wird bei **jedem** Laden neu geholt — die URL ist die
|
||||||
Quelle der Wahrheit, lokale Änderungen daran überleben ein Neuladen nicht.
|
Quelle der Wahrheit, lokale Änderungen daran überleben ein Neuladen nicht.
|
||||||
|
Die **Adresszeile folgt dem aktiven Dokument** (§9, `?live=`): Beim Umschalten
|
||||||
|
auf ein anderes verschwindet der Parameter, beim Zurückschalten steht er wieder
|
||||||
|
da.
|
||||||
Scheitert das Laden (häufigster Fall: das Ziel sendet keinen
|
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.
|
||||||
@@ -796,6 +799,13 @@ anderen, ohne neu zu laden.
|
|||||||
jede Version in der Historie des Servers.
|
jede Version in der Historie des Servers.
|
||||||
- Der Abruf läuft **nur im sichtbaren Tab**; im Hintergrund ruht er und holt
|
- Der Abruf läuft **nur im sichtbaren Tab**; im Hintergrund ruht er und holt
|
||||||
beim Zurückkommen den Rückstand.
|
beim Zurückkommen den Rückstand.
|
||||||
|
- **Die Adresszeile beschreibt das Dokument, das vorn ist.** Umschalten auf ein
|
||||||
|
lokales Dokument räumt `?live=` weg, Umschalten auf ein anderes
|
||||||
|
Server-Dokument trägt dessen Adresse ein — ein Neuladen bringt also zurück,
|
||||||
|
was man vor sich hatte. Dasselbe gilt für `?sourceUrl=` (oben). Und die
|
||||||
|
gemeinsame Sitzung gehört dem sichtbaren Dokument: Wer ein Server-Dokument im
|
||||||
|
Wähler auswählt, arbeitet darin wieder gemeinsam; wer es verlässt, hört auf,
|
||||||
|
mitzuschreiben. Siehe D80.
|
||||||
|
|
||||||
Siehe D76 (Protokoll und Begründung) und
|
Siehe D76 (Protokoll und Begründung) und
|
||||||
`backend/docs/live-editing-proposal.md`.
|
`backend/docs/live-editing-proposal.md`.
|
||||||
|
|||||||
+75
-20
@@ -8,6 +8,7 @@ import { depFragment, collectIds, matchIds, depIdAt, idLine } from './autocomple
|
|||||||
import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel }
|
import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel }
|
||||||
from './snapshots.js';
|
from './snapshots.js';
|
||||||
import { FILE_ACCEPT, FILE_TYPES, saveFileName } from './localfile.js';
|
import { FILE_ACCEPT, FILE_TYPES, saveFileName } from './localfile.js';
|
||||||
|
import { LIVE_PARAM, SOURCE_PARAM, ETHERPAD_PARAM, docSearch } from './docurl.js';
|
||||||
/* Neuigkeiten (D58): die git-Historie, zur BAUZEIT eingelesen (Vite-Plugin in
|
/* Neuigkeiten (D58): die git-Historie, zur BAUZEIT eingelesen (Vite-Plugin in
|
||||||
vite.config.js). Zur Laufzeit gibt es kein git — und keinen Server, der
|
vite.config.js). Zur Laufzeit gibt es kein git — und keinen Server, der
|
||||||
nachliefern könnte (D11/D19). Leer, wo git nicht erreichbar war. */
|
nachliefern könnte (D11/D19). Leer, wo git nicht erreichbar war. */
|
||||||
@@ -80,6 +81,13 @@ let mobilePane = 'diagram';
|
|||||||
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(). */
|
||||||
let sourceWarning = null;
|
let sourceWarning = null;
|
||||||
|
/* Steht die App? Erst danach folgt die Adresszeile dem aktiven Dokument (D80)
|
||||||
|
und wird eine Live-Sitzung beim Umschalten übernommen. Während des Starts
|
||||||
|
dürfen beide nicht laufen: `loadRemoteSource()`/`loadLive()` lesen ihre
|
||||||
|
Parameter erst nach dem Wiederherstellen des zuletzt aktiven Dokuments —
|
||||||
|
ein vorschnelles Aufräumen der Adresszeile nähme ihnen die Vorlage. Hier
|
||||||
|
oben deklariert, weil `loadActiveIntoEditor()` schon beim Start läuft. */
|
||||||
|
let bootDone = false;
|
||||||
/* „Was ist neu?" (D28): Knoten, die gegenüber der zuletzt GESEHENEN Fassung neu
|
/* „Was ist neu?" (D28): Knoten, die gegenüber der zuletzt GESEHENEN Fassung neu
|
||||||
in Produktion sind. Gilt immer für genau ein Dokument (`freshDocId`) — nur
|
in Produktion sind. Gilt immer für genau ein Dokument (`freshDocId`) — nur
|
||||||
Dokumente von außen (mitgeliefert oder ?sourceUrl=) haben eine
|
Dokumente von außen (mitgeliefert oder ?sourceUrl=) haben eine
|
||||||
@@ -3892,9 +3900,44 @@ 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();
|
snapBase = src.value; closeSnapMenu();
|
||||||
render(); updateDocName(); updateFreshBtn(); }
|
render(); updateDocName(); updateFreshBtn();
|
||||||
|
/* Ein Dokumentwechsel ist mehr als neuer Text im Feld: Adresszeile und
|
||||||
|
Live-Sitzung gehören dem, was man vor sich hat (D80). Hier, weil jeder
|
||||||
|
Weg zu einem anderen aktiven Dokument durch diese eine Stelle führt —
|
||||||
|
Umschalten, Anlegen, Löschen, Datei öffnen, Server-Dokument laden. */
|
||||||
|
followActiveDoc(); }
|
||||||
|
|
||||||
|
/* Die Adresszeile beschreibt das aktive Dokument (D80). Geschrieben wird nur,
|
||||||
|
wenn sich wirklich etwas ändert — sonst stünde in der Historie des Browsers
|
||||||
|
bei jedem Rendern ein Eintrag mehr. */
|
||||||
|
function syncDocUrl(){
|
||||||
|
let u;
|
||||||
|
try{ u = new URL(location.href); }catch(_){ return; }
|
||||||
|
const neu = docSearch(u.search, activeId);
|
||||||
|
if(neu === u.search) return;
|
||||||
|
try{ history.replaceState(null, '', u.origin + u.pathname + neu + u.hash); }catch(_){}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Live-Sitzung gehört dem sichtbaren Dokument (D80). Ohne das liefe der
|
||||||
|
Feed eines Server-Dokuments weiter, während ein anderes vorn steht — und
|
||||||
|
`setLiveText()` schriebe die fremde Änderung in **dessen** Text. */
|
||||||
|
function followActiveDoc(){
|
||||||
|
if(!bootDone) return;
|
||||||
|
syncDocUrl();
|
||||||
|
if(liveState && liveState.id !== activeId) stopLive();
|
||||||
|
const d = activeDoc();
|
||||||
|
if(!liveState && d && String(d.id).startsWith('live:')) startLive(d.id.slice(5));
|
||||||
|
}
|
||||||
|
|
||||||
function switchDoc(id){
|
function switchDoc(id){
|
||||||
if(id === activeId) return;
|
if(id === activeId) return;
|
||||||
|
/* Was noch im Debounce steckt, ist getippt und gemeint: erst loswerden,
|
||||||
|
solange das Textfeld noch den Text dieses Dokuments zeigt (D80). */
|
||||||
|
if(liveActive() && liveState.pushTimer){
|
||||||
|
clearTimeout(liveState.pushTimer);
|
||||||
|
liveState.pushTimer = null;
|
||||||
|
pushLive(); /* liest src.value synchron; das Ergebnis braucht niemand mehr */
|
||||||
|
}
|
||||||
flushActive();
|
flushActive();
|
||||||
activeId = id;
|
activeId = id;
|
||||||
foldOverrides.clear(); /* Falt-Eingriffe gelten je Dokument-Sitzung (D38) */
|
foldOverrides.clear(); /* Falt-Eingriffe gelten je Dokument-Sitzung (D38) */
|
||||||
@@ -4231,8 +4274,8 @@ function initDocs(){
|
|||||||
|
|
||||||
`?etherpad=` gab es hier einmal daneben (D31) und ist ausgebaut (D78) — ein
|
`?etherpad=` gab es hier einmal daneben (D31) und ist ausgebaut (D78) — ein
|
||||||
alter Link meldet sich, statt still nichts zu tun. */
|
alter Link meldet sich, statt still nichts zu tun. */
|
||||||
const SOURCE_PARAM = 'sourceUrl';
|
/* Die Parameternamen stehen in docurl.js — dort wird auch entschieden, welcher
|
||||||
const ETHERPAD_PARAM = 'etherpad';
|
von ihnen zum aktiven Dokument gehört (D80). */
|
||||||
function urlParam(name){
|
function urlParam(name){
|
||||||
try{ return new URLSearchParams(location.search).get(name); }catch(_){ return null; }
|
try{ return new URLSearchParams(location.search).get(name); }catch(_){ return null; }
|
||||||
}
|
}
|
||||||
@@ -4315,7 +4358,6 @@ async function loadRemoteSource(){
|
|||||||
Was hier NICHT passiert: Zusammenführen. Überschneiden sich zwei Änderungen
|
Was hier NICHT passiert: Zusammenführen. Überschneiden sich zwei Änderungen
|
||||||
wirklich, entscheidet der Mensch (Konflikt-Band unten). Alles andere
|
wirklich, entscheidet der Mensch (Konflikt-Band unten). Alles andere
|
||||||
verschiebt der Server selbst. */
|
verschiebt der Server selbst. */
|
||||||
const LIVE_PARAM = 'live';
|
|
||||||
const LIVE_DEBOUNCE_MS = 600; /* Ruhe vor dem Senden; D76, D79 */
|
const LIVE_DEBOUNCE_MS = 600; /* Ruhe vor dem Senden; D76, D79 */
|
||||||
const LIVE_WAIT_S = 25; /* Wartezeit des Feeds; der Server klemmt sie ohnehin */
|
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 */
|
const LIVE_RETRY_MS = 5000; /* nach einem Netzfehler, bevor der Feed erneut fragt */
|
||||||
@@ -4367,8 +4409,12 @@ function stopLive(){
|
|||||||
hideConflictBanner();
|
hideConflictBanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLive(){
|
function loadLive(){ startLive(urlParam(LIVE_PARAM)); }
|
||||||
const raw = urlParam(LIVE_PARAM);
|
|
||||||
|
/* Eine Sitzung für dieses Server-Dokument aufnehmen: Stand holen, Feed öffnen.
|
||||||
|
Zwei Wege hierher — der `?live=`-Parameter beim Laden und das Umschalten auf
|
||||||
|
ein Server-Dokument im Wähler (D80). */
|
||||||
|
async function startLive(raw){
|
||||||
if(!raw) return;
|
if(!raw) return;
|
||||||
const urls = live.liveUrls(raw, location.href);
|
const urls = live.liveUrls(raw, location.href);
|
||||||
if(!urls){
|
if(!urls){
|
||||||
@@ -4444,7 +4490,12 @@ async function pushLive(){
|
|||||||
const ops = live.computeOps(liveState.shadow, now);
|
const ops = live.computeOps(liveState.shadow, now);
|
||||||
if(!ops.length) return;
|
if(!ops.length) return;
|
||||||
|
|
||||||
liveState.busy = true;
|
/* Die Sitzung selbst festhalten, nicht nur ihre Felder: Wer während des
|
||||||
|
Sendens auf ein anderes Dokument umschaltet, beendet sie (D80) — die
|
||||||
|
Fortsetzung unten dürfte danach weder schreiben noch in ein `null`
|
||||||
|
greifen. Der PATCH ist dann trotzdem draußen, und das ist gewollt. */
|
||||||
|
const sitzung = liveState;
|
||||||
|
sitzung.busy = true;
|
||||||
const seq = nextSeq();
|
const seq = nextSeq();
|
||||||
/* Die Basis, gegen die `ops` gerechnet sind — VOR dem Warten festgehalten.
|
/* Die Basis, gegen die `ops` gerechnet sind — VOR dem Warten festgehalten.
|
||||||
Sie hinterher aus `liveState` zu lesen hieße anzunehmen, dass sich
|
Sie hinterher aus `liveState` zu lesen hieße anzunehmen, dass sich
|
||||||
@@ -4452,21 +4503,22 @@ async function pushLive(){
|
|||||||
Feed dazwischenfunkt (D76-Nachtrag 9). Der Feed lässt sich jetzt aus,
|
Feed dazwischenfunkt (D76-Nachtrag 9). Der Feed lässt sich jetzt aus,
|
||||||
solange wir senden — aber eine Rechnung, die nur wegen einer Sperre
|
solange wir senden — aber eine Rechnung, die nur wegen einer Sperre
|
||||||
anderswo stimmt, schreibt man nicht auf. */
|
anderswo stimmt, schreibt man nicht auf. */
|
||||||
const basis = liveState.shadow;
|
const basis = sitzung.shadow;
|
||||||
try{
|
try{
|
||||||
const body = {
|
const body = {
|
||||||
baseVersion: liveState.version,
|
baseVersion: sitzung.version,
|
||||||
checksum: await live.checksum(live.text(liveState.shadow)),
|
checksum: await live.checksum(live.text(sitzung.shadow)),
|
||||||
clientId: clientId(),
|
clientId: clientId(),
|
||||||
displayName: displayName(),
|
displayName: displayName(),
|
||||||
seq,
|
seq,
|
||||||
ops,
|
ops,
|
||||||
};
|
};
|
||||||
const result = await fetchJson(liveState.urls.content, {
|
const result = await fetchJson(sitzung.urls.content, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
if(liveState !== sitzung) return; /* inzwischen umgeschaltet (D80) */
|
||||||
/* Angenommen. Hat der Server verschoben, stehen die fremden Operationen in
|
/* Angenommen. Hat der Server verschoben, stehen die fremden Operationen in
|
||||||
`opsSinceBase`: Die Schattenkopie zieht erst darüber nach, dann kommt
|
`opsSinceBase`: Die Schattenkopie zieht erst darüber nach, dann kommt
|
||||||
unsere eigene Änderung darauf — verschoben um die fremde. Genau diese
|
unsere eigene Änderung darauf — verschoben um die fremde. Genau diese
|
||||||
@@ -4475,14 +4527,14 @@ async function pushLive(){
|
|||||||
const foreign = (result.opsSinceBase || []);
|
const foreign = (result.opsSinceBase || []);
|
||||||
const meine = foreign.length ? live.rebaseOps(ops, foreign) : ops;
|
const meine = foreign.length ? live.rebaseOps(ops, foreign) : ops;
|
||||||
if(meine == null){ await reloadLive(); return; } /* kann nicht sein - dann lieber neu */
|
if(meine == null){ await reloadLive(); return; } /* kann nicht sein - dann lieber neu */
|
||||||
liveState.shadow = live.applyOps(
|
sitzung.shadow = live.applyOps(
|
||||||
foreign.length ? live.applyOps(basis, foreign) : basis, meine);
|
foreign.length ? live.applyOps(basis, foreign) : basis, meine);
|
||||||
liveState.version = result.version;
|
sitzung.version = result.version;
|
||||||
if(foreign.length) applyForeign(basis, foreign, liveState.shadow, liveState.version);
|
if(foreign.length) applyForeign(basis, foreign, sitzung.shadow, sitzung.version);
|
||||||
}catch(err){
|
}catch(err){
|
||||||
handlePushError(err);
|
if(liveState === sitzung) handlePushError(err);
|
||||||
}finally{
|
}finally{
|
||||||
liveState.busy = false;
|
sitzung.busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4736,10 +4788,10 @@ async function putOnServer(){
|
|||||||
runFeed();
|
runFeed();
|
||||||
|
|
||||||
/* Die Adresszeile IST der Link — dort sucht man ihn, und ein Neuladen
|
/* Die Adresszeile IST der Link — dort sucht man ihn, und ein Neuladen
|
||||||
führt zurück ins selbe Dokument. Zusätzlich in die Zwischenablage,
|
führt zurück ins selbe Dokument. Gesetzt hat sie `adoptLive()` schon
|
||||||
weil Weitergeben der eigentliche Zweck ist. */
|
(D80); in die Zwischenablage geht der Link ohne fremde Parameter, denn
|
||||||
const teilen = location.origin + location.pathname + '?live=' + urls.doc;
|
ein `?server=` geht den Empfänger nichts an. */
|
||||||
try{ history.replaceState(null, '', teilen); }catch(_){}
|
const teilen = location.origin + location.pathname + '?' + LIVE_PARAM + '=' + urls.doc;
|
||||||
try{ await navigator.clipboard.writeText(teilen); }catch(_){}
|
try{ await navigator.clipboard.writeText(teilen); }catch(_){}
|
||||||
flashBtn(document.getElementById('docTrigger'));
|
flashBtn(document.getElementById('docTrigger'));
|
||||||
sourceWarning = null;
|
sourceWarning = null;
|
||||||
@@ -4979,6 +5031,9 @@ 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) */
|
loadLive(); /* ?live= — Server-Dokument samt Feed (asynchron, D76) */
|
||||||
|
/* Beide haben ihren Parameter jetzt gelesen (synchron, vor dem ersten
|
||||||
|
`await`). Ab hier folgt die Adresszeile dem aktiven Dokument (D80). */
|
||||||
|
bootDone = true;
|
||||||
|
|
||||||
/* ---------- 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
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/* ---------- Welche Adresse beschreibt das aktive Dokument? (D80) ----------
|
||||||
|
|
||||||
|
Die Adresszeile ist der Link, den man weitergibt, und der Stand, den ein
|
||||||
|
Neuladen wiederherstellt. Sie muss deshalb sagen, welches Dokument **gerade
|
||||||
|
vorn** ist — nicht, mit welchem die Seite einmal aufgerufen wurde. Bleibt
|
||||||
|
ein `?live=` stehen, nachdem man auf ein anderes Dokument umgeschaltet hat,
|
||||||
|
zeigt die Adresse auf etwas anderes als der Bildschirm, und ein Neuladen
|
||||||
|
holt das falsche Dokument zurück.
|
||||||
|
|
||||||
|
Die beiden Eingänge, die ein Dokument adressieren, sind zugleich seine
|
||||||
|
Identität: `live:<url>` (D76) und `url:<href>` (D23). Aus der id lässt sich
|
||||||
|
der Parameter also zurückrechnen — hier steht diese eine Regel. Eigene
|
||||||
|
Dokumente, Dateien und die mitgelieferten haben keine Adresse: Dort fällt
|
||||||
|
der Parameter weg. `?etherpad=` ist ausgebaut (D78) und wird nur noch
|
||||||
|
weggeräumt. */
|
||||||
|
|
||||||
|
export const LIVE_PARAM = 'live';
|
||||||
|
export const SOURCE_PARAM = 'sourceUrl';
|
||||||
|
export const ETHERPAD_PARAM = 'etherpad';
|
||||||
|
|
||||||
|
/* Die Parameter, über die diese Regel bestimmt. Alles andere ist fremd und
|
||||||
|
bleibt unangetastet. */
|
||||||
|
const OWNED = [LIVE_PARAM, SOURCE_PARAM, ETHERPAD_PARAM];
|
||||||
|
|
||||||
|
/* Der Parameter, der dieses Dokument wieder öffnet — oder null. */
|
||||||
|
export function docParam(id){
|
||||||
|
if(typeof id !== 'string') return null;
|
||||||
|
if(id.startsWith('live:')) return {name: LIVE_PARAM, value: id.slice(5)};
|
||||||
|
if(id.startsWith('url:')) return {name: SOURCE_PARAM, value: id.slice(4)};
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der neue Query-String zu `search` (mit oder ohne führendes `?`) für das
|
||||||
|
Dokument `id`.
|
||||||
|
|
||||||
|
Fremde Parameter (etwa `?server=`, D76-Nachtrag 8) bleiben **wörtlich**
|
||||||
|
stehen, Schreibweise eingeschlossen: Der Umweg über `URLSearchParams`
|
||||||
|
schriebe jedes `:` und `/` als `%3A`/`%2F` und machte damit gerade die URL
|
||||||
|
unleserlich, um die es hier geht. Aus demselben Grund wird der eigene Wert
|
||||||
|
nur dort maskiert, wo er den Query-String sonst zerrisse (`&`, `#`). */
|
||||||
|
export function docSearch(search, id){
|
||||||
|
const roh = String(search == null ? '' : search).replace(/^\?/, '');
|
||||||
|
const rest = roh ? roh.split('&').filter(p => p && OWNED.indexOf(p.split('=')[0]) < 0) : [];
|
||||||
|
const mein = docParam(id);
|
||||||
|
if(mein){
|
||||||
|
const wert = String(mein.value).replace(/&/g, '%26').replace(/#/g, '%23');
|
||||||
|
rest.push(mein.name + '=' + wert);
|
||||||
|
}
|
||||||
|
return rest.length ? '?' + rest.join('&') : '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { docParam, docSearch, LIVE_PARAM, SOURCE_PARAM } from '../src/docurl.js';
|
||||||
|
|
||||||
|
const LIVE = 'live:https://werkbaum.example/api/v1/documents/44753df1';
|
||||||
|
const URLDOC = 'url:https://example.org/plan.werkbaum';
|
||||||
|
|
||||||
|
describe('docParam — welcher Parameter öffnet dieses Dokument wieder?', () => {
|
||||||
|
it('ein Server-Dokument wird über ?live= adressiert', () => {
|
||||||
|
expect(docParam(LIVE)).toEqual(
|
||||||
|
{name: LIVE_PARAM, value: 'https://werkbaum.example/api/v1/documents/44753df1'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein geholtes Dokument über ?sourceUrl=', () => {
|
||||||
|
expect(docParam(URLDOC)).toEqual(
|
||||||
|
{name: SOURCE_PARAM, value: 'https://example.org/plan.werkbaum'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eigene, mitgelieferte und aus Dateien geöffnete Dokumente haben keine Adresse', () => {
|
||||||
|
expect(docParam('example')).toBe(null);
|
||||||
|
expect(docParam('werkbaum')).toBe(null);
|
||||||
|
expect(docParam('k3f9x1')).toBe(null);
|
||||||
|
expect(docParam(null)).toBe(null);
|
||||||
|
expect(docParam(undefined)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('docSearch — die Adresszeile folgt dem aktiven Dokument', () => {
|
||||||
|
it('setzt den Parameter des Server-Dokuments', () => {
|
||||||
|
expect(docSearch('', LIVE))
|
||||||
|
.toBe('?live=https://werkbaum.example/api/v1/documents/44753df1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('räumt ihn weg, sobald ein lokales Dokument vorn ist', () => {
|
||||||
|
expect(docSearch('?live=https://werkbaum.example/api/v1/documents/44753df1', 'example'))
|
||||||
|
.toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tauscht ihn beim Wechsel auf ein anderes Server-Dokument', () => {
|
||||||
|
expect(docSearch('?live=https://werkbaum.example/api/v1/documents/aaa',
|
||||||
|
'live:https://werkbaum.example/api/v1/documents/bbb'))
|
||||||
|
.toBe('?live=https://werkbaum.example/api/v1/documents/bbb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tauscht auch zwischen den beiden Eingängen', () => {
|
||||||
|
expect(docSearch('?sourceUrl=https://example.org/plan.werkbaum', LIVE))
|
||||||
|
.toBe('?live=https://werkbaum.example/api/v1/documents/44753df1');
|
||||||
|
expect(docSearch('?live=https://werkbaum.example/api/v1/documents/44753df1', URLDOC))
|
||||||
|
.toBe('?sourceUrl=https://example.org/plan.werkbaum');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('räumt den ausgebauten ?etherpad= mit weg (D78)', () => {
|
||||||
|
expect(docSearch('?etherpad=https://pad.example/p/plan', 'example')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt fremde Parameter wörtlich stehen — auch ihre Schreibweise', () => {
|
||||||
|
expect(docSearch('?server=http://localhost:8080&live=https://a/x', 'example'))
|
||||||
|
.toBe('?server=http://localhost:8080');
|
||||||
|
expect(docSearch('?server=http://localhost:8080', LIVE))
|
||||||
|
.toBe('?server=http://localhost:8080&live=https://werkbaum.example/api/v1/documents/44753df1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schreibt die URL unmaskiert — lesbar ist der Zweck', () => {
|
||||||
|
expect(docSearch('', LIVE)).toContain('https://werkbaum.example/api/v1/documents/44753df1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maskiert nur, was den Query-String zerrisse', () => {
|
||||||
|
expect(docSearch('', 'url:https://example.org/p?a=1&b=2#tail'))
|
||||||
|
.toBe('?sourceUrl=https://example.org/p?a=1%26b=2%23tail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt den Query-String mit und ohne führendes Fragezeichen', () => {
|
||||||
|
expect(docSearch('server=x', 'example')).toBe('?server=x');
|
||||||
|
expect(docSearch('?server=x', 'example')).toBe('?server=x');
|
||||||
|
expect(docSearch(undefined, 'example')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ändert nichts, wenn schon das Richtige dasteht', () => {
|
||||||
|
const s = '?live=https://werkbaum.example/api/v1/documents/44753df1';
|
||||||
|
expect(docSearch(s, LIVE)).toBe(s);
|
||||||
|
expect(docSearch('', 'example')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user