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 e459973149
2 changed files with 108 additions and 1 deletions
+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';