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