/* * 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 ("
\n", 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 , 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 ("食べ ます") // 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