Scroll Position Tracking Library

Library for scroll position tracking

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.greasyfork.org/scripts/583749/1856870/Scroll%20Position%20Tracking%20Library.js

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

Advertisement:

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

Advertisement:

// ==UserScript==
// @name            Scroll Position Tracking Library
// @description     Library for scroll position tracking
// @version         1.0.1
// @author          Anonymous, GitHub Copilot (Claude Haiku 4.5, Grok Code Fast 1)
// @namespace       861ddd094884eac5bea7a3b12e074f34
// @license         MIT
// @grant           none
// ==/UserScript==

/* Attribution
 ****************

   - Adapted from spin-drift's AO3 Bunker, itself adapted from code by jcunews
       https://greasyfork.org/en/scripts/567423-ao3-bunker
       https://www.reddit.com/r/userscripts/comments/1ayfnoh/add_scroll_position_to_url_is_this_possible/

*/

(async function () {
    'use strict';

    let NAME = 'Scroll Position Tracking';

    let CONFIG = {};
    const DEFAULT_CONFIG = {
        /* Location matching
            [{
                host: String,
                path: RegExp,
                params?: [
                    { key: String, value?: String }, ...
                ],
                spa?: Boolean
            }, ...]
        */
        origins: [],
        // Delay between ceasing scroll and saving position (ms)
        save_delay: 500,
        // Lifetime of individually cached scroll positions (days)
        ttl: 42,
        // Smooth-scroll to saved position on restore
        animate_restore: true,
        // Duration of the scroll animation (ms)
        animation_length: 1000,
        // Max waiting period for page height check (s)
        // https://backlinko.com/page-speed-stats
        timeout: 30,
        // Verbose logging
        debug: false,
    };

    const INTERNET_ARCHIVE = ['web.archive.org'];
    const ARCHIVE_TODAY = ['archive.ph', 'archive.is', 'archive.today'];

    let STATE = {
        id: null,
        last_scrollX: null,
        last_scrollY: null,
        positions: null,
        timer: null,
    };

    // Logging
    /////////////

    const logger = {};
    ['debug', 'log', 'info', 'warn', 'error'].forEach(function(method) {
        logger[method] = function() {
            if (!CONFIG.debug && method === 'debug') return;
            const args = Array.prototype.slice.call(arguments);
            args.unshift(`[${NAME}]`);
            console[method].apply(console, args);
        }
    });

    // Storage
    /////////////

    function getLocalStorage(k, d = {}) {
        try {
            const v = localStorage.getItem(k);
            return (v === null) ? d : JSON.parse(v);
        } catch (e) {
            return d;
        }
    }

    function setLocalStorage(k, v) {
        localStorage.setItem(
            k, JSON.stringify(v)
        );
    }

    // Cache management
    //////////////////////

    function getPosition(k) {
        const position = STATE.positions?.[k];
        if (!position) {
            logger.debug(`Cache miss for "${k}"`);
            return { x: 0, y: 0, ts: Date.now() };
        }
        return position;
    }

    function commitCache(cache = STATE.positions) {
        try {
            setLocalStorage('scroll_positions', cache);
        } catch (e) {
            if (e) logger.error(`Storage update: "${e}"`);
            return false;
        }
        return true;
    }

    function pruneCache (commit = true) {
        const cache = STATE.positions;
        const keys = Object.keys(cache);
        const ttlMs = CONFIG.ttl * 86400000;
        const now = Date.now();
        let pruned = 0;
        for (const key of keys) {
            if (now - cache[key].ts >= ttlMs) {
                delete cache[key];
                pruned++;
                logger.debug(`Pruned entry '${key}'`);
            }
        }

        if (!pruned) return false;
        if (commit) commitCache();

        if (CONFIG.debug)
            logger.log(`Pruned ${pruned} entries`);
        else
            logger.log('Pruned site positions cache');

        return true;
    }

    // Site matching
    ///////////////////

    function checkHost(l) {
        let host = null;
        let url = new URL(l.href);
        if (INTERNET_ARCHIVE.includes(l.host)) {
            const regexp = /^\/web\/[^\/]+\/(.+)$/;
            const matches = url.pathname.match(regexp);
            if (!matches) return;
            url = new URL(matches[1]);
            host = url.host;
        } else if (ARCHIVE_TODAY.includes(l.host)) {
            let element = document.querySelector('input[name="q"]');
            if (!element) {
                logger.error('Failed to parse archived URL');
                return;
            }
            url = new URL(element.value);
            if (INTERNET_ARCHIVE.includes(url.host)) {
                let elements = document.querySelectorAll('input[readonly]');
                url = new URL(elements[elements.length - 1].value);
                if (!url) {
                    logger.error('Failed to parse archived URL');
                    return;
                }
            }
            host = url.host;
        } else {
            host = l.host;
        }
        const origin = CONFIG.origins.find(o => o.host === host);
        return (origin) ? { url, origin } : false;
    }

    const getCanonicalId = (url, origin) => {
        if (!origin?.path) return null;

        const pathMatches = url.pathname.match(origin.path);
        if (!pathMatches) return null;

        let id = pathMatches[1];
        if (origin.params?.length) {
            const params = Array.from(url.searchParams.entries());
            const matchingParam = params.find(([k, v]) =>
                origin.params.some(o =>
                    o.key === k && (!o.value || o.value === v)
                )
            );
            if (matchingParam) id += '?' + matchingParam.join('=');
        }
        return id;
    }

    function isSupported() {
        const host = checkHost(window.location);
        if (!host) return false;
        const { url, origin } = host;
        if (CONFIG.debug && url) {
            let log = `Matched site ${url.host}`;
            if (url.host !== location.host)
                log += ` (via ${location.host})`;
            logger.debug(log);
        }

        const id = getCanonicalId(url, origin);
        if (!id) {
            if (CONFIG.debug)
                logger.debug(`Unsupported path ${url.pathname}`);
            return false;
        }

        logger.debug(`Matched page '${id}'`);
        return { origin: { ...origin, spa: origin.spa ?? false }, id };
    }

    // SPA navigation detection
    //////////////////////////////

    function observeNavigation() {
        let oldUrl = new URL(location.href);
        const observer = new MutationObserver(mutations => {
            if (oldUrl.host === location.host
                && oldUrl.pathname !== location.pathname) {
                    oldUrl = new URL(location.href);
                    const supported = isSupported();
                    if (!supported) return;
                    STATE.id = supported.id;
                    const position = getPosition(STATE.id);
                    restorePosition(position);
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
    }

    // Page height checking
    //////////////////////////

    async function waitUntil(predicate, timeout) {
        return await new Promise(resolve => {
            let elapsed = 0;
            const poll = setInterval(() => {
                elapsed += 100;
                if (predicate()) {
                    clearInterval(poll);
                    resolve(true);
                } else if (elapsed >= timeout) {
                    clearInterval(poll);
                    resolve(false);
                }
            }, 100);
        });
    }

    // dynamic content may affect page height after document load
    async function pageHeightCheck(p) {
        if (p.y === 0) return true;
        logger.debug('Checking page height');
        const m = (p.y + window.innerHeight);
        if (document.documentElement.scrollHeight >= m)
            return true;
        return await waitUntil(() => {
            return document.documentElement.scrollHeight >= m
        }, (CONFIG.timeout * 1000));
    }

    // Scroll position restoration
    /////////////////////////////////

    // exponential curves around midpoint
    // https://chatgpt.com/s/cb_69ef5cceae3c81919f3e2ffe2f8158b4
    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 restoreAnimated(x, y) {
        const cancel = () => {
            window.removeEventListener('wheel', cancel);
            window.removeEventListener('touchstart', cancel);
            if (!animating) return;
            animating = false;
        };
        const props = { once: true, passive: true };
        window.addEventListener('wheel', cancel, props);
        window.addEventListener('touchstart', cancel, props);

        let animating = true;
        let startTime = 0;
        const startX = window.scrollX, startY = window.scrollY;
        const step = (timestamp) => {
            if (!animating) return;
            if (!startTime) startTime = timestamp;

            const elapsed = timestamp - startTime;
            const t = Math.min((elapsed / CONFIG.animation_length), 1);
            const e = easeInOutExpo(t);
            scrollTo(
                (startX + x * e),
                (startY + y * e)
            );

            if (t < 1)
                requestAnimationFrame(step);
            else
                animating = false;
        };

        requestAnimationFrame(step);
    }

    function restoreInstant(x, y) {
        scrollTo(x, y);
    }

    function restorePosition(p) {
        const dx = p.x - scrollX, dy = p.y - scrollY;
        if (dx === 0 && dy === 0) return false; // cached page refresh

        logger.log('Restoring scroll position');
        if (!CONFIG.animate_restore || CONFIG.animation_length <= 0)
            restoreInstant(p.x, p.y);
        else
            restoreAnimated(dx, dy);
    }

    // Scroll position tracking
    //////////////////////////////

    function scrollTracking() {
        const commit = pruneCache(false);

        // moot position change
        if (scrollX === STATE.last_scrollX
            && scrollY === STATE.last_scrollY) {
                // persist changes if prune job did work
                if (commit) commitCache();
                return;
        }
        STATE.last_scrollX = scrollX
        STATE.last_scrollY = scrollY;

        STATE.positions[STATE.id] = {
            x: STATE.last_scrollX,
            y: STATE.last_scrollY,
            ts: Date.now()
        };
        commitCache();
        logger.log('Updated entry cache');
    }

    // Initialize
    ////////////////

    async function init(customConfig = {}) {
        CONFIG = Object.assign({}, DEFAULT_CONFIG, customConfig);

        // check
        const supported = isSupported();
        if (!supported) return;
        const { origin, id } = supported;
        STATE.id = id;

        // restore
        STATE.positions = getLocalStorage('scroll_positions');

        // expire
        pruneCache();

        const position = getPosition(STATE.id);
        if (await pageHeightCheck(position))
            restorePosition(position);
        // navigation.addEventListener('navigate', () => {
        //     STATE.id = isSupported().id;
        //     position = getPosition(STATE.id);
        //     restorePosition(position);
        // });

        // track
        if (origin.spa) observeNavigation();
        addEventListener('popstate', () => {
            const supported = isSupported();
            if (supported) STATE.id = supported.id;
        });
        addEventListener('beforeunload', scrollTracking);
        addEventListener('scroll', () => {
            // register after restore to prevent unnecessary save
            clearTimeout(STATE.timer);
            STATE.timer = setTimeout(
                scrollTracking, CONFIG.save_delay
            );
        });
        addEventListener('blur', scrollTracking); // tap
        addEventListener('focus', scrollTracking);

        logger.info('Library initialized');
    }

    window.ScrollPositionTracker = { init };
})();