Files
bitvid/dist/js/docsView.js
T
google-labs-jules[bot]andPR0M3TH3AN 5e1709b900 perf: Optimize docs scrollspy using IntersectionObserver
Replaces the O(N) getBoundingClientRect polling loop in the docs scroll spy with an IntersectionObserver. This eliminates potential layout thrashing and reduces main thread CPU usage during scrolling.

- Replaces `window.addEventListener("scroll")` with `IntersectionObserver`.
- Maintains active section state using `passedHeadings` set.
- Updates `setupScrollSpy` and `clearScrollSpy` to manage the observer.
- Preserves existing behavior where the active section is the last one that has passed the top offset (96px). Note: Activation now occurs when the header *fully* exits the top offset area, which is a minor behavior change (delayed by header height) but improves performance and stability.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-05 21:04:36 +00:00

655 lines
18 KiB
JavaScript

import { createModalAccessibility } from "./ui/components/modalAccessibility.js";
import logger from "./utils/logger.js";
const TOC_URL = "content/docs/toc.json";
const DOCS_VIEW_NAME = "docs";
const TOC_DRAWER_ID = "docsTocDrawer";
const TOC_DRAWER_NAV_ID = "docsTocDrawerNav";
const TOC_LIST_ID = "docsTocList";
const tocState = {
items: [],
slugLookup: new Map(),
parentLookup: new Map(),
linkLookup: new Map(),
groupLookup: new Map(),
activeSlug: "",
drawer: {
root: null,
panel: null,
toggle: null,
accessibility: null,
isOpen: false,
},
};
const scrollSpyState = {
headings: [],
linkLookup: new Map(),
activeId: "",
rafId: null,
onScroll: null,
observer: null,
passedHeadings: null,
};
function getHashParams() {
const hash = typeof window !== "undefined" ? window.location.hash : "";
return new URLSearchParams(hash.startsWith("#") ? hash.slice(1) : hash);
}
function getDocSlugFromHash() {
const params = getHashParams();
if (params.get("view") !== DOCS_VIEW_NAME) {
return "";
}
return params.get("doc") || "";
}
function setDocsHash(slug, { replace = false } = {}) {
if (typeof window === "undefined") {
return;
}
const nextHash = `view=${DOCS_VIEW_NAME}&doc=${encodeURIComponent(slug)}`;
if (replace) {
const url = new URL(window.location.href);
url.hash = nextHash;
window.history.replaceState({}, "", url.toString());
return;
}
if (window.location.hash === `#${nextHash}`) {
return;
}
window.location.hash = nextHash;
}
function indexItems(items, parent = null) {
items.forEach((item) => {
if (!item?.slug) {
logger.dev.warn("Docs TOC item missing slug.", item);
return;
}
tocState.slugLookup.set(item.slug, item);
if (parent?.slug) {
tocState.parentLookup.set(item.slug, parent.slug);
}
if (Array.isArray(item.children) && item.children.length > 0) {
indexItems(item.children, item);
}
});
}
function buildLink(item) {
const link = document.createElement("a");
link.href = `#view=${DOCS_VIEW_NAME}&doc=${encodeURIComponent(item.slug)}`;
link.textContent = item.title || item.slug;
link.className = "text-sm text-muted transition-colors hover:text-text-strong";
link.dataset.slug = item.slug;
link.dataset.docsTocItem = "true";
link.addEventListener("click", (event) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
setDocsHash(item.slug);
});
if (!tocState.linkLookup.has(item.slug)) {
tocState.linkLookup.set(item.slug, new Set());
}
tocState.linkLookup.get(item.slug)?.add(link);
return link;
}
function getChevronIcon(expanded) {
if (expanded) {
// Chevron Down
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>`;
}
// Chevron Right
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>`;
}
function buildToggleButton(item, controlsId, expanded, groupList) {
const button = document.createElement("button");
button.type = "button";
button.className = "btn-ghost btn-xs btn-square text-muted hover:text-text-primary";
button.setAttribute("aria-expanded", expanded ? "true" : "false");
button.setAttribute("aria-controls", controlsId);
button.setAttribute("aria-label", `Toggle ${item.title || item.slug} section`);
button.innerHTML = getChevronIcon(expanded);
button.addEventListener("click", () => {
const isExpanded = button.getAttribute("aria-expanded") === "true";
const nextExpanded = !isExpanded;
button.setAttribute("aria-expanded", nextExpanded ? "true" : "false");
button.innerHTML = getChevronIcon(nextExpanded);
if (groupList) {
groupList.hidden = !nextExpanded;
groupList.classList.toggle("hidden", !nextExpanded);
}
});
return button;
}
function renderTocItems(items, container, level = 0, groupPrefix = "docs-toc") {
const list = document.createElement("ul");
list.className = "bv-stack bv-stack--tight";
list.setAttribute("role", "list");
items.forEach((item) => {
if (!item?.slug) {
return;
}
const listItem = document.createElement("li");
listItem.className = "bv-stack bv-stack--tight";
const row = document.createElement("div");
row.className = "flex items-center justify-between gap-2";
const link = buildLink(item);
row.appendChild(link);
listItem.appendChild(row);
if (Array.isArray(item.children) && item.children.length > 0) {
const groupId = `${groupPrefix}-group-${item.slug}`;
const expanded = level === 0;
const childList = renderTocItems(item.children, listItem, level + 1, groupPrefix);
childList.id = groupId;
childList.classList.add("ml-md", "border-l", "border-border", "pl-md");
childList.hidden = !expanded;
if (!expanded) {
childList.classList.add("hidden");
}
const toggle = buildToggleButton(item, groupId, expanded, childList);
row.appendChild(toggle);
if (!tocState.groupLookup.has(item.slug)) {
tocState.groupLookup.set(item.slug, new Set());
}
tocState.groupLookup.get(item.slug)?.add({ button: toggle, list: childList });
}
list.appendChild(listItem);
});
container.appendChild(list);
return list;
}
function renderToc(items) {
const tocRoot = document.getElementById(TOC_LIST_ID);
const tocDrawerRoot = document.getElementById(TOC_DRAWER_NAV_ID);
const roots = [tocRoot, tocDrawerRoot].filter(Boolean);
if (roots.length === 0) {
logger.user.warn("Docs table of contents container not found.");
return;
}
tocState.linkLookup.clear();
tocState.groupLookup.clear();
roots.forEach((root) => {
root.innerHTML = "";
const groupPrefix = root.id ? `docs-${root.id}` : "docs-toc";
renderTocItems(items, root, 0, groupPrefix);
});
}
function updateActiveToc(slug) {
const previousLinks = tocState.linkLookup.get(tocState.activeSlug);
if (previousLinks) {
previousLinks.forEach((link) => {
link.removeAttribute("aria-current");
link.classList.remove("text-text-strong", "font-semibold");
link.classList.add("text-muted");
});
}
tocState.activeSlug = slug || "";
if (!slug) {
return;
}
const links = tocState.linkLookup.get(slug);
if (links) {
links.forEach((link) => {
link.setAttribute("aria-current", "page");
link.classList.remove("text-muted");
link.classList.add("text-text-strong", "font-semibold");
});
}
let current = slug;
while (tocState.parentLookup.has(current)) {
const parentSlug = tocState.parentLookup.get(current);
const groups = tocState.groupLookup.get(parentSlug);
if (groups) {
groups.forEach((group) => {
if (group?.button && group?.list) {
group.button.setAttribute("aria-expanded", "true");
group.button.innerHTML = getChevronIcon(true);
group.list.hidden = false;
group.list.classList.remove("hidden");
}
});
}
current = parentSlug;
}
}
function resetSectionHighlight() {
const previousLinks = scrollSpyState.linkLookup.get(scrollSpyState.activeId);
if (previousLinks) {
previousLinks.forEach((link) => {
link.removeAttribute("data-docs-section-current");
link.classList.remove("text-text-strong", "font-semibold");
link.classList.add("text-muted");
});
}
scrollSpyState.activeId = "";
}
function clearScrollSpy() {
if (scrollSpyState.observer) {
scrollSpyState.observer.disconnect();
scrollSpyState.observer = null;
}
scrollSpyState.passedHeadings = null;
scrollSpyState.headings = [];
scrollSpyState.linkLookup = new Map();
resetSectionHighlight();
}
function setActiveSection(id) {
if (!id || id === scrollSpyState.activeId) {
return;
}
resetSectionHighlight();
scrollSpyState.activeId = id;
const links = scrollSpyState.linkLookup.get(id);
if (!links) {
return;
}
links.forEach((link) => {
link.setAttribute("data-docs-section-current", "true");
link.classList.remove("text-muted");
link.classList.add("text-text-strong", "font-semibold");
});
}
function resolveSectionLinks() {
const linkLookup = new Map();
if (typeof document === "undefined") {
return linkLookup;
}
const links = document.querySelectorAll(`[data-docs-toc-item][href*="#"]`);
links.forEach((link) => {
const rawHref = link.getAttribute("href") || "";
const hashIndex = rawHref.indexOf("#");
if (hashIndex === -1) {
return;
}
const fragment = rawHref.slice(hashIndex + 1);
if (!fragment || fragment.includes("view=")) {
return;
}
const decoded = decodeURIComponent(fragment.split("?")[0]);
if (!decoded) {
return;
}
if (!linkLookup.has(decoded)) {
linkLookup.set(decoded, new Set());
}
linkLookup.get(decoded)?.add(link);
});
return linkLookup;
}
function setupScrollSpy(container) {
clearScrollSpy();
if (typeof window === "undefined" || !container) {
return;
}
const headings = Array.from(
container.querySelectorAll("h2[id], h3[id], h4[id], h5[id]")
);
console.log(`[Spy] setup found ${headings.length} headings`);
if (headings.length === 0) {
return;
}
const linkLookup = resolveSectionLinks();
if (linkLookup.size === 0) {
return;
}
const trackedHeadings = headings.filter((heading) => linkLookup.has(heading.id));
if (trackedHeadings.length === 0) {
return;
}
scrollSpyState.headings = trackedHeadings;
scrollSpyState.linkLookup = linkLookup;
const observerCallback = (entries) => {
const passed = scrollSpyState.passedHeadings || new Set();
entries.forEach((entry) => {
// If the heading intersects (enters the viewport from top or bottom),
// we check if it is above the offset.
if (entry.isIntersecting) {
// If it is intersecting, it is definitely "visible" in the viewport (minus top margin).
// It means it's not "passed" (above the top).
passed.delete(entry.target.id);
} else {
// If not intersecting, check if it's above.
// rootMargin top is -96px. The root starts at 96px from top.
// If entry.boundingClientRect.top < 96, it is above the root.
if (entry.boundingClientRect.top < 96) {
passed.add(entry.target.id);
} else {
passed.delete(entry.target.id);
}
}
});
scrollSpyState.passedHeadings = passed;
let nextId = "";
// Find the last heading in the passed set (by DOM order)
for (let i = scrollSpyState.headings.length - 1; i >= 0; i--) {
const id = scrollSpyState.headings[i].id;
if (passed.has(id)) {
nextId = id;
break;
}
}
// Fallback to the first heading if none are passed yet (scrolled to top)
if (!nextId && scrollSpyState.headings.length > 0) {
nextId = scrollSpyState.headings[0].id;
}
if (nextId) {
setActiveSection(nextId);
}
};
const observer = new IntersectionObserver(observerCallback, {
rootMargin: "-96px 0px 0px 0px",
threshold: 0,
});
scrollSpyState.headings.forEach((heading) => observer.observe(heading));
scrollSpyState.observer = observer;
}
function resolveDocItem(slug) {
if (!slug) {
return null;
}
return tocState.slugLookup.get(slug) || null;
}
async function fetchToc() {
logger.dev.log("fetchToc: fetching", TOC_URL);
const response = await fetch(TOC_URL);
logger.dev.log("fetchToc: response status", response.status);
if (!response.ok) {
throw new Error(`Failed to load TOC (${response.status})`);
}
const data = await response.json();
if (!data || !Array.isArray(data.items)) {
throw new Error("Invalid TOC manifest.");
}
return data.items;
}
function highlightCodeBlocks(container) {
const highlighter = window.hljs;
const highlight =
highlighter && typeof highlighter.highlightElement === "function"
? (block) => highlighter.highlightElement(block)
: highlighter && typeof highlighter.highlightBlock === "function"
? (block) => highlighter.highlightBlock(block)
: null;
if (!highlight) {
return;
}
container.querySelectorAll("pre code").forEach((block) => {
try {
highlight(block);
} catch (highlightError) {
logger.dev.warn("Failed to highlight code block.", highlightError);
}
});
}
async function renderMarkdown(path) {
const container = document.getElementById("markdown-container");
if (!container) {
logger.user.warn("Docs markdown container not found.");
return;
}
try {
clearScrollSpy();
logger.dev.log("renderMarkdown: fetching", path);
if (!window.marked || typeof window.marked.parse !== "function") {
throw new Error("Markdown renderer is unavailable.");
}
const response = await fetch(path);
if (!response.ok) {
throw new Error(`Failed to load markdown (${response.status})`);
}
const markdownText = await response.text();
const html = window.marked.parse(markdownText);
container.innerHTML = html;
highlightCodeBlocks(container);
const schedule =
typeof requestIdleCallback === "function"
? requestIdleCallback
: (callback) => requestAnimationFrame(callback);
schedule(() => {
setupScrollSpy(container);
});
} catch (error) {
logger.user.error("Error loading docs content.", error);
container.innerHTML =
'<p class="text-critical-strong">Error loading content. Please try again later.</p>';
}
}
function updateDocumentTitle(title) {
if (typeof document === "undefined") {
return;
}
if (!title) {
document.title = "Docs | bitvid";
return;
}
document.title = `${title} | bitvid docs`;
}
async function handleHashChange() {
const params = getHashParams();
const view = params.get("view");
if (view && view !== DOCS_VIEW_NAME) {
return;
}
const currentSlug = getDocSlugFromHash();
const defaultSlug = tocState.items[0]?.slug || "";
const slug = currentSlug || defaultSlug;
if (!slug) {
logger.user.warn("Docs view has no available documents.");
return;
}
if (!currentSlug) {
setDocsHash(slug, { replace: true });
}
if (slug === tocState.activeSlug) {
return;
}
const item = resolveDocItem(slug);
if (!item) {
logger.user.warn("Requested docs page not found.", slug);
return;
}
await renderMarkdown(item.path);
updateActiveToc(slug);
updateDocumentTitle(item.title || "Docs");
if (tocState.drawer.isOpen) {
closeTocDrawer();
}
}
function updateDrawerState(nextState) {
tocState.drawer.isOpen = nextState;
if (tocState.drawer.toggle) {
tocState.drawer.toggle.setAttribute("aria-expanded", nextState ? "true" : "false");
}
if (!tocState.drawer.root) {
return;
}
const doc =
typeof document !== "undefined" && document ? document : tocState.drawer.root.ownerDocument;
if (nextState) {
tocState.drawer.root.classList.remove("hidden");
tocState.drawer.root.setAttribute("data-open", "true");
doc?.documentElement?.classList.add("modal-open");
doc?.body?.classList.add("modal-open");
tocState.drawer.accessibility?.activate({
triggerElement: tocState.drawer.toggle,
});
} else {
tocState.drawer.root.classList.add("hidden");
tocState.drawer.root.setAttribute("data-open", "false");
tocState.drawer.accessibility?.deactivate();
if (doc) {
const openModals = doc.querySelectorAll(".bv-modal:not(.hidden)");
if (openModals.length === 0) {
doc.documentElement?.classList.remove("modal-open");
doc.body?.classList.remove("modal-open");
}
}
}
}
function focusFirstDrawerItem() {
const panel = tocState.drawer.panel;
if (!panel) {
return;
}
const firstLink = panel.querySelector("[data-docs-toc-item]");
if (firstLink && typeof firstLink.focus === "function") {
firstLink.focus({ preventScroll: true });
return;
}
if (typeof panel.focus === "function") {
panel.focus({ preventScroll: true });
}
}
function openTocDrawer() {
if (tocState.drawer.isOpen) {
return;
}
updateDrawerState(true);
const schedule =
typeof requestAnimationFrame === "function"
? requestAnimationFrame
: (callback) => setTimeout(callback, 0);
schedule(() => {
focusFirstDrawerItem();
});
}
function closeTocDrawer() {
if (!tocState.drawer.isOpen) {
return;
}
updateDrawerState(false);
}
function setupTocDrawer() {
if (typeof document === "undefined") {
return;
}
const drawerRoot = document.getElementById(TOC_DRAWER_ID);
const drawerPanel = drawerRoot?.querySelector("[data-docs-toc-panel]") || null;
const drawerToggle = document.querySelector("[data-docs-toc-toggle]");
if (!drawerRoot || !drawerPanel || !drawerToggle) {
return;
}
tocState.drawer.root = drawerRoot;
tocState.drawer.panel = drawerPanel;
tocState.drawer.toggle = drawerToggle;
tocState.drawer.accessibility = createModalAccessibility({
root: drawerRoot,
panel: drawerPanel,
backdrop: drawerRoot.querySelector(".bv-modal-backdrop"),
document,
onRequestClose: () => {
closeTocDrawer();
},
});
drawerToggle.addEventListener("click", () => {
if (tocState.drawer.isOpen) {
closeTocDrawer();
} else {
openTocDrawer();
}
});
}
async function initDocsView() {
logger.dev.log("initDocsView: starting");
try {
tocState.items = await fetchToc();
logger.dev.log("initDocsView: TOC items loaded", tocState.items);
indexItems(tocState.items);
renderToc(tocState.items);
setupTocDrawer();
window.addEventListener("hashchange", handleHashChange);
await handleHashChange();
} catch (error) {
logger.user.error("Failed to initialize docs view.", error);
const tocRoot = document.getElementById(TOC_LIST_ID);
if (tocRoot) {
tocRoot.innerHTML =
'<p class="text-critical-strong">Unable to load docs table of contents.</p>';
}
const container = document.getElementById("markdown-container");
if (container) {
container.innerHTML =
'<p class="text-critical-strong">Error loading content. Please try again later.</p>';
}
}
}
export { initDocsView };