diff --git a/CLAUDE.md b/CLAUDE.md index 3b3a32b..7143046 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -235,6 +235,38 @@ Original-Zurueck/Weiter-Links, das Sheet verschwindet. einer verschachtelten Tabelle, `closest('tr')` trifft die innere Zeile ohne Autor- und Profilspalte. +**Der Umbau erbt die Scrollposition, die der Browser vorher gesetzt hat.** +`replaceBody()` laeuft auf `DOMContentLoaded` und ruehrt `scrollY` nicht an; +weil ein Thread auch danach laenger als der Viewport bleibt, klemmt der Browser +sie nie auf 0 zurueck. Auf dem iPhone startete der Thread deshalb mitten im +ersten Beitrag: iOS Safari stellt die Position einer schon besuchten Adresse +von sich aus wieder her und verankert sie ausserdem beim Relayout auf +`width=device-width`. Im Chrome-Debugging faellt das nicht auf, weil dort jede +Adresse frisch geladen wird und es gar keine Position zu uebernehmen gibt. Zwei +Bremsen dagegen: `history.scrollRestoration = 'manual'` direkt beim +Script-Start — spaeter waere es zu spaet, Safari hat dann laengst gescrollt — +und `holdStart()` am Ende von `run()`. + +Ein einzelner Reset auf `DOMContentLoaded` reichte nicht — am Geraet +widerlegt: Safari legt die Position nicht in einem Rutsch fest, sondern +springt nach `load` und nach dem Layout nachgeladener Bilder noch einmal nach +und ueberschreibt den Reset. `holdStart()` haelt den Anfang deshalb fest — +zwoelf Versuche im 50-ms-Takt plus einer auf `load` fuer den Sprung, der erst +nach dem letzten Bild kommt. Die Bremse loest bei der ersten eigenen Geste des +Lesers aus (`touchstart`/`wheel`/`keydown`, jeweils `once`), sonst risse sie +ihn mitten im Lesen zurueck. `scroll` taugt als Signal dafuer nicht: +`toStart()` loest selbst eines aus und wuerde sich sofort selbst abschalten. + +**Die Post-Anker der Originalseite muessen auf die Karten wandern.** Ein +Beitrag traegt sein Sprungziel als `` in der Autorenzelle, und +die verschwindet mit dem Original-DOM. Bis 3.14 hatte `#pXXXX` deshalb kein +Ziel mehr — der „Neueste ↓"-Link aus der Themenliste landete nicht am Beitrag, +sondern dort, wo der Browser beim Umbau zufaellig stand. `buildThread()` +uebernimmt den Namen jetzt als `id` der `.rm-post`-Karte, und `toStart()` +springt darauf statt an den Anfang, wenn die Adresse ein Fragment traegt. +`scroll-margin-top` auf `.rm-post` haelt dabei die fixierte Brotkrumenleiste +frei, sonst steht der Kartenkopf dahinter. + **Die Lupe und die Login-Pille haengen an einer gemeinsamen `margin-left:auto` auf einem Wrapper (`.rm-auth-group`), nicht je an sich selbst.** Saesse die Regel wie zuvor direkt auf `.rm-auth`, wuerde nur die Pille an den Rand @@ -368,6 +400,19 @@ Am Beitrag selbst zu pruefen: alles hinter dem ersten `
` im `postbody` landet als zugeklapptes `details` („Signatur") am Kartenfuss, Beitraege ohne `
` behalten ihren Text vollstaendig im Koerper. +Fuer den Sprung an den Threadanfang genuegt derselbe Fixture zweimal, einmal +mit und einmal ohne Fragment in der Adresse: ohne Fragment darf nur +`scrollTo(0, 0)` fallen, mit `#pXXXX` nur ein `scrollIntoView()` auf genau der +Karte mit dieser `id` — und jede der 15 Karten muss eine tragen. Fuer +`holdStart()` kommen drei zeitabhaengige Faelle dazu: dass ohne Zutun weitere +Resets nachkommen, dass ein abgesetztes `touchstart` sie sofort stoppt, und +dass `load` nach dem Ende der Schleife noch einmal greift, aber nach einer +eigenen Geste eben nicht mehr. Achtung: jsdom +kennt `history.scrollRestoration` nicht, das Script laeuft dort stumm in seinen +Rueckfall. Wer den Zweig pruefen will, definiert die Eigenschaft vor `eval()` +selbst auf `window.history`. `scrollTo`, `scrollIntoView` und +`requestAnimationFrame` fehlen ohnehin und muessen gestubbt werden. + Dieselben Faelle gelten seit 3.5 fuer die Liste, die sich die Leiste mit dem Thread teilt. Eckdaten von `dev/viewforum.php`: `f=10`, `per_page = 100`, 610 Seiten, letzte Seite `start=60900`, dazu 4 Unterforen, 7 globale diff --git a/rin-mobile.user.js b/rin-mobile.user.js index 8d11b6b..e1c1c0d 100644 --- a/rin-mobile.user.js +++ b/rin-mobile.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name RIN Mobile // @description Mobile Ansicht fuer cs.rin.ru: Themenlisten als Karten, Threads als Beitragsstrom -// @version 3.14 +// @version 3.15 // @match *://cs.rin.ru/forum/* // @run-at document-start // ==/UserScript== @@ -60,6 +60,12 @@ : ''; log.push('ansicht=' + (view || 'keine')); + // Muss zum fruehestmoeglichen Zeitpunkt stehen, nicht erst beim Aufbau: iOS + // Safari stellt die Scrollposition einer schon besuchten Adresse von sich aus + // wieder her, und zwar bevor das Script ueberhaupt etwas gebaut hat. Ein + // spaeterer Reset kaeme dagegen nur hinterher. + if (view && 'scrollRestoration' in history) history.scrollRestoration = 'manual'; + if (view && !document.querySelector('meta[name="viewport"][data-rm]')) { var m = document.createElement('meta'); m.name = 'viewport'; @@ -237,9 +243,13 @@ font-size:21px; font-weight:650; line-height:1.3; overflow-wrap:break-word; } + /* Die Karte traegt seit dem Anker-Uebertrag die id des Beitrags und ist + damit Sprungziel von #pXXXX. Ohne den Freiraum landet sie hinter der + fixierten Brotkrumenleiste, der Kartenkopf waere verdeckt. */ .rm-post { box-sizing:border-box !important; width:100% !important; max-width:100% !important; padding:14px !important; + scroll-margin-top:calc(53px + env(safe-area-inset-top)); border-top:1px solid rgba(128,128,128,.25) !important; } .rm-head { display:flex; align-items:center; gap:9px; @@ -1502,6 +1512,16 @@ pr = pr.previousElementSibling; } + // --- Sprungziel --- + // Der Anker eines Beitrags steht als
in der Autorenzelle + // und wandert mit dem Original-DOM in die Unsichtbarkeit. Ohne ihn auf der + // Karte hat eine Adresse mit #pXXXX kein Ziel mehr, und der Browser bleibt + // stehen, wo er beim Umbau gerade stand. + var anc = null; + for (var ci = 0; ci < cells.length && !anc; ci++) + anc = cells[ci].querySelector('a[name^="p"]'); + if (anc) card.id = anc.getAttribute('name'); + // --- Username --- var user = ''; for (var i = 0; i < cells.length && !user; i++) { @@ -1694,6 +1714,46 @@ // ========================================================================== + // Der Umbau laeuft erst auf DOMContentLoaded, also lange nachdem der Browser + // seine Scrollposition festgelegt hat — und replaceBody() ruehrt sie nicht an. + // Weil ein Thread auch nach dem Umbau laenger als der Viewport bleibt, klemmt + // der Browser sie auch nicht auf 0 zurueck: der Thread startet dann mitten im + // ersten Beitrag statt an seinem Anfang. Auf dem Geraet faellt das auf, im + // Chrome-Debugging nicht, weil dort jede Adresse frisch geladen wird und es + // gar keine Position zu uebernehmen gibt. + function toStart() { + var id = location.hash ? location.hash.slice(1) : ''; + var target = id ? document.getElementById(id) : null; + if (target) target.scrollIntoView(); + else window.scrollTo(0, 0); + } + + // Einmal zuruecksetzen genuegt nicht: Safari legt die Position nicht in einem + // Rutsch fest, sondern springt nach 'load' und nach dem Layout nachgeladener + // Bilder noch einmal nach — ein Reset auf DOMContentLoaded wird davon einfach + // ueberschrieben. Der Anfang wird deshalb kurz festgehalten. Die Bremse loest + // bei der ersten eigenen Geste des Lesers sofort aus, sonst risse sie ihn + // waehrend des Lesens zurueck; 'scroll' taugt dafuer nicht als Signal, weil + // toStart() selbst eines ausloest. + function holdStart() { + var free = false; + function release() { free = true; } + ['touchstart', 'wheel', 'keydown'].forEach(function (ev) { + window.addEventListener(ev, release, { passive: true, once: true }); + }); + + var tries = 0; + (function tick() { + if (free) return; + toStart(); + if (++tries < 12) setTimeout(tick, 50); + })(); + + // Faengt den Sprung ab, der erst nach dem letzten Bild kommt und damit + // jenseits der 600 ms der Schleife liegen kann. + window.addEventListener('load', function () { if (!free) toStart(); }); + } + function run() { try { if (view === 'liste') buildList(); @@ -1701,6 +1761,8 @@ else if (view === 'login') buildLogin(); } catch (e) { log.push('build: ' + e.message); } + if (view && document.querySelector('.rm-root')) holdStart(); + if (DEBUG) { var d = document.createElement('div'); d.className = 'rm-dbg';