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:
mhoennig
2026-08-26 21:19:29 +02:00
co-authored by Claude Opus 5
parent 627f6feca8
commit fdca934cf0
8 changed files with 313 additions and 20 deletions
+6
View File
@@ -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.
+5
View File
@@ -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
+3
View File
@@ -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
+82
View File
@@ -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.
+10
View File
@@ -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
View File
@@ -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
+50
View File
@@ -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('&') : '';
}
+82
View File
@@ -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('');
});
});