Search / static /reader /ui /reader-toc.mjs
vomebook's picture
feat(reader): improve keyboard focus and accessible controls
9ebca63 verified
Raw History Blame Contribute Delete
14.7 kB
export function createReaderToc(readerContext) {
function updateNavigationState(patch) {
return readerContext.readerRuntime.update("navigation", patch);
}
function headingTocEntries(root) {
return [...root.querySelectorAll("h1,h2,h3,h4,h5,h6")]
.filter((heading) => heading.textContent.trim())
.map((target) => ({
label: target.textContent.trim(),
depth: Number(target.tagName.slice(1)) - 1,
target,
activate: () => target.scrollIntoView({ block: "start" })
}));
}
function createHeadingLocationCache(root, entries) {
let positions = null, targets = [], width = -1, extent = -1, zoom = -1, wasAnimating = false, live = false;
const invalidate = () => { positions = null; };
const mutations = new MutationObserver(invalidate), resize = new ResizeObserver(invalidate);
mutations.observe(root, {subtree:true, childList:true, characterData:true,
attributes:true, attributeFilter:["style", "class", "hidden", "width", "height"]});
resize.observe(root);
root.addEventListener("load", invalidate, true);
root.ownerDocument.fonts?.addEventListener("loadingdone", invalidate);
return { root, entries,
at(marker, scroll) {
if (mutations.takeRecords().length) invalidate();
const nextWidth = root.clientWidth, nextExtent = root.scrollHeight, nextZoom = readerContext.documentState.zoom;
const animating = root.getAnimations?.({subtree:true}).some(animation => animation.playState === "running" || animation.pending) || false;
if (live || animating || wasAnimating || nextWidth !== width || nextExtent !== extent || nextZoom !== zoom ||
targets.some(row => row.target !== row.entry.target || row.connected !== !!row.target?.isConnected)) invalidate();
wasAnimating = animating;
if (!positions) {
targets = entries.map((entry, index) => ({entry, index, target:entry.target, connected:!!entry.target?.isConnected}));
const fixed = new WeakMap();
const scrollDependent = target => {
const ancestors = [];
let value = false;
for (let node = target; node; node = node.parentElement) {
if (fixed.has(node)) { value = fixed.get(node); break; }
ancestors.push(node);
const position = root.ownerDocument.defaultView?.getComputedStyle(node).position;
if (position === "fixed" || position === "sticky") { value = true; break; }
if (node === root) break;
}
for (const node of ancestors) fixed.set(node, value);
return value;
};
// Source CSS may pin headings while scrolling; keep their live geometry.
live = targets.some(row => row.connected && scrollDependent(row.target));
positions = targets.filter(row => row.connected).map(row => ({index:row.index,
top:row.target.getBoundingClientRect().top + scroll - marker})).sort((a,b) => a.top-b.top || a.index-b.index);
width = root.clientWidth; extent = root.scrollHeight; zoom = nextZoom;
}
let low = 0, high = positions.length;
while (low < high) {
const middle = (low + high) >> 1;
if (positions[middle].top <= scroll) low = middle + 1;
else high = middle;
}
return positions[low ? low - 1 : 0]?.index ?? -1;
},
dispose() {
mutations.disconnect(); resize.disconnect();
root.removeEventListener("load", invalidate, true);
root.ownerDocument.fonts?.removeEventListener("loadingdone", invalidate);
positions = null; targets = [];
}
};
}
function syncHeadingLocation() {
const marker = readerContext.htmlFrame ? 8 : readerContext.viewport.getBoundingClientRect().top + 8;
const root = readerContext.htmlFrame?.contentDocument?.body || readerContext.content;
if (!["foliate", "epub-chapters"].includes(readerContext.capability.mode)) {
if (readerContext.headingLocationCache?.root !== root || readerContext.headingLocationCache?.entries !== readerContext.navigationState.tocEntries) {
readerContext.headingLocationCache?.dispose();
readerContext.headingLocationCache = readerContext.createHeadingLocationCache(root, readerContext.navigationState.tocEntries);
}
const current = readerContext.headingLocationCache.at(marker, readerContext.htmlFrame ? readerContext.htmlFrame.contentWindow.scrollY : readerContext.viewport.scrollTop);
if (current >= 0 && current !== readerContext.navigationState.currentChapterIndex) {
readerContext.updateNavigationState({currentChapterIndex:current});
readerContext.updateTocCurrentMark();
}
return;
}
let current = -1,
currentPosition = -Infinity,
first = -1,
firstPosition = Infinity;
for (const [index, entry] of readerContext.navigationState.tocEntries.entries()) {
if (!entry.target?.isConnected) continue;
const position = entry.target.getBoundingClientRect().top;
if (position <= marker && (position > currentPosition || (position === currentPosition && index > current))) {
current = index;
currentPosition = position;
}
if (position > marker && position < firstPosition) {
first = index;
firstPosition = position;
}
}
if (current < 0) current = first;
if (current >= 0 && current !== readerContext.navigationState.currentChapterIndex) {
readerContext.updateNavigationState({ currentChapterIndex: current });
readerContext.updateTocCurrentMark();
}
}
async function navigateTocEntry(index) {
if (index < 0 || index >= readerContext.navigationState.tocEntries.length) return;
if (readerContext.navigationState.tocEntries[index].group) return;
const generation = readerContext.beginReaderNavigation(),
entry = readerContext.navigationState.tocEntries[index];
try {
const activated = readerContext.foliateContinuous
? await readerContext.activateFoliateTocEntry(entry, generation)
: await entry.activate(generation);
if (activated === false || !readerContext.isReaderGenerationCurrent("navigation", generation)) return;
readerContext.updateNavigationState({ currentChapterIndex: index });
readerContext.updateTocCurrentMark();
readerContext.updateProgressTools();
readerContext.setReaderPanelOpen(false, true);
} catch (error) {
readerContext.reportNavigationError(error, generation);
}
}
function createTocRow(entry, index) {
const row = document.createElement("div");
row.className = "panel-item toc-item";
row.dataset.tocIndex = String(index);
row.style.setProperty("--toc-depth", String(entry.depth || 0));
const link = document.createElement("div");
link.className = "panel-item-main";
link.textContent = entry.label;
link.title = entry.label;
if (entry.group) {
row.classList.add("toc-group");
link.setAttribute("role", "heading");
link.setAttribute("aria-level", String(Math.min(6, (entry.depth || 0) + 1)));
row.appendChild(link);
return row;
}
link.tabIndex = 0;
link.setAttribute("role", "link");
const activate = (event) => {
const selection = getSelection();
if (event?.type === "click" && selection?.toString() && selection.containsNode(link, true)) return;
readerContext.navigateTocEntry(index);
};
link.addEventListener("click", activate);
link.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activate();
}
});
row.appendChild(link);
return row;
}
function renderVirtualToc() {
const state = readerContext.tocVirtualState;
if (!state) return;
const height = Math.max(320, state.panelView.clientHeight),
start = Math.max(0, Math.floor(state.panelView.scrollTop / readerContext.TOC_VIRTUAL_ROW_HEIGHT) - 12),
end = Math.min(state.entries.length, start + Math.ceil(height / readerContext.TOC_VIRTUAL_ROW_HEIGHT) + 24),
fragment = document.createDocumentFragment();
state.list.replaceChildren();
for (let index = start; index < end; index++) {
const row = readerContext.createTocRow(state.entries[index], index);
row.classList.add("toc-virtual-row");
row.style.top = `${index * readerContext.TOC_VIRTUAL_ROW_HEIGHT}px`;
fragment.appendChild(row);
}
state.list.appendChild(fragment);
readerContext.updateTocCurrentMark();
}
let tocHintShown = false, tocHintPending = false, tocHintTimer = 0;
const cancelTocHint = () => {
clearTimeout(tocHintTimer);
tocHintTimer = 0;
tocHintPending = false;
};
const finishTocHint = () => {
tocHintTimer = 0;
if (readerContext.readerLifecycle.disposed || readerContext.readerAbortController.signal.aborted || !tocHintPending) return;
tocHintPending = false;
if (!readerContext.navigationState.tocEntries.length ||
readerContext.panelState.open || readerContext.readerPanelInteractionSerial > 0 ||
readerContext.readerUiHistoryState !== "base") {
tocHintShown = true;
return;
}
tocHintShown = true;
// Keep the directory open using the normal panel owner: toolbar, Escape,
// system Back and aria-expanded must all agree with the visible drawer.
readerContext.setReaderPanelOpen(true);
readerContext.selectPanel("toc");
};
let phaseCleanup = null;
function ensureTocHintPhaseListener() {
if (phaseCleanup) return;
phaseCleanup = readerContext.readerRuntime.events.on("phase", ({ phase }) => {
if (phase === "failed" || phase === "disposed") {
cancelTocHint();
phaseCleanup?.();
phaseCleanup = null;
}
});
readerContext.readerRuntime.track(() => { cancelTocHint(); phaseCleanup?.(); phaseCleanup = null; });
}
function hintReaderToc() {
if (tocHintShown || tocHintPending || !readerContext.navigationState.tocEntries.length) return;
ensureTocHintPhaseListener();
tocHintPending = true;
// Do not wait for the overall Reader phase: TOC availability itself is the
// useful signal, and the hint should be visible near the opening moment.
tocHintTimer = setTimeout(finishTocHint, 0);
}
function setToc(entries) {
const normalizedEntries = (entries || []).map((entry) => ({
...entry,
href: String(entry.href || ""),
label: readerContext.cleanTocLabel(entry.label)
}));
readerContext.updateNavigationState({
tocEntries: normalizedEntries,
currentChapterIndex: normalizedEntries.findIndex((entry) => !entry.group)
});
const tocTab = document.querySelector("#toc-tab");
tocTab.hidden = false;
tocTab.textContent = normalizedEntries.length ? "目录" : "阅读";
const previousList = document.querySelector("#toc-list"),
list = previousList.cloneNode(false);
readerContext.tocVirtualState?.destroy();
readerContext.tocVirtualState = null;
previousList.replaceWith(list);
const virtualizationThreshold = readerContext.chapterManifestUrl ? 2000 : readerContext.TOC_VIRTUALIZATION_THRESHOLD;
if (normalizedEntries.length > virtualizationThreshold) {
const panelView = list.parentElement;
list.classList.add("toc-list-virtualized");
list.style.height = `${normalizedEntries.length * readerContext.TOC_VIRTUAL_ROW_HEIGHT}px`;
let frame = 0;
const onScroll = () => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
readerContext.renderVirtualToc();
});
};
panelView.addEventListener("scroll", onScroll, { passive: true });
readerContext.tocVirtualState = {
entries: normalizedEntries,
list,
panelView,
render: readerContext.renderVirtualToc,
destroy: () => {
panelView.removeEventListener("scroll", onScroll);
if (frame) cancelAnimationFrame(frame);
}
};
readerContext.renderVirtualToc();
} else {
for (const [index, entry] of normalizedEntries.entries())
list.appendChild(readerContext.createTocRow(entry, index));
}
readerContext.updateTocCurrentMark();
hintReaderToc();
}
function cleanTocLabel(value) {
return (
String(value || "未命名章节")
.replace(/[\uFFFD\u2610-\u2612\u25a1\u0000-\u001F\u007F]/gu, "")
.replace(/\s{2,}/gu, " ")
.trim() || "未命名章节"
);
}
function updateTocCurrentMark() {
const rows = [...document.querySelectorAll("#toc-list .toc-item")];
if (readerContext.documentState.pageCount > 1 && readerContext.documentState.page > 0) {
let pageIndex = -1;
for (const [index, entry] of readerContext.navigationState.tocEntries.entries()) {
const match = entry.label.match(/第\s*(\d+)\s*页/u);
if (match && Number(match[1]) <= readerContext.documentState.page) pageIndex = index;
}
if (pageIndex >= 0) readerContext.updateNavigationState({ currentChapterIndex: pageIndex });
}
for (const row of rows) {
const index = Number(row.dataset.tocIndex);
let mark = row.querySelector(".toc-current-mark");
if (!mark) {
mark = document.createElement("span");
mark.className = "toc-current-mark";
mark.setAttribute("aria-hidden", "true");
mark.textContent = "✓";
row.appendChild(mark);
}
mark.hidden = index !== readerContext.navigationState.currentChapterIndex;
row.classList.toggle("is-current", index === readerContext.navigationState.currentChapterIndex);
const link = row.querySelector('[role="link"]');
if (index === readerContext.navigationState.currentChapterIndex) link?.setAttribute("aria-current", "location");
else link?.removeAttribute("aria-current");
}
}
function syncTocTab() {
const tab = document.querySelector("#toc-tab");
if (!tab) return;
tab.hidden = !readerContext.navigationState.tocEntries.length;
if (readerContext.navigationState.tocEntries.length && readerContext.navigationState.currentChapterIndex < 0)
readerContext.updateNavigationState({ currentChapterIndex: readerContext.navigationState.tocEntries.findIndex((entry) => !entry.group) });
}
return {
updateNavigationState,
headingTocEntries,
createHeadingLocationCache,
syncHeadingLocation,
navigateTocEntry,
createTocRow,
renderVirtualToc,
setToc,
cleanTocLabel,
updateTocCurrentMark,
syncTocTab
};
}