|
// retoor <retoor@molodetz.nl>
|
|
|
|
export class ScrollMemory {
|
|
constructor() {
|
|
this.positionsKey = "dp-scroll:positions";
|
|
this.trailKey = "dp-scroll:trail";
|
|
this.intentKey = "dp-scroll:intent";
|
|
this.maxEntries = 50;
|
|
this.maxTrail = 20;
|
|
this.maxAgeMs = 60 * 60 * 1000;
|
|
this.intentAgeMs = 30 * 1000;
|
|
this.restoreWindowMs = 4000;
|
|
this.stableFramesNeeded = 10;
|
|
this.saveDelayMs = 200;
|
|
this.saveTimer = null;
|
|
this.backSelector = "a.back-link, a[data-scroll-back], .breadcrumb a";
|
|
if (!this.storageAvailable()) return;
|
|
history.scrollRestoration = "manual";
|
|
this.url = this.normalize(location.href);
|
|
this.previousUrl = this.recordTrail();
|
|
this.upgradeBackLinks();
|
|
this.bindSave();
|
|
this.bindIntent();
|
|
window.addEventListener("pageshow", (e) => {
|
|
if (!e.persisted) return;
|
|
this.takeIntent();
|
|
this.previousUrl = this.recordTrail();
|
|
});
|
|
this.restoreIfNeeded();
|
|
}
|
|
|
|
storageAvailable() {
|
|
try {
|
|
const probe = "dp-scroll:probe";
|
|
sessionStorage.setItem(probe, "1");
|
|
sessionStorage.removeItem(probe);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
normalize(href) {
|
|
const url = new URL(href, location.href);
|
|
return url.pathname + url.search;
|
|
}
|
|
|
|
readJson(key, fallback) {
|
|
try {
|
|
const raw = sessionStorage.getItem(key);
|
|
if (!raw) return fallback;
|
|
const value = JSON.parse(raw);
|
|
return value === null || typeof value !== "object" ? fallback : value;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
writeJson(key, value) {
|
|
try {
|
|
sessionStorage.setItem(key, JSON.stringify(value));
|
|
} catch {}
|
|
}
|
|
|
|
positions() {
|
|
return this.readJson(this.positionsKey, {});
|
|
}
|
|
|
|
prune(positions) {
|
|
const now = Date.now();
|
|
const entries = Object.entries(positions).filter(([, v]) => Array.isArray(v) && now - v[1] <= this.maxAgeMs);
|
|
entries.sort((a, b) => b[1][1] - a[1][1]);
|
|
return Object.fromEntries(entries.slice(0, this.maxEntries));
|
|
}
|
|
|
|
savePosition() {
|
|
const y = Math.round(window.scrollY);
|
|
const positions = this.prune(this.positions());
|
|
if (y > 0) {
|
|
positions[this.url] = [y, Date.now()];
|
|
} else {
|
|
delete positions[this.url];
|
|
}
|
|
this.writeJson(this.positionsKey, positions);
|
|
}
|
|
|
|
recordTrail() {
|
|
const trail = this.readJson(this.trailKey, []);
|
|
if (trail[trail.length - 1] !== this.url) trail.push(this.url);
|
|
while (trail.length > this.maxTrail) trail.shift();
|
|
this.writeJson(this.trailKey, trail);
|
|
return trail.length > 1 ? trail[trail.length - 2] : null;
|
|
}
|
|
|
|
upgradeBackLinks() {
|
|
if (!this.previousUrl) return;
|
|
const previous = new URL(this.previousUrl, location.origin);
|
|
document.querySelectorAll("a.back-link, a[data-scroll-back]").forEach((link) => {
|
|
const href = link.getAttribute("href");
|
|
if (!href) return;
|
|
const target = new URL(href, location.href);
|
|
if (target.origin !== location.origin || target.search || target.hash) return;
|
|
if (target.pathname !== previous.pathname) return;
|
|
if (this.normalize(target.href) === this.previousUrl) return;
|
|
link.setAttribute("href", this.previousUrl);
|
|
});
|
|
}
|
|
|
|
bindSave() {
|
|
window.addEventListener("scroll", () => {
|
|
if (this.saveTimer) return;
|
|
this.saveTimer = setTimeout(() => {
|
|
this.saveTimer = null;
|
|
this.savePosition();
|
|
}, this.saveDelayMs);
|
|
}, { passive: true });
|
|
window.addEventListener("pagehide", () => this.savePosition());
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.hidden) this.savePosition();
|
|
});
|
|
}
|
|
|
|
bindIntent() {
|
|
document.addEventListener("click", (e) => {
|
|
if (e.defaultPrevented || e.button !== 0) return;
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
const link = e.target instanceof Element ? e.target.closest("a[href]") : null;
|
|
if (!link || (link.target && link.target !== "_self")) return;
|
|
const target = new URL(link.getAttribute("href"), location.href);
|
|
if (target.origin !== location.origin) return;
|
|
const destination = this.normalize(target.href);
|
|
if (destination === this.url) return;
|
|
if (!this.positions()[destination]) return;
|
|
if (destination !== this.previousUrl && !link.matches(this.backSelector)) return;
|
|
this.writeJson(this.intentKey, { url: destination, t: Date.now() });
|
|
});
|
|
}
|
|
|
|
takeIntent() {
|
|
const intent = this.readJson(this.intentKey, null);
|
|
try {
|
|
sessionStorage.removeItem(this.intentKey);
|
|
} catch {}
|
|
if (!intent || typeof intent.url !== "string") return null;
|
|
if (Date.now() - (intent.t || 0) > this.intentAgeMs) return null;
|
|
return intent.url;
|
|
}
|
|
|
|
restoreIfNeeded() {
|
|
const intent = this.takeIntent();
|
|
if (location.hash) return;
|
|
const nav = performance.getEntriesByType("navigation")[0];
|
|
const type = nav ? nav.type : "navigate";
|
|
if (type !== "back_forward" && type !== "reload" && intent !== this.url) return;
|
|
const saved = this.positions()[this.url];
|
|
if (!saved) return;
|
|
const [y, t] = saved;
|
|
if (y <= 0 || Date.now() - t > this.maxAgeMs) return;
|
|
this.applyScroll(y);
|
|
}
|
|
|
|
applyScroll(y) {
|
|
const doc = document.scrollingElement || document.documentElement;
|
|
const deadline = performance.now() + this.restoreWindowMs;
|
|
const cancelEvents = ["wheel", "touchstart", "keydown", "pointerdown"];
|
|
let cancelled = false;
|
|
let stableFrames = 0;
|
|
let lastHeight = 0;
|
|
const cancel = () => { cancelled = true; };
|
|
cancelEvents.forEach((name) => window.addEventListener(name, cancel, { once: true, passive: true }));
|
|
const cleanup = () => cancelEvents.forEach((name) => window.removeEventListener(name, cancel));
|
|
const step = () => {
|
|
if (cancelled) {
|
|
cleanup();
|
|
return;
|
|
}
|
|
const limit = Math.max(0, doc.scrollHeight - window.innerHeight);
|
|
const target = Math.min(y, limit);
|
|
if (Math.abs(window.scrollY - target) > 1) this.scrollInstant(target);
|
|
stableFrames = doc.scrollHeight === lastHeight && target === y ? stableFrames + 1 : 0;
|
|
lastHeight = doc.scrollHeight;
|
|
if (stableFrames >= this.stableFramesNeeded || performance.now() > deadline) {
|
|
cleanup();
|
|
return;
|
|
}
|
|
requestAnimationFrame(step);
|
|
};
|
|
step();
|
|
}
|
|
|
|
scrollInstant(top) {
|
|
try {
|
|
window.scrollTo({ top, left: 0, behavior: "instant" });
|
|
} catch {
|
|
window.scrollTo(0, top);
|
|
}
|
|
}
|
|
}
|