ROM Scroll Position Tracking

Keep your place in the stories you're reading!

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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