fix: start threads at the top on iOS Safari

The mobile view is assembled after DOMContentLoaded, long after the browser
has settled on a scroll position, and the rebuild never touched it. A rebuilt
thread stays taller than the viewport, so an inherited offset was never
clamped back to zero and the thread opened somewhere inside its first post.
Safari makes this visible where desktop Chrome does not: it restores the
position of an address it has seen before and revises it again after load and
after late images settle, so resetting once is not enough.

Deciding what "top" means also required a target for addresses that carry a
fragment. Post anchors existed only in the original markup, which the rebuild
hides, so a jump from the topic list had nowhere to land and ended up wherever
the browser happened to stand.
This commit is contained in:
Andre
2026-08-03 11:33:14 +02:00
parent 2ea94f8a2a
commit ec343ec3c1
2 changed files with 108 additions and 1 deletions
+45
View File
@@ -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 `<a name="pXXXX">` 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 `<hr>` im `postbody`
landet als zugeklapptes `details` („Signatur") am Kartenfuss, Beitraege ohne
`<hr>` 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
+63 -1
View File
@@ -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 <a name="pXXXX"> 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';