Keep your place in the stories you're reading!
// ==UserScript==
// @name ROM Scroll Position Tracking
// @namespace 861ddd094884eac5bea7a3b12e074f34
// @version 1.0.1
// @author Anonymous, GitHub Copilot (Claude Haiku 4.5)
// @description Keep your place in the stories you're reading!
// @match https://readonlymind.com/@*/*
// @icon https://external-content.duckduckgo.com/ip3/readonlymind.com.ico
// @license MIT
// ==/UserScript==
/* Attribution
****************
- Adapted from spin-drift's AO3 Bunker, itself adapted from code by jcunews
https://greasyfork.org/en/scripts/567423-ao3-bunker
https://greasyfork.org/en/users/85671-jcunews
*/
(function () {
'use strict';
// ==============================
// CONFIGURATION
// ==============================
// Save scroll after this many ms of no scrolling
let SCROLL_SAVE_MS = 500;
// Expire saved positions after this many days
let SCROLL_KEEP_DAYS = 30;
// Smooth-scroll to saved position on restore
let SCROLL_ANIMATE = true;
// Duration (in ms) of the scroll animation
let SCROLL_ANIMATE_MS = 1000;
// Time (in ms) between page-height checks before restoring
let SCROLL_POLL_INTERVAL = 100;
// Time (in ms) to wait for page to be tall enough
let SCROLL_POLL_MAX = 2000;
// ==============================
let LS_KEY = 'readonlymind_scroll';
let SCROLL_CACHE = null;
let TRACKING_ACTIVE = false;
let SAVE_TIMER = null;
let LAST_SAVED_X = null;
let LAST_SAVEDY = null;
let RESTORING_POSITION = false;
let PATH_REGEX = /^(\/@[^\/]+\/[^\/]+(?:\/\d+)?)/;
// ----------------------------
// URL NORMALIZATION
// Extract a stable path reference so that all chapter views of the same
// story resolve to the same canonical URL
// e.g. /@USERNAME/TITLE[/CHAPTER_NUMBER]
// ----------------------------
function getCanonicalPath() {
let url = new URL(location.href, location.origin);
let matches = url.pathname.match(PATH_REGEX);
if (!matches) return null;
return url.origin + matches[0];
}
// ----------------------------
// Storage
// ----------------------------
function LS_getValue(k, d) {
try {
let v = localStorage.getItem(k)
return v === null ? d : JSON.parse(v)
} catch (e) {
return d;
}
}
function LS_setValue(k, v) {
try {
localStorage.setItem(k, JSON.stringify(v));
} catch (e) {}
}
function getScrollPositions() {
if (SCROLL_CACHE) return SCROLL_CACHE;
var raw = LS_getValue(LS_KEY, {});
SCROLL_CACHE = (
raw && typeof raw === 'object' && !Array.isArray(raw)
) ? raw : {};
return SCROLL_CACHE;
}
function saveScrollPositions(s) {
SCROLL_CACHE = s;
LS_setValue(LS_KEY, s);
}
// ----------------------------
// Scroll position tracking
// ----------------------------
function easeInOutExpo(t) {
if (t <= 0) return 0;
if (t >= 1) return 1;
if (t < 0.5) return Math.pow(2, 20 * t - 10) / 2;
return (2 - Math.pow(2, -20 * t + 10)) / 2;
}
function saveCurrentScrollPosition() {
if (scrollX === LAST_SAVED_X && scrollY === LAST_SAVEDY) return;
LAST_SAVED_X = window.scrollX;
LAST_SAVEDY = window.scrollY;
let positions = getScrollPositions();
positions[getCanonicalPath()] = {
x: window.scrollX,
y: window.scrollY,
ts: Date.now()
};
let maxAge = SCROLL_KEEP_DAYS * 86400000;
let now = Date.now();
let keys = Object.keys(positions);
for (var i = 0; i < keys.length; i++) {
if (now - positions[keys[i]].ts > maxAge)
delete positions[keys[i]];
}
saveScrollPositions(positions);
console.log('Saved scroll position');
}
// Perform the actual scroll (instant or animated)
function doRestore(rec) {
if (!SCROLL_ANIMATE || SCROLL_ANIMATE_MS <= 0) {
RESTORING_POSITION = true;
scrollTo(rec.x, rec.y);
requestAnimationFrame(() => { RESTORING_POSITION = false; });
return;
}
let startX = scrollX, startY = scrollY;
let dx = rec.x - startX, dy = rec.y - startY;
if (dx === 0 && dy === 0) return;
let duration = SCROLL_ANIMATE_MS;
let startTime = null;
let animating = true;
RESTORING_POSITION = true;
function step(timestamp) {
if (!animating) return;
if (!startTime) startTime = timestamp;
let elapsed = timestamp - startTime;
let t = Math.min(elapsed / duration, 1);
let e = easeInOutExpo(t);
scrollTo(startX + dx * e, startY + dy * e);
if (t < 1) {
requestAnimationFrame(step);
} else {
animating = false;
RESTORING_POSITION = false;
}
}
let listenerProps = { once: true, passive: true };
var cancel = () => {
if (!animating) return;
animating = false;
RESTORING_POSITION = false;
window.removeEventListener('wheel', cancel);
window.removeEventListener('touchstart', cancel);
};
window.addEventListener('wheel', cancel, listenerProps);
window.addEventListener('touchstart', cancel, listenerProps);
requestAnimationFrame(step);
}
// Wait for page to be tall enough, then restore
function restoreScrollPosition() {
var rec = getScrollPositions()[getCanonicalPath()];
if (!rec || (rec.x === 0 && rec.y === 0)) return;
console.log('Restoring scroll position');
const minHeight = (rec.y + window.innerHeight);
// If the page is already tall enough, restore immediately
if (document.documentElement.scrollHeight >= minHeight) {
doRestore(rec);
return;
}
// Poll until the page is tall enough or we hit the timeout
let elapsed = 0;
var poll = setInterval(() => {
elapsed += SCROLL_POLL_INTERVAL;
if (
document.documentElement.scrollHeight >= minHeight
|| elapsed >= SCROLL_POLL_MAX
) {
clearInterval(poll);
doRestore(rec);
}
}, SCROLL_POLL_INTERVAL);
}
function ensureScrollTracking() {
if (TRACKING_ACTIVE) return;
TRACKING_ACTIVE = true;
addEventListener('beforeunload', saveCurrentScrollPosition);
addEventListener('blur', saveCurrentScrollPosition);
addEventListener('focus', saveCurrentScrollPosition);
addEventListener('scroll', () => {
clearTimeout(SAVE_TIMER);
SAVE_TIMER = setTimeout(saveCurrentScrollPosition, SCROLL_SAVE_MS);
});
}
let url = new URL(location.href, location.origin);
let matches = url.pathname.match(PATH_REGEX);
if (!matches) return;
ensureScrollTracking();
console.info('Loaded scroll position tracking');
restoreScrollPosition();
})();