initial commit
This commit is contained in:
@@ -0,0 +1,516 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
// This fixed program runs only in Qt's isolated ApplicationWorld. It adds
|
||||
// no DOM nodes, IDs, bridges, document scripts, or host capabilities.
|
||||
if (globalThis.__docviewReader) return;
|
||||
const MAX_INDEX = 2000;
|
||||
let headings = [];
|
||||
let options = { fixed: false, rtl: false, publisherStyle: true };
|
||||
let originalTypography = null;
|
||||
let focusedLink = null;
|
||||
let linkOutline = null;
|
||||
let restoredRange = null;
|
||||
let layoutAnchor = null, resourceRestoreFrame = 0;
|
||||
let userScrollPending = false, userScrollFrame = 0;
|
||||
let navigationTargets = [], navigationRevision = "";
|
||||
let resourceIssues = Object.create(null);
|
||||
// Only aggregate directive classes. Never copy blockedURI, sourceFile,
|
||||
// originalPolicy or sample into the application or its saved state.
|
||||
document.addEventListener("securitypolicyviolation", event => {
|
||||
if (!event.isTrusted || event.disposition !== "enforce") return;
|
||||
const directive = String(event.effectiveDirective || "").split("-")[0];
|
||||
const category = ({img:"image", style:"style", font:"font", script:"script", frame:"frame",
|
||||
child:"frame", connect:"connect", form:"form", base:"base", object:"object", media:"media"})[directive] || "other";
|
||||
const key = "csp." + category;
|
||||
resourceIssues[key] = Math.min(999999, (resourceIssues[key] || 0) + 1);
|
||||
});
|
||||
|
||||
function text(node, limit = 512) {
|
||||
return String(node ? node.textContent || "" : "").replace(/\s+/g, " ").trim().slice(0, limit);
|
||||
}
|
||||
function visible(element) {
|
||||
if (!element || !element.isConnected || element.hidden || element.closest("[hidden],[aria-hidden='true']")) return false;
|
||||
const style = getComputedStyle(element);
|
||||
return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse" && element.getClientRects().length > 0;
|
||||
}
|
||||
function axis() {
|
||||
const style = getComputedStyle(document.body || document.documentElement);
|
||||
const vertical = /^(vertical|sideways)/.test(style.writingMode);
|
||||
return { vertical, reverse: vertical && /-rl$/.test(style.writingMode), writingMode: style.writingMode };
|
||||
}
|
||||
function progression() {
|
||||
const root = document.scrollingElement || document.documentElement;
|
||||
const a = axis();
|
||||
const total = a.vertical ? root.scrollWidth - innerWidth : root.scrollHeight - innerHeight;
|
||||
const position = a.vertical ? (a.reverse ? -scrollX : scrollX) : scrollY;
|
||||
return total > 0 ? Math.max(0, Math.min(1, position / total)) : 0;
|
||||
}
|
||||
function setProgression(value) {
|
||||
const root = document.scrollingElement || document.documentElement;
|
||||
const a = axis();
|
||||
const p = Math.max(0, Math.min(1, Number(value) || 0));
|
||||
const total = a.vertical ? root.scrollWidth - innerWidth : root.scrollHeight - innerHeight;
|
||||
if (a.vertical) scrollTo({ left: Math.max(0, total) * p * (a.reverse ? -1 : 1), top: 0, behavior: "instant" });
|
||||
else scrollTo({ left: 0, top: Math.max(0, total) * p, behavior: "instant" });
|
||||
}
|
||||
function hrefFor(reference, base = document.baseURI) {
|
||||
try {
|
||||
const url = new URL(reference, base);
|
||||
if (url.protocol !== "doc:" || url.host !== location.host || url.username || url.password) return "";
|
||||
return url.pathname.replace(/^\//, "") + url.search + url.hash;
|
||||
} catch (_) { return ""; }
|
||||
}
|
||||
function headingIndex() {
|
||||
headings = [];
|
||||
const nodes = document.querySelectorAll("h1,h2,h3,h4,h5,h6,[role='heading']");
|
||||
let truncated = false;
|
||||
for (const node of nodes) {
|
||||
if (!visible(node)) continue;
|
||||
const name = node.localName.toLowerCase();
|
||||
const nativeLevel = /^h[1-6]$/.test(name) ? Number(name[1]) : 0;
|
||||
const ariaLevel = Number(node.getAttribute("aria-level"));
|
||||
const level = nativeLevel || (Number.isInteger(ariaLevel) && ariaLevel > 0 && ariaLevel <= 6 ? ariaLevel : 0);
|
||||
if (!level) continue;
|
||||
if (headings.length === MAX_INDEX) { truncated = true; break; }
|
||||
headings.push({ element: node, level, title: text(node) || "見出し(レベル" + level + ")" });
|
||||
}
|
||||
return { truncated, rows: headings.map((h, index) => ({ index, title: h.title, level: h.level, depth: h.level - 1,
|
||||
href: h.element.id ? hrefFor("#" + encodeURIComponent(h.element.id), location.href) : "", source: "html-heading", precision: "exact" })) };
|
||||
}
|
||||
function index() {
|
||||
const result = headingIndex();
|
||||
const outline = [];
|
||||
const navs = document.querySelectorAll("[role='doc-toc'],nav");
|
||||
for (const nav of navs) {
|
||||
// XHTML keeps epub:type in its XML namespace; HTML retains the
|
||||
// prefixed attribute name. CSS attribute selectors do not match both.
|
||||
const epubType = nav.getAttributeNS("http://www.idpf.org/2007/ops", "type") || nav.getAttribute("epub:type") || "";
|
||||
if (nav.getAttribute("role") !== "doc-toc" && !epubType.split(/\s+/).includes("toc")) continue;
|
||||
for (const link of nav.querySelectorAll("a[href]")) {
|
||||
if (!visible(link) || outline.length >= MAX_INDEX) continue;
|
||||
let depth = 0;
|
||||
for (let parent = link.parentElement; parent && parent !== nav; parent = parent.parentElement)
|
||||
if (parent.localName.toLowerCase() === "ol" || parent.localName.toLowerCase() === "ul") ++depth;
|
||||
const href = hrefFor(link.getAttribute("href"));
|
||||
outline.push({ title: text(link) || "目次項目", href, depth: Math.max(0, depth - 1), missing: !href, source: "html-toc" });
|
||||
}
|
||||
}
|
||||
return { title: String(document.title || "").slice(0, 4096), headings: result.rows, outline,
|
||||
truncated: result.truncated, scriptCount: document.scripts.length,
|
||||
fixedViewport: viewport(), writingMode: axis().writingMode };
|
||||
}
|
||||
function viewport() {
|
||||
const element = document.querySelector("meta[name='viewport']");
|
||||
const value = element ? element.getAttribute("content") || "" : "";
|
||||
const w = /(?:^|[,;\s])width\s*=\s*([0-9.]+)/i.exec(value);
|
||||
const h = /(?:^|[,;\s])height\s*=\s*([0-9.]+)/i.exec(value);
|
||||
let width = w ? Number(w[1]) : 0, height = h ? Number(h[1]) : 0;
|
||||
if ((!width || !height) && document.documentElement.localName === "svg") {
|
||||
const box = document.documentElement.viewBox && document.documentElement.viewBox.baseVal;
|
||||
if (box) { width = box.width; height = box.height; }
|
||||
}
|
||||
return width > 0 && height > 0 && width <= 100000 && height <= 100000 ? { width, height } : {};
|
||||
}
|
||||
function cfiFor(node, offset = 0) {
|
||||
const steps = [];
|
||||
let target = node;
|
||||
if (!target) return "";
|
||||
if (target.nodeType === Node.TEXT_NODE) {
|
||||
let elements = 0, previousText = 0;
|
||||
for (const sibling of target.parentNode.childNodes) {
|
||||
if (sibling === target) break;
|
||||
if (sibling.nodeType === Node.ELEMENT_NODE) { ++elements; previousText = 0; }
|
||||
if (sibling.nodeType === Node.TEXT_NODE) previousText += sibling.length;
|
||||
}
|
||||
steps.unshift(elements * 2 + 1);
|
||||
offset += previousText;
|
||||
target = target.parentElement;
|
||||
}
|
||||
while (target && target !== document.documentElement && steps.length < 128) {
|
||||
let order = 1;
|
||||
for (let sibling = target.previousElementSibling; sibling; sibling = sibling.previousElementSibling) ++order;
|
||||
steps.unshift(order * 2);
|
||||
target = target.parentElement;
|
||||
}
|
||||
return target === document.documentElement ? "epubcfi(/" + steps.join("/") + ":" + Math.max(0, offset) + ")" : "";
|
||||
}
|
||||
function resolveCfi(cfi) {
|
||||
if (typeof cfi !== "string" || cfi.length > 4096 || !/^epubcfi\(.*\)$/.test(cfi)) return null;
|
||||
let value = cfi.slice(8, -1);
|
||||
if (value.includes("!")) value = value.slice(value.lastIndexOf("!") + 1);
|
||||
value = value.replace(/\[(?:\^.|[^\]])*\]/g, "");
|
||||
if (!/^\/(?:[1-9][0-9]*\/)*[1-9][0-9]*(?::[0-9]+)?$/.test(value)) return null;
|
||||
const parts = value.split(":");
|
||||
const steps = parts[0].slice(1).split("/").map(Number);
|
||||
if (steps.length > 128) return null;
|
||||
let node = document.documentElement;
|
||||
let offset = Number(parts[1] || 0);
|
||||
for (let i = 0; i < steps.length; ++i) {
|
||||
const step = steps[i];
|
||||
if (step % 2 === 0) node = node && node.children && node.children[step / 2 - 1];
|
||||
else {
|
||||
if (!node || i !== steps.length - 1) return null;
|
||||
const slot = (step - 1) / 2;
|
||||
let elements = 0, found = null;
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === Node.ELEMENT_NODE) ++elements;
|
||||
else if (child.nodeType === Node.TEXT_NODE && elements === slot) {
|
||||
if (offset <= child.length) { found = child; break; }
|
||||
offset -= child.length;
|
||||
}
|
||||
}
|
||||
node = found;
|
||||
}
|
||||
if (!node) return null;
|
||||
}
|
||||
const range = document.createRange();
|
||||
if (node.nodeType === Node.TEXT_NODE) range.setStart(node, Math.min(offset, node.length));
|
||||
else range.setStart(node, Math.min(offset, node.childNodes.length));
|
||||
range.collapse(true);
|
||||
return range;
|
||||
}
|
||||
function visibleRange() {
|
||||
const a = axis();
|
||||
const x = Math.max(1, Math.min(innerWidth - 1, a.vertical && a.reverse ? innerWidth - 20 : 20));
|
||||
const y = Math.max(1, Math.min(innerHeight - 1, 20));
|
||||
let range = document.caretRangeFromPoint ? document.caretRangeFromPoint(x, y) : null;
|
||||
if (range && range.startContainer.nodeType === Node.TEXT_NODE) return range;
|
||||
const element = document.elementFromPoint(x, y) || document.body || document.documentElement;
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
let node, inspected = 0;
|
||||
while ((node = walker.nextNode()) && ++inspected < 1000) {
|
||||
if (!node.textContent.trim() || !visible(node.parentElement)) continue;
|
||||
range = document.createRange(); range.setStart(node, 0); range.collapse(true); return range;
|
||||
}
|
||||
range = document.createRange(); range.selectNodeContents(element); range.collapse(true); return range;
|
||||
}
|
||||
function locate() {
|
||||
// A font can change layout before its loadingdone event, and a location
|
||||
// poll can run before the image's animation-frame restoration. Retain
|
||||
// the pre-layout anchor until that restoration has used it.
|
||||
if (layoutAnchor && layoutMoved(layoutAnchor)) resourcesSettled();
|
||||
// Reflow can put a different character at the viewport sampling point.
|
||||
// Keep the restored logical character until the reader actually scrolls.
|
||||
const keep = restoredRange && document.documentElement.contains(restoredRange.range.startContainer) &&
|
||||
Math.abs(scrollX - restoredRange.x) < 1 && Math.abs(scrollY - restoredRange.y) < 1;
|
||||
if (!keep) restoredRange = null;
|
||||
const range = keep ? restoredRange.range : visibleRange();
|
||||
const node = range.startContainer;
|
||||
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
||||
const rect = range.getBoundingClientRect();
|
||||
const result = { url: location.href, cfi: cfiFor(node, range.startOffset), fragment: element && element.id || "",
|
||||
progression: progression(), textQuote: node.nodeType === Node.TEXT_NODE ? node.textContent.slice(range.startOffset, range.startOffset + 160) : text(element, 160),
|
||||
offsetX: rect.left / Math.max(1, innerWidth), offsetY: rect.top / Math.max(1, innerHeight), writingMode: axis().writingMode,
|
||||
currentOutline: currentNavigation(range), navigationRevision };
|
||||
if (!resourceRestoreFrame) layoutAnchor = { range: range.cloneRange(), saved: result,
|
||||
x:scrollX, y:scrollY, documentX:rect.left + scrollX, documentY:rect.top + scrollY,
|
||||
width:innerWidth, height:innerHeight };
|
||||
return result;
|
||||
}
|
||||
function layoutMoved(anchor) {
|
||||
if (!anchor.range.startContainer.isConnected || anchor.width !== innerWidth || anchor.height !== innerHeight) return false;
|
||||
const rect = anchor.range.getBoundingClientRect();
|
||||
return Math.abs(rect.left + scrollX - anchor.documentX) > 1 || Math.abs(rect.top + scrollY - anchor.documentY) > 1;
|
||||
}
|
||||
function cancelResourceRestore() {
|
||||
if (resourceRestoreFrame) cancelAnimationFrame(resourceRestoreFrame);
|
||||
resourceRestoreFrame = 0;
|
||||
layoutAnchor = null;
|
||||
}
|
||||
function resourcesSettled() {
|
||||
if (resourceRestoreFrame || !layoutAnchor || options.fixed) return;
|
||||
const anchor = layoutAnchor;
|
||||
resourceRestoreFrame = requestAnimationFrame(() => {
|
||||
resourceRestoreFrame = 0;
|
||||
// Retain the character and its viewport fraction across a decoded
|
||||
// image or font swap. Never insert helper nodes into the source DOM.
|
||||
// Clear the old snapshot before locate(), otherwise it would see
|
||||
// the same completed layout change and schedule another frame.
|
||||
const current = layoutAnchor === anchor;
|
||||
if (current) layoutAnchor = null;
|
||||
if (current && anchor.range.startContainer.isConnected)
|
||||
positionRange(anchor.range, anchor.saved);
|
||||
userScrollPending = false;
|
||||
locate();
|
||||
});
|
||||
}
|
||||
document.addEventListener("load", event => {
|
||||
if (event.isTrusted && event.target instanceof HTMLImageElement) resourcesSettled();
|
||||
}, true);
|
||||
document.addEventListener("error", event => {
|
||||
if (event.isTrusted && event.target instanceof HTMLImageElement) resourcesSettled();
|
||||
}, true);
|
||||
if (document.fonts) {
|
||||
document.fonts.addEventListener("loadingdone", resourcesSettled);
|
||||
document.fonts.addEventListener("loadingerror", resourcesSettled);
|
||||
}
|
||||
for (const type of ["wheel", "touchstart", "touchmove", "pointerdown", "pointermove", "keydown"])
|
||||
document.addEventListener(type, event => {
|
||||
if (!event.isTrusted || (type === "pointermove" && !event.buttons)) return;
|
||||
cancelResourceRestore();
|
||||
// Clicking without scrolling must not leave a 400 ms gap without
|
||||
// any anchor. A following input scroll still takes precedence.
|
||||
if (document.body) locate();
|
||||
userScrollPending = true;
|
||||
// Input intent applies to this frame, not to an unrelated native
|
||||
// anchor adjustment long after a click that did not scroll.
|
||||
if (userScrollFrame) cancelAnimationFrame(userScrollFrame);
|
||||
userScrollFrame = requestAnimationFrame(() => { userScrollFrame = 0; userScrollPending = false; });
|
||||
}, {capture:true, passive:true});
|
||||
document.addEventListener("scroll", () => {
|
||||
if (layoutAnchor && Math.abs(scrollX - layoutAnchor.x) < 1 && Math.abs(scrollY - layoutAnchor.y) < 1) return;
|
||||
if (!userScrollPending && layoutAnchor && layoutMoved(layoutAnchor)) {
|
||||
// Native overflow-anchor can move the scroll offset as resources
|
||||
// reflow. This is not a new reader navigation.
|
||||
resourcesSettled();
|
||||
return;
|
||||
}
|
||||
userScrollPending = false;
|
||||
cancelResourceRestore();
|
||||
restoredRange = null;
|
||||
locate();
|
||||
}, {passive:true});
|
||||
window.addEventListener("hashchange", () => { cancelResourceRestore(); locate(); });
|
||||
function configureNavigation(args) {
|
||||
navigationTargets = []; navigationRevision = "";
|
||||
if (!args || typeof args.revision !== "string" || !/^[0-9]{1,19}$/.test(args.revision) ||
|
||||
!Array.isArray(args.targets) || args.targets.length > 20000) return false;
|
||||
const targets = [];
|
||||
for (const item of args.targets) {
|
||||
if (!item || !Number.isInteger(item.row) || item.row < 0 || item.row >= 20000) return false;
|
||||
let element;
|
||||
if (Number.isInteger(item.heading) && item.heading >= 0 && item.heading < headings.length)
|
||||
element = headings[item.heading].element;
|
||||
else if (typeof item.fragment === "string" && item.fragment.length <= 2048)
|
||||
element = item.fragment ? document.getElementById(item.fragment) : document.body || document.documentElement;
|
||||
if (!visible(element)) continue;
|
||||
const range = document.createRange(); range.selectNodeContents(element); range.collapse(true);
|
||||
targets.push({ row:item.row, element, range });
|
||||
}
|
||||
targets.sort((a, b) => a.range.compareBoundaryPoints(Range.START_TO_START, b.range) || a.row - b.row);
|
||||
navigationTargets = targets; navigationRevision = args.revision;
|
||||
return true;
|
||||
}
|
||||
function currentNavigation(position) {
|
||||
// DOM order is stable across zoom/reflow. Avoid measuring every target's
|
||||
// layout at each 400 ms location poll, even for a 20,000-item EPUB nav.
|
||||
let low = 0, high = navigationTargets.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if (navigationTargets[mid].range.compareBoundaryPoints(Range.START_TO_START, position) <= 0) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
for (let i = low - 1; i >= 0; --i)
|
||||
if (visible(navigationTargets[i].element)) return navigationTargets[i].row;
|
||||
return -1;
|
||||
}
|
||||
function positionRange(range, saved) {
|
||||
const node = range.startContainer;
|
||||
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
||||
if (!element || !visible(element)) return false;
|
||||
element.scrollIntoView({ block: "start", inline: "start", behavior: "instant" });
|
||||
const rect = range.getBoundingClientRect();
|
||||
const a = axis();
|
||||
const offset = a.vertical ? Number(saved.offsetX) : Number(saved.offsetY);
|
||||
const fraction = Number.isFinite(offset) && Math.abs(offset) <= 2 ? offset : 0;
|
||||
if (a.vertical) scrollBy({ left: rect.left - fraction * innerWidth, behavior: "instant" });
|
||||
else scrollBy({ top: rect.top - fraction * innerHeight, behavior: "instant" });
|
||||
restoredRange = { range: range.cloneRange(), x: scrollX, y: scrollY };
|
||||
return true;
|
||||
}
|
||||
function restore(saved) {
|
||||
if (!saved || typeof saved !== "object") return { restored: false, approximate: true };
|
||||
let range = resolveCfi(saved.cfi);
|
||||
if (range && positionRange(range, saved)) return { restored: true, approximate: false };
|
||||
const fragment = typeof saved.fragment === "string" ? saved.fragment.slice(0, 1024) : "";
|
||||
const element = fragment ? document.getElementById(fragment) : null;
|
||||
if (element && visible(element)) {
|
||||
restoredRange = null;
|
||||
element.scrollIntoView({ block: "start", inline: "start", behavior: "instant" });
|
||||
// A failed character-level CFI resolved only to its containing ID.
|
||||
return { restored: true, approximate: Boolean(saved.cfi) };
|
||||
}
|
||||
const quote = typeof saved.textQuote === "string" ? saved.textQuote.slice(0, 160) : "";
|
||||
if (quote) {
|
||||
const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
|
||||
let node, inspected = 0;
|
||||
while ((node = walker.nextNode()) && ++inspected < 100000) {
|
||||
const offset = node.textContent.indexOf(quote);
|
||||
if (offset >= 0 && visible(node.parentElement)) {
|
||||
range = document.createRange(); range.setStart(node, offset); range.collapse(true);
|
||||
if (positionRange(range, saved)) return { restored: true, approximate: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
setProgression(saved.progression);
|
||||
return { restored: true, approximate: true };
|
||||
}
|
||||
function heading(direction) {
|
||||
if (!headings.length) headingIndex();
|
||||
const a = axis();
|
||||
const candidates = direction > 0 ? headings : headings.slice().reverse();
|
||||
const seen = new Set();
|
||||
for (const h of candidates) {
|
||||
const rect = h.element.getBoundingClientRect();
|
||||
const distance = a.vertical ? (a.reverse ? innerWidth - rect.right : rect.left) : rect.top;
|
||||
const position = Math.round(distance);
|
||||
if (seen.has(position)) continue;
|
||||
seen.add(position);
|
||||
if ((direction > 0 && distance > 3) || (direction < 0 && distance < -3)) {
|
||||
const x = scrollX, y = scrollY;
|
||||
h.element.scrollIntoView({ block: "start", inline: "start", behavior: "instant" });
|
||||
// Several final headings can clamp to the same destination.
|
||||
// Check the current layout's actual result, so a repeated
|
||||
// destination cannot masquerade as movement or block a boundary.
|
||||
const after = h.element.getBoundingClientRect();
|
||||
if (Math.abs(scrollX - x) < 1 && Math.abs(scrollY - y) < 1 &&
|
||||
Math.abs(after.left - rect.left) < 1 && Math.abs(after.top - rect.top) < 1) continue;
|
||||
return { moved: true, title: h.title };
|
||||
}
|
||||
}
|
||||
return { moved: false, boundary: direction, empty: !headings.length };
|
||||
}
|
||||
function applyOptions(next) {
|
||||
options = Object.assign({}, options, next || {});
|
||||
if (!options.fixed && options.publisherStyle === false) {
|
||||
const size = Number(options.fontSize), height = Number(options.lineHeight);
|
||||
if (!originalTypography) {
|
||||
const body = document.body || document.documentElement;
|
||||
const base = parseFloat(getComputedStyle(body).fontSize) || 16;
|
||||
const nodes = [document.documentElement, body];
|
||||
// Inline priorities override authored fixed-pixel declarations.
|
||||
// Relative heading/ruby sizes survive; DOM nodes and CFI paths do not change.
|
||||
for (const node of body.querySelectorAll("*")) {
|
||||
if (node.namespaceURI !== "http://www.w3.org/1999/xhtml") continue;
|
||||
if (Array.from(node.childNodes).some(child => child.nodeType === Node.TEXT_NODE && child.textContent.trim())) nodes.push(node);
|
||||
}
|
||||
originalTypography = Array.from(new Set(nodes)).map(node => ({ node,
|
||||
hadStyle: node.hasAttribute("style"),
|
||||
font: node.style.getPropertyValue("font-size"), fontPriority: node.style.getPropertyPriority("font-size"),
|
||||
line: node.style.getPropertyValue("line-height"), linePriority: node.style.getPropertyPriority("line-height"),
|
||||
ratio: node === document.documentElement || node === body ? 1 : (parseFloat(getComputedStyle(node).fontSize) || base) / base }));
|
||||
}
|
||||
for (const entry of originalTypography) {
|
||||
if (size >= 12 && size <= 40) entry.node.style.setProperty("font-size", size * entry.ratio + "px", "important");
|
||||
if (height >= 1 && height <= 2.5) entry.node.style.setProperty("line-height", String(height), "important");
|
||||
}
|
||||
} else if (originalTypography) {
|
||||
for (const entry of originalTypography) {
|
||||
if (entry.font) entry.node.style.setProperty("font-size", entry.font, entry.fontPriority);
|
||||
else entry.node.style.removeProperty("font-size");
|
||||
if (entry.line) entry.node.style.setProperty("line-height", entry.line, entry.linePriority);
|
||||
else entry.node.style.removeProperty("line-height");
|
||||
if (!entry.hadStyle && !entry.node.getAttribute("style")) entry.node.removeAttribute("style");
|
||||
}
|
||||
originalTypography = null;
|
||||
}
|
||||
return { fixed: !!options.fixed, fixedViewport: viewport() };
|
||||
}
|
||||
function restoreLinkOutline() {
|
||||
if (!focusedLink || !linkOutline) return;
|
||||
for (const entry of linkOutline.properties) {
|
||||
if (entry.value) focusedLink.style.setProperty(entry.name, entry.value, entry.priority);
|
||||
else focusedLink.style.removeProperty(entry.name);
|
||||
}
|
||||
if (!linkOutline.hadStyle && !focusedLink.getAttribute("style")) focusedLink.removeAttribute("style");
|
||||
linkOutline = null;
|
||||
}
|
||||
function clearLinkSelection() {
|
||||
restoreLinkOutline();
|
||||
focusedLink = null;
|
||||
// selectedLink also supports a natively focused anchor. Clear both
|
||||
// routes so Enter cannot follow a link after selection is cancelled.
|
||||
const active = document.activeElement;
|
||||
if (active && active.localName === "a") active.blur();
|
||||
return { cleared: true };
|
||||
}
|
||||
function focusLink(direction) {
|
||||
const links = Array.from(document.querySelectorAll("a[href]")).filter(visible).slice(0, 20000);
|
||||
if (!links.length) return { empty: true };
|
||||
const current = links.indexOf(focusedLink || document.activeElement);
|
||||
const index = current < 0 ? (direction > 0 ? 0 : links.length - 1) : (current + direction + links.length) % links.length;
|
||||
restoreLinkOutline();
|
||||
focusedLink = links[index];
|
||||
linkOutline = { hadStyle: focusedLink.hasAttribute("style"),
|
||||
properties: ["outline-color", "outline-style", "outline-width", "outline-offset"].map(name => ({
|
||||
name, value: focusedLink.style.getPropertyValue(name), priority: focusedLink.style.getPropertyPriority(name)
|
||||
})) };
|
||||
focusedLink.style.outline = "2px solid #008577";
|
||||
focusedLink.style.outlineOffset = "3px";
|
||||
focusedLink.focus({ preventScroll: false });
|
||||
return { focused: true, title: text(focusedLink), index, count: links.length };
|
||||
}
|
||||
function selectedLink() {
|
||||
const element = focusedLink && focusedLink.isConnected ? focusedLink : document.activeElement;
|
||||
if (!element || element.localName !== "a" || !element.hasAttribute("href")) return { empty: true };
|
||||
try {
|
||||
const url = new URL(element.getAttribute("href"), document.baseURI);
|
||||
if (!["doc:", "http:", "https:"].includes(url.protocol) || url.username || url.password) return { blocked: true };
|
||||
if (url.protocol === "doc:" && url.host !== location.host) return { blocked: true };
|
||||
return { url: url.href.slice(0, 8192), title: text(element) };
|
||||
} catch (_) { return { blocked: true }; }
|
||||
}
|
||||
function executeCommand(id, args) {
|
||||
args = args && typeof args === "object" ? args : {};
|
||||
const a = axis();
|
||||
const step = Math.max(1, Math.min(100, Number(args.count) || 1));
|
||||
switch (id) {
|
||||
case "resource.issues": {
|
||||
const result = resourceIssues;
|
||||
resourceIssues = Object.create(null);
|
||||
return result;
|
||||
}
|
||||
case "index": return index();
|
||||
case "location": return locate();
|
||||
case "navigation.configure": return configureNavigation(args);
|
||||
case "location.restore": return restore(args);
|
||||
case "layout": return applyOptions(args);
|
||||
case "link.next": return focusLink(1);
|
||||
case "link.previous": return focusLink(-1);
|
||||
case "link.clear": return clearLinkSelection();
|
||||
case "link.activate": return selectedLink();
|
||||
case "scroll.down": scrollBy({ top: 48 * step, behavior: "instant" }); break;
|
||||
case "scroll.up": scrollBy({ top: -48 * step, behavior: "instant" }); break;
|
||||
case "scroll.left": scrollBy({ left: -48 * step, behavior: "instant" }); break;
|
||||
case "scroll.right": scrollBy({ left: 48 * step, behavior: "instant" }); break;
|
||||
case "scroll.half_down": case "scroll.half_up": case "scroll.page_down": case "scroll.page_up": {
|
||||
const forward = id.endsWith("down") ? 1 : -1;
|
||||
const size = id.includes("half") ? 0.5 : 0.9;
|
||||
scrollBy(a.vertical ? { left: innerWidth * size * forward * (a.reverse ? -1 : 1), behavior: "instant" }
|
||||
: { top: innerHeight * size * forward, behavior: "instant" }); break;
|
||||
}
|
||||
case "nav.document_start": setProgression(0); break;
|
||||
case "nav.document_end": setProgression(1); break;
|
||||
case "nav.heading_next": return heading(1);
|
||||
case "nav.heading_previous": return heading(-1);
|
||||
case "heading.first": if (!headings.length) headingIndex(); if (headings.length) headings[0].element.scrollIntoView({block:"start",inline:"start"}); return {moved:!!headings.length};
|
||||
case "heading.last": if (!headings.length) headingIndex(); if (headings.length) headings[headings.length-1].element.scrollIntoView({block:"start",inline:"start"}); return {moved:!!headings.length};
|
||||
case "heading.index": {
|
||||
const index = Number(args.index);
|
||||
if (Number.isInteger(index) && index >= 0 && index < headings.length) headings[index].element.scrollIntoView({ block: "start", inline: "start", behavior: "instant" });
|
||||
break;
|
||||
}
|
||||
default: return { handled: false };
|
||||
}
|
||||
return { handled: true };
|
||||
}
|
||||
function command(id, args) {
|
||||
const passive = ["location", "index", "navigation.configure", "resource.issues"].includes(id);
|
||||
if (!passive) { cancelResourceRestore(); userScrollPending = false; }
|
||||
try { return executeCommand(id, args); }
|
||||
finally { if (!passive && document.body) locate(); }
|
||||
}
|
||||
function block(event) { event.preventDefault(); event.stopImmediatePropagation(); }
|
||||
document.addEventListener("submit", block, true);
|
||||
document.addEventListener("reset", block, true);
|
||||
document.addEventListener("beforeinput", block, true);
|
||||
document.addEventListener("drop", block, true);
|
||||
document.addEventListener("click", event => {
|
||||
if (event.target.closest && event.target.closest("button,input,select,textarea,[contenteditable]")) block(event);
|
||||
}, true);
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.target.closest && event.target.closest("button,input,select,textarea,[contenteditable]") && !(event.ctrlKey && /^(a|c)$/i.test(event.key))) block(event);
|
||||
}, true);
|
||||
document.addEventListener("play", event => { if (event.target.pause) event.target.pause(); }, true);
|
||||
globalThis.__docviewReader = Object.freeze({ command, index, location: locate, restore });
|
||||
})();
|
||||
Reference in New Issue
Block a user