// 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);
}
}
}