Library for scroll position tracking
אין להתקין סקריפט זה ישירות. זוהי ספריה עבור סקריפטים אחרים // @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 };
})();