ROM Scroll Position Tracking

Keep your place in the stories you're reading!

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

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