ROM Scroll Position Tracking

Keep your place in the stories you're reading!

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
})();