Keep the contents sidebar where it was across page loads
Every navigation is a full document load, so the sidebar came back at scrollTop 0 and revealCurrent() smooth-scrolled the current chapter into view: a jump on every click even though the link was already in view. app.js saves the panel's scrollTop to sessionStorage (large screens only, keyed by the issue) on link clicks and pagehide; theme.js, which already runs before first paint, restores it as soon as the parser has closed the nav. The load-time reveals (initial and the first scroll-tracking paint on the full issue page) are now instant; opening the mobile panel and tracking while scrolling stay smooth. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXYGBPoHZSDSfE5WcJ9bvj
This commit is contained in:
+18
-6
@@ -132,9 +132,19 @@ if (tocPanel) {
|
|||||||
const tocStatus = document.querySelector("[data-toc-status]");
|
const tocStatus = document.querySelector("[data-toc-status]");
|
||||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
const largeScreen = window.matchMedia("(min-width: 64rem)");
|
const largeScreen = window.matchMedia("(min-width: 64rem)");
|
||||||
|
const saveScroll = () => {
|
||||||
|
if (!largeScreen.matches || !tocPanel.dataset.tocIssue) return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("toc-scroll", `${tocPanel.dataset.tocIssue}\n${tocPanel.scrollTop}`);
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
window.addEventListener("pagehide", saveScroll);
|
||||||
|
tocPanel.addEventListener("click", (event) => {
|
||||||
|
if (event.target.closest?.("a")) saveScroll();
|
||||||
|
});
|
||||||
|
|
||||||
// Long issues overflow the sidebar; scroll just enough to show where we are.
|
// Long issues overflow the sidebar; scroll just enough to show where we are.
|
||||||
const revealCurrent = () => {
|
const revealCurrent = (behavior = "smooth") => {
|
||||||
const current = tocPanel.querySelector(".toc-link[aria-current]");
|
const current = tocPanel.querySelector(".toc-link[aria-current]");
|
||||||
if (!current || tocPanel.scrollHeight <= tocPanel.clientHeight) return;
|
if (!current || tocPanel.scrollHeight <= tocPanel.clientHeight) return;
|
||||||
const stickyHeader = tocPanel.querySelector(":scope > div");
|
const stickyHeader = tocPanel.querySelector(":scope > div");
|
||||||
@@ -147,7 +157,7 @@ if (tocPanel) {
|
|||||||
const visibleBottom = tocPanel.scrollTop + tocPanel.clientHeight - revealMargin;
|
const visibleBottom = tocPanel.scrollTop + tocPanel.clientHeight - revealMargin;
|
||||||
if (top >= visibleTop && top + currentRect.height <= visibleBottom) return;
|
if (top >= visibleTop && top + currentRect.height <= visibleBottom) return;
|
||||||
const target = Math.max(0, top - Math.max(topMargin, (tocPanel.clientHeight - currentRect.height) / 2));
|
const target = Math.max(0, top - Math.max(topMargin, (tocPanel.clientHeight - currentRect.height) / 2));
|
||||||
tocPanel.scrollTo({ top: target, behavior: reducedMotion.matches ? "auto" : "smooth" });
|
tocPanel.scrollTo({ top: target, behavior: reducedMotion.matches ? "auto" : behavior });
|
||||||
};
|
};
|
||||||
const sizeOpenPanel = () => {
|
const sizeOpenPanel = () => {
|
||||||
if (tocPanel.dataset.open !== "true" || largeScreen.matches || !tocBar) return;
|
if (tocPanel.dataset.open !== "true" || largeScreen.matches || !tocBar) return;
|
||||||
@@ -168,7 +178,7 @@ if (tocPanel) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
revealCurrent();
|
revealCurrent("auto");
|
||||||
if (tocToggle) {
|
if (tocToggle) {
|
||||||
tocToggle.addEventListener("click", () => setOpen(tocPanel.dataset.open !== "true"));
|
tocToggle.addEventListener("click", () => setOpen(tocPanel.dataset.open !== "true"));
|
||||||
document.addEventListener("click", (event) => {
|
document.addEventListener("click", (event) => {
|
||||||
@@ -196,7 +206,7 @@ if (tocPanel) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
if (currentLink) markPassed(currentLink);
|
if (currentLink) markPassed(currentLink);
|
||||||
const setCurrent = (link) => {
|
const setCurrent = (link, behavior = "smooth") => {
|
||||||
if (!link || (link === currentLink && link.getAttribute("aria-current") === "location")) return;
|
if (!link || (link === currentLink && link.getAttribute("aria-current") === "location")) return;
|
||||||
tocLinks.forEach((candidate) => candidate.removeAttribute("aria-current"));
|
tocLinks.forEach((candidate) => candidate.removeAttribute("aria-current"));
|
||||||
markPassed(link);
|
markPassed(link);
|
||||||
@@ -216,7 +226,7 @@ if (tocPanel) {
|
|||||||
delete bar.dataset.live;
|
delete bar.dataset.live;
|
||||||
bar.value = position;
|
bar.value = position;
|
||||||
});
|
});
|
||||||
revealCurrent();
|
revealCurrent(behavior);
|
||||||
};
|
};
|
||||||
|
|
||||||
const tocEntries = Array.from(document.querySelectorAll("[data-toc-entry]")).map((entry) => ({
|
const tocEntries = Array.from(document.querySelectorAll("[data-toc-entry]")).map((entry) => ({
|
||||||
@@ -225,6 +235,7 @@ if (tocPanel) {
|
|||||||
})).filter(({ link }) => link);
|
})).filter(({ link }) => link);
|
||||||
if (tocEntries.length) {
|
if (tocEntries.length) {
|
||||||
let queued = false;
|
let queued = false;
|
||||||
|
let firstPaint = true;
|
||||||
const paintCurrent = () => {
|
const paintCurrent = () => {
|
||||||
queued = false;
|
queued = false;
|
||||||
const threshold = (tocBar ? tocBar.offsetHeight : 0) + window.innerHeight / 3;
|
const threshold = (tocBar ? tocBar.offsetHeight : 0) + window.innerHeight / 3;
|
||||||
@@ -232,7 +243,8 @@ if (tocPanel) {
|
|||||||
tocEntries.forEach(({ entry, link }) => {
|
tocEntries.forEach(({ entry, link }) => {
|
||||||
if (entry.getBoundingClientRect().top <= threshold) next = link;
|
if (entry.getBoundingClientRect().top <= threshold) next = link;
|
||||||
});
|
});
|
||||||
setCurrent(next);
|
setCurrent(next, firstPaint ? "auto" : "smooth");
|
||||||
|
firstPaint = false;
|
||||||
};
|
};
|
||||||
const scheduleCurrent = () => {
|
const scheduleCurrent = () => {
|
||||||
if (queued) return;
|
if (queued) return;
|
||||||
|
|||||||
+31
-2
@@ -31,9 +31,38 @@
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
let tocPanel;
|
||||||
|
let tocRestored = false;
|
||||||
|
const findToc = (root) => {
|
||||||
|
if (tocPanel || root.nodeType !== Node.ELEMENT_NODE) return;
|
||||||
|
tocPanel = root.matches("[data-toc-panel]") ? root : root.querySelector("[data-toc-panel]");
|
||||||
|
};
|
||||||
|
const restoreToc = () => {
|
||||||
|
if (!tocPanel || tocRestored) return;
|
||||||
|
tocRestored = true;
|
||||||
|
if (!window.matchMedia("(min-width: 64rem)").matches) return;
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem("toc-scroll");
|
||||||
|
const separator = saved ? saved.lastIndexOf("\n") : -1;
|
||||||
|
if (separator < 0 || saved.slice(0, separator) !== tocPanel.dataset.tocIssue) return;
|
||||||
|
const scrollTop = Number(saved.slice(separator + 1));
|
||||||
|
if (Number.isFinite(scrollTop)) tocPanel.scrollTop = scrollTop;
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
const detailsObserver = new MutationObserver((records) => {
|
const detailsObserver = new MutationObserver((records) => {
|
||||||
records.forEach((record) => record.addedNodes.forEach(restoreDetails));
|
const addedNodes = [];
|
||||||
|
records.forEach((record) => record.addedNodes.forEach((node) => {
|
||||||
|
addedNodes.push(node);
|
||||||
|
restoreDetails(node);
|
||||||
|
findToc(node);
|
||||||
|
}));
|
||||||
|
// A following node means the parser has closed the nav and its list is complete.
|
||||||
|
if (tocPanel && addedNodes.some((node) => !tocPanel.contains(node)
|
||||||
|
&& (tocPanel.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING))) restoreToc();
|
||||||
});
|
});
|
||||||
detailsObserver.observe(document.documentElement, { childList: true, subtree: true });
|
detailsObserver.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
document.addEventListener("DOMContentLoaded", () => detailsObserver.disconnect(), { once: true });
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
restoreToc();
|
||||||
|
detailsObserver.disconnect();
|
||||||
|
}, { once: true });
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<span class="shrink-0 font-sans text-xs tabular-nums text-muted" data-toc-count{% if toc.position == 0 %} hidden{% endif %}>{{ toc.position }} / {{ toc.total }}</span>
|
<span class="shrink-0 font-sans text-xs tabular-nums text-muted" data-toc-count{% if toc.position == 0 %} hidden{% endif %}>{{ toc.position }} / {{ toc.total }}</span>
|
||||||
<progress class="toc-progress absolute inset-x-0 -bottom-px" data-toc-progress value="{{ toc.position }}" max="{{ toc.total }}" aria-hidden="true"></progress>
|
<progress class="toc-progress absolute inset-x-0 -bottom-px" data-toc-progress value="{{ toc.position }}" max="{{ toc.total }}" aria-hidden="true"></progress>
|
||||||
</div>
|
</div>
|
||||||
<nav id="toc" data-toc-panel class="absolute inset-x-0 top-full z-30 overflow-y-auto overscroll-contain border-b border-rule bg-paper pb-7 pl-4 pr-6 pt-2 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)] sm:pl-6 sm:pr-8 lg:sticky lg:inset-x-auto lg:top-6 lg:z-auto lg:mt-12 lg:max-h-[calc(100dvh-3rem)] lg:border-0 lg:pb-10 lg:pl-0 lg:pr-3 lg:pt-0 lg:shadow-none" aria-label="Contents">
|
<nav id="toc" data-toc-panel data-toc-issue="{{ toc.issue_href }}" class="absolute inset-x-0 top-full z-30 overflow-y-auto overscroll-contain border-b border-rule bg-paper pb-7 pl-4 pr-6 pt-2 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)] sm:pl-6 sm:pr-8 lg:sticky lg:inset-x-auto lg:top-6 lg:z-auto lg:mt-12 lg:max-h-[calc(100dvh-3rem)] lg:border-0 lg:pb-10 lg:pl-0 lg:pr-3 lg:pt-0 lg:shadow-none" aria-label="Contents">
|
||||||
<div class="hidden lg:sticky lg:top-0 lg:z-10 lg:block lg:bg-paper lg:pb-3">
|
<div class="hidden lg:sticky lg:top-0 lg:z-10 lg:block lg:bg-paper lg:pb-3">
|
||||||
<a class="block font-sans text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted no-underline hover:text-ink" href="{{ toc.issue_href }}">No. {{ toc.issue_number }} <span class="text-rule" aria-hidden="true">·</span> {{ toc.short_date }}</a>
|
<a class="block font-sans text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted no-underline hover:text-ink" href="{{ toc.issue_href }}">No. {{ toc.issue_number }} <span class="text-rule" aria-hidden="true">·</span> {{ toc.short_date }}</a>
|
||||||
<p class="mt-2 font-sans text-[0.72rem] text-ink-2" data-toc-status>{% if toc.position > 0 %}Chapter {{ toc.position }} of {{ toc.total }}{% else %}Front page{% endif %}</p>
|
<p class="mt-2 font-sans text-[0.72rem] text-ink-2" data-toc-status>{% if toc.position > 0 %}Chapter {{ toc.position }} of {{ toc.total }}{% else %}Front page{% endif %}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user