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