Library for scroll position tracking
Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta
// @require https://update.greasyfork.org/scripts/583749/1856870/Scroll%20Position%20Tracking%20Library.js
// ==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 };
})();