Scroll Position Tracking Library

Library for scroll position tracking

Этот скрипт недоступен для установки пользователем. Он является библиотекой, которая подключается к другим скриптам мета-ключом // @require https://update.greasyfork.org/scripts/583749/1856870/Scroll%20Position%20Tracking%20Library.js

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например 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 };
})();