Files
SubMiner/vendor/hachidori/extension/content.js
T
sudacode d9fdc7ef6d feat(dictionary): add Hachidori backend support
- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
2026-09-22 00:21:19 -07:00

4064 lines
154 KiB
JavaScript

/*
* Hover scanning, popup hosting, and offscreen-engine messaging for
* Hachidori.
*
* Rendering lives in render/popup.js and render/glossary.js (ported from
* GameSentenceMiner PR #549); this file only produces the
* {sentence, matchOffset, sourceElements} candidates those modules consume and
* drives the request/reply state machine. Like Yomitan's default layout-unaware
* scan, page text is read in DOM order regardless of how it is boxed, and a
* pointer candidate's sources are the text nodes around the hovered glyph.
*
* Copyright (C) 2026 Manhhao
* SPDX-License-Identifier: GPL-3.0-or-later
*/
(function () {
"use strict";
const TARGET = "hoshidicts-offscreen";
const PAGE_ZOOM_TARGET = "hachidori-page-zoom";
const WORKER_TARGET = "hoshidicts-worker";
const READER_TARGET = "hachidori-reader";
const HIGHLIGHT_NAME = "gsm-hoshidicts-match";
const READER_STYLESHEET = "render/reader.css";
const HOST_TAG = "hachidori-host";
const POPUP_SHOWN_EVENT = "hachidori-popup-shown";
const POPUP_HIDDEN_EVENT = "hachidori-popup-hidden";
const EXTENSION_PROTOCOL = (() => {
try {
return new URL(chrome.runtime.getURL("")).protocol;
} catch {
return "";
}
})();
const {
DEFAULT_OPTIONS,
KEYBIND_MODIFIERS,
KEYBIND_MODIFIER_CODES,
clampOption,
definitionBlurFrequencyEvidence,
definitionBlurQualifies,
normaliseActivationKey,
projectContentOptions,
} = globalThis.HDReaderOptions;
const { normaliseDictionaryGroups } = globalThis.HDDictionaryGroups;
const { normaliseLookupTerm, lookupStatsKey } = globalThis.HDLookupStats;
const { normaliseDictionaryTab: normalizedDictionaryTab } = globalThis.HDPopup;
const MODIFIER_PROPERTIES = new Map([
["Shift", "shiftKey"],
["Control", "ctrlKey"],
["Alt", "altKey"],
["Meta", "metaKey"],
]);
const POPUP_GAP_PX = 4;
const POPUP_PADDING_PX = 6;
const MAX_MEDIA_CACHE_BYTES = 16 * 1024 * 1024;
const MAX_MEDIA_CACHE_ENTRIES = 64;
const MAX_MEDIA_CONCURRENT_REQUESTS = 4;
const MAX_MEDIA_PENDING_REQUESTS = 128;
const MEDIA_REQUEST_TIMEOUT_MS = 4000;
// Yomitan's sentence scan extent: how far the sentence reaches to either
// side of the hovered glyph before a newline cuts it.
const SENTENCE_SCAN_EXTENT = 200;
// Same character set PR #549 gates lookups on: kana, halfwidth katakana, CJK
// ideographs (including ext-A and ext-B), and the iteration/repeat marks.
const JAPANESE_CHARACTER_PATTERN =
/[々-〇〻぀-ヿㇰ-ㇿ㐀-䶿一-鿿豈-﫿ヲ-゚\u{20000}-\u{2fa1f}]/u;
const TOKEN_BOUNDARY_PATTERN = /[\p{White_Space}\p{Punctuation}\p{Symbol}]/u;
const COLLAPSIBLE_WHITESPACE_PATTERN = /[\t\n\r\f ]/u;
const SEGMENT_BREAK_PATTERN = /[\n\r]/u;
// Deliberately narrow: "receiving end does not exist" also fires while the
// service worker is still waking up, and tearing down on that would kill the
// content script over a transient race.
const INVALIDATED_MESSAGE_PATTERN = /context invalidated/iu;
// Text in these never belongs to the running prose: script and style hold
// source, rt/rp hold reading annotations that must not splice into the
// scanned string, and form controls hold values rather than page text.
const OPAQUE_TAGS = new Set([
"audio",
"canvas",
"embed",
"head",
"iframe",
"math",
"noscript",
"object",
"option",
"optgroup",
"rp",
"rt",
"script",
"select",
"style",
"svg",
"template",
"textarea",
"title",
"video",
]);
const EDITING_TAGS = new Set(["button", "input", "select", "textarea"]);
const EDITING_SELECTOR = [...EDITING_TAGS, "[contenteditable]"].join(",");
// A whitespace-only text node with a line break separates blocks in the
// source ("</p>\n<p>", an overlay's block separator) and ends the sentence.
const BLOCK_SEPARATOR_PATTERN = /^\s*[\n\r]\s*$/u;
const PRESERVED_WHITESPACE = new Set([
"pre",
"pre-wrap",
"pre-line",
"break-spaces",
]);
if (typeof document.createTreeWalker !== "function") {
return;
}
// The reader belongs to ordinary pages. First-run setup loads these same
// scripts into its own startup page for the practice step. Its native skip
// link may leave the known heading fragment before the module loads or on
// reload; query variants and every other internal page stay excluded.
if (
location.protocol === EXTENSION_PROTOCOL &&
location.href !== chrome.runtime.getURL("startup.html") &&
location.href !== chrome.runtime.getURL("startup.html#setup-heading")
) {
return;
}
let disposed = false;
let appearance;
let customStyle;
let audio, mining;
let options = { ...DEFAULT_OPTIONS };
let dictionaries = [];
let dictionaryGroups = [];
let nextRequestId = 0;
let currentGeneration = -1;
const rootLevel = createLevelState(0);
const levels = [rootLevel];
let nextLevelId = 0;
function createLevelState(depth) {
return {
depth, popup: null, view: null, highlighter: null, retired: false,
activeCandidate: null, activeSignature: null, activeHighlightText: "",
activeTermRender: null, currentViewRequest: null, noteEditing: false,
pendingCustomAppends: 0, deferredDictionaryInvalidationRevision: -1,
deferredRefresh: null, lookupToken: 0, pendingHover: null, pendingLink: null,
pendingPopupInteraction: null,
retainedView: false,
pendingViewReplay: null,
blurTimer: null,
capturePin: null,
capturePinPromise: null,
};
}
let host = null;
let shadow = null;
let highlighter = null;
let uiPromise = null;
let popupLayoutFrame = null;
let popupLayouts = new Map();
let pageZoom = 1;
let pageZoomRatio = null;
let pageZoomRequest = 0;
let sessionPopupSize = null;
let popupResize = null;
let styleGeneration = -1;
let styleRequest = null;
const mediaCache = new Map();
const pendingMedia = new Map();
let mediaCacheBytes = 0;
let activeMediaRequests = 0;
let mediaQueue = [];
let popupImageSources = null;
let lastPointer = null;
let scanTimer = null;
let hideTimer = null;
let transferTimer = null;
let descendantTimer = null;
let pointerLevel = null;
let pointerInPopup = false;
let activationPressed = false;
let activationCode = null;
let pendingCandidateLookup = null;
let selectionDragActive = false;
let activeSelectionCandidate = null;
// A drag the reader selects itself, glyph by glyph, in an overlay host.
let dragSelection = null;
let overlayMode = false;
let hostCapabilities = { linkButtons: true, externalLinkHost: false, mediaCapture: true };
let hostAttentionPublished = false;
let hostAttentionHold = 0;
let optionsStorageRevision = -1;
let ankiMaturityEpoch = 0;
let dictionaryStateRevision = -1;
let lookupStatsDescriptor = { generation: null, revision: -1 };
const DEFINITION_BLUR_KEYS = [
"definitionBlurEnabled", "definitionBlurAnkiMature", "definitionBlurFrequencyEnabled",
"definitionBlurFrequencyDictionary", "definitionBlurFrequencyOrder", "definitionBlurFrequencyThreshold",
"definitionBlurDirection", "definitionBlurThreshold", "definitionBlurReveal", "definitionBlurDelayMs",
];
function extensionAlive() {
try {
return Boolean(chrome && chrome.runtime && chrome.runtime.id);
} catch {
return false;
}
}
// An overlay host such as GSM passes clicks through to the game unless the
// reader says it needs the window. A popup needs it, and so does a drag that
// is selecting text: the host answers a mousedown by turning click-through on,
// which would lose the drag before release could look anything up. The claim
// carries over to the selection's pending lookup, so the host never sees a
// gap between the drag and the popup it produces.
function syncHostAttention() {
const wanted = Boolean(rootLevel.popup && !rootLevel.popup.hidden) || selectionDragActive
|| hostAttentionHold > 0 || pendingCandidateLookup?.candidate?.exactSelection === true;
if (wanted === hostAttentionPublished) return;
hostAttentionPublished = wanted;
globalThis.SubMinerHachidori?.attention(host, wanted);
window.dispatchEvent(new CustomEvent(wanted ? POPUP_SHOWN_EVENT : POPUP_HIDDEN_EVENT));
}
function setSelectionDrag(active) {
selectionDragActive = active;
if (!active) dragSelection = null;
syncHostAttention();
}
// Overlay hosts (docs/overlay-mode.md) set the flag in overlay-mode.js. This
// classic script reads the module through its extension URL; a host without
// it, such as a test page, gets the browser behaviour.
async function loadOverlayMode() {
try {
const module = await import(chrome.runtime.getURL("overlay-mode.js"));
overlayMode = module.OVERLAY_MODE === true;
const advertised = module.HOST_CAPABILITIES ?? {};
hostCapabilities = { ...hostCapabilities, ...advertised };
if (!Object.hasOwn(advertised, "linkButtons") && Object.hasOwn(advertised, "customLinks")) {
hostCapabilities.linkButtons = advertised.customLinks;
}
const next = applyHostCapabilities(options);
const customButtonsChanged = JSON.stringify(next.customButtons) !== JSON.stringify(options.customButtons);
const miningChanged = customButtonsChanged
|| JSON.stringify(next.mediaCapture) !== JSON.stringify(options.mediaCapture);
options = next;
if (customButtonsChanged) {
for (const level of levels) level.view?.setCustomButtons(options.customButtons);
}
if (miningChanged) mining?.update(options, optionsStorageRevision >= 0);
} catch {
overlayMode = false;
}
}
function applyHostCapabilities(projected) {
if (!hostCapabilities.mediaCapture) {
projected = { ...projected, mediaCapture: { ...projected.mediaCapture, enabled: false } };
}
if (!hostCapabilities.linkButtons) projected = {
...projected,
customLinks: [],
customButtons: projected.customButtons.filter(button => button.type !== "link"),
};
return projected;
}
const projectHostOptions = stored => applyHostCapabilities(projectContentOptions(stored));
function nonnegativeCount(value) {
const count = Math.trunc(Number(value));
return Number.isFinite(count) && count > 0 ? count : 0;
}
function normalizeDictionaryState(stored) {
const state = stored && typeof stored === "object" ? stored : {};
const rows = Array.isArray(state.dictionaries) ? state.dictionaries : [];
const normalized = rows.flatMap((entry) => {
const title = typeof entry?.title === "string" ? entry.title : "";
if (!title) {
return [];
}
return [{
id: typeof entry.id === "string" ? entry.id : "",
title,
displayName: typeof entry.displayName === "string" && entry.displayName.trim() !== ""
? entry.displayName.trim()
: null,
path: typeof entry.path === "string" ? entry.path : "",
revision: typeof entry.revision === "string" ? entry.revision : "",
enabled: entry.enabled !== false,
favorite: entry.favorite === true,
termCount: nonnegativeCount(entry.termCount),
frequencyCount: nonnegativeCount(entry.frequencyCount),
frequencyMode: entry.frequencyMode,
pitchCount: nonnegativeCount(entry.pitchCount),
kanjiCount: nonnegativeCount(entry.kanjiCount),
longKeyLength: nonnegativeCount(entry.longKeyLength),
}];
});
return {
revision: Number.isInteger(state.revision) && state.revision >= 0 ? state.revision : 0,
dictionaries: normalized,
groups: normaliseDictionaryGroups(state.groups, normalized),
};
}
function sameDictionaries(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}
function sameDictionaryContents(left, right) {
const contents = (entries) => entries.map(({ displayName, favorite, frequencyMode, ...dictionary }) => dictionary);
return left === right || sameDictionaries(contents(left), contents(right));
}
function dictionaryPresentation() {
return dictionaries
.filter((entry) => entry.enabled !== false)
.map((entry) => ({
id: entry.id,
title: entry.title,
favorite: entry.favorite,
frequencyMode: entry.frequencyMode,
...(entry.displayName ? { displayName: entry.displayName } : {}),
}));
}
function dictionaryTabGroups() {
const titles = new Map(dictionaries.filter((entry) => entry.enabled)
.map((entry) => [entry.id, entry.title]));
return dictionaryGroups.map((group) => ({
id: group.id,
name: group.name,
dictionaries: group.dictionaryIds.filter((id) => titles.has(id)).map((id) => titles.get(id)),
}));
}
function selectedKanjiDictionaryCapability() {
return globalThis.HDReaderOptions.resolveKanjiDictionary(options.kanjiClickDictionary, dictionaries);
}
function projectResultsToDictionary(results, title) {
const projected = [];
for (const result of results) {
const glossaries = Array.isArray(result?.term?.glossaries)
? result.term.glossaries.filter((glossary) => glossary && glossary.dictionary === title)
: [];
if (glossaries.length > 0) {
projected.push({
...result,
term: { ...result.term, glossaries },
});
}
}
return projected;
}
function isJapaneseToken(text) {
const token = text.split(TOKEN_BOUNDARY_PATTERN, 1)[0];
return JAPANESE_CHARACTER_PATTERN.test(token);
}
function computedStyleFor(element, styleCache) {
let style = styleCache.get(element);
if (!style) {
style = window.getComputedStyle(element);
styleCache.set(element, style);
}
return style;
}
function isHiddenElement(element, styleCache) {
const style = computedStyleFor(element, styleCache);
return style.display === "none" ||
style.visibility === "hidden" ||
style.visibility === "collapse";
}
function preservesWhitespace(element, styleCache) {
if (!element) {
return false;
}
const style = computedStyleFor(element, styleCache);
const collapse = style.whiteSpaceCollapse;
if (typeof collapse === "string" && collapse) {
return collapse !== "collapse";
}
return PRESERVED_WHITESPACE.has(style.whiteSpace);
}
function isOurNode(node) {
if (!host) {
return false;
}
// The popup lives in a closed shadow root, so a caret or event inside it is
// retargeted to the host; a node whose root is not the page document also
// means "not page text" (user-agent shadow DOM of <input>, page shadow DOM).
return node === host || (node.nodeType === Node.ELEMENT_NODE
? host.contains(node)
: host.contains(node.parentNode));
}
/**
* Both caret APIs return the nearest caret *boundary*, so a pointer in the
* right half of a glyph reports the offset after it and the scan would start
* one character late -- pointing straight at 食 in 食べたかった would look up
* べたかった. Step back onto the preceding character when the pointer is
* actually inside its box.
*/
function alignToCharacter(range, clientX, clientY) {
const node = range.startContainer;
const offset = range.startOffset;
if (!range.collapsed || offset === 0 || node.nodeType !== Node.TEXT_NODE) {
return range;
}
const probe = document.createRange();
try {
probe.setStart(node, offset - 1);
probe.setEnd(node, offset);
} catch {
return range;
}
for (const rect of probe.getClientRects()) {
if (
clientX >= rect.left && clientX <= rect.right &&
clientY >= rect.top && clientY <= rect.bottom
) {
range.setStart(node, offset - 1);
range.collapse(true);
return range;
}
}
return range;
}
function rangeFromCaretPosition(position, clientX, clientY) {
if (!position) {
return null;
}
const range = document.createRange();
try {
range.setStart(position.offsetNode, position.offset);
range.setEnd(position.offsetNode, position.offset);
} catch {
return null;
}
return alignToCharacter(range, clientX, clientY);
}
function caretRangeAt(clientX, clientY, shadowRoot = null) {
if (shadowRoot) {
if (typeof document.caretPositionFromPoint !== "function") {
return null;
}
try {
return rangeFromCaretPosition(
document.caretPositionFromPoint(clientX, clientY, {
shadowRoots: [shadowRoot],
}),
clientX,
clientY
);
} catch {
return null;
}
}
if (typeof document.caretRangeFromPoint === "function") {
const range = document.caretRangeFromPoint(clientX, clientY);
return range === null ? null : alignToCharacter(range, clientX, clientY);
}
if (typeof document.caretPositionFromPoint === "function") {
return rangeFromCaretPosition(
document.caretPositionFromPoint(clientX, clientY),
clientX,
clientY
);
}
return null;
}
/**
* The glyph under the pointer as a text range, for a drag the reader selects
* itself. An overlay boxes each glyph in a span wider than the glyph, and from
* the box's trailing margin the caret APIs report the boundary after its text;
* the box still belongs to its last glyph.
*/
function glyphAtPoint(clientX, clientY) {
const range = caretRangeAt(clientX, clientY);
const node = range?.startContainer;
if (!node || !isScannableTextNode(node, new Map())) return null;
const text = node.nodeValue || "";
let offset = range.startOffset;
if (offset >= text.length) {
const box = node.parentElement.getBoundingClientRect();
if (text.length === 0 || clientX < box.left || clientX > box.right
|| clientY < box.top || clientY > box.bottom) return null;
offset = text.length - 1;
if (offset > 0 && (text.charCodeAt(offset) & 0xfc00) === 0xdc00) offset -= 1;
}
return { node, start: offset, end: offset + (text.codePointAt(offset) > 0xffff ? 2 : 1) };
}
function isEditingElement(element) {
return element?.isContentEditable === true || EDITING_TAGS.has(element?.localName);
}
function pageEditorFocused() {
for (let focused = document.activeElement; focused; focused = focused.shadowRoot?.activeElement) {
if (isEditingElement(focused)) {
// Startup is the only extension page allowed above. Its scene arrow
// keeps keyboard focus without pausing the practice lookup.
if (location.protocol === EXTENSION_PROTOCOL && focused.matches(".vn-next")) continue;
return true;
}
}
return false;
}
function isScannableElement(element, styleCache) {
if (!element || element.getRootNode() !== document || isOurNode(element) || isHiddenElement(element, styleCache)) {
return false;
}
for (let current = element; current; current = current.parentElement) {
if (isEditingElement(current) || OPAQUE_TAGS.has(current.localName)
|| computedStyleFor(current, styleCache).display === "none") {
return false;
}
}
return true;
}
function isScannableTextNode(node, styleCache) {
return node?.nodeType === Node.TEXT_NODE && isScannableElement(node.parentElement, styleCache);
}
function createScanWalker(root, styleCache) {
return document.createTreeWalker(
root,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
return isHiddenElement(node.parentElement, styleCache) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
}
const editing = isEditingElement(node);
if (
(!editing && OPAQUE_TAGS.has(node.localName)) ||
isOurNode(node) ||
computedStyleFor(node, styleCache).display === "none"
) {
return NodeFilter.FILTER_REJECT;
}
if (editing) return hasVisibleContent(node, styleCache) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
// Visible controls and line breaks are boundaries. Every other element
// is crossed whatever its layout, as Yomitan's layout-unaware scan
// does: a word boxed one glyph per positioned span is still one word.
// Hidden wrappers are skipped too, since a descendant may restore
// visibility.
return node.localName === "br"
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_SKIP;
},
}
);
}
/**
* The text nodes around `startNode`, in document order, that make up the
* sentence: neighbours up to SENTENCE_SCAN_EXTENT characters each way, cut at
* a block separator, a line break or a control. They are the candidate's
* `sourceElements`, so `sourceElements.map(textContent).join("") === sentence`
* holds by construction, which is what createSourceHighlighter requires.
*/
function collectSentenceSources(startNode, root, styleCache) {
const sources = [startNode];
for (const backward of [true, false]) {
const walker = createScanWalker(root, styleCache);
walker.currentNode = startNode;
let length = 0;
while (length < SENTENCE_SCAN_EXTENT) {
const node = backward ? walker.previousNode() : walker.nextNode();
if (
!node ||
node.nodeType !== Node.TEXT_NODE ||
BLOCK_SEPARATOR_PATTERN.test(node.nodeValue || "") ||
// The walker stops at a control going forward but reaches its text
// first going backward.
(backward && isEditingElement(node.parentElement?.closest(EDITING_SELECTOR)))
) {
break;
}
if (backward) sources.unshift(node); else sources.push(node);
length += (node.nodeValue || "").length;
}
}
return sources;
}
function withinSources(sources, node) {
return sources.some((source) => source === node
|| (source.nodeType === Node.ELEMENT_NODE && source.contains(node)));
}
/** `offset` inside `node` expressed in the concatenated text of `sources`. */
function sourceOffset(sources, node, offset) {
let consumed = 0;
for (const source of sources) {
if (source === node) return consumed + offset;
if (source.nodeType === Node.ELEMENT_NODE && source.contains(node)) {
return consumed + rangeOffsetWithin(source, node, offset);
}
consumed += (source.textContent || "").length;
}
throw new RangeError("node is not inside the candidate sources");
}
function pushCollapsedSpace(entries, node, offset, sourceLength, segmentBreak) {
const previous = entries[entries.length - 1];
if (previous && previous.collapsed) {
// A run split across text nodes ("<span>食べ </span><span> ます</span>")
// is still one collapsed space in the rendered line.
previous.segmentBreak = previous.segmentBreak || segmentBreak;
return;
}
entries.push({
collapsed: true,
node,
offset,
segmentBreak,
sourceLength,
text: " ",
});
}
/** Appends `node`'s characters from `from` onward; false stops the walk. */
function appendTextNode(entries, node, from, budget, styleCache) {
const raw = node.nodeValue || "";
const preserve = preservesWhitespace(node.parentElement, styleCache);
let index = from;
while (index < raw.length && entries.length < budget) {
const character = String.fromCodePoint(raw.codePointAt(index));
if (preserve) {
if (SEGMENT_BREAK_PATTERN.test(character)) {
return false;
}
} else if (COLLAPSIBLE_WHITESPACE_PATTERN.test(character)) {
let end = index;
let segmentBreak = false;
while (
end < raw.length &&
COLLAPSIBLE_WHITESPACE_PATTERN.test(raw[end])
) {
segmentBreak = segmentBreak || SEGMENT_BREAK_PATTERN.test(raw[end]);
end += 1;
}
pushCollapsedSpace(entries, node, index, end - index, segmentBreak);
index = end;
continue;
}
entries.push({
collapsed: false,
node,
offset: index,
segmentBreak: false,
sourceLength: character.length,
text: character,
});
index += character.length;
}
return true;
}
function dropCjkSegmentBreaks(entries) {
for (let index = entries.length - 1; index >= 1; index -= 1) {
const entry = entries[index];
const next = entries[index + 1];
if (!entry.collapsed || !entry.segmentBreak || !next) {
continue;
}
// CSS drops a segment break between two wide characters instead of
// turning it into a space, so a source-wrapped 「日本\n語」 renders as
// 日本語 and has to be scanned that way.
if (
JAPANESE_CHARACTER_PATTERN.test(entries[index - 1].text) &&
JAPANESE_CHARACTER_PATTERN.test(next.text)
) {
entries.splice(index, 1);
}
}
}
// How many code points of page text a lookup is given. The engine scans
// options.scanLength of them as before, and reaches further only when the
// text begins like a dictionary key longer than that (hoshidicts long-key
// index; each package row carries the longest such key it lists). Eight more
// leaves room for an inflected ending, matching the engine. Dictionaries
// imported before the index existed report 0 and cost nothing extra. Off by
// default: Settings → Advanced → Experimental features → Long dictionary
// entries switches it on.
const LONG_KEY_INFLECTION_SLACK = 8;
const MAX_SCAN_WINDOW = 256;
function scanWindow() {
if (options.experimental.longKeyScan !== true) return options.scanLength;
let longest = 0;
for (const entry of dictionaries) {
if (entry.enabled !== false && entry.termCount > 0 && entry.longKeyLength > longest) {
longest = entry.longKeyLength;
}
}
if (longest === 0) return options.scanLength;
return Math.min(MAX_SCAN_WINDOW, Math.max(options.scanLength, longest + LONG_KEY_INFLECTION_SLACK));
}
function collectScanEntries(startNode, startOffset, root, scanLength, styleCache) {
const walker = createScanWalker(root, styleCache);
walker.currentNode = startNode;
const entries = [];
// Collapsing and segment-break removal can only shorten the scan, so
// over-collect and trim once the string is final.
const budget = scanLength * 3 + 32;
let node = startNode;
let offset = startOffset;
while (node && node.nodeType === Node.TEXT_NODE && entries.length < budget) {
if (!appendTextNode(entries, node, offset, budget, styleCache)) {
break;
}
offset = 0;
node = walker.nextNode();
// A word never continues into the next block, as Yomitan's kept "\n"
// ends its match there.
if (node?.nodeType === Node.TEXT_NODE && BLOCK_SEPARATOR_PATTERN.test(node.nodeValue || "")) {
break;
}
}
dropCjkSegmentBreaks(entries);
return entries.slice(0, scanLength);
}
function rangeOffsetWithin(container, node, offset) {
const range = document.createRange();
range.selectNodeContents(container);
range.setEnd(node, offset);
return range.toString().length;
}
/**
* Builds a candidate for the caret at (clientX, clientY), or null when there
* is nothing Japanese to look up there.
*/
function resolveCandidate(clientX, clientY) {
const caretRange = caretRangeAt(clientX, clientY);
if (!caretRange) {
return null;
}
return resolveCandidateAt(caretRange.startContainer, caretRange.startOffset);
}
function resolveCandidateAt(startNode, startOffset) {
const styleCache = new Map();
if (!isScannableTextNode(startNode, styleCache)) {
return null;
}
const entries = collectScanEntries(
startNode,
Math.min(startOffset, (startNode.nodeValue || "").length),
document.body,
scanWindow(),
styleCache
);
if (entries.length === 0) {
return null;
}
const query = entries.map((entry) => entry.text).join("");
if (options.onlyScanJapaneseText && !isJapaneseToken(query)) {
return null;
}
const first = entries[0];
const sourceElements = collectSentenceSources(first.node, document.body, styleCache);
let matchOffset;
let anchorRange;
try {
matchOffset = sourceOffset(sourceElements, first.node, first.offset);
anchorRange = document.createRange();
anchorRange.setStart(first.node, first.offset);
anchorRange.setEnd(
first.node,
Math.min(
(first.node.nodeValue || "").length,
first.offset + first.sourceLength
)
);
} catch {
return null;
}
return {
anchor: first.node.parentElement,
anchorRange,
matchOffset,
query,
scanEntries: entries,
sentence: sourceElements.map((source) => source.nodeValue || "").join(""),
sourceDepth: -1,
sourceElements,
vertical: computedStyleFor(first.node.parentElement, styleCache)
.writingMode.startsWith("vertical"),
};
}
function resolveDefinitionCandidate(clientX, clientY, level) {
if (
!shadow ||
!level ||
level.retired ||
levels[level.depth] !== level ||
!level.popup ||
level.popup.hidden
) {
return null;
}
const styleCache = new Map();
const caretRange = caretRangeAt(clientX, clientY, shadow);
if (!caretRange) {
return null;
}
const startNode = caretRange.startContainer;
if (startNode.nodeType !== Node.TEXT_NODE) {
return null;
}
const lookupText = startNode.parentElement?.closest(
".gsm-hoshidicts-glossary-content, .gsm-hoshidicts-compact-definition-summary"
);
if (
!lookupText ||
!level.popup.contains(lookupText) ||
!lookupText.contains(startNode)
) {
return null;
}
for (
let current = startNode.parentElement;
current;
current = current.parentElement
) {
if (
isHiddenElement(current, styleCache) ||
isEditingElement(current) ||
current.localName === "a" ||
OPAQUE_TAGS.has(current.localName) ||
computedStyleFor(current, styleCache).display === "none"
) {
return null;
}
if (current === lookupText) {
break;
}
if (current === level.popup) {
return null;
}
}
let entries = collectScanEntries(
startNode,
Math.min(caretRange.startOffset, (startNode.nodeValue || "").length),
lookupText,
scanWindow(),
styleCache
);
const linkBoundary = entries.findIndex((entry) =>
entry.node.parentElement?.closest("a")
);
if (linkBoundary >= 0) {
entries = entries.slice(0, linkBoundary);
}
if (entries.length === 0) {
return null;
}
const query = entries.map((entry) => entry.text).join("");
if (options.onlyScanJapaneseText && !isJapaneseToken(query)) {
return null;
}
const first = entries[0];
let matchOffset;
let anchorRange;
try {
matchOffset = rangeOffsetWithin(lookupText, first.node, first.offset);
anchorRange = document.createRange();
anchorRange.setStart(first.node, first.offset);
anchorRange.setEnd(
first.node,
Math.min(
(first.node.nodeValue || "").length,
first.offset + first.sourceLength
)
);
} catch {
return null;
}
return {
anchor: lookupText,
anchorRange,
matchOffset,
query,
scanEntries: entries,
sentence: lookupText.textContent || "",
sourceDepth: level.depth,
sourceElements: [lookupText],
vertical: computedStyleFor(lookupText, styleCache)
.writingMode.startsWith("vertical"),
};
}
function selectionBoundaryElement(node) {
return node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
}
function hasVisibleContent(element, styleCache) {
if (computedStyleFor(element, styleCache).display === "none") return false;
const visible = !isHiddenElement(element, styleCache);
if (visible && element.getClientRects().length > 0) return true;
for (const child of element.childNodes) {
if (child.nodeType === Node.ELEMENT_NODE && hasVisibleContent(child, styleCache)) return true;
if (visible && child.nodeType === Node.TEXT_NODE) {
// A display:contents editor has no box, but its editable text still does.
const range = document.createRange();
range.selectNodeContents(child);
if (range.getClientRects().length > 0) return true;
}
}
return false;
}
function resolveSelectedLookupCandidate(selection = window.getSelection()) {
if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null;
const range = selection.getRangeAt(0);
const styleCache = new Map();
if (!isScannableElement(selectionBoundaryElement(range.startContainer), styleCache)
|| !isScannableElement(selectionBoundaryElement(range.endContainer), styleCache)) return null;
const query = selection.toString();
if (!query.trim()) return null;
const anchor = selectionBoundaryElement(range.commonAncestorContainer);
for (const control of anchor.querySelectorAll(EDITING_SELECTOR)) {
if (isEditingElement(control) && range.intersectsNode(control)
&& hasVisibleContent(control, styleCache)) return null;
}
return {
anchor,
anchorRange: range.cloneRange(),
exactSelection: true,
matchOffset: rangeOffsetWithin(anchor, range.startContainer, range.startOffset),
query,
rawSelectionText: range.toString(),
sentence: anchor.textContent || "",
sourceDepth: -1,
sourceElements: [anchor],
vertical: computedStyleFor(anchor, styleCache).writingMode.startsWith("vertical"),
};
}
// Yomitan's Scan text at selection: an ordinary scan from the selection's
// first text. The live selection, not the scanned word, keeps it retained.
function resolveSelectionScanCandidate(selection = window.getSelection()) {
if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null;
const range = selection.getRangeAt(0);
let node = range.startContainer, offset = range.startOffset;
if (node.nodeType !== Node.TEXT_NODE) {
const walker = document.createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_TEXT);
do node = walker.nextNode(); while (node && !range.intersectsNode(node));
offset = 0;
}
const candidate = node ? resolveCandidateAt(node, offset) : null;
return candidate && { ...candidate, selectionRange: range.cloneRange(), selectionText: selection.toString() };
}
function candidateStart(candidate) {
if (candidate.linkAnchor) return { node: candidate.anchor, offset: 0 };
return candidate.exactSelection === true
? { node: candidate.anchorRange.startContainer, offset: candidate.anchorRange.startOffset }
: candidate.scanEntries[0];
}
function candidateSignature(candidate) {
const first = candidateStart(candidate);
return `${candidate.exactSelection === true}\u001f${first.offset}\u001f${candidate.matchOffset}\u001f${candidate.query}`;
}
function sameAnchorNode(candidate, other) {
return Boolean(other) &&
other.anchor === candidate.anchor &&
candidateStart(other).node === candidateStart(candidate).node;
}
/** Returns the last scanned source character covered by the engine match. */
function matchedScanEnd(candidate, matched) {
const wanted = typeof matched === "string" ? matched.length : 0;
if (wanted <= 0 || !Array.isArray(candidate.scanEntries)) return null;
let consumed = 0;
let last = null;
for (const entry of candidate.scanEntries) {
if (consumed >= wanted) {
break;
}
consumed += entry.text.length;
last = entry;
}
return last;
}
function expandCandidateAnchor(candidate, matched) {
if (candidate.linkAnchor || candidate.exactSelection === true || !candidate.anchorRange) return;
const first = candidate.scanEntries?.[0];
const last = matchedScanEnd(candidate, matched);
if (!first || !last) return;
// A page can move scanned text while the lookup is pending.
if (!withinSources(candidate.sourceElements, first.node) || !withinSources(candidate.sourceElements, last.node)) return;
try {
// Scanning starts with a one-glyph range. Once lookup identifies the
// complete match, place the popup against that word like Yomitan/PR 549.
const range = document.createRange();
range.setStart(first.node, first.offset);
range.setEnd(
last.node,
Math.min(
(last.node.nodeValue || "").length,
last.offset + last.sourceLength
)
);
if (!range.collapsed) candidate.anchorRange = range;
} catch {
// Keep the original hovered-glyph range if the page changed meanwhile.
}
}
/**
* Translates a matched length in scan coordinates into the raw substring of
* `candidate.sentence` that covers it. createSourceHighlighter measures the
* highlight as `matchedText.length` from `candidate.matchOffset` inside
* `sentence`, and `sentence` still carries the rt text and uncollapsed
* whitespace the scan dropped -- so the engine's own `matched` string is the
* wrong length whenever the word crosses ruby or a line wrap.
*/
function rawMatchedText(candidate, matched) {
if (candidate.linkAnchor) return candidate.sentence;
if (candidate.exactSelection === true) return candidate.rawSelectionText;
const last = matchedScanEnd(candidate, matched);
if (!last) {
return "";
}
try {
const end = sourceOffset(
candidate.sourceElements,
last.node,
Math.min(
(last.node.nodeValue || "").length,
last.offset + last.sourceLength
)
);
if (end > candidate.matchOffset) {
return candidate.sentence.slice(candidate.matchOffset, end);
}
} catch {
// Fall through to the engine's own string.
}
return matched;
}
function releaseCapture(value) {
if (!value) return;
void Promise.resolve(value).then(pin => window.HDCapture?.release(pin)).catch(() => {});
}
function releaseProvisionalCapture(value, level) {
// Child requests borrow the root pin. A root replay also borrows the pin
// already adopted by the visible popup, even if that replay becomes stale.
if (level !== rootLevel) return;
void Promise.resolve(value).then(pin => {
if (pin !== rootLevel.capturePin) releaseCapture(pin);
}).catch(() => {});
}
function releaseRootCapture() {
const capture = rootLevel.capturePinPromise ?? rootLevel.capturePin;
rootLevel.capturePin = null;
rootLevel.capturePinPromise = null;
releaseCapture(capture);
}
function teardown(reason) {
if (disposed) {
return;
}
releaseRootCapture();
audio?.dispose();
mining?.retire();
disposed = true;
disconnectSubminer?.();
selectionDragActive = false;
dragSelection = null;
cancelPopupLayout();
clearDictionaryResources();
window.clearTimeout(scanTimer);
window.clearTimeout(hideTimer);
clearTransferTimer();
clearDescendantTimer();
scanTimer = null;
hideTimer = null;
document.removeEventListener("mousemove", onMouseMove, true);
document.removeEventListener("mousedown", onMouseDown, true);
document.removeEventListener("mouseup", onMouseUp, true);
document.removeEventListener("selectionchange", onSelectionChange);
document.removeEventListener("focusin", onPageFocusIn, true);
document.removeEventListener("keydown", onKeyDown, true);
document.removeEventListener("keyup", onKeyUp, true);
document.removeEventListener("mouseout", onMouseOut, true);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("blur", onWindowBlur);
window.removeEventListener("pagehide", onPageHide);
window.removeEventListener("pageshow", onPageShow);
window.removeEventListener("resize", refreshPageZoom);
try {
chrome.storage.onChanged.removeListener(onStorageChanged);
chrome.runtime.onMessage?.removeListener(onReaderCommand);
} catch {
// The context is already gone; the listener died with it.
}
try {
highlighter?.clearAll();
for (const level of levels) level.view?.destroy();
} catch {
// Teardown is best effort.
}
appearance?.destroy();
customStyle?.destroy();
host?.remove();
host = null;
shadow = null;
rootLevel.popup = null;
rootLevel.view = null;
highlighter = null;
rootLevel.activeCandidate = null;
rootLevel.activeTermRender = null;
rootLevel.currentViewRequest = null;
rootLevel.noteEditing = false;
syncHostAttention();
if (reason) {
console.debug(`hachidori: content script stopped (${reason})`);
}
}
function discardUi() {
releaseRootCapture();
audio?.retire();
mining?.retire();
cancelPopupLayout();
clearDictionaryResources();
try {
highlighter?.clearAll();
for (const level of levels) level.view?.destroy();
} catch {
// Best effort: the point is only to leave nothing half-built behind.
}
host?.remove();
host = null;
shadow = null;
rootLevel.popup = null;
rootLevel.view = null;
highlighter = null;
rootLevel.activeCandidate = null;
rootLevel.activeSignature = null;
rootLevel.activeTermRender = null;
rootLevel.currentViewRequest = null;
rootLevel.noteEditing = false;
syncHostAttention();
}
function clearDictionaryResources() {
mediaCache.clear();
mediaCacheBytes = 0;
mediaQueue = [];
for (const job of [...pendingMedia.values()]) {
finishMediaJob(job, new Error("obsolete media request"));
}
styleGeneration = -1;
styleRequest = null;
}
function noteGeneration(generation, owner = rootLevel) {
if (!Number.isFinite(generation) || generation === currentGeneration) {
return;
}
currentGeneration = generation;
audio?.retire();
mining?.retire();
clearDictionaryResources();
// Generation is an engine incarnation, not a monotonic storage revision.
// Invalidate other in-flight owners even when a restarted engine returns 1.
for (const level of levels) {
if (level !== owner) {
level.lookupToken += 1;
level.retainedView = Boolean(level.currentViewRequest && !level.popup.hidden);
}
}
}
function sendRequest(type, payload, target = TARGET) {
return new Promise((resolve, reject) => {
if (disposed || !extensionAlive()) {
teardown("context-invalidated");
reject(new Error("extension context invalidated"));
return;
}
const requestId = payload?.requestId ?? `${type.replace(/^hd_/u, "")}-${nextRequestId += 1}`;
const request = { ...payload, requestId, target, type };
try {
chrome.runtime.sendMessage(request, (reply) => {
const lastError = chrome.runtime.lastError;
if (lastError) {
const message = lastError.message || "sendMessage failed";
if (INVALIDATED_MESSAGE_PATTERN.test(message)) {
teardown("context-invalidated");
}
reject(new Error(message));
return;
}
if (
!reply ||
reply.type !== `${type}_result` ||
reply.requestId !== requestId
) {
reject(new Error(`unexpected reply for ${type}`));
return;
}
if (reply.ok !== true) {
const error = new Error(reply.error || `${type} failed`);
error.responseReceived = true;
if (typeof reply.errorCode === "string") error.code = reply.errorCode;
reject(error);
return;
}
resolve(reply);
});
} catch (error) {
teardown("context-invalidated");
reject(error);
}
});
}
function cacheMedia(key, url) {
// The engine produces base64 data URLs. Count decoded bytes without
// decoding or copying the payload merely to maintain the cache budget.
const padding = url.endsWith("==") ? 2 : url.endsWith("=") ? 1 : 0;
const byteLength = (url.length - url.indexOf(",") - 1) / 4 * 3 - padding;
mediaCache.set(key, { url, byteLength });
mediaCacheBytes += byteLength;
while (mediaCache.size > MAX_MEDIA_CACHE_ENTRIES || mediaCacheBytes > MAX_MEDIA_CACHE_BYTES) {
const oldestKey = mediaCache.keys().next().value;
mediaCacheBytes -= mediaCache.get(oldestKey).byteLength;
// These are data URLs, not revocable Blob URLs. Drop our reference;
// an image already rendered from it retains its independent DOM owner.
mediaCache.delete(oldestKey);
}
}
function finishMediaJob(job, error, url) {
if (job.settled) return;
job.settled = true;
if (job.timer !== null) window.clearTimeout(job.timer);
if (pendingMedia.get(job.key) === job) pendingMedia.delete(job.key);
if (job.active) {
job.active = false;
activeMediaRequests -= 1;
}
if (error) job.reject(error);
else job.resolve(url);
}
function pruneMediaQueue() {
mediaQueue = mediaQueue.filter((job) => {
if (job.consumers.some((isCurrent) => isCurrent())) return true;
finishMediaJob(job, new Error("obsolete media request"));
return false;
});
}
async function dispatchMedia(job) {
try {
const reply = await sendRequest("hd_media", job.payload);
if (job.settled) return;
if (pendingMedia.get(job.key) !== job || job.payload.generation !== currentGeneration
|| reply.generation !== job.payload.generation) {
throw new Error("obsolete media reply");
}
if (typeof reply.dataUrl !== "string") throw new Error("dictionary image is unavailable");
// Started resource fetches may finish while hidden; image callbacks
// separately check their current view before touching DOM.
cacheMedia(job.key, reply.dataUrl);
finishMediaJob(job, null, reply.dataUrl);
} catch (error) {
finishMediaJob(job, error);
} finally {
pumpMediaQueue();
}
}
function pumpMediaQueue() {
while (mediaQueue.length > 0 && activeMediaRequests < MAX_MEDIA_CONCURRENT_REQUESTS) {
const job = mediaQueue.shift();
if (!job.consumers.some((isCurrent) => isCurrent())) {
finishMediaJob(job, new Error("obsolete media request"));
continue;
}
job.active = true;
activeMediaRequests += 1;
job.timer = window.setTimeout(() => {
finishMediaJob(job, new Error("dictionary image request timed out"));
pumpMediaQueue();
}, MEDIA_REQUEST_TIMEOUT_MS);
void dispatchMedia(job);
}
}
function resolveMedia({ dictionary, generation, path, isCurrent }) {
if (!isCurrent() || generation !== currentGeneration) {
return Promise.reject(new Error("obsolete media request"));
}
const key = `${generation}\u0000${dictionary}\u0000${path}`;
const cached = mediaCache.get(key);
if (cached) {
mediaCache.delete(key);
mediaCache.set(key, cached);
return Promise.resolve(cached.url);
}
const pending = pendingMedia.get(key);
if (pending) {
pending.consumers.push(isCurrent);
return pending.promise;
}
if (pendingMedia.size >= MAX_MEDIA_PENDING_REQUESTS) pruneMediaQueue();
if (pendingMedia.size >= MAX_MEDIA_PENDING_REQUESTS) {
return Promise.reject(new Error("dictionary image queue is full"));
}
const job = { key, consumers: [isCurrent], payload: { dictionary, generation, path },
active: false, settled: false, timer: null };
job.promise = new Promise((resolveJob, rejectJob) => {
job.resolve = resolveJob;
job.reject = rejectJob;
});
pendingMedia.set(key, job);
mediaQueue.push(job);
pumpMediaQueue();
return job.promise;
}
function imageSourceContext() {
const next = window.HDReaderOptions.resolvePopupImageSources(options.popupImageSource, dictionaries, dictionaryGroups);
// Keep the effective route's identity through alias/name-only changes.
// In-flight consumers capture it, independently of broad storage revisions.
if (next !== popupImageSources && !sameDictionaries(next, popupImageSources)) popupImageSources = next;
return { popupImageSources, resolveMedia: resolvePopupMedia };
}
function resolvePopupMedia(request) {
const sources = popupImageSources;
const isCurrent = () => sources === popupImageSources && request.generation === currentGeneration && request.isCurrent();
const ownedRequest = { ...request, isCurrent };
if (sources !== null) return resolveRoutedMedia(ownedRequest, sources);
// Automatic retains the direct cache/queue path without candidate scans.
return resolveMedia(ownedRequest).then(url => {
if (!isCurrent()) throw new Error("obsolete media reply");
return url;
});
}
async function resolveRoutedMedia(request, sources) {
const { isCurrent } = request;
for (const dictionary of sources) {
if (!isCurrent()) throw new Error("obsolete media request");
let url;
try {
url = await resolveMedia({ ...request, dictionary, isCurrent });
} catch (error) {
if (!isCurrent()) throw error;
// Availability is per requested path, not one global group winner.
continue;
}
if (!isCurrent()) throw new Error("obsolete media reply");
request.onResolvedSource?.(dictionary);
return url;
}
throw new Error("dictionary image is unavailable");
}
function ensureDictionaryStyles(generation) {
if (!shadow || generation === styleGeneration) {
return;
}
styleGeneration = generation;
const request = {};
styleRequest = request;
sendRequest("hd_styles", {}).then((reply) => {
if (disposed || !shadow || styleRequest !== request) {
return;
}
if (reply.generation !== generation) throw new Error("obsolete dictionary styles");
window.HDGlossary.applyDictionaryStyles(
document,
shadow,
generation,
Array.isArray(reply.styles) ? reply.styles : []
);
}).catch(() => {
// Dictionary CSS is cosmetic; a failure must not block the lookup that
// asked for it. Retry on the next render without resetting a newer job.
if (styleRequest === request) {
styleGeneration = -1;
styleRequest = null;
}
});
}
// Browser zoom scales CSS pixels. The popup cancels it with CSS zoom to keep
// one on-screen size, so its lengths are unzoomed pixels and page geometry is
// converted into them before placement.
function popupRect(rect) {
return window.HDPopup.scaleRect(rect, window.HDPopup.popupCoordinateScale(pageZoom, options.popupScalePercent));
}
function popupViewport() {
const factor = window.HDPopup.popupCoordinateScale(pageZoom, options.popupScalePercent);
return { width: window.innerWidth * factor, height: window.innerHeight * factor };
}
function applyPageZoom() {
host?.style.setProperty("--gsm-hoshidicts-page-zoom", String(1 / pageZoom));
}
function refreshPageZoom() {
// Resizing a window keeps its device pixel ratio; a zoom change does not.
if (disposed || window.devicePixelRatio === pageZoomRatio) return;
pageZoomRatio = window.devicePixelRatio;
const request = ++pageZoomRequest;
sendRequest("hd_page_zoom", {}, PAGE_ZOOM_TARGET).then((reply) => {
if (disposed || request !== pageZoomRequest || !(reply.zoomFactor > 0) || reply.zoomFactor === pageZoom) return;
pageZoom = reply.zoomFactor;
applyPageZoom();
for (const level of levels) level.view?.hideImagePreview();
positionPopup();
}, (error) => console.debug("hachidori: page zoom unavailable", error));
}
function calculatePopupPosition(anchorRect, viewport, vertical) {
return window.HDPopup.calculatePopupPosition(anchorRect, sessionPopupSize ?? {
width: options.popupWidthPx, height: options.popupHeightPx,
}, viewport, { gap: POPUP_GAP_PX, padding: POPUP_PADDING_PX, vertical });
}
function anchorRectFor(candidate) {
if (candidate.anchorRange) {
try {
const first = candidate.scanEntries?.[0];
if (first && !candidate.linkAnchor && candidate.exactSelection !== true) {
const origin = document.createRange();
origin.setStart(first.node, first.offset);
origin.setEnd(first.node, first.offset + first.sourceLength);
const glyph = origin.getBoundingClientRect();
const x = (glyph.left + glyph.right) / 2;
const y = (glyph.top + glyph.bottom) / 2;
const fragment = [...candidate.anchorRange.getClientRects()].find(rect =>
rect.left <= x && rect.right >= x && rect.top <= y && rect.bottom >= y);
if (fragment) return fragment;
}
const rect = candidate.anchorRange.getBoundingClientRect();
if (rect && Number.isFinite(rect.left) && (rect.width > 0 || rect.height > 0)) {
return rect;
}
} catch {
// The range's nodes moved; fall back to the container box.
}
}
return candidate.anchor.getBoundingClientRect();
}
function anchorConnected(candidate) {
return Boolean(candidate) &&
candidate.anchor.isConnected &&
candidateStart(candidate).node.isConnected &&
(candidate.exactSelection !== true || (
!candidate.anchorRange.collapsed
&& candidate.anchor.contains(candidate.anchorRange.startContainer)
&& candidate.anchor.contains(candidate.anchorRange.endContainer)
));
}
function requestCanRender(token, candidate, level = rootLevel) {
if (disposed || level.retired || token !== level.lookupToken || !level.popup) return false;
if (retireDetachedAncestor(level)) return false;
// Initial selections still own the live page selection; Note/Back replays
// intentionally use their stored descriptor even after focus collapses it.
if (!anchorConnected(candidate) || (level === rootLevel && pendingCandidateLookup?.token === token
&& candidate.exactSelection === true && !selectionIsUnchanged(candidate))) {
hide(level);
return false;
}
return true;
}
function retireDetachedAncestor(level) {
for (let depth = 0; depth < level.depth; depth += 1) {
const ancestor = levels[depth];
if (!anchorConnected(ancestor.activeCandidate)) {
hide(ancestor);
return true;
}
}
return false;
}
function lookupFailureState(error, request = null) {
const message = error instanceof Error ? error.message : String(error);
if (error?.code === "dictionary-structured-content-limit") {
return {
kind: "render",
title: typeof error.userTitle === "string"
? error.userTitle
: "Dictionary content could not be rendered.",
detail: typeof error.userDetail === "string" ? error.userDetail : message,
};
}
if (error?.code === "engine-mutating" || message === "the dictionary engine is busy mutating") {
return {
kind: "updating",
title: "Dictionary update in progress.",
detail: "Try the lookup again when the update finishes.",
};
}
if (error?.code === "sharing-disconnected" || message === "The linked Hachidori is not reachable.") {
return {
kind: "disconnected",
title: "Shared Hachidori is disconnected.",
detail: "Reconnect it in Settings → Sharing, then try again.",
};
}
if (error?.code === "engine-starting" || message === "the dictionary engine is still starting") {
return {
kind: "starting",
title: "Dictionary engine is starting.",
detail: "Wait a moment, then try again.",
};
}
if (error?.code === "engine-start-failed") {
return {
kind: "engine",
title: "Dictionary engine could not start.",
detail: "Open Settings to check the engine status, then try again.",
};
}
if (request?.kind === "kanji") {
return {
kind: "kanji",
title: "Kanji lookup failed.",
detail: "The current definition is still available. Try again.",
};
}
return null;
}
function retainFailedView(request, token, level, replayOptions) {
if (!replayOptions?.preserveViewControls || disposed || level.retired
|| token !== level.lookupToken || level.currentViewRequest !== request
|| level.popup.hidden || !requestCanRender(token, request.candidate, level)) return false;
level.retainedView = true;
return true;
}
function handleRequestFailure(request, token, error, level, replayOptions) {
const preserveView = retainProtectedReplay(request, token, level, replayOptions)
|| retainFailedView(request, token, level, replayOptions);
return handleLookupFailure(token, error, level, request, preserveView);
}
function handleLookupFailure(token, error, level = rootLevel, request = null, preserveView = false) {
if (disposed || level.retired || token !== level.lookupToken) return false;
if (error?.code !== "dictionary-structured-content-limit") {
console.debug("hachidori: lookup failed", error);
}
const state = lookupFailureState(error, request);
if (state === null) {
if (!preserveView) hide(level);
return false;
}
if (!preserveView) {
show(request?.candidate ?? level.activeCandidate, level);
level.currentViewRequest = request;
level.activeHighlightText = "";
level.activeTermRender = null;
clearDefinitionBlurTimer(level);
pruneLevels(level.depth + 1);
}
level.view.renderLookupFailure({
...state,
actionLabel: "Try again",
onAction: () => executeViewRequest(
request,
level,
preserveView ? { preserveViewControls: true } : null,
),
}, { preserveView });
positionPopup(level);
return false;
}
function handleRenderFailure(token, error, request, level = rootLevel) {
if (disposed || level.retired || token !== level.lookupToken) return false;
if (error?.cause instanceof Error) {
console.warn("hachidori: could not render results", error, "caused by", error.cause);
} else {
console.warn("hachidori: could not render results", error);
}
return handleLookupFailure(token, error, level, request);
}
function retainProtectedReplay(request, token, level, replayOptions) {
if (!replayOptions?.preserveViewControls || disposed || level.retired
|| token !== level.lookupToken || level.currentViewRequest !== request
|| level.popup.hidden
|| (!level.noteEditing && level.pendingCustomAppends === 0)) return false;
if (!requestCanRender(token, request.candidate, level)) return false;
level.retainedView = true;
return true;
}
function positionToolbar(level, placement, reset = false) {
const desired = window.HDPopup.resolveToolbarPosition(options.popupToolbarPosition, placement,
reset ? "top" : level.popup.dataset.toolbarPosition);
if (level.popup.dataset.toolbarPosition !== desired) level.view.setToolbarPosition(desired);
}
function positionPopup(fromLevel = rootLevel, resetToolbar = false) {
if (fromLevel.retired || fromLevel.popup?.inert || !rootLevel.popup || rootLevel.popup.hidden || !rootLevel.activeCandidate) {
return;
}
if (retireDetachedAncestor(fromLevel)) return;
if (!anchorConnected(rootLevel.activeCandidate)) {
hide();
return;
}
highlighter?.refresh();
if (fromLevel === rootLevel) {
const position = popupResize?.level === rootLevel ? popupResizePosition() : calculatePopupPosition(
popupRect(anchorRectFor(rootLevel.activeCandidate)),
popupViewport(),
rootLevel.activeCandidate.vertical
);
positionToolbar(rootLevel, position.placement, resetToolbar);
rootLevel.popup.style.left = `${position.left}px`;
rootLevel.popup.style.top = `${position.top}px`;
rootLevel.popup.style.width = `${position.width}px`;
rootLevel.popup.style.height = `${position.height}px`;
}
if (levels.length === 1) return;
const viewport = popupViewport();
if (viewport.width <= POPUP_PADDING_PX * 2 || viewport.height <= POPUP_PADDING_PX * 2) {
pruneLevels(1);
// Finish this placement before a newly unprotected view can reproject.
window.queueMicrotask(flushDictionaryPresentation);
return;
}
const startDepth = Math.max(1, fromLevel.depth);
let parentRect = popupRect(levels[startDepth - 1].popup.getBoundingClientRect());
for (const level of levels.slice(startDepth)) {
if (level.popup.hidden) break;
if (!anchorConnected(level.activeCandidate)) {
hide(level);
break;
}
positionToolbar(level, "beside", resetToolbar);
const anchorRect = popupRect(anchorRectFor(level.activeCandidate));
const width = Math.min(sessionPopupSize?.width ?? options.popupWidthPx, viewport.width - POPUP_PADDING_PX * 2);
const height = Math.min(sessionPopupSize?.height ?? options.popupHeightPx, viewport.height - POPUP_PADDING_PX * 2);
const rightRoom = viewport.width - parentRect.right - POPUP_GAP_PX - POPUP_PADDING_PX;
const leftRoom = parentRect.left - POPUP_GAP_PX - POPUP_PADDING_PX;
const preferredLeft = rightRoom >= width || rightRoom >= leftRoom
? parentRect.right + POPUP_GAP_PX
: parentRect.left - width - POPUP_GAP_PX;
const left = Math.max(POPUP_PADDING_PX, Math.min(preferredLeft, viewport.width - width - POPUP_PADDING_PX));
const top = Math.max(POPUP_PADDING_PX, Math.min(anchorRect.top, viewport.height - height - POPUP_PADDING_PX));
level.popup.style.left = `${left}px`;
level.popup.style.top = `${top}px`;
level.popup.style.width = `${width}px`;
level.popup.style.height = `${height}px`;
if (popupResize?.level === level) {
const position = popupResizePosition();
level.popup.style.left = `${position.left}px`;
level.popup.style.top = `${position.top}px`;
level.popup.style.width = `${position.width}px`;
level.popup.style.height = `${position.height}px`;
}
// Each parent box is read once, after its own placement, not once per
// ancestor for every descendant. Narrow viewports may overlap panes.
parentRect = popupRect(level.popup.getBoundingClientRect());
}
}
function cancelPopupLayout() {
if (popupLayoutFrame !== null) window.cancelAnimationFrame(popupLayoutFrame);
popupLayoutFrame = null;
popupLayouts.clear();
}
function cancelMasonry(level, layout) {
if (popupLayouts.get(level) !== layout) return;
popupLayouts.delete(level);
if (popupLayouts.size === 0) cancelPopupLayout();
}
function popupResizePosition() {
const viewport = popupViewport();
const left = Math.min(popupResize.left, viewport.width - POPUP_PADDING_PX);
const top = Math.min(popupResize.top, viewport.height - POPUP_PADDING_PX);
return { left, top, placement: "beside",
width: Math.min(sessionPopupSize.width, viewport.width - left - POPUP_PADDING_PX),
height: Math.min(sessionPopupSize.height, viewport.height - top - POPUP_PADDING_PX) };
}
function startPopupResize(event, level) {
if (event.button !== 0 || level.retired) return;
event.preventDefault();
const handle = event.currentTarget;
const rect = popupRect(level.popup.getBoundingClientRect());
const minimum = popupRect(handle.getBoundingClientRect());
cancelCandidateScan();
clearHideTimer();
clearTransferTimer();
clearDescendantTimer();
sessionPopupSize = { width: rect.width, height: rect.height };
popupResize = { level, handle, pointerId: event.pointerId, ...rect,
x: event.clientX, y: event.clientY, minimum };
handle.setPointerCapture(event.pointerId);
}
function movePopupResize(event) {
if (!popupResize || event.pointerId !== popupResize.pointerId) return;
if ((event.buttons & 1) === 0) { stopPopupResize(); return; }
const drag = popupResize;
const factor = window.HDPopup.popupCoordinateScale(pageZoom, options.popupScalePercent);
sessionPopupSize = {
width: Math.max(drag.minimum.width, drag.width + (event.clientX - drag.x) * factor),
height: Math.max(drag.minimum.height, drag.height + (event.clientY - drag.y) * factor),
};
const position = popupResizePosition();
sessionPopupSize = { width: position.width, height: position.height };
positionPopup();
}
function stopPopupResize() {
if (!popupResize) return;
const { handle, pointerId } = popupResize;
popupResize = null;
if (handle.hasPointerCapture(pointerId)) handle.releasePointerCapture(pointerId);
}
function queueMasonry(level, layout) {
if (disposed || level.retired || level.popup.hidden || level.popup.inert) return;
popupLayouts.set(level, layout);
if (popupLayoutFrame !== null) return;
// Lay out every dirty pane before placing the chain once in this frame.
// A width change can queue another observer batch without losing its work.
popupLayoutFrame = window.requestAnimationFrame(() => {
const layouts = popupLayouts;
popupLayouts = new Map();
popupLayoutFrame = null;
let owner = null;
for (const [level, layout] of layouts) {
if (level.retired || level.popup.hidden || level.popup.inert) continue;
layout();
if (!owner || level.depth < owner.depth) owner = level;
}
if (owner) positionPopup(owner);
});
}
// A screenshot of the page must not contain anything Hachidori drew: the host
// carries the popup, its image preview and the fallback highlight paint, and the
// registered highlight is suspended beside it. Two frames give the change time
// to paint before the capture. Concealment is counted, so one capture cannot
// reveal the reader while another still owns it, and everything is restored
// whatever the captures did.
let concealing = 0;
let restoreMatchHighlight = null;
let hostOpacity = "";
let hostOpacityPriority = "";
async function concealReader(during) {
if (host === null) return during();
// The source-term highlight is painted by the document, not by the shadow
// tree, so the highlighter stops publishing for as long as this lasts —
// including for a lookup that settles while the picture is being taken.
if (concealing === 0) {
restoreMatchHighlight = highlighter?.suspend() ?? null;
hostOpacity = host.style.getPropertyValue("opacity");
hostOpacityPriority = host.style.getPropertyPriority("opacity");
// Descendants can override inherited visibility, including masonry cards.
// Opacity composites the whole host without changing its layout.
host.style.setProperty("opacity", "0", "important");
}
concealing += 1;
try {
await new Promise(resolve => window.requestAnimationFrame(() => window.requestAnimationFrame(resolve)));
return await during();
} finally {
concealing -= 1;
if (concealing === 0) {
host.style.setProperty("opacity", hostOpacity, hostOpacityPriority);
restoreMatchHighlight?.();
restoreMatchHighlight = null;
}
}
}
async function readerStyleSheet() {
const response = await fetch(chrome.runtime.getURL(READER_STYLESHEET));
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const iconResponse = await fetch(chrome.runtime.getURL("icons.css"));
if (!iconResponse.ok) throw new Error(`HTTP ${iconResponse.status}`);
const text = `${await response.text()}\n${await iconResponse.text()}`;
try {
const sheet = new CSSStyleSheet();
sheet.replaceSync(text);
return { sheet, text };
} catch {
// A constructed sheet is preferred (one parse shared by every frame), but
// a plain <style> in the shadow root renders the same rules.
return { sheet: null, text };
}
}
function buildUi(styles) {
host = document.createElement(HOST_TAG);
globalThis.SubMinerHachidori?.markHost(host, hostAttentionPublished);
// Inline !important is the only declaration a page cannot override, and the
// host must stay a zero-sized, non-interactive fixed anchor whatever the
// page's CSS says. `all: initial` also stops inherited page typography from
// reaching the shadow tree.
host.style.cssText = [
"all: initial !important",
"position: fixed !important",
"top: 0 !important",
"left: 0 !important",
"width: 0 !important",
"height: 0 !important",
"pointer-events: none !important",
"z-index: 2147483647 !important",
].join("; ");
applyPageZoom();
shadow = host.attachShadow({ mode: "open" });
if (styles.sheet) {
shadow.adoptedStyleSheets = [styles.sheet];
} else {
const fallback = document.createElement("style");
fallback.textContent = styles.text;
shadow.appendChild(fallback);
}
document.body.appendChild(host);
appearance = window.HDPopup.createPopupAppearance(host);
appearance.update(options);
customStyle = window.HDPopup.createCustomPopupStyle(shadow);
customStyle.update(options.customPopupCss);
highlighter = window.HDPopup.createSourceHighlighter(
window,
document,
HIGHLIGHT_NAME,
shadow
);
buildLevelUi(rootLevel);
}
function buildLevelUi(level) {
mining ??= window.HDAnki.createAnkiController({
send: (type, fields) => sendRequest(type, fields, "hachidori-anki"),
capture: (type, fields) => sendRequest(type, fields, "hachidori-capture"),
onChange: owner => positionPopup(owner),
conceal: concealReader,
});
mining.update(options, optionsStorageRevision >= 0);
audio ??= window.HDAudio.createAudioController({ window,
send: (type, fields) => sendRequest(type, fields, "hachidori-audio"),
onMenuChange(owner) { cancelCandidateScan(); clearHideTimer(); positionPopup(owner); },
onSelectionChange: owner => mining.refresh(owner),
});
audio.update(options, optionsStorageRevision >= 0);
const popup = document.createElement("div");
popup.className = "gsm-hoshidicts-popup";
popup.dataset.hoshidictsDepth = String(level.depth);
popup.hidden = true;
globalThis.SubMinerHachidori?.popup(popup);
popup.addEventListener("focusin", () => {
cancelCandidateScan();
clearHideTimer();
});
popup.addEventListener("focusout", onPopupFocusOut);
// Scroll events do not bubble from the definition pane or its inner cards.
// Keep linked popups aligned with anchors moving inside those scrollers.
popup.addEventListener("scroll", () => {
if (level.retired) return;
const child = levels[level.depth + 1];
if (child) positionPopup(child);
}, { capture: true, passive: true });
popup.addEventListener("wheel", onPopupWheel, { passive: false });
popup.addEventListener("mouseenter", () => onPopupEnter(level));
popup.addEventListener(
"mousemove",
(event) => onPopupMouseMove(event, level),
{ capture: true, passive: true }
);
popup.addEventListener("mouseover", (event) => {
const request = level.currentViewRequest;
if (request?.blur && request.blur.state !== "revealed" && event.target instanceof Element
&& event.target.closest(".gsm-hoshidicts-definitions, .gsm-hoshidicts-compact-definition-summary")) {
revealDefinitions(request, level);
}
});
shadow.appendChild(popup);
level.popup = popup;
level.highlighter = highlighter.scope(level);
level.view = window.HDPopup.createPopupView({
appendExpressionRuby: window.HDGlossary.appendExpressionRuby,
buildPitchAccentMorae: window.HDGlossary.buildPitchAccentMorae,
appendTextOnlyGlossary: window.HDGlossary.appendTextOnlyGlossary,
appendStructuredImage: window.HDGlossary.appendStructuredImage,
document,
getPageZoom: () => pageZoom,
getPopupScalePercent: () => options.popupScalePercent,
getPopupColumns: () => options.popupColumns,
onResizeStart: event => startPopupResize(event, level),
onResizeMove: movePopupResize,
onResizeEnd: stopPopupResize,
customButtons: options.customButtons,
highlightName: HIGHLIGHT_NAME,
idPrefix: level === rootLevel ? "hoshidicts" : `hoshidicts-${nextLevelId += 1}`,
onAddCustomEntry: (entry) => appendCustomEntry(entry, level),
onCustomLinkClick(link) {
if (!level.currentViewRequest || level.retainedView
|| !requestCanRender(level.lookupToken, level.activeCandidate, level)) return;
openExternalLink(link);
},
onKanjiClick: (character, result, candidate, link) => showKanji(character, result, candidate, link, level),
onNoteEditingChange: (editing) => onNoteEditingChange(editing, level),
onResultsRendered: rendered => bindResultActions(rendered, level),
onResultsExpanded: rendered => bindResultActions(rendered, level),
onBeforeResultsRendered: (intent) => {
audio.retire(level);
mining.retire(level);
pruneLevels(level.depth + 1);
if (level.retainedView) {
replayVisibleView(level, intent);
return false;
}
},
canProjectDictionaryPresentation: () => !level.retired && !level.noteEditing
&& level.pendingCustomAppends === 0 && levels.length === level.depth + 1
&& requestCanRender(level.lookupToken, level.activeCandidate, level),
canUpdateCompactSummary: () => requestCanRender(level.lookupToken, level.activeCandidate, level),
parseTagList: window.HDGlossary.parseTagList,
popup,
positionPopup: () => positionPopup(level),
queueMasonry: (layout) => queueMasonry(level, layout),
cancelMasonry: (layout) => cancelMasonry(level, layout),
sourceHighlighter: level.highlighter,
sourceHighlightEnabled: options.sourceHighlightEnabled,
toolbarPosition: window.HDPopup.resolveToolbarPosition(options.popupToolbarPosition),
window,
});
}
function bindResultActions(rendered, level) {
const token = level.lookupToken, request = level.currentViewRequest;
// Keybinds index these like the view's entries; Show more grows the same
// arrays and rebinds only the newly revealed controls. The count element
// belongs to the initial render and stays until the next one.
level.entryAudio = rendered.audioButtons;
level.entryMining = rendered.miningActions;
if ("lookupStats" in rendered) {
level.lookupStatsElement = rendered.lookupStats;
paintLookupStatistics(request, level);
}
const context = { owner: level, popup: level.popup, request,
isCurrent: () => level.currentViewRequest === request && !level.retainedView
&& requestCanRender(token, level.activeCandidate, level),
};
// The primary result's autoplay waits until its definitions are revealed,
// whichever tab or expansion binds.
audio.bind(rendered.audioButtons, {
...context,
...("lookupStats" in rendered ? { autoplayHeld: () => request?.blur?.autoplayHeld === true } : {}),
});
mining.bind(rendered.miningActions, { ...context, getRequest: result => {
const candidate = level.activeCandidate;
const selection = shadow.getSelection?.() ?? window.getSelection();
const frequencyModes = new Map(dictionaries.map(item => [item.title, item.frequencyMode]));
const term = { ...result.term, frequencies: result.term.frequencies.map(group =>
({ ...group, frequencyMode: frequencyModes.get(group.dictionary) })) };
return { ...result, term, generation: level.activeTermRender.generation, sentence: candidate.sentence,
matchOffset: candidate.matchOffset, matched: rawMatchedText(candidate, result.matched || result.term.expression),
searchQuery: request?.payload?.text ?? request?.termPayload?.text ?? candidate.query,
popupSelectionText: selection?.anchorNode && level.popup.contains(selection.anchorNode) ? selection.toString() : "",
documentTitle: document.title, audioSelection: audio.selectionFor(result) ?? undefined,
capturePin: rootLevel.capturePin ?? undefined,
dictionaryAliases: Object.fromEntries(dictionaries.filter(item => item.displayName).map(item => [item.title, item.displayName])),
dictionaryIds: Object.fromEntries(dictionaries.map(item => [item.title, item.id])),
frequencyDictionaries: dictionaries.filter(item => item.enabled && item.frequencyCount > 0).map(item => item.title),
};
} });
}
function paintLookupStatistics(request, level) {
if (!options.showLookupCounts) {
if (level.lookupStatsElement) level.lookupStatsElement.hidden = true;
return;
}
if (request !== level.currentViewRequest
|| !requestCanRender(level.lookupToken, level.activeCandidate, level) || !level.lookupStatsElement?.isConnected) return;
const entry = request?.lookupStats;
const statistics = entry?.payload?.descriptor.generation === lookupStatsDescriptor.generation
? entry.payload.statistics : null;
level.view.setLookupStats(level.lookupStatsElement, statistics);
}
function adoptLookupStatsDescriptor(descriptor, changes = {}) {
if (!Number.isSafeInteger(descriptor?.revision) || descriptor.revision < 0
|| (descriptor.generation !== null
&& (typeof descriptor.generation !== "string" || descriptor.generation === ""))) return;
const sameGeneration = descriptor.generation === lookupStatsDescriptor.generation;
if (descriptor.revision < lookupStatsDescriptor.revision && !sameGeneration) return;
const replaced = descriptor.generation !== lookupStatsDescriptor.generation;
if (descriptor.revision >= lookupStatsDescriptor.revision) lookupStatsDescriptor = descriptor;
for (const level of levels) {
const request = level.currentViewRequest;
const entry = request?.lookupStats;
if (!entry) continue;
const row = changes[lookupStatsKey(descriptor, entry)]?.newValue;
if (row && (!entry.payload || entry.payload.descriptor.generation !== descriptor.generation
|| entry.payload.descriptor.revision < descriptor.revision)) {
entry.payload = { descriptor, statistics: row };
entry.needsRefresh = false;
} else if (replaced) entry.needsRefresh = true;
else continue;
paintLookupStatistics(request, level);
if (row) settleDefinitionBlur(request, level, currentLookupCount(entry));
refreshLookupStatistics(request, level);
}
}
// The count that decides definition blur: null while unknown or unavailable.
function currentLookupCount(entry) {
const payload = entry.payload;
if (!payload || payload.descriptor.generation !== lookupStatsDescriptor.generation) return null;
return payload.statistics?.lookupCount ?? null;
}
function refreshLookupStatistics(request, level, record = false) {
const entry = request.lookupStats;
if (!options.showLookupCounts || entry.pending || (!record && !entry.needsRefresh)
|| request !== level.currentViewRequest
|| !requestCanRender(level.lookupToken, level.activeCandidate, level)) return;
entry.pending = true;
entry.needsRefresh = false;
const requestedOptionsRevision = optionsStorageRevision;
void sendRequest(record ? "hd_lookup_stats_record" : "hd_lookup_stats_read", {
term: entry.term, reading: entry.reading,
}, "hoshidicts-worker").then(payload => {
adoptLookupStatsDescriptor(payload.descriptor);
if (payload.descriptor.generation !== lookupStatsDescriptor.generation) {
entry.needsRefresh = true;
return;
}
if (!(entry.payload?.descriptor.revision > payload.descriptor.revision)) {
entry.payload = payload;
entry.needsRefresh = options.showLookupCounts
&& payload.statistics === null && requestedOptionsRevision !== optionsStorageRevision;
}
if (request.lookupStats === entry) {
paintLookupStatistics(request, level);
settleDefinitionBlur(request, level, currentLookupCount(entry));
}
}).catch(error => {
// A lost reply may follow a committed increment. Never retry the write.
console.debug("hachidori: lookup statistics unavailable", error);
if (request.lookupStats === entry) settleDefinitionBlur(request, level, null);
}).finally(() => {
entry.pending = false;
if (request.lookupStats === entry && entry.needsRefresh) refreshLookupStatistics(request, level);
});
}
function acceptLookupStatistics(results, request, level) {
const { term, reading } = normaliseLookupTerm(results[0].term.expression, results[0].term.reading);
const firstVisit = !request.lookupStats;
let entry = request.lookupStats;
if (!entry || entry.term !== term || entry.reading !== reading) {
entry = request.lookupStats = { term, reading, pending: false, payload: null, needsRefresh: true };
} else if (entry.payload && entry.payload.descriptor.generation !== lookupStatsDescriptor.generation) {
entry.needsRefresh = true;
}
// The descriptor owns one visit, including a visit while counts are off.
// Back, tabs and Note refresh can read a replacement, but cannot increment.
paintLookupStatistics(request, level);
refreshLookupStatistics(request, level, firstVisit);
checkDefinitionBlurMaturity(request, level);
}
// The primary word owns one local maturity-cache check per visit. It starts after
// rendering and never joins the lookup or storage queue. Back keeps its
// result; a changed Anki configuration discards the old evidence.
function checkDefinitionBlurMaturity(request, level) {
const blur = request.blur;
if (blur.awaitingOptions || !options.definitionBlurAnkiMature || blur.ankiCheck !== undefined) return;
const check = blur.ankiCheck = { epoch: ankiMaturityEpoch };
const { expression, reading } = level.activeTermRender.results[0].term;
void sendRequest("hd_anki_maturity", { request: { term: { expression, reading } } }, "hachidori-anki")
.then(payload => payload.mature === true, () => false).then(mature => {
if (blur.ankiCheck !== check) return;
blur.ankiMature = check.epoch === ankiMaturityEpoch && mature;
settleDefinitionBlur(request, level);
});
}
function definitionBlurActive(candidate = options) {
return (candidate.definitionBlurEnabled && candidate.showLookupCounts)
|| candidate.definitionBlurAnkiMature || candidate.definitionBlurFrequencyEnabled;
}
function snapshotDefinitionBlurFrequency(results) {
const groups = Array.isArray(results?.[0]?.term?.frequencies) ? results[0].term.frequencies : [];
return {
groups: groups.flatMap(group => typeof group?.dictionary === "string" && Array.isArray(group.frequencies)
? [{ dictionary: group.dictionary,
frequencies: group.frequencies.map(frequency => ({ value: frequency?.value })) }]
: []),
dictionaries: dictionaries
.filter(dictionary => dictionary.enabled !== false && dictionary.frequencyCount > 0)
.map(({ title, frequencyMode, frequencyCount }) => ({ title, frequencyMode, frequencyCount })),
};
}
function currentDefinitionBlurFrequency(blur, candidate = options) {
return definitionBlurFrequencyEvidence(candidate, blur.frequency.groups, blur.frequency.dictionaries);
}
function discardStaleAnkiMaturity(request, level) {
const blur = request.blur;
if (!blur.ankiCheck || blur.ankiCheck.epoch === ankiMaturityEpoch) return;
blur.ankiCheck = null;
blur.ankiMature = false;
settleDefinitionBlur(request, level);
}
// Definition blur (issue #9 L5). The decision lives on the request, so tabs,
// expansion, Note refresh and Back keep it while a new request starts fresh.
// One absolute reveal deadline runs from the first display; a live timer
// exists only while that request is the level's current view.
function clearDefinitionBlurTimer(level) {
if (level.blurTimer === null) return;
clearTimeout(level.blurTimer);
level.blurTimer = null;
}
function applyDefinitionBlurState(request, level) {
if (level.currentViewRequest === request && level.view) level.view.setDefinitionBlurState(request.blur.state);
}
function revealDefinitions(request, level) {
const blur = request?.blur;
if (!blur || blur.state === "revealed") return;
blur.state = "revealed";
if (level.currentViewRequest === request) clearDefinitionBlurTimer(level);
applyDefinitionBlurState(request, level);
releaseDefinitionBlurAutoplay(request, level);
}
function armDefinitionBlurTimer(request, level) {
clearDefinitionBlurTimer(level);
const blur = request.blur;
if (blur.state === "revealed" || options.definitionBlurReveal !== "timed") return;
const remaining = blur.displayedAt + options.definitionBlurDelayMs - Date.now();
if (remaining <= 0) {
revealDefinitions(request, level);
return;
}
level.blurTimer = setTimeout(() => {
level.blurTimer = null;
if (level.currentViewRequest === request) revealDefinitions(request, level);
}, remaining);
}
// Before the stored settings arrive the decision stays pending, like the
// audio controller's own options hold, so a first lookup cannot reveal or
// auto-play against defaults that the stored settings then contradict.
function beginDefinitionBlur(request, level, results) {
clearDefinitionBlurTimer(level);
if (!request) return;
if (!request.blur) {
const awaitingOptions = optionsStorageRevision < 0;
const active = awaitingOptions || definitionBlurActive();
request.blur = { state: active ? "pending" : "revealed", displayedAt: Date.now(), awaitingOptions,
lookupCount: undefined, ankiMature: undefined, autoplayHeld: active,
frequency: snapshotDefinitionBlurFrequency(results) };
}
if (!request.blur.awaitingOptions) {
discardStaleAnkiMaturity(request, level);
settleDefinitionBlur(request, level);
armDefinitionBlurTimer(request, level);
}
}
function resolveDefinitionBlurOptions(request, level) {
const blur = request.blur;
blur.awaitingOptions = false;
checkDefinitionBlurMaturity(request, level);
armDefinitionBlurTimer(request, level);
settleDefinitionBlur(request, level);
}
// Pending and blurred definitions hold the first result's autoplay; every
// reveal releases it once.
function releaseDefinitionBlurAutoplay(request, level) {
if (!request.blur.autoplayHeld) return;
request.blur.autoplayHeld = false;
audio?.settleAutoplay(level, request);
}
// Any enabled signal can qualify immediately. Frequency is synchronous,
// while a negative decision waits for enabled count and Anki evidence.
// Retain the first count while Anki is pending so later row events cannot
// change this visit's decision. Hover and the absolute deadline can reveal
// before either reply, without reblurring.
function settleDefinitionBlur(request, level, lookupCount) {
const blur = request.blur;
if (!blur) return;
if (blur.lookupCount === undefined && lookupCount !== undefined) blur.lookupCount = lookupCount;
if (blur.awaitingOptions || blur.state === "revealed") return;
const countEnabled = options.definitionBlurEnabled && options.showLookupCounts;
const frequency = currentDefinitionBlurFrequency(blur);
const qualifies = definitionBlurQualifies(options, countEnabled ? blur.lookupCount : null,
blur.ankiMature, frequency.qualified);
const pending = !qualifies && ((countEnabled && blur.lookupCount === undefined)
|| (options.definitionBlurAnkiMature && blur.ankiMature === undefined));
if (pending) {
if (blur.state !== "pending") {
blur.state = "pending";
applyDefinitionBlurState(request, level);
}
return;
}
if (!qualifies) {
revealDefinitions(request, level);
return;
}
if (blur.state !== "blurred") {
blur.state = "blurred";
applyDefinitionBlurState(request, level);
}
}
function ensureUi() {
if (!uiPromise) {
uiPromise = (async () => {
if (!document.body || !window.HDPopup || !window.HDGlossary) {
throw new Error("render modules or document body unavailable");
}
let styles;
try {
styles = await readerStyleSheet();
} catch (error) {
throw new Error(`could not load ${READER_STYLESHEET}: ${error.message}`);
}
if (disposed) {
throw new Error("torn down");
}
buildUi(styles);
})().catch((error) => {
console.warn("hachidori: popup unavailable", error);
// The next hover retries, so a half-built host must not stay in the page
// and must not leave `view` null behind a non-null `popup`.
discardUi();
uiPromise = null;
throw error;
});
}
return uiPromise;
}
function show(candidate, level = rootLevel) {
level.activeCandidate = candidate;
level.activeSignature = candidateSignature(candidate);
if (!host.isConnected && document.body) {
// A single-page app that swapped out document.body took the host with it.
document.body.appendChild(host);
}
level.popup.hidden = false;
level.popup.inert = false;
level.view.scrollElement.scrollTop = 0;
syncHostAttention();
}
function pruneLevels(depth, restoreFocus = true) {
clearDescendantTimer();
if (levels.length <= Math.max(1, depth)) {
if (levels.length === 1) clearTransferTimer();
return;
}
const source = levels[depth]?.activeCandidate?.anchor;
const removed = levels.splice(Math.max(1, depth));
const focused = removed.some((level) => level.popup?.contains(shadow?.activeElement));
for (const level of removed.reverse()) {
if (popupResize?.level === level) stopPopupResize();
audio?.retire(level);
mining?.retire(level);
clearDefinitionBlurTimer(level);
level.retired = true;
level.lookupToken += 1;
level.popup.hidden = true;
level.view?.clear();
level.view?.destroy();
level.popup?.remove();
}
// Removing a pane can uncover surviving fallback source paint.
highlighter?.refresh();
if (restoreFocus && focused && source?.isConnected) source.focus({ preventScroll: true });
if (levels.length === 1) clearTransferTimer();
}
function hide(level = rootLevel) {
if (level === rootLevel || popupResize?.level === level) stopPopupResize();
audio?.retire(level);
mining?.retire(level);
clearDefinitionBlurTimer(level);
if (level !== rootLevel) {
if (!level.retired) {
pruneLevels(level.depth);
flushDeferredNotes();
flushDictionaryPresentation();
}
return;
}
cancelPopupLayout();
clearScanTimer();
selectionDragActive = false;
dragSelection = null;
activeSelectionCandidate = null;
pendingCandidateLookup = null;
clearHideTimer();
clearTransferTimer();
pointerLevel = null;
pruneLevels(1, false);
releaseRootCapture();
rootLevel.activeCandidate = null;
rootLevel.activeSignature = null;
rootLevel.activeHighlightText = "";
rootLevel.activeTermRender = null;
rootLevel.currentViewRequest = null;
rootLevel.noteEditing = false;
rootLevel.deferredDictionaryInvalidationRevision = -1;
rootLevel.deferredRefresh = null;
rootLevel.retainedView = false;
rootLevel.lookupToken += 1;
if (rootLevel.popup) {
rootLevel.popup.hidden = true;
rootLevel.popup.inert = false;
rootLevel.view.clear();
highlighter.clearAll();
}
syncHostAttention();
}
function clearHideTimer() {
if (hideTimer !== null) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
}
function clearTransferTimer() {
if (transferTimer !== null) window.clearTimeout(transferTimer);
transferTimer = null;
}
function scheduleTransferCheck() {
clearTransferTimer();
transferTimer = window.setTimeout(() => {
transferTimer = null;
if (lastPointer && (isOurNode(lastPointer.target)
|| pointInsidePopup(lastPointer.clientX, lastPointer.clientY))) {
pointerInPopup = true;
clearHideTimer();
} else if (lastPointer) scanPointer(lastPointer);
else scheduleHide();
}, 80);
}
function clearDescendantTimer() {
if (descendantTimer !== null) window.clearTimeout(descendantTimer);
descendantTimer = null;
}
function onPopupEnter(level) {
if (level.retired) return;
pointerLevel = level;
pointerInPopup = true;
clearTransferTimer();
clearHideTimer();
scheduleDescendantPrune(level);
}
function scheduleDescendantPrune(level) {
clearDescendantTimer();
const depth = level.depth + 1;
if (depth >= levels.length) return;
const prune = () => {
descendantTimer = null;
if (!hasProtectedNote(depth) && (!pointerLevel || pointerLevel.depth < depth)
&& !levels.slice(depth).some((child) => child.popup.contains(shadow.activeElement))) {
pruneLevels(depth);
flushDeferredNotes();
flushDictionaryPresentation();
}
};
if (options.popupHideDelayMs === 0) prune();
else descendantTimer = window.setTimeout(prune, options.popupHideDelayMs);
}
function popupHasFocus() {
return levels.some((level) => level.popup && !level.popup.hidden && level.popup.contains(shadow?.activeElement));
}
function hasProtectedNote(fromDepth = 0) {
for (let index = fromDepth; index < levels.length; index += 1) {
if (levels[index].noteEditing || levels[index].pendingCustomAppends > 0) return true;
}
return false;
}
// The popup's wheel belongs to the popup. Readers such as ttu turn pages from
// wheel events on their body, and a pane that cannot scroll further (or has
// nothing to scroll) would otherwise chain the gesture into the page.
function onPopupWheel(event) {
event.stopPropagation();
if (event.defaultPrevented || event.ctrlKey) return;
const popup = event.currentTarget;
for (let node = event.target; node instanceof Element; node = node === popup ? null : node.parentElement) {
if (canScrollBy(node, event.deltaX, event.deltaY)) return;
}
event.preventDefault();
}
function canScrollBy(element, deltaX, deltaY) {
const room = (delta, offset, size, viewport) => delta > 0 ? offset + viewport < size - 1 : delta < 0 && offset > 0;
const vertical = room(deltaY, element.scrollTop, element.scrollHeight, element.clientHeight);
const horizontal = room(deltaX, element.scrollLeft, element.scrollWidth, element.clientWidth);
if (!vertical && !horizontal) return false;
const style = window.getComputedStyle(element);
const scrolls = overflow => overflow === "auto" || overflow === "scroll";
return (vertical && scrolls(style.overflowY)) || (horizontal && scrolls(style.overflowX));
}
function onPopupFocusOut(event) {
const target = event.target;
window.queueMicrotask(() => {
// A redraw can remove the focused Note form. That is not departure
// from the refreshed popup; wait until removal/focus transfer settles.
if (target.isConnected && levels.some((level) => level.popup?.contains(target))) scheduleHide();
flushDictionaryPresentation();
});
}
function scheduleHide() {
if (disposed || hasProtectedNote() || audio?.hasMenu() || !rootLevel.popup || rootLevel.popup.hidden
|| popupHasFocus() || hideTimer !== null || transferTimer !== null) {
return;
}
// The gap between the word and the popup is dead space; give the pointer
// time to cross it so the popup stays reachable and selectable.
const dismiss = () => {
hideTimer = null;
if (!hasProtectedNote() && !audio?.hasMenu() && !pointerInPopup && !popupHasFocus()) {
hide();
}
};
if (options.popupHideDelayMs === 0) dismiss();
else hideTimer = window.setTimeout(dismiss, options.popupHideDelayMs);
}
function compactSummaryOptions() {
return { showCompactDefinitionSummary: options.showCompactDefinitionSummary,
compactDefinitionSummaryCount: options.compactDefinitionSummaryCount,
compactDefinitionSummaryDictionary: options.compactDefinitionSummaryDictionary };
}
function openExternalLink({ url, active }) {
if (hostCapabilities.externalLinkHost) {
void window.HDExternalLinkHost.open(window, { url, active }).catch((error) => {
console.debug("hachidori: overlay host could not open external link", error);
});
return;
}
// A lost reply may follow a successful open, so never retry navigation.
void sendRequest("hd_open_external", { url, active }, "hoshidicts-worker").catch((error) => {
console.debug("hachidori: external link could not be opened", error);
});
}
function renderContextFor(level = rootLevel) {
return {
definitionBlurState: level.currentViewRequest?.blur?.state ?? "revealed",
dictionaryPresentation: dictionaryPresentation(),
dictionaryTabGroups: dictionaryTabGroups(),
generation: currentGeneration,
onExternalLink: openExternalLink,
onInternalLink: (link) => onInternalLink(link, level),
...imageSourceContext(),
...compactSummaryOptions(),
...window.HDPopup.metadataOptions(options),
};
}
function focusPopupControl(selector, level = rootLevel) {
const control = level.popup?.querySelector(selector);
if (typeof control?.focus !== "function") {
return;
}
try {
control.focus({ preventScroll: true });
} catch {
control.focus();
}
}
function kanjiLinkFocusTarget(sourceLink, character, level = rootLevel) {
const links = level.popup?.querySelectorAll(".gsm-hoshidicts-kanji-link");
const index = links ? Array.prototype.indexOf.call(links, sourceLink) : -1;
return { character, index };
}
function focusKanjiLink(focusTarget, level = rootLevel) {
const links = level.popup?.querySelectorAll(".gsm-hoshidicts-kanji-link");
if (!links || links.length === 0) {
return;
}
const character = typeof focusTarget === "object" ? focusTarget?.character : focusTarget;
let target = links[0];
const index = Number.isInteger(focusTarget?.index) ? focusTarget.index : -1;
if (index >= 0 && index < links.length && links[index].textContent === character) {
target = links[index];
} else if (typeof character === "string" && character !== "") {
for (const link of links) {
if (link.textContent === character) {
target = link;
break;
}
}
}
if (typeof target?.focus !== "function") {
return;
}
try {
target.focus({ preventScroll: true });
} catch {
target.focus();
}
}
async function restoreTermRender(previous, focusTarget, level = rootLevel) {
audio?.retire(level);
mining?.retire(level);
if (previous.generation !== currentGeneration || !sameDictionaryContents(previous.dictionaries, dictionaries)) {
const restoring = executeViewRequest(previous.request, level, previous.viewport);
const token = level.lookupToken;
if (await restoring && token === level.lookupToken && level.currentViewRequest === previous.request) {
focusKanjiLink(focusTarget, level);
}
return;
}
level.lookupToken += 1;
renderTerms(
previous.results,
previous.candidate,
previous.matchedText,
previous.renderOptions,
previous.request,
level,
previous.viewport,
);
focusKanjiLink(focusTarget, level);
}
function dictionarySelectionContext(request) {
return {
selectedDictionaryTab: request?.selectedDictionaryTab ?? null,
onDictionaryTabSelected(selection) {
if (request) request.selectedDictionaryTab = normalizedDictionaryTab(selection);
},
};
}
function backRenderOptions(request, level = rootLevel) {
return request?.previous
? { onBack: () => restoreTermRender(request.previous, request.returnFocus, level) }
: level === rootLevel ? {} : { onClose: () => hide(level) };
}
function renderTerms(
results,
candidate,
matchedText,
renderOptions = {},
request,
level = rootLevel,
replayOptions = null,
) {
request ??= level.currentViewRequest;
level.deferredRefresh = null;
level.deferredDictionaryInvalidationRevision = -1;
level.retainedView = false;
pruneLevels(level.depth + 1);
const token = level.lookupToken;
level.currentViewRequest = request ?? null;
beginDefinitionBlur(request, level, results);
level.activeTermRender = {
candidate,
dictionaries,
generation: currentGeneration,
matchedText,
renderOptions,
request: level.currentViewRequest,
results,
token,
};
try {
level.view.renderResults(results, candidate, {
...renderContextFor(level),
...renderOptions,
...replayOptions,
highlightText: matchedText,
isCurrentRequest: () => !disposed && !level.retired && token === level.lookupToken,
isCurrentView: () => !disposed && !level.retired && level.currentViewRequest === request
&& (token === level.lookupToken || level.retainedView),
onRenderError(error) { handleRenderFailure(token, error, request, level); },
...dictionarySelectionContext(request),
});
} catch (error) {
// A malformed result must cost one hover, not the whole content script.
handleRenderFailure(token, error, request, level);
return false;
}
level.activeHighlightText = matchedText;
ensureDictionaryStyles(currentGeneration);
positionPopup(level);
if (!replayOptions?.preserveViewControls
&& (typeof renderOptions.onBack === "function" || typeof renderOptions.onClose === "function")
&& (level === rootLevel || request?.previous || level.focusLinkedBack)) {
focusPopupControl(renderOptions.onClose ? ".gsm-hoshidicts-popup-close" : ".gsm-hoshidicts-kanji-back", level);
}
acceptLookupStatistics(results, request, level);
if (!replayOptions) globalThis.SubMinerHachidori?.lookup();
return true;
}
function handleTermMiss(request, dictionaryCount, token, level, replayOptions) {
if (retainProtectedReplay(request, token, level, replayOptions)) return false;
if (dictionaryCount === 0 || request.exactSelection) {
show(request.candidate, level);
level.activeHighlightText = "";
level.activeTermRender = null;
clearDefinitionBlurTimer(level);
// Keep the exact request so saving a new word can refresh this notice
// into its personal definition, even after the form collapses selection.
level.currentViewRequest = request;
level.view.renderNotice(
dictionaryCount === 0
? "No dictionaries loaded. Import a Yomitan .zip in Settings, or add your own definition with the pencil."
: "No definition found. Add your own with the pencil.",
request.candidate,
{ isCurrentRequest: () => !disposed && !level.retired && token === level.lookupToken },
);
positionPopup(level);
return false;
}
hide(level);
return false;
}
async function executeTermRequest(request, level = rootLevel, replayOptions = null) {
audio?.retire(level);
mining?.retire(level);
const token = (level.lookupToken += 1);
level.retainedView = replayOptions?.preserveViewControls === true;
if (level.popup && !level.popup.hidden && request !== level.currentViewRequest) {
pruneLevels(level.depth + 1, false);
clearDefinitionBlurTimer(level);
level.popup.inert = true;
level.currentViewRequest = null;
level.activeTermRender = null;
level.deferredRefresh = null;
level.deferredDictionaryInvalidationRevision = -1;
level.noteEditing = false;
}
level.view?.hideImagePreview();
let reply, capturePin;
const capturePinPromise = request.capturePinPromise ?? Promise.resolve(rootLevel.capturePin);
try {
// The first hover pays for the popup host and the stylesheet fetch; run
// them alongside the lookup instead of ahead of it.
[, reply, capturePin] = await Promise.all([
ensureUi(),
sendRequest("hd_lookup", request.payload),
capturePinPromise,
]);
} catch (error) {
releaseProvisionalCapture(capturePinPromise, level);
return handleRequestFailure(request, token, error, level, replayOptions);
}
request.capturePin = capturePin;
// Hover fires far faster than lookups return; anything but the newest reply
// would repaint a word the pointer already left.
if (!requestCanRender(token, request.candidate, level)) {
releaseProvisionalCapture(capturePin, level);
return;
}
if (level === rootLevel) rootLevel.capturePin = capturePin;
noteGeneration(reply.generation, level);
let results = (Array.isArray(reply.results) ? reply.results : [])
.filter((result) => result && result.term
&& (!request.exactSelection || result.matched === request.payload.text));
results = globalThis.SubMinerHachidori?.prioritizeCharacterResults(
results, request.payload.options, dictionaryPresentation()) ?? results;
if (results.length === 0) {
return handleTermMiss(request, reply.dictionaryCount, token, level, replayOptions);
}
const matched = results[0].matched || results[0].term.expression;
expandCandidateAnchor(request.candidate, matched);
if (!replayOptions?.preserveViewControls) show(request.candidate, level);
if (request.highlightText === undefined) {
request.highlightText = rawMatchedText(request.candidate, matched);
}
return renderTerms(
results,
request.candidate,
request.highlightText,
backRenderOptions(request, level),
request,
level,
replayOptions,
);
}
function runLookup(candidate, overrides = {}, level = rootLevel) {
const text = typeof overrides.text === "string" ? overrides.text : candidate.query;
const exactSelection = candidate.exactSelection === true && overrides.text === undefined;
return executeTermRequest({
candidate,
exactSelection,
highlightText: overrides.keepHighlight === true ? level.activeHighlightText : undefined,
kind: "term",
payload: {
maxResults: options.maxResults,
options: {
frequencyDictionary: options.frequencyDictionary,
frequencyOrder: options.frequencyOrder,
primaryReading: typeof overrides.primaryReading === "string"
? overrides.primaryReading
: "",
},
scanLength: exactSelection ? clampOption("scanLength", Array.from(text).length) : options.scanLength,
text,
},
previous: overrides.previous ?? null,
returnFocus: overrides.returnFocus ?? null,
selectedDictionaryTab: normalizedDictionaryTab(overrides.selectedDictionaryTab),
capturePinPromise: level === rootLevel ? level.capturePinPromise : rootLevel.capturePinPromise,
}, level);
}
function sameChildLookup(existing, candidate, primaryReading) {
return Boolean(existing?.activeCandidate) &&
existing.primaryReading === primaryReading &&
existing.activeSignature === candidateSignature(candidate) &&
sameAnchorNode(candidate, existing.activeCandidate);
}
function openChildLookup(candidate, level, {
focusChild = false,
primaryReading = "",
source = "hover",
} = {}) {
if (
level.retired ||
!level.activeCandidate ||
!anchorConnected(candidate) ||
!level.popup.contains(candidate.anchor) ||
level.depth >= options.popupNestingMaxDepth ||
popupViewport().width <= POPUP_PADDING_PX * 2 ||
popupViewport().height <= POPUP_PADDING_PX * 2
) {
return;
}
clearHideTimer();
clearTransferTimer();
clearDescendantTimer();
const existing = levels[level.depth + 1];
const pendingKey = source === "link" ? "pendingLink" : "pendingHover";
const pending = existing?.[pendingKey];
if (
sameChildLookup(existing, candidate, primaryReading) &&
(
pending?.token === existing.lookupToken ||
(
existing.currentViewRequest?.kind === "term" &&
existing.activeTermRender?.token === existing.lookupToken
)
)
) {
if (focusChild) {
existing.focusLinkedBack = true;
if (!existing.popup.hidden) {
focusPopupControl(
existing.currentViewRequest?.previous
? ".gsm-hoshidicts-kanji-back"
: ".gsm-hoshidicts-popup-close",
existing
);
}
}
return pending?.promise;
}
pruneLevels(level.depth + 2, false);
const child = existing ?? createLevelState(level.depth + 1);
if (!existing) {
levels.push(child);
buildLevelUi(child);
}
child.primaryReading = primaryReading;
child.focusLinkedBack = focusChild;
child.activeCandidate = candidate;
child.activeSignature = candidateSignature(candidate);
const promise = runLookup(candidate, {
primaryReading,
selectedDictionaryTab: level.currentViewRequest?.selectedDictionaryTab,
}, child);
const record = { promise, token: child.lookupToken };
child[pendingKey] = record;
void promise.finally(() => {
if (child[pendingKey] === record) {
child[pendingKey] = null;
}
});
return promise;
}
function onInternalLink({ anchor, focusChild = false, primaryReading = "", query }, level = rootLevel) {
if (!anchor?.isConnected || !query) {
return;
}
// Link text is an anchor/highlight, never the query's page-scan offsets.
const candidate = {
anchor, linkAnchor: true, query, matchOffset: 0,
sentence: anchor.textContent || "", sourceElements: [anchor], sourceDepth: level.depth,
vertical: false,
};
return openChildLookup(candidate, level, {
focusChild,
primaryReading,
source: "link",
});
}
async function executeKanjiRequest(request, level = rootLevel, replayOptions = null) {
audio?.retire(level);
mining?.retire(level);
const { candidate, capability, character } = request;
const useTermDictionary = capability?.kind === "term";
const token = (level.lookupToken += 1);
level.pendingPopupInteraction = token;
const finishInteraction = () => {
if (level.pendingPopupInteraction === token) level.pendingPopupInteraction = null;
};
level.retainedView = replayOptions?.preserveViewControls === true;
level.view?.hideImagePreview();
let reply;
try {
reply = useTermDictionary
? await sendRequest("hd_lookup_dictionary", request.termPayload)
: await sendRequest("hd_kanji", request.kanjiPayload);
} catch (error) {
finishInteraction();
return handleLookupFailure(token, error, level, request, true);
}
if (!requestCanRender(token, candidate, level) || level.popup.hidden) {
finishInteraction();
return false;
}
noteGeneration(reply.generation, level);
if (useTermDictionary) {
const results = projectResultsToDictionary(
Array.isArray(reply.results) ? reply.results : [],
capability.title
);
if (results.length > 0) {
finishInteraction();
return renderTerms(
results,
candidate,
request.highlightText,
backRenderOptions(request, level),
request,
level,
replayOptions,
);
}
try {
reply = await sendRequest("hd_kanji", request.kanjiPayload);
} catch (error) {
finishInteraction();
return handleLookupFailure(token, error, level, request, true);
}
if (!requestCanRender(token, candidate, level) || level.popup.hidden) {
finishInteraction();
return false;
}
noteGeneration(reply.generation, level);
}
const kanji = reply.kanji;
if (!kanji || !Array.isArray(kanji.entries) || kanji.entries.length === 0) {
finishInteraction();
return handleLookupFailure(token, new Error("kanji lookup returned no usable result"), level, request, true);
}
const validEntries = kanji.entries.filter((entry) => entry && typeof entry === "object"
&& typeof entry.dictionary === "string" && entry.dictionary !== "");
if (validEntries.length === 0) {
finishInteraction();
return handleLookupFailure(token, new Error("kanji lookup returned no usable result"), level, request, true);
}
const selectedEntries = capability?.kind === "kanji"
? validEntries.filter((entry) => entry.dictionary === capability.title)
: validEntries;
const entries = selectedEntries.length > 0 ? selectedEntries : validEntries;
clearDefinitionBlurTimer(level);
level.currentViewRequest = request;
level.deferredRefresh = null;
level.deferredDictionaryInvalidationRevision = -1;
level.retainedView = false;
pruneLevels(level.depth + 1);
try {
level.view.renderKanji({ ...kanji, entries }, candidate, {
...renderContextFor(level),
isCurrentRequest: () => !disposed && !level.retired && token === level.lookupToken,
isCurrentView: () => !disposed && !level.retired && level.currentViewRequest === request
&& (token === level.lookupToken || level.retainedView),
onRenderError(error) { handleLookupFailure(token, error, level); },
...dictionarySelectionContext(request),
highlightText: request.highlightText,
...backRenderOptions(request, level),
...replayOptions,
});
} catch (error) {
console.warn("hachidori: could not render kanji", error);
return handleLookupFailure(token, error, level, request, true);
} finally {
finishInteraction();
}
ensureDictionaryStyles(currentGeneration);
positionPopup(level);
if (!replayOptions?.preserveViewControls) focusPopupControl(".gsm-hoshidicts-kanji-back", level);
return true;
}
function showKanji(character, _result, _candidate, sourceLink, level = rootLevel) {
if (!level.activeCandidate || typeof character !== "string" || !character) {
return;
}
const capability = selectedKanjiDictionaryCapability();
return executeKanjiRequest({
candidate: level.activeCandidate,
capability,
character,
highlightText: level.activeHighlightText || character,
kanjiPayload: { character },
kind: "kanji",
previous: level.activeTermRender && {
...level.activeTermRender,
viewport: level.view.captureTermView?.(),
},
returnFocus: kanjiLinkFocusTarget(sourceLink, character, level),
selectedDictionaryTab: normalizedDictionaryTab(level.currentViewRequest?.selectedDictionaryTab),
termPayload: capability?.kind === "term"
? {
dictionary: capability.title,
maxResults: options.maxResults,
options: {
frequencyDictionary: options.frequencyDictionary,
frequencyOrder: options.frequencyOrder,
primaryReading: "",
},
scanLength: 1,
text: character,
}
: null,
}, level);
}
function executeViewRequest(request, level = rootLevel, replayOptions = null) {
return request.kind === "kanji"
? executeKanjiRequest(request, level, replayOptions)
: executeTermRequest(request, level, replayOptions);
}
function replayVisibleView(level, intent) {
const request = level.currentViewRequest;
const pending = level.pendingViewReplay;
if (pending?.request === request && pending.token === level.lookupToken) {
pending.options.expandAll = intent?.expandAll === true;
return;
}
const replayOptions = { preserveViewControls: true, expandAll: intent?.expandAll === true };
const operation = executeViewRequest(request, level, replayOptions);
const replay = { request, token: level.lookupToken, options: replayOptions };
level.pendingViewReplay = replay;
void operation.finally(() => {
if (level.pendingViewReplay === replay) level.pendingViewReplay = null;
});
}
async function appendCustomEntry(entry, level = rootLevel) {
const expectedView = level.currentViewRequest;
level.pendingCustomAppends += 1;
try {
const reply = await sendRequest("hd_custom_append", { entry });
const adoption = adoptDictionaryState(reply.state);
if (adoption.dictionaryChanged) invalidateStoredState(true);
else if (adoption.presentationChanged) updateDictionaryPresentation();
if (
expectedView !== null
&& !level.retired
&& level.currentViewRequest === expectedView
&& level.popup && !level.popup.hidden
&& anchorConnected(expectedView.candidate)
) {
level.deferredRefresh = expectedView;
}
return reply;
} finally {
level.pendingCustomAppends -= 1;
flushDeferredNotes();
flushDictionaryPresentation();
}
}
function flushDeferredNotes() {
for (const level of levels) {
if (level.pendingCustomAppends > 0 || hasProtectedNote(level.depth + 1)) continue;
const request = level.deferredRefresh;
if (request && request === level.currentViewRequest && !level.popup.hidden
&& anchorConnected(request.candidate)) {
level.deferredRefresh = null;
level.deferredDictionaryInvalidationRevision = -1;
// The append has committed. Replay failures must never invite a second
// append, and a protected descendant must keep its source DOM alive.
void executeViewRequest(request, level).catch((error) => {
console.debug("hachidori: Note saved but the lookup could not refresh", error);
});
return;
}
if (!level.noteEditing && level.deferredDictionaryInvalidationRevision >= 0) {
hide(level);
return;
}
}
}
function clearScanTimer() {
if (scanTimer !== null) {
window.clearTimeout(scanTimer);
scanTimer = null;
}
}
function discardPendingCandidate() {
if (pendingCandidateLookup?.candidate === activeSelectionCandidate) activeSelectionCandidate = null;
pendingCandidateLookup = null;
}
function cancelCandidateScan() {
clearScanTimer();
if (rootLevel.popup?.inert) {
hide();
return;
}
// Retaining a rendered popup during transfer must not invalidate its media
// or deferred glossary. Only an unfinished candidate loses ownership.
if (pendingCandidateLookup?.token === rootLevel.lookupToken) rootLevel.lookupToken += 1;
discardPendingCandidate();
}
function cancelPendingHover(level) {
const child = levels[level.depth + 1];
if (
!child ||
child.pendingHover?.token !== child.lookupToken ||
(!child.popup?.hidden && !child.popup?.inert)
) {
return;
}
pruneLevels(child.depth, false);
}
function lookupCandidate(candidate, signature = candidateSignature(candidate)) {
rootLevel.capturePin = null;
rootLevel.capturePinPromise = Promise.resolve(window.HDCapture?.rootLookup(candidate) ?? null);
const lookup = runLookup(candidate);
const pending = { token: rootLevel.lookupToken, candidate, signature };
pendingCandidateLookup = pending;
void lookup.finally(() => {
if (pendingCandidateLookup === pending) pendingCandidateLookup = null;
syncHostAttention();
});
}
function activationAllowed() {
return options.lookupMode === "hover" || activationPressed;
}
// Yomitan's default: once shown, the popup outlives the activation key and
// the pointer's wanderings; only an explicit dismissal or a new lookup ends it.
function schedulePointerHide() {
if (options.lookupMode !== "activationSticky") scheduleHide();
}
function updateModifierState(event) {
const property = MODIFIER_PROPERTIES.get(options.activationKey);
if (property) activationPressed = event[property] === true;
}
function pointInsidePopup(clientX, clientY) {
if (!rootLevel.popup || rootLevel.popup.hidden) return false;
let previous = null;
for (const level of levels) {
if (!level.popup || level.popup.hidden) continue;
const rect = level.popup.getBoundingClientRect();
if (clientX >= rect.left && clientX <= rect.right
&& clientY >= rect.top && clientY <= rect.bottom) return true;
if (previous) {
const left = previous.right <= rect.left ? previous.right : rect.right;
const right = previous.right <= rect.left ? rect.left : previous.left;
if (left <= right && clientX >= left - 2 && clientX <= right + 2
&& clientY >= Math.max(previous.top, rect.top) - 4
&& clientY <= Math.min(previous.bottom, rect.bottom) + 4) return true;
}
previous = rect;
}
return false;
}
function activePointerLevel(pointer) {
const level = pointer?.level;
return level &&
!level.retired &&
levels[level.depth] === level &&
level.popup?.contains(pointer.target)
? level
: null;
}
function popupLinkAt(target, level) {
const element = target?.nodeType === Node.ELEMENT_NODE
? target
: target?.parentElement;
const link = element?.closest?.("a");
return link && level.popup.contains(link) ? link : null;
}
function scanDefinitionPointer(pointer, level) {
pointerInPopup = true;
pointerLevel = level;
clearHideTimer();
const link = popupLinkAt(pointer.target, level);
if (link) {
cancelPendingHover(level);
if (link.hasAttribute("data-hoshidicts-query")) clearDescendantTimer();
else scheduleDescendantPrune(level);
return;
}
if (!activationAllowed() || level.depth >= options.popupNestingMaxDepth) {
cancelPendingHover(level);
scheduleDescendantPrune(level);
return;
}
const candidate = resolveDefinitionCandidate(
pointer.clientX,
pointer.clientY,
level
);
if (!candidate) {
cancelPendingHover(level);
scheduleDescendantPrune(level);
return;
}
clearDescendantTimer();
openChildLookup(candidate, level);
}
function scanPointer(pointer) {
if (disposed || !extensionAlive()) {
teardown("context-invalidated");
return;
}
if (!options.hoverEnabled) return;
if (transferTimer !== null) return;
const popupLevel = activePointerLevel(pointer);
if (hasProtectedNote() || popupHasFocus()) {
cancelCandidateScan();
if (popupLevel) cancelPendingHover(popupLevel);
clearHideTimer();
return;
}
if (popupLevel) {
scanDefinitionPointer(pointer, popupLevel);
return;
}
if (selectionDragActive || retainSelectedLookup()) return;
pointerInPopup = isOurNode(pointer.target) ||
pointInsidePopup(pointer.clientX, pointer.clientY);
if (pointerInPopup) {
cancelCandidateScan();
clearHideTimer();
return;
}
const selection = window.getSelection();
if (selection && !selection.isCollapsed) {
if (!activationAllowed()) {
cancelCandidateScan();
schedulePointerHide();
return;
}
const selected = resolveSelectedLookupCandidate(selection);
if (selected) startSelectionLookup(selected);
else {
cancelCandidateScan();
scheduleHide();
}
return;
}
if (!activationAllowed()) {
cancelCandidateScan();
schedulePointerHide();
return;
}
const candidate = resolveCandidate(pointer.clientX, pointer.clientY);
if (!candidate) {
cancelCandidateScan();
schedulePointerHide();
return;
}
const signature = candidateSignature(candidate);
if (pendingCandidateLookup?.token === rootLevel.lookupToken
&& pendingCandidateLookup.signature === signature
&& sameAnchorNode(candidate, pendingCandidateLookup.candidate)) {
clearHideTimer();
return;
}
if (
rootLevel.popup && !rootLevel.popup.hidden && !rootLevel.popup.inert &&
rootLevel.activeSignature === signature &&
sameAnchorNode(candidate, rootLevel.activeCandidate)
) {
clearHideTimer();
return;
}
clearHideTimer();
lookupCandidate(candidate, signature);
}
function onPopupMouseMove(event, level) {
if (popupResize) return;
if (disposed || !options.hoverEnabled || level.retired) {
return;
}
lastPointer = {
clientX: event.clientX,
clientY: event.clientY,
level,
target: event.target,
};
updateModifierState(event);
pointerInPopup = true;
pointerLevel = level;
clearTransferTimer();
clearHideTimer();
if (hasProtectedNote() || popupHasFocus()) {
cancelPendingHover(level);
clearScanTimer();
return;
}
const link = popupLinkAt(event.target, level);
if (link) {
cancelPendingHover(level);
clearScanTimer();
if (link.hasAttribute("data-hoshidicts-query")) clearDescendantTimer();
else scheduleDescendantPrune(level);
return;
}
if (selectionDragActive || !activationAllowed()) {
cancelPendingHover(level);
clearScanTimer();
return;
}
scheduleScan();
}
function onMouseMove(event) {
if (popupResize) return;
if (disposed || !options.hoverEnabled) {
return;
}
lastPointer = {
clientX: event.clientX,
clientY: event.clientY,
target: event.target,
};
updateModifierState(event);
// A release the page never saw, such as one outside the window, ends the
// drag on the next move.
if (selectionDragActive && (event.buttons & 1) === 0) finishSelectionDrag({ dismissClick: false });
if (dragSelection) {
extendGlyphDrag(event);
return;
}
// Cancel a pending dismissal here rather than waiting for the throttled
// scan, so the popup stays reachable even with hoverDelayMs turned up. The
// retargeted event target is enough; the rect test costs a layout and can
// wait for the scan.
if (isOurNode(event.target)) {
pointerInPopup = true;
clearTransferTimer();
cancelCandidateScan();
clearHideTimer();
return;
}
const leavingChain = pointerInPopup && levels.length > 1;
pointerInPopup = false;
pointerLevel = null;
if (leavingChain) scheduleTransferCheck();
if (hasProtectedNote() || popupHasFocus()) {
cancelCandidateScan();
return;
}
if (selectionDragActive) return;
if (activeSelectionCandidate) {
clearHideTimer();
scheduleScan();
return;
}
if (!activationAllowed()) {
cancelCandidateScan();
schedulePointerHide();
return;
}
scheduleScan();
}
function scheduleScan() {
if (scanTimer !== null) {
return;
}
// Trailing-edge throttle: at most one scan per hoverDelayMs, always at the
// pointer's latest position.
scanTimer = window.setTimeout(() => {
scanTimer = null;
if (lastPointer) {
scanPointer(lastPointer);
}
}, options.hoverDelayMs);
}
const GLYPH_DRAG_START_PX = 3;
// In an overlay the browser's own drag cannot be trusted: Chromium loses the
// anchor when the press lands after a boxed glyph, and the selection ends as
// the glyph under the pointer or nothing. The reader selects whole glyphs from
// the pressed one to the pointer's instead, and the prevented mousedown keeps
// the browser's selection out of it.
function startGlyphDrag(event) {
const anchor = glyphAtPoint(event.clientX, event.clientY);
if (!anchor) return null;
event.preventDefault();
window.getSelection()?.removeAllRanges();
return { anchor, focus: null, x: event.clientX, y: event.clientY };
}
function extendGlyphDrag(event) {
// A press that does not travel is a click, not a one-glyph selection.
if (!dragSelection.focus
&& Math.hypot(event.clientX - dragSelection.x, event.clientY - dragSelection.y) < GLYPH_DRAG_START_PX) return;
// A gap between boxes keeps the last glyph, as does the popup itself.
const focus = glyphAtPoint(event.clientX, event.clientY) ?? dragSelection.focus;
if (!focus) return;
dragSelection.focus = focus;
const { anchor } = dragSelection;
const forward = anchor.node === focus.node
? anchor.start <= focus.start
: Boolean(anchor.node.compareDocumentPosition(focus.node) & Node.DOCUMENT_POSITION_FOLLOWING);
try {
const selection = window.getSelection();
if (forward) selection.setBaseAndExtent(anchor.node, anchor.start, focus.node, focus.end);
else selection.setBaseAndExtent(anchor.node, anchor.end, focus.node, focus.start);
} catch {
// The page replaced a glyph mid-drag; the next move selects afresh.
}
}
function onMouseDown(event) {
if (disposed) {
return;
}
if (isOurNode(event.target) || pointInsidePopup(event.clientX, event.clientY)) return;
updateModifierState(event);
if (event.button === 0 && options.hoverEnabled
&& isScannableElement(selectionBoundaryElement(event.target), new Map())) {
// A press on text may start a selection, so the popup stays until release
// decides, and the host hears now that the reader needs the window.
cancelCandidateScan();
clearHideTimer();
activeSelectionCandidate = null;
setSelectionDrag(true);
if (overlayMode) dragSelection = startGlyphDrag(event);
return;
}
hide();
}
// Release decides what the press was: a selection looks up that text, a
// plain click dismisses. The drag's claim on the host window carries over to
// the lookup it starts.
function finishSelectionDrag({ dismissClick }) {
hostAttentionHold += 1;
try {
setSelectionDrag(false);
const token = rootLevel.lookupToken;
onSelectionChange();
if (dismissClick && rootLevel.lookupToken === token) hide();
} finally {
hostAttentionHold -= 1;
syncHostAttention();
}
}
function selectionIsUnchanged(candidate = activeSelectionCandidate) {
if (!candidate) return false;
const selection = window.getSelection();
if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return false;
const range = selection.getRangeAt(0);
const previous = candidate.selectionRange ?? candidate.anchorRange;
return range.startContainer === previous.startContainer && range.startOffset === previous.startOffset
&& range.endContainer === previous.endContainer && range.endOffset === previous.endOffset
&& selection.toString() === (candidate.selectionText ?? candidate.query);
}
function startSelectionLookup(candidate) {
hostAttentionHold += 1;
try {
hide();
activeSelectionCandidate = candidate;
lookupCandidate(candidate);
} finally {
hostAttentionHold -= 1;
syncHostAttention();
}
}
function retainSelectedLookup() {
if (!activeSelectionCandidate) return false;
if (!selectionIsUnchanged()) onSelectionChange();
if (!activeSelectionCandidate) return false;
clearHideTimer();
return true;
}
function onSelectionChange() {
if (disposed || !options.hoverEnabled || selectionDragActive || hasProtectedNote()
|| popupHasFocus() || pageEditorFocused() || selectionIsUnchanged()) return;
const selection = window.getSelection();
if ([selection?.anchorNode, selection?.focusNode].some((node) =>
node && (node === host || node.getRootNode() === shadow))) return;
const candidate = resolveSelectedLookupCandidate(selection);
if (!candidate && !activeSelectionCandidate) return;
if (candidate && !activationAllowed()) hide();
else if (candidate) startSelectionLookup(candidate);
else hide();
}
function onMouseUp(event) {
if (disposed || event.button !== 0 || !selectionDragActive) return;
updateModifierState(event);
finishSelectionDrag({ dismissClick: true });
}
function onPageFocusIn() {
if (!disposed && pageEditorFocused()) {
cancelCandidateScan();
activationPressed = false;
activationCode = null;
}
}
// Close keeps the reader's Escape order: an audio menu, then a Note form,
// then the focused or deepest popup, then a pending lookup. Nothing closed
// leaves the key to activation.
function closeFromKeybind(event) {
if (audio?.closeMenu()) {
event.preventDefault();
event.stopPropagation();
return true;
}
if (rootLevel.popup && !rootLevel.popup.hidden) {
const focused = levels.find((level) => level.popup.contains(shadow.activeElement));
const editing = focused?.noteEditing ? focused : levels.findLast((level) => level.noteEditing);
const noteOwner = editing || focused || levels.at(-1);
if (!noteOwner.popup.inert && noteOwner.view?.closeNoteForm?.() === true) {
event.preventDefault();
event.stopPropagation();
return true;
}
event.stopPropagation();
hide(focused || levels.at(-1));
return true;
}
const dismissedCandidate = pendingCandidateLookup !== null || activeSelectionCandidate !== null;
hide();
return dismissedCandidate;
}
// Yomitan leaves unmodified character keys to a focused text field.
function textFieldFocused() {
let focused = document.activeElement;
while (focused) {
if (focused.isContentEditable || ["input", "select", "textarea"].includes(focused.localName)) return true;
focused = focused === host ? shadow.activeElement : focused.shadowRoot?.activeElement;
}
return false;
}
function clickKeybindControl(control) {
if (!control?.isConnected || control.hidden || control.disabled) return false;
control.click();
return true;
}
function scanSelectedText() {
if (disposed || !options.hoverEnabled) return false;
const candidate = resolveSelectedLookupCandidate();
if (!candidate) return false;
startSelectionLookup(candidate);
return true;
}
function runKeybindAction({ action, argument }, event) {
if (action === "close") return closeFromKeybind(event);
if (action === "scanSelectedText" || action === "scanTextAtSelection") {
if (!options.hoverEnabled) return false;
if (action === "scanSelectedText") return scanSelectedText();
const candidate = resolveSelectionScanCandidate();
if (!candidate) return false;
startSelectionLookup(candidate);
return true;
}
if (action === "toggleOption") {
if (!argument || optionsStorageRevision < 0) return false;
// A conflicting write changes nothing; the storage event carries the result.
void sendRequest("hd_options_write", { baseRevision: optionsStorageRevision,
options: { [argument]: !options[argument] } }, WORKER_TARGET).catch(() => {});
return true;
}
const level = levels.findLast((item) => item.popup && !item.popup.hidden);
if (!level?.view || level.popup.inert) return false;
const entry = level.view.currentEntryIndex();
switch (action) {
case "nextEntry":
case "previousEntry":
return level.view.focusEntry({ offset: (action === "nextEntry" ? 1 : -1) * Number(argument) });
case "firstEntry":
case "lastEntry":
return level.view.focusEntry(action === "firstEntry" ? "first" : "last");
case "nextEntryDifferentDictionary":
case "previousEntryDifferentDictionary":
return level.view.focusEntry({ dictionary: action === "nextEntryDifferentDictionary" ? 1 : -1 });
case "historyBackward":
return clickKeybindControl(level.popup.querySelector(".gsm-hoshidicts-kanji-back"));
case "addNote":
case "viewNotes":
return clickKeybindControl(level.entryMining?.[entry]?.actions.querySelector(
`.gsm-hoshidicts-mine-button[data-action="${action === "addNote" ? "add" : "view"}"]`));
case "playAudio":
case "playAudioFromSource": {
const button = level.entryAudio?.[entry]?.button;
if (!button || (action === "playAudioFromSource" && !argument)) return false;
return audio.playButton(button, action === "playAudioFromSource" ? argument : "");
}
default:
return false;
}
}
// A browser shortcut runs its keybind action here; entry moves go one entry.
function onReaderCommand(message) {
if (disposed || message?.target !== READER_TARGET || message.type !== "hd_reader_command") return false;
runKeybindAction({ action: message.action, argument: ["nextEntry", "previousEntry"].includes(message.action) ? "1" : "" },
{ preventDefault() {}, stopPropagation() {} });
return false;
}
// After Yomitan's HotkeyHandler: the physical key and the exact modifier set
// select enabled keybinds whose scope applies; the first handled one wins.
function runKeybinds(event) {
const key = KEYBIND_MODIFIER_CODES.has(event.code) ? null : event.code;
const modifiers = KEYBIND_MODIFIERS.filter(modifier => event[`${modifier}Key`] === true);
// A pending lookup counts as its popup: Escape has always cancelled one.
const popupScope = Boolean(rootLevel.popup && !rootLevel.popup.hidden)
|| pendingCandidateLookup !== null || activeSelectionCandidate !== null;
const characterInput = (modifiers.length === 0 || modifiers.join() === "shift")
&& (event.key?.length === 1 || event.key === "Process");
for (const bind of options.keybinds) {
if (!bind.enabled || bind.action === "" || (bind.key === null && bind.modifiers.length === 0)
|| bind.key !== key || bind.modifiers.join() !== modifiers.join()
|| !(bind.scopes.includes("web") || (popupScope && bind.scopes.includes("popup")))
|| (characterInput && textFieldFocused())) continue;
if (runKeybindAction(bind, event) === false) continue;
if (bind.action !== "close") event.preventDefault();
return true;
}
return false;
}
function onKeyDown(event) {
if (disposed || event.repeat || runKeybinds(event)) {
return;
}
if (!options.hoverEnabled) return;
// Autofocused search fields (such as Jisho's) must not disable lookups on
// the rest of the page. Modifier activation leaves native typing intact;
// printable/editor keys stay reserved for the focused field.
if (pageEditorFocused() && !MODIFIER_PROPERTIES.has(options.activationKey)) return;
// Pressing the gate key while the pointer is stationary should reveal the
// word under it without asking the reader to jiggle the mouse.
const wasPressed = activationPressed;
updateModifierState(event);
if (normaliseActivationKey(event.key, null) === options.activationKey) {
activationPressed = true;
activationCode = event.code;
}
const popupLevel = activePointerLevel(lastPointer);
if (!wasPressed && activationPressed && options.lookupMode !== "hover"
&& lastPointer && !hasProtectedNote() && !popupHasFocus()
&& (!pointerInPopup || popupLevel)
&& !selectionDragActive
&& (popupLevel || !retainSelectedLookup())) {
scheduleScan();
}
}
function onKeyUp(event) {
if (disposed) return;
updateModifierState(event);
if (!MODIFIER_PROPERTIES.has(options.activationKey)
&& (event.code === activationCode || normaliseActivationKey(event.key, null) === options.activationKey)) {
activationPressed = false;
}
if (!activationPressed) activationCode = null;
if (options.lookupMode === "activation" && !activationPressed) {
if (selectionIsUnchanged()) return;
cancelCandidateScan();
scheduleHide();
}
}
function onMouseOut(event) {
if (popupResize) return;
// A null relatedTarget on a document-level mouseout means the pointer left
// the window entirely, which mouseleave cannot report from here: it does not
// bubble, and a capture listener would fire for every element left.
if (!disposed && event.relatedTarget === null) {
lastPointer = null;
pointerInPopup = false;
cancelCandidateScan();
schedulePointerHide();
}
}
function onWindowBlur() {
stopPopupResize();
if (!disposed) {
setSelectionDrag(false);
lastPointer = null;
activationPressed = false;
activationCode = null;
pointerInPopup = false;
const interaction = levels.find((level) => !level.popup?.hidden
&& level.pendingPopupInteraction === level.lookupToken);
if (interaction) {
interaction.pendingPopupInteraction = null;
return;
}
hide();
}
}
function onPageHide() {
stopPopupResize();
sessionPopupSize = null;
// Navigation can destroy the content owner without blurring the tab.
// Retire while runtime messaging is alive; a BFCache return can reuse UI.
audio?.retire();
mining?.retire();
for (const level of levels) clearDefinitionBlurTimer(level);
}
function onPageShow(event) {
if (!event.persisted) return;
positionPopup();
// The absolute deadline kept running while the page was cached.
for (const level of levels) {
const request = level.currentViewRequest;
if (request?.blur && !request.blur.awaitingOptions) armDefinitionBlurTimer(request, level);
}
}
function onScroll() {
cancelCandidateScan();
rootLevel.view?.hideImagePreview();
if (disposed || !rootLevel.popup || rootLevel.popup.hidden || !rootLevel.activeCandidate) {
return;
}
if (!anchorConnected(rootLevel.activeCandidate)) {
hide();
return;
}
const rect = anchorRectFor(rootLevel.activeCandidate);
if (
rect.bottom < 0 || rect.top > window.innerHeight ||
rect.right < 0 || rect.left > window.innerWidth
) {
hide();
return;
}
positionPopup();
}
function invalidateStoredState(dictionaryChanged) {
audio?.retire();
mining?.retire();
discardPendingCandidate();
// A completed selection hit or miss also belongs to the old lookup state.
// Preserve Note's view ownership through its deferred refresh.
if (!hasProtectedNote()) activeSelectionCandidate = null;
for (const level of levels) {
level.view?.hideImagePreview();
if (level.popup?.inert) {
hide(level);
return;
}
level.lookupToken += 1;
level.retainedView = Boolean(level.currentViewRequest && !level.popup.hidden);
if (dictionaryChanged && level.popup && !level.popup.hidden) {
if (!hasProtectedNote(level.depth)) {
hide(level);
return;
}
if (level.noteEditing || level.pendingCustomAppends > 0) {
level.deferredDictionaryInvalidationRevision = dictionaryStateRevision;
}
}
}
}
function onNoteEditingChange(editing, level = rootLevel) {
if (level.retired) return;
level.noteEditing = editing === true;
if (level.noteEditing) {
cancelCandidateScan();
clearHideTimer();
} else {
flushDeferredNotes();
flushDictionaryPresentation();
}
}
function updateDictionaryPresentation() {
const context = { dictionaryPresentation: dictionaryPresentation(), dictionaryTabGroups: dictionaryTabGroups(),
...compactSummaryOptions(), ...imageSourceContext(), ...window.HDPopup.metadataOptions(options) };
for (const level of levels) {
if (level.popup && !level.popup.hidden) level.view.updateDictionaryPresentation(context);
}
}
function flushDictionaryPresentation() {
for (const level of levels) {
if (level.popup && !level.popup.hidden) level.view.flushDictionaryPresentation();
}
}
function adoptDictionaryState(stored) {
const next = normalizeDictionaryState(stored);
if (next.revision <= dictionaryStateRevision) {
return { adopted: false, dictionaryChanged: false, presentationChanged: false };
}
const dictionaryChanged = !sameDictionaryContents(next.dictionaries, dictionaries);
const presentationChanged = !sameDictionaries(next.dictionaries, dictionaries)
|| !sameDictionaries(next.groups, dictionaryGroups);
if (dictionaryChanged) clearDictionaryResources();
dictionaryStateRevision = next.revision;
dictionaries = next.dictionaries;
dictionaryGroups = next.groups;
return { adopted: true, dictionaryChanged, presentationChanged };
}
function onStorageChanged(changes, area) {
if (disposed || area !== "local") {
return;
}
let changed = false;
const previousLevelCount = levels.length;
let dictionaryChanged = false;
let presentationChanged = false;
if (changes.options) {
const adoption = adoptOptions(changes.options.newValue);
changed = adoption.lookupChanged;
presentationChanged = adoption.presentationChanged;
}
if (changes.dictionaryState) {
const adoption = adoptDictionaryState(changes.dictionaryState.newValue);
dictionaryChanged = adoption.dictionaryChanged;
presentationChanged ||= adoption.presentationChanged;
changed ||= dictionaryChanged;
}
if (changes.lookupStats) adoptLookupStatsDescriptor(changes.lookupStats.newValue, changes);
if (changed) {
invalidateStoredState(dictionaryChanged);
} else if (presentationChanged) updateDictionaryPresentation();
if (levels.length < previousLevelCount) {
flushDeferredNotes();
flushDictionaryPresentation();
}
}
function adoptOptions(stored) {
const revision = Number.isInteger(stored?.revision) && stored.revision >= 0 ? stored.revision : 0;
if (revision <= optionsStorageRevision) return { lookupChanged: false, presentationChanged: false };
const next = projectHostOptions(stored);
const lookupChanged = next.scanLength !== options.scanLength || next.maxResults !== options.maxResults
|| next.frequencyDictionary !== options.frequencyDictionary || next.frequencyOrder !== options.frequencyOrder
|| JSON.stringify(next.kanjiClickDictionary) !== JSON.stringify(options.kanjiClickDictionary);
const activationChanged = next.lookupMode !== options.lookupMode || next.activationKey !== options.activationKey;
const interactionChanged = activationChanged || next.hoverEnabled !== options.hoverEnabled
|| next.onlyScanJapaneseText !== options.onlyScanJapaneseText;
const scanDelayChanged = next.hoverDelayMs !== options.hoverDelayMs && scanTimer !== null;
const hideDelayChanged = next.popupHideDelayMs !== options.popupHideDelayMs && hideTimer !== null;
const columnsChanged = next.popupColumns !== options.popupColumns;
const sizeChanged = next.popupWidthPx !== options.popupWidthPx || next.popupHeightPx !== options.popupHeightPx
|| next.popupScalePercent !== options.popupScalePercent;
const toolbarChanged = next.popupToolbarPosition !== options.popupToolbarPosition;
const highlightChanged = next.sourceHighlightEnabled !== options.sourceHighlightEnabled;
const summaryChanged = next.showCompactDefinitionSummary !== options.showCompactDefinitionSummary
|| next.compactDefinitionSummaryCount !== options.compactDefinitionSummaryCount
|| next.compactDefinitionSummaryDictionary !== options.compactDefinitionSummaryDictionary;
const imageSourceChanged = JSON.stringify(next.popupImageSource) !== JSON.stringify(options.popupImageSource);
const metadataChanged = Object.entries(window.HDPopup.metadataOptions(next)).some(([key, value]) => value !== options[key]);
// The caller adopts the complete storage delivery before new summary work.
// A simultaneous dictionary replacement must invalidate the old view first.
const countsChanged = next.showLookupCounts !== options.showLookupCounts;
const ankiChanged = JSON.stringify(next.anki) !== JSON.stringify(options.anki);
const customButtonsChanged = JSON.stringify(next.customButtons) !== JSON.stringify(options.customButtons);
if (ankiChanged || next.definitionBlurAnkiMature !== options.definitionBlurAnkiMature) ankiMaturityEpoch++;
const blurChanged = ankiChanged || next.showLookupCounts !== options.showLookupCounts || DEFINITION_BLUR_KEYS
.some(key => next[key] !== options[key]);
const optionsArrived = optionsStorageRevision < 0;
const adoption = { lookupChanged,
presentationChanged: (summaryChanged || imageSourceChanged || metadataChanged) && next.hoverEnabled };
if (activationChanged) {
activationPressed = false;
activationCode = null;
}
optionsStorageRevision = revision;
options = next;
if (customButtonsChanged) {
for (const level of levels) level.view?.setCustomButtons(options.customButtons);
}
if (countsChanged) {
for (const level of levels) {
const request = level.currentViewRequest;
const entry = request?.lookupStats;
if (!entry) continue;
if (next.showLookupCounts) entry.needsRefresh = true;
paintLookupStatistics(request, level);
refreshLookupStatistics(request, level);
}
}
if (optionsArrived) {
for (const level of levels) {
const request = level.currentViewRequest;
if (request?.blur?.awaitingOptions) resolveDefinitionBlurOptions(request, level);
}
} else if (blurChanged) {
for (const level of levels) {
const request = level.currentViewRequest;
const blur = request?.blur;
if (!blur) continue;
discardStaleAnkiMaturity(request, level);
settleDefinitionBlur(request, level);
if (blur.state === "pending") checkDefinitionBlurMaturity(request, level);
// Disabling reveals at once; other edits apply to unrevealed views
// from their original display time. Note drafts are untouched.
if (!definitionBlurActive()) {
revealDefinitions(request, level);
} else armDefinitionBlurTimer(request, level);
}
}
audio?.update(options);
mining?.update(options);
appearance?.update(options);
if (sizeChanged) for (const level of levels) level.view?.hideImagePreview();
const cssChanged = customStyle?.update(options.customPopupCss);
if (highlightChanged) {
for (const level of levels) level.view?.setSourceHighlightEnabled(options.sourceHighlightEnabled);
}
if (toolbarChanged) {
for (const level of levels) {
if (level.popup && (!options.hoverEnabled || level.popup.hidden)) positionToolbar(level, null, true);
}
}
if (levels.length > options.popupNestingMaxDepth + 1) pruneLevels(options.popupNestingMaxDepth + 1);
// Masonry must measure the new inline width, not lay out the old width and
// wait for ResizeObserver to correct every card in a second frame.
if ((sizeChanged || toolbarChanged) && options.hoverEnabled && rootLevel.popup && !rootLevel.popup.hidden) {
positionPopup(rootLevel, toolbarChanged);
}
if ((columnsChanged || sizeChanged || cssChanged) && options.hoverEnabled) {
for (const level of levels) {
if (!level.popup?.hidden) level.view?.scheduleMasonry();
}
}
if (!options.hoverEnabled) {
setSelectionDrag(false);
lastPointer = null;
activationPressed = false;
activationCode = null;
hide();
}
else if (interactionChanged || scanDelayChanged) {
if (selectionIsUnchanged()) {
clearScanTimer();
clearHideTimer();
return adoption;
}
cancelCandidateScan();
clearHideTimer();
const popupLevel = activePointerLevel(lastPointer);
if (!hasProtectedNote() && !popupHasFocus() && (!pointerInPopup || popupLevel)) {
if (!activationAllowed()) {
if (popupLevel) cancelPendingHover(popupLevel);
else schedulePointerHide();
}
else if (lastPointer) scheduleScan();
}
} else if (hideDelayChanged) {
clearHideTimer();
scheduleHide();
}
return adoption;
}
function start() {
void loadOverlayMode();
// Startup awaits this snapshot before demonstrating its first selection.
let storageReady;
const reader = Object.freeze({ scanSelectedText });
globalThis.HDReaderReady = new Promise(resolve => {
storageReady = () => { resolve(reader); };
});
try {
chrome.storage.onChanged.addListener(onStorageChanged);
// Optional like the worker's commands API: reader smoke hosts have no runtime messages.
chrome.runtime.onMessage?.addListener(onReaderCommand);
chrome.storage.local.get({ dictionaryState: null, options: DEFAULT_OPTIONS, lookupStats: null }, (stored) => {
try {
if (disposed || chrome.runtime.lastError) return;
const optionsAdoption = adoptOptions(stored && stored.options);
const adoption = adoptDictionaryState(stored && stored.dictionaryState);
adoptLookupStatsDescriptor(stored && stored.lookupStats);
if (optionsAdoption.lookupChanged || adoption.dictionaryChanged) {
invalidateStoredState(adoption.dictionaryChanged);
} else if (optionsAdoption.presentationChanged || adoption.presentationChanged) updateDictionaryPresentation();
} finally {
storageReady();
}
});
} catch {
// Without storage access the defaults are still usable.
storageReady();
}
// Capture so a page that stops propagation on its own text still gets
// scanned; passive so the hot pointer and scroll paths can never delay the
// page's own scrolling. A press stays cancelable: an overlay drag is the
// reader's, not the browser's.
const observe = { capture: true, passive: true };
document.addEventListener("mousemove", onMouseMove, observe);
document.addEventListener("mousedown", onMouseDown, { capture: true });
document.addEventListener("mouseup", onMouseUp, observe);
document.addEventListener("selectionchange", onSelectionChange);
document.addEventListener("focusin", onPageFocusIn, observe);
document.addEventListener("mouseout", onMouseOut, observe);
document.addEventListener("keydown", onKeyDown, true);
document.addEventListener("keyup", onKeyUp, true);
window.addEventListener("scroll", onScroll, observe);
window.addEventListener("blur", onWindowBlur);
window.addEventListener("pagehide", onPageHide);
window.addEventListener("pageshow", onPageShow);
window.addEventListener("resize", refreshPageZoom);
refreshPageZoom();
}
let subminerAudioSource = "";
const disconnectSubminer = globalThis.SubMinerHachidori?.connect({
hide,
clear() { hide(); highlighter?.clearAll(); },
action(action) { runKeybindAction({ action }, new KeyboardEvent("keydown")); },
keydown: onKeyDown,
scroll(deltaX, deltaY) {
levels.findLast(level => level.popup && !level.popup.hidden)?.view?.scrollElement.scrollBy({ left: deltaX, top: deltaY });
},
cycleAudio(direction) {
const sources = options.audioSources.filter(source => source.enabled);
if (!sources.length) return;
const previous = sources.findIndex(source => source.id === subminerAudioSource);
const index = previous < 0 ? (direction === 1 ? 0 : sources.length - 1)
: (previous + direction + sources.length) % sources.length;
subminerAudioSource = sources[index].id;
runKeybindAction({ action: "playAudioFromSource", argument: subminerAudioSource }, new KeyboardEvent("keydown"));
},
});
start();
}());