/* * Hoshidicts popup view. * * Keeps rendering and source highlighting out of the content script's pointer * and messaging state machine. Ported from GameSentenceMiner PR #549 * (GSM_Overlay/features/hoshidicts/popup.js) with its Anki mining and audio * surfaces removed. Popup structure is adapted from Hoshi Reader: * https://github.com/Manhhao/Hoshi-Reader/tree/c31c9d0ce376ff83bf6a91d908bf9f8e0fb4947b/Features/Popup * * Copyright (C) 2026 Manhhao * SPDX-License-Identifier: GPL-3.0-or-later */ (function (root, factory) { const api = factory(); if (typeof module !== "undefined" && module.exports) { module.exports = api; } if (root) { root.HDPopup = api; } }(typeof window !== "undefined" ? window : globalThis, function () { "use strict"; const DEFAULT_INITIAL_RESULT_COUNT = 1; const DEFAULT_MAX_METADATA_TAGS = 12; const METADATA_OPTION_KEYS = ["averageFrequency", "showFrequencyDictionaryNames", "showPitchAccentFurigana", "pitchAccentFuriganaDictionary", "showPitchAccentBadge", "hidePopupGrammarTags"]; function metadataOptions(context) { return Object.fromEntries(METADATA_OPTION_KEYS.map(key => [key, context[key]])); } function frequencyModes(context) { return context.averageFrequency === true ? JSON.stringify((context.dictionaryPresentation || []).map(({ title, frequencyMode }) => [title, frequencyMode])) : ""; } const DEFAULT_HIGHLIGHT_NAME = "gsm-hoshidicts-match"; // Adopted sheets follow ordinary dictionary styles, even ones appended later. // Shadow DOM provides the scope; wrapping user rules would change their CSS. function createCustomPopupStyle(shadow) { let current = ""; let sheet; const detach = () => { shadow.adoptedStyleSheets = shadow.adoptedStyleSheets.filter(value => value !== sheet); }; return { update(css) { if (css === current) return false; if (css) { sheet ??= new shadow.ownerDocument.defaultView.CSSStyleSheet(); sheet.replaceSync(css); if (!current) shadow.adoptedStyleSheets = [...shadow.adoptedStyleSheets, sheet]; } else detach(); current = css; return true; }, destroy() { if (current) detach(); }, }; } // Both the live reader and Settings preview use the same palette and sizing // boundary. Colour edits touch styles only, not result projection or layout. function createPopupAppearance(host) { const document = host.ownerDocument; const window = document.defaultView; let current = {}; let highlightSheet; function refreshHighlight() { const primary = window.getComputedStyle(host).getPropertyValue("--hoshidicts-palette-primary").trim(); // The preview's linked palette is asynchronous; its load event retries. if (!primary) return; if (!highlightSheet) { highlightSheet = new window.CSSStyleSheet(); highlightSheet.insertRule(`::highlight(${DEFAULT_HIGHLIGHT_NAME}) {}`, 0); document.adoptedStyleSheets = [...document.adoptedStyleSheets, highlightSheet]; } const mix = `color-mix(in srgb, ${primary} ${current.popupTheme === "high-contrast" ? 56 : 34}%, transparent)`; highlightSheet.cssRules[0].style.setProperty("background-color", mix); } const colorScheme = window.matchMedia?.("(prefers-color-scheme: dark)"); function applyTheme(theme = current.popupTheme) { let resolvedTheme = theme; if (resolvedTheme === "auto") resolvedTheme = colorScheme?.matches ? "dark" : "light"; host.dataset.hoshidictsTheme = resolvedTheme; } const colorSchemeChanged = () => { if (current.popupTheme !== "auto") return; applyTheme(); refreshHighlight(); }; colorScheme?.addEventListener("change", colorSchemeChanged); return { update(options) { const themeChanged = current.popupTheme !== options.popupTheme; if (themeChanged) applyTheme(options.popupTheme); // Only CSS hides the button: it stays bound, so autoplay and keybinds still play. if (options.showPopupAudioButton === false) host.dataset.hoshidictsAudioButton = "hidden"; else delete host.dataset.hoshidictsAudioButton; for (const [key, variable, unit] of [ ["popupOpacityPercent", "opacity", "%"], ["popupWidthPx", "width", "px"], ["popupHeightPx", "height", "px"], ["popupScalePercent", "scale", "%"], ]) { if (current[key] !== options[key]) host.style.setProperty(`--gsm-hoshidicts-popup-${variable}`, `${options[key]}${unit}`); } current = { popupTheme: options.popupTheme, popupWidthPx: options.popupWidthPx, popupHeightPx: options.popupHeightPx, popupOpacityPercent: options.popupOpacityPercent, popupScalePercent: options.popupScalePercent }; if (themeChanged) refreshHighlight(); }, refreshHighlight, destroy() { colorScheme?.removeEventListener("change", colorSchemeChanged); if (highlightSheet) document.adoptedStyleSheets = document.adoptedStyleSheets.filter(sheet => sheet !== highlightSheet); }, }; } const MASONRY_GAP_PX = 8; const DEFINITION_BLUR_STATES = new Set(["pending", "blurred"]); const DEFAULT_COMPACT_DEFINITION_SUMMARY_COUNT = 3; const MIN_COMPACT_DEFINITION_SUMMARY_COUNT = 1; const MAX_COMPACT_DEFINITION_SUMMARY_COUNT = 6; const COMPACT_DEFINITION_MAX_CHARACTERS = 240; const COMPACT_DEFINITION_MAX_NODES = 512; const COMPACT_DEFINITION_MAX_DEPTH = 16; const COMPACT_DEFINITION_LETTER = /[A-Za-zぁ-ゟァ-ヿ㐀-鿿A-Za-z]/u; const COMPACT_DEFINITION_JAPANESE = /[ぁ-ゟァ-ヿ㐀-鿿]/u; const COMPACT_DEFINITION_BLOCK_TAGS = new Set([ "article", "blockquote", "dd", "div", "dt", "h1", "h2", "h3", "h4", "h5", "h6", "li", "p", "section", "td", "th", "tr", ]); const COMPACT_DEFINITION_IGNORED_TAGS = new Set([ "audio", "button", "canvas", "iframe", "img", "input", "rp", "rt", "script", "source", "style", "svg", "video", ]); const DICTIONARY_DISPLAY_ALIASES = new Map([ ["Jitendex.org", "Jitendex"], ]); const DICTIONARY_DECORATION_PATTERN = /\s+(?:\[([^\]]+)\]|\(([^()]*)\))\s*$/u; const DICTIONARY_DATE_DECORATION_PATTERN = /^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}$/u; const DICTIONARY_VERSION_DECORATION_PATTERN = /^(?:(?:version|ver(?:sion)?|v|revision|rev|release)\s*[:#.-]?\s*)?v?\d+(?:\.\d+)+(?:[-+][0-9a-z.-]+)?$/iu; const DICTIONARY_LABELED_REVISION_PATTERN = /^(?:version|ver(?:sion)?|v|revision|rev|release)\s*[:#.-]?\s*v?\d+(?:\.\d+)*(?:[-+][0-9a-z.-]+)?$/iu; const DEINFLECTION_STRINGS = new Map([ ["en", { steps: "Deinflection steps", summary: (matched, deinflected) => `Why this matched: ${matched} became ${deinflected}`, }], ["ja", { steps: "活用解除の手順", summary: (matched, deinflected) => `一致した理由: ${matched} から ${deinflected} に戻しました`, }], ["uk", { steps: "Кроки відновлення словникової форми", summary: (matched, deinflected) => `Чому це збіглося: ${matched} перетворено на ${deinflected}`, }], ]); const DEINFLECTION_TEXT_MAX_BYTES = 4096; const DEINFLECTION_STEP_MAX_COUNT = 31; const DEINFLECTION_OMITTED_MARKER = "…"; const RENDER_DIAGNOSTIC_TEXT_MAX_BYTES = 512; function utf8Length(value) { return typeof TextEncoder === "function" ? new TextEncoder().encode(value).length : unescape(encodeURIComponent(value)).length; } function truncateUtf8(value, maxBytes = DEINFLECTION_TEXT_MAX_BYTES) { if (utf8Length(value) <= maxBytes) return value; let low = 0; let high = value.length; while (low < high) { const middle = Math.ceil((low + high) / 2); const end = value.charCodeAt(middle - 1) >= 0xD800 && value.charCodeAt(middle - 1) <= 0xDBFF ? middle - 1 : middle; if (utf8Length(value.slice(0, end)) <= maxBytes) low = middle; else high = middle - 1; } const end = value.charCodeAt(low - 1) >= 0xD800 && value.charCodeAt(low - 1) <= 0xDBFF ? low - 1 : low; return value.slice(0, end); } function diagnosticText(value, fallback = "unknown") { return typeof value === "string" && value ? truncateUtf8(value, RENDER_DIAGNOSTIC_TEXT_MAX_BYTES) : fallback; } function dictionaryStableId(title, presentation) { const dictionary = Array.isArray(presentation) ? presentation.find((entry) => entry?.title === title) : null; return diagnosticText(dictionary?.id); } function structuredContentRenderError(error, { definitionIndex, dictionary, dictionaryId, resultIndex, term, }) { if (error?.code !== "structured-content-limit") return error; const title = diagnosticText(dictionary); const id = diagnosticText(dictionaryId); const expression = diagnosticText(term?.expression, ""); const reading = diagnosticText(term?.reading, ""); const entry = `entry ${resultIndex + 1}, definition ${definitionIndex + 1}`; const word = expression ? `, term ${JSON.stringify(expression)}${reading ? `, reading ${JSON.stringify(reading)}` : ""}` : ""; const message = `Dictionary ${JSON.stringify(title)} (stable ID ${JSON.stringify(id)}) could not render ${entry}${word}: ${error.message}`; const contextual = new RangeError(message, { cause: error }); contextual.code = "dictionary-structured-content-limit"; contextual.definitionIndex = definitionIndex; contextual.dictionaryId = id; contextual.dictionaryTitle = title; contextual.entryIndex = resultIndex; contextual.originalStack = error.stack; contextual.termExpression = expression; contextual.termReading = reading; contextual.userDetail = message; contextual.userTitle = "Dictionary content could not be rendered."; return contextual; } function deinflectionSteps(result) { return Array.isArray(result.trace) ? result.trace.filter((step) => typeof step?.name === "string" && step.name.length > 0) : []; } function buildDeinflectionDisclosure(documentRef, result, locale) { const { matched, deinflected } = result; if (typeof matched !== "string" || !matched || typeof deinflected !== "string" || !deinflected || matched === deinflected) return null; const allSteps = deinflectionSteps(result); const steps = allSteps.slice(0, DEINFLECTION_STEP_MAX_COUNT); if (steps.length === 0) return null; const strings = DEINFLECTION_STRINGS.get(locale.toLowerCase().split("-")[0]) ?? DEINFLECTION_STRINGS.get("en"); const details = documentRef.createElement("details"); details.className = "gsm-hoshidicts-deinflection"; const summary = documentRef.createElement("summary"); summary.setAttribute("aria-label", strings.summary( truncateUtf8(matched), truncateUtf8(deinflected), )); const path = documentRef.createElement("span"); path.className = "gsm-hoshidicts-deinflection-path"; for (const [index, endpoint] of [matched, deinflected].entries()) { if (index > 0) { const arrow = documentRef.createElement("span"); arrow.setAttribute("aria-hidden", "true"); arrow.textContent = " → "; path.appendChild(arrow); } const value = documentRef.createElement("span"); value.className = "gsm-hoshidicts-deinflection-endpoint"; value.textContent = truncateUtf8(endpoint); path.appendChild(value); } summary.appendChild(path); const list = documentRef.createElement("ol"); list.className = "gsm-hoshidicts-deinflection-steps"; list.setAttribute("aria-label", strings.steps); for (const step of steps) { const item = documentRef.createElement("li"); const name = documentRef.createElement("span"); name.className = "gsm-hoshidicts-deinflection-step-name"; name.textContent = truncateUtf8(step.name); item.appendChild(name); if (typeof step.description === "string" && step.description) { const description = documentRef.createElement("span"); description.className = "gsm-hoshidicts-deinflection-step-description"; description.textContent = truncateUtf8(step.description); item.appendChild(description); } list.appendChild(item); } if (steps.length < allSteps.length) { const omitted = documentRef.createElement("li"); omitted.textContent = DEINFLECTION_OMITTED_MARKER; list.appendChild(omitted); } details.append(summary, list); return details; } function isDictionaryDecoration(value) { const decoration = String(value || "").trim(); return DICTIONARY_DATE_DECORATION_PATTERN.test(decoration) || DICTIONARY_VERSION_DECORATION_PATTERN.test(decoration) || DICTIONARY_LABELED_REVISION_PATTERN.test(decoration); } function cleanDictionaryDisplayName(value) { const canonicalName = String(value || "").trim(); let displayName = canonicalName; while (displayName) { const suffix = DICTIONARY_DECORATION_PATTERN.exec(displayName); const decoration = suffix && (suffix[1] ?? suffix[2]); if (!suffix || !isDictionaryDecoration(decoration)) { break; } displayName = displayName.slice(0, suffix.index).trimEnd(); } displayName = DICTIONARY_DISPLAY_ALIASES.get(displayName) || displayName; return displayName || canonicalName; } function createDictionaryDisplayNames(dictionaries, presentation = []) { const aliases = new Map(); for (const entry of presentation) { const title = typeof entry?.title === "string" ? entry.title : ""; const displayName = typeof entry?.displayName === "string" ? entry.displayName.trim() : ""; if (title && displayName && !aliases.has(title)) { aliases.set(title, displayName); } } const uniqueDictionaries = [...new Set(dictionaries)]; const cleanedNames = new Map(); const counts = new Map(); for (const dictionary of uniqueDictionaries) { const cleanedName = cleanDictionaryDisplayName(dictionary); cleanedNames.set(dictionary, cleanedName); const preferredName = aliases.get(dictionary) || cleanedName; counts.set(preferredName, (counts.get(preferredName) || 0) + 1); } const candidates = new Map(); const candidateCounts = new Map(); for (const dictionary of uniqueDictionaries) { const alias = aliases.get(dictionary); const cleanedName = cleanedNames.get(dictionary); const preferredName = alias || cleanedName; const candidate = counts.get(preferredName) === 1 ? preferredName : alias ? `${alias} (${cleanedName})` : dictionary; candidates.set(dictionary, candidate); candidateCounts.set(candidate, (candidateCounts.get(candidate) || 0) + 1); } const displayNames = new Map(); const usedNames = new Set(); for (const dictionary of uniqueDictionaries) { const alias = aliases.get(dictionary); let displayName = candidates.get(dictionary); if (candidateCounts.get(displayName) > 1 && alias) { displayName = `${alias} (${dictionary})`; } if (usedNames.has(displayName)) { const baseName = `${displayName} — ${dictionary}`; displayName = baseName; let suffix = 2; while (usedNames.has(displayName)) { displayName = `${baseName} ${suffix}`; suffix += 1; } } usedNames.add(displayName); displayNames.set(dictionary, displayName); } return displayNames; } function createTag(documentRef, text, description, kind) { const tag = documentRef.createElement("span"); tag.className = `gsm-hoshidicts-tag gsm-hoshidicts-tag-${kind}`; tag.textContent = text; if (description) { tag.title = description; } return tag; } function formatCompactFrequencyNumber(value) { const absoluteValue = Math.abs(value); const units = [ { minimum: 1_000_000_000, suffix: "b" }, { minimum: 1_000_000, suffix: "m" }, { minimum: 1_000, suffix: "k" }, ]; const unit = units.find(({ minimum }) => absoluteValue >= minimum); if (!unit) { return String(value); } const roundedValue = Math.round((value / unit.minimum) * 10) / 10; return `${roundedValue}${unit.suffix}`; } const JITEN_KANA_FREQUENCY_MARKER = "㋕"; function isKanaFrequency(frequency) { return typeof frequency.displayValue === "string" && frequency.displayValue.trim().endsWith(JITEN_KANA_FREQUENCY_MARKER); } function formatCompactFrequencyValue(frequency) { return `${formatCompactFrequencyNumber(frequency.value)}${ isKanaFrequency(frequency) ? JITEN_KANA_FREQUENCY_MARKER : "" }`; } function formatFrequencyValue(frequency) { if (typeof frequency.displayValue === "string") { const displayValue = frequency.displayValue.trim(); if (!displayValue) { return null; } const numericText = isKanaFrequency(frequency) ? displayValue.slice(0, -JITEN_KANA_FREQUENCY_MARKER.length) : displayValue; const numericDisplayValue = Number(numericText.replaceAll(",", "")); if (!Number.isFinite(numericDisplayValue) || numericDisplayValue !== frequency.value) { return displayValue; } if (isKanaFrequency(frequency)) { return formatCompactFrequencyValue(frequency); } } return formatCompactFrequencyNumber(frequency.value); } function frequencyNumberForAverage(frequency) { return Number.isFinite(frequency.value) && frequency.value > 0 ? frequency.value : null; } function createFrequencyTag( documentRef, group, dictionaryDisplayName, frequencies, showDictionaryName = false ) { const tag = createTag(documentRef, "", group.dictionary, "frequency"); tag.dataset.dictionary = group.dictionary; if (showDictionaryName) { const source = documentRef.createElement("span"); source.className = "gsm-hoshidicts-frequency-source"; source.textContent = dictionaryDisplayName; tag.appendChild(source); } const body = documentRef.createElement("span"); body.className = "gsm-hoshidicts-frequency-body"; tag.appendChild(body); const values = documentRef.createElement("span"); values.className = "gsm-hoshidicts-frequency-values"; body.appendChild(values); const frequencyLabels = []; frequencies.forEach(({ display, frequency }, index) => { if (index > 0) { values.append(" · "); } const value = documentRef.createElement("span"); value.className = "gsm-hoshidicts-frequency-value"; value.dataset.frequency = String(frequency.value); value.textContent = display; let detail = String(frequency.value); if (isKanaFrequency(frequency)) { detail = `Kana frequency: ${frequency.value}`; } else if (typeof frequency.displayValue === "string" && frequency.displayValue !== detail) { detail = `${frequency.value} (${frequency.displayValue})`; } value.title = detail; frequencyLabels.push(detail); values.appendChild(value); }); tag.setAttribute( "aria-label", `${group.dictionary}: ${frequencyLabels.join(", ")}` ); return tag; } function createFrequencyTags( documentRef, result, dictionaryPresentation, maximumTags, averageFrequency = false, showFrequencyDictionaryNames = false ) { if (averageFrequency) { const modes = new Map(dictionaryPresentation.map(({ title, frequencyMode }) => [title, frequencyMode])); const aggregates = new Map(); const seen = new Set(); for (const group of result.term.frequencies) { if (seen.has(group.dictionary)) continue; for (const frequency of group.frequencies) { const value = frequencyNumberForAverage(frequency); if (value !== null) { const mode = modes.get(group.dictionary); const label = mode === "rank-based" ? { accessible: "Rank average", display: "Avg rank" } : mode === "occurrence-based" ? { accessible: "Occurrence average", display: "Avg count" } : { accessible: "Frequency average (unspecified)", display: "Avg frequency" }; const aggregate = aggregates.get(label.accessible) || { count: 0, reciprocalSum: 0, display: label.display }; aggregate.count += 1; aggregate.reciprocalSum += 1 / value; aggregates.set(label.accessible, aggregate); seen.add(group.dictionary); break; } } } return Array.from(aggregates, ([label, { count, reciprocalSum, display }]) => { const value = Math.floor(count / reciprocalSum); return createFrequencyTag( documentRef, { dictionary: label }, display, [{ display: formatCompactFrequencyNumber(value), frequency: { value, displayValue: null } }], // These labels identify units, not a source dictionary. true ); }); } const tags = []; const seen = new Set(); const dictionaryDisplayNames = createDictionaryDisplayNames( result.term.frequencies.map(({ dictionary }) => dictionary), dictionaryPresentation ); for (const group of result.term.frequencies) { const frequencies = []; const seenFrequencies = new Set(); for (const frequency of group.frequencies) { const originalDisplay = formatFrequencyValue(frequency); if (originalDisplay === null) { continue; } const display = showFrequencyDictionaryNames ? originalDisplay : formatCompactFrequencyValue(frequency); const key = JSON.stringify([frequency.value, originalDisplay]); if (!seenFrequencies.has(key)) { seenFrequencies.add(key); frequencies.push({ display, frequency }); } } frequencies.sort((left, right) => Number(isKanaFrequency(right.frequency)) - Number(isKanaFrequency(left.frequency)) ); const key = JSON.stringify([ group.dictionary, frequencies.map(({ display, frequency }) => [frequency.value, display]), ]); if ( frequencies.length > 0 && !seen.has(key) && tags.length < maximumTags ) { seen.add(key); tags.push(createFrequencyTag( documentRef, group, dictionaryDisplayNames.get(group.dictionary) || group.dictionary, frequencies, showFrequencyDictionaryNames )); } } return tags; } function createPitchTag( documentRef, group, dictionaryDisplayName, pitch, reading, buildPitchAccentMorae ) { const positionText = [`[${pitch.position}]`, pitch.pattern].filter(Boolean).join(" "); const bodyText = reading ? `${reading} ${positionText}` : positionText; const tag = createPronunciationTag(documentRef, group, dictionaryDisplayName, bodyText, "pitch"); const morae = buildPitchAccentMorae(reading, pitch.position); if (morae === null) return tag; // The same contour the header furigana draws, so every dictionary's // accent reads as a graph; the text stays in the title and aria-label. const contour = documentRef.createElement("span"); contour.className = "gsm-hoshidicts-pitch-contour"; for (const mora of morae) { const span = documentRef.createElement("span"); span.className = "gsm-hoshidicts-pitch-mora"; span.dataset.pitchLevel = mora.level; if (mora.transition) span.dataset.pitchTransition = mora.transition; span.textContent = mora.text; contour.appendChild(span); } const position = documentRef.createElement("span"); position.className = "gsm-hoshidicts-pitch-position"; position.textContent = positionText; tag.firstChild.replaceChildren(contour, position); return tag; } function updatePronunciationLabel(tag, dictionaryDisplayName) { const dictionary = tag.dataset.dictionary; const source = dictionaryDisplayName !== dictionary && !dictionaryDisplayName.endsWith(` (${dictionary})`) ? `${dictionaryDisplayName} (${dictionary})` : dictionary; const label = `${source}: ${tag.dataset.pronunciation}`; if (tag.title === label) return false; tag.title = label; tag.setAttribute("aria-label", label); return true; } function createPronunciationTag(documentRef, group, dictionaryDisplayName, bodyText, kind) { const tag = createTag(documentRef, "", "", kind); tag.dataset.dictionary = group.dictionary; tag.dataset.pronunciation = bodyText; const body = documentRef.createElement("span"); body.className = `gsm-hoshidicts-${kind}-body`; body.textContent = bodyText; tag.appendChild(body); updatePronunciationLabel(tag, dictionaryDisplayName); return tag; } function formatLookupCount(label, value) { if (!Number.isSafeInteger(value) || value < 0) { return null; } return `${label} ${value} ${value === 1 ? "time" : "times"}`; } function intersectHighlightRect(rect, clip) { const left = Math.max(rect.left, clip.left), right = Math.min(rect.right, clip.right); const top = Math.max(rect.top, clip.top), bottom = Math.min(rect.bottom, clip.bottom); return right > left && bottom > top ? { left, right, top, bottom } : null; } function subtractHighlightRect(rect, cover) { const overlap = intersectHighlightRect(rect, cover); if (!overlap) return [rect]; return [ { ...rect, bottom: overlap.top }, { ...rect, top: overlap.bottom }, { left: rect.left, right: overlap.left, top: overlap.top, bottom: overlap.bottom }, { left: overlap.right, right: rect.right, top: overlap.top, bottom: overlap.bottom }, ].filter(piece => piece.right > piece.left && piece.bottom > piece.top); } // Range.getClientRects() also includes fully selected inline element boxes. // Text-node subranges avoid painting their padding or tinting text twice. function highlightTextFragments(documentRef, match) { const fragments = []; for (const range of match.ranges) { const root = range.commonAncestorContainer; const walker = documentRef.createTreeWalker(root, 4); let node = root.nodeType === 3 ? root : walker.nextNode(); while (node) { if (range.intersectsNode(node)) { const start = node === range.startContainer ? range.startOffset : 0; const end = node === range.endContainer ? range.endOffset : node.length; if (end > start) { const fragment = documentRef.createRange(); fragment.setStart(node, start); fragment.setEnd(node, end); fragments.push(fragment); } } node = walker.nextNode(); } } return fragments; } function createHighlightFallback(windowRef, documentRef, root) { const layer = documentRef.createElement("div"); layer.className = "gsm-hoshidicts-source-highlight-layer"; layer.setAttribute("aria-hidden", "true"); root.appendChild(layer); const owners = new Map(); let frame = null; let resizeTargets = new Set(); let geometryTargets = new Map(); let motionTargets = new Map(); let coverTargets = new Set(); let layoutRoots = new Set(); let pageOccluders = null; let stylesheetState = null; let stylesheetTimer = null; let polledStyles = false; let watchedSheets = new Set(); const styleMedia = new Map(); let coverMotion = new WeakSet(); const animationWatches = new Map(); const motionRoots = new Set(); const motionStarts = ["animationstart", "transitionrun"]; const motionEnds = ["animationend", "animationcancel", "transitionend", "transitioncancel"]; const motionEvents = [...motionStarts, ...motionEnds, "pointerover", "pointerout", "focusin", "focusout"]; const resize = typeof windowRef.ResizeObserver === "function" ? new windowRef.ResizeObserver(schedule) : null; const affectsGeometry = change => !layer.contains(change.target) && (change.type === "attributes" || change.type === "characterData" || [...change.addedNodes, ...change.removedNodes].some(node => node !== layer)); const geometry = new windowRef.MutationObserver(changes => { if (layoutMutations(changes)) schedule(); }); windowRef.addEventListener("scroll", schedule, true); root.addEventListener("scroll", schedule, true); windowRef.addEventListener("resize", layoutChanged); function schedule() { if (frame === null) frame = windowRef.requestAnimationFrame(paint); } function layoutChanged() { pageOccluders = null; schedule(); } function stylesheetRules(sheet) { try { return sheet.cssRules; } catch (error) { if (error.name !== "SecurityError") throw error; // Cross-origin rules are unreadable and cannot be edited by page CSSOM // either. Their load events still refresh effective styles. return []; } } function stylesheetSnapshot() { const seen = new Set(); const snapshot = []; function sheetState(sheet) { // Object references retain shared-sheet identity without serializing // each rule into a nested array and copying all CSS through JSON. snapshot.push(sheet, sheet.disabled, sheet.media?.mediaText); if (seen.has(sheet)) return; seen.add(sheet); for (const rule of stylesheetRules(sheet)) { snapshot.push(rule.cssText); if (rule.styleSheet) sheetState(rule.styleSheet); } snapshot.push(null); } for (const tree of layoutRoots) { if (tree === root && root instanceof windowRef.ShadowRoot) continue; snapshot.push(tree); for (const sheet of [...(tree.styleSheets || []), ...(tree.adoptedStyleSheets || [])]) sheetState(sheet); } watchedSheets = seen; return snapshot; } function refreshStyleMedia() { // These common preference changes also cover unreadable page CSS. const queries = new Set(["(prefers-color-scheme: dark)", "(prefers-reduced-motion: reduce)"]); function collect(rules) { for (const rule of rules) { if (rule.media?.mediaText) queries.add(rule.media.mediaText); const nested = rule.cssRules; if (nested?.length) collect(nested); } } for (const sheet of watchedSheets) { if (sheet.media?.mediaText) queries.add(sheet.media.mediaText); collect(stylesheetRules(sheet)); } for (const [query, media] of styleMedia) { if (!queries.has(query)) { media.removeEventListener("change", layoutChanged); styleMedia.delete(query); } } for (const query of queries) { if (styleMedia.has(query)) continue; const media = windowRef.matchMedia(query); media.addEventListener("change", layoutChanged); styleMedia.set(query, media); } } function checkLayoutState() { const next = stylesheetSnapshot(); if (next.length !== stylesheetState.length || next.some((value, index) => value !== stylesheetState[index])) { stylesheetState = next; polledStyles = true; layoutChanged(); } refreshAnimations(readAnimations()); } function stylesheetLoaded(event) { if (event.target.localName === "style" || (event.target.localName === "link" && event.target.relList.contains("stylesheet"))) layoutChanged(); } function changesCoverMembership(change) { // Our shadow contents are excluded from the page catalogue. Their layout // still matters, but cannot add a page header or change its selectors. if (root instanceof windowRef.ShadowRoot && change.target.getRootNode() === root) return false; const element = change.target.nodeType === 1 ? change.target : change.target.parentElement; if (change.type === "attributes" || element?.localName === "style") return true; // Text can change :dir() beneath automatic-direction elements even when // both old and new values are non-empty. if (element?.closest('[dir="auto" i], bdi')) return true; if (change.type === "characterData") { return change.target.nodeType === 3 && (change.oldValue === "") !== (change.target.data === ""); } const added = [...change.addedNodes], removed = [...change.removedNodes]; if ([...added, ...removed].some(node => node.nodeType === 1)) return true; const hasText = nodes => nodes.some(node => node.nodeType === 3 && node.data !== ""); // Non-empty text replacement leaves :empty/:has membership unchanged. return hasText(added) !== hasText(removed); } function layoutMutations(changes) { const relevant = changes.filter(affectsGeometry); if (relevant.some(changesCoverMembership)) pageOccluders = null; return relevant.length > 0; } function isCoverPosition(position) { return position === "fixed" || position === "sticky"; } function isPageElement(element) { return element !== root.host && !layer.contains(element) && !element.closest(".gsm-hoshidicts-popup"); } function isPageCover(element, style) { return isCoverPosition(style.position) || element.localName === "dialog" || element.hasAttribute("popover"); } function isCoverMotion(animation, frames = animation.effect.getKeyframes()) { return (animation.playState === "running" || animation.playState === "paused") && frames.some(keyframe => isCoverPosition(keyframe.position)); } function isRunningMotion(animation) { return animation.playState === "running" && animation.playbackRate !== 0; } function isMotionTarget(target) { for (const [element, subtree] of motionTargets) { if (element === target || (subtree && element.contains(target))) return true; } return false; } function readAnimations() { const animations = new Set(); for (const tree of motionRoots) for (const animation of tree.getAnimations()) animations.add(animation); return [...animations].filter(animation => motionRoots.has(animation.effect.target.getRootNode())) .map(animation => ({ animation, effect: animation.effect, target: animation.effect.target, frames: animation.effect.getKeyframes() })); } function unwatchAnimation(animation, record) { animation.removeEventListener("finish", record.listener); animation.removeEventListener("cancel", record.listener); animationWatches.delete(animation); } function refreshAnimations(animations, discovering = false) { const current = new Set(); for (const { animation, effect, target, frames } of animations) { const cover = isPageElement(target) && frames.some(keyframe => isCoverPosition(keyframe.position)); if (!cover && !isMotionTarget(target)) continue; current.add(animation); let record = animationWatches.get(animation); const structure = JSON.stringify([frames, effect.getTiming()]); // Running frames already follow time. Paused/seeking/zero-rate effects // need a wake when their time, timing or keyframes change without events. const state = JSON.stringify([animation.playState, animation.playbackRate, isRunningMotion(animation) ? null : animation.currentTime]); const changed = !record || record.target !== target || record.effect !== effect || record.structure !== structure || record.state !== state; const membership = (record?.cover ?? false) !== cover || (cover && (!record || record.target !== target || record.effect !== effect || record.structure !== structure)); if (!record) { record = { listener() { if (isPageElement(record.target) && coverMotionChanged(record.target, true)) layoutChanged(); else schedule(); } }; animation.addEventListener("finish", record.listener); animation.addEventListener("cancel", record.listener); animationWatches.set(animation, record); } Object.assign(record, { target, effect, cover, structure, state }); if (changed && !discovering) { if (membership) layoutChanged(); else schedule(); } } for (const [animation, record] of animationWatches) { if (current.has(animation)) continue; unwatchAnimation(animation, record); if (!discovering) { if (record.cover && coverMotion.has(record.target)) layoutChanged(); else schedule(); } } } function coverMotionChanged(target, ended) { const tracked = coverMotion.has(target); const active = target.getAnimations().some(animation => isCoverMotion(animation)); if (active) { coverMotion.add(target); return !tracked; } if (tracked) { coverMotion.delete(target); return true; } return ended && isPageCover(target, windowRef.getComputedStyle(target)) !== (pageOccluders?.includes(target) ?? false); } function sourceMotion(event) { const target = event.target; const ended = motionEnds.includes(event.type); // A currently static element may become a cover halfway through motion. // Track only effects with cover-position keyframes, not every animation // on the page. Keep paused effects until they finish or are cancelled. if ((ended || motionStarts.includes(event.type)) && isPageElement(target) && coverMotionChanged(target, ended)) { layoutChanged(); return; } for (const [target, subtree] of motionTargets) { if (target === event.target || (subtree && target.contains(event.target)) || (event.relatedTarget !== undefined && target instanceof windowRef.Element && target.contains(event.target) !== target.contains(event.relatedTarget))) { if (ended) schedule(); else layoutChanged(); return; } } } function unwatchMotion(target) { for (const type of motionEvents) target.removeEventListener(type, sourceMotion, true); target.removeEventListener("load", stylesheetLoaded, true); motionRoots.delete(target); } function reconcileTargets() { motionTargets = new Map(geometryTargets); for (const target of coverTargets) if (!motionTargets.has(target)) motionTargets.set(target, false); const nextResizeTargets = new Set([...motionTargets.keys()].filter(target => target instanceof windowRef.Element)); for (const target of nextResizeTargets) if (!resizeTargets.has(target)) resize?.observe(target); for (const target of resizeTargets) if (!nextResizeTargets.has(target)) resize?.unobserve(target); resizeTargets = nextResizeTargets; } function observeGeometry(targets) { geometry.disconnect(); const nextMotionRoots = new Set(); for (const target of targets.keys()) { if (target === documentRef || target instanceof windowRef.ShadowRoot) { nextMotionRoots.add(target); } } // A sibling can move the source inside a fixed-size ancestor without // resizing any observed source. Layout notifications therefore span its // containing trees; native range observers remain source-scoped. const nextLayoutRoots = new Set(nextMotionRoots); if (root instanceof windowRef.ShadowRoot) nextLayoutRoots.add(root); if (layoutRoots.size !== nextLayoutRoots.size || [...layoutRoots].some(target => !nextLayoutRoots.has(target))) { pageOccluders = null; } layoutRoots = nextLayoutRoots; for (const target of layoutRoots) { geometry.observe(target, { attributes: true, characterData: true, characterDataOldValue: true, childList: true, subtree: true }); } for (const target of motionRoots) { if (!nextMotionRoots.has(target)) unwatchMotion(target); } for (const target of nextMotionRoots) { if (!motionRoots.has(target)) { for (const type of motionEvents) target.addEventListener(type, sourceMotion, true); target.addEventListener("load", stylesheetLoaded, true); motionRoots.add(target); } } geometryTargets = targets; reconcileTargets(); } function clipBounds(element, cache) { if (cache.has(element)) return cache.get(element); const style = windowRef.getComputedStyle(element); const clips = value => value && value !== "visible"; const clipX = clips(style.overflowX), clipY = clips(style.overflowY); let bounds = null; if (clipX || clipY) { const rect = element.getBoundingClientRect(); const sx = element.offsetWidth ? rect.width / element.offsetWidth : 1; const sy = element.offsetHeight ? rect.height / element.offsetHeight : 1; const left = rect.left + element.clientLeft * sx, top = rect.top + element.clientTop * sy; bounds = { left: clipX ? left : -Infinity, right: clipX ? left + element.clientWidth * sx : Infinity, top: clipY ? top : -Infinity, bottom: clipY ? top + element.clientHeight * sy : Infinity }; } const result = { bounds, style, invisible: style.opacity === "0" || style.contentVisibility === "hidden", hiddenText: style.visibility === "hidden" || style.visibility === "collapse" }; cache.set(element, result); return result; } function visibleClip(source, cache, cover = false) { let clip = { left: 0, top: 0, right: windowRef.innerWidth, bottom: windowRef.innerHeight }; // Overflow clips contents, not a cover's own painted border. Fixed boxes // also escape intermediate scrollports before their containing block. let clipping = !cover || clipBounds(source, cache).style.position !== "fixed"; const containingBlock = !clipping && source.offsetParent; for (let ancestor = source; ancestor && clip; ancestor = ancestor.parentElement || ancestor.getRootNode().host) { const { bounds, invisible, hiddenText } = clipBounds(ancestor, cache); if (invisible || (ancestor === source && hiddenText)) return null; if (ancestor === containingBlock) clipping = true; if (bounds && clipping && (!cover || ancestor !== source)) clip = intersectHighlightRect(clip, bounds); } return clip; } function pageCovers(cache) { if (pageOccluders === null) { pageOccluders = []; coverMotion = new WeakSet(); // Reuse one animation-list/keyframe read for discovery and listeners. // Programmatic effects need listeners on Animation itself, not the DOM. const animations = readAnimations(); for (const { animation, target, frames } of animations) { if (isPageElement(target) && isCoverMotion(animation, frames)) coverMotion.add(target); } for (const tree of layoutRoots) { if (tree === root && tree instanceof windowRef.ShadowRoot) continue; for (const element of tree.querySelectorAll("*")) { if (!isPageElement(element)) continue; const style = windowRef.getComputedStyle(element); if (isPageCover(element, style) || coverMotion.has(element)) pageOccluders.push(element); } } coverTargets = new Set(); for (const element of pageOccluders) { for (let ancestor = element; ancestor; ancestor = ancestor.parentElement || ancestor.getRootNode().host) { coverTargets.add(ancestor); } } reconcileTargets(); refreshAnimations(animations, true); // CSSOM has no mutation event in the content-script world. A bounded // fallback-only poll reads stylesheet text, never page geometry, and // only a changed snapshot requests discovery/paint. Snapshot before // starting the timer so early CSSOM edits cannot become its baseline. if (!polledStyles) stylesheetState = stylesheetSnapshot(); polledStyles = false; refreshStyleMedia(); stylesheetTimer ??= windowRef.setInterval(checkLayoutState, 250); } return pageOccluders.flatMap(element => { if (!isPageCover(element, clipBounds(element, cache).style)) return []; const clip = visibleClip(element, cache, true); const rect = clip && intersectHighlightRect(element.getBoundingClientRect(), clip); return rect ? [{ element, rect, tree: element.getRootNode(), pointerEvents: clipBounds(element, cache).style.pointerEvents }] : []; }); } function coveredByPage(source, rect, cover) { let localSource = source; while (localSource && localSource.getRootNode() !== cover.tree) localSource = localSource.getRootNode().host; if (!localSource || cover.element.contains(localSource)) return false; const overlap = intersectHighlightRect(rect, cover.rect); if (!overlap) return false; const stack = cover.tree.elementsFromPoint((overlap.left + overlap.right) / 2, (overlap.top + overlap.bottom) / 2); const coverIndex = stack.findIndex(element => cover.element.contains(element)); const sourceIndex = stack.findIndex(element => element.contains(localSource) || localSource.contains(element)); // Hit-testing cannot order pointer-transparent paint. Conservatively omit // that intersection rather than tint an overlay above the page's text. return coverIndex < 0 ? cover.pointerEvents === "none" : sourceIndex < 0 || coverIndex < sourceIndex; } function fragmentRects(fragment, cache, popups, page) { const source = fragment.startContainer.parentElement; const clip = visibleClip(source, cache); if (!clip) return []; let rects; try { rects = [...fragment.getClientRects()].map(rect => intersectHighlightRect(rect, clip)).filter(Boolean); } catch { return []; } // No exact geometry: never substitute a whole paragraph. const popup = source.closest(".gsm-hoshidicts-popup"); const ownerIndex = popups.findIndex(entry => entry.popup === popup); const covers = popups.slice(ownerIndex + 1).map(entry => entry.rect); const toolbar = popup?.querySelector(".gsm-hoshidicts-result-chrome"); if (toolbar && !toolbar.contains(source)) covers.push(toolbar.getBoundingClientRect()); for (const cover of covers) rects = rects.flatMap(rect => subtractHighlightRect(rect, cover)); for (const cover of page) rects = rects.flatMap(rect => coveredByPage(source, rect, cover) ? subtractHighlightRect(rect, cover.rect) : [rect]); return rects; } function paint() { frame = null; const cache = new Map(); const popups = [...root.querySelectorAll(".gsm-hoshidicts-popup")].filter(popup => !popup.hidden) .map(popup => ({ popup, rect: popup.getBoundingClientRect() })); const page = pageCovers(cache); // Read all owners before writing any paint rectangles. const plans = [...owners.values()].map(owner => ({ owner, rects: owner.fragments.flatMap(fragment => fragmentRects(fragment, cache, popups, page)) })); const moving = [...motionTargets].some(([target, subtree]) => target instanceof windowRef.Element && target.getAnimations({ subtree }).some(isRunningMotion)); if (root.lastChild !== layer) root.appendChild(layer); for (const { owner, rects } of plans) { while (owner.group.children.length > rects.length) owner.group.lastChild.remove(); rects.forEach((rect, index) => { let mark = owner.group.children[index]; if (!mark) { mark = documentRef.createElement("span"); mark.className = "gsm-hoshidicts-source-match"; owner.group.appendChild(mark); } Object.assign(mark.style, { left: `${rect.left}px`, top: `${rect.top}px`, width: `${rect.right - rect.left}px`, height: `${rect.bottom - rect.top}px` }); }); } // Transforms do not notify ResizeObserver, and CSS motion has no DOM // mutations between frames. Stay live only while a source or cover moves. if (moving) schedule(); } return { schedule, update(records) { let dirty = layoutMutations(geometry.takeRecords()); const current = new Set(records); for (const [record, owner] of owners) { if (!current.has(record)) { owner.group.remove(); owners.delete(record); } } const targets = new Map(); for (const record of records) { let owner = owners.get(record); if (!owner) { owner = { group: documentRef.createElement("div") }; layer.appendChild(owner.group); owners.set(record, owner); } if (owner.match !== record.match) { owner.match = record.match; owner.fragments = highlightTextFragments(documentRef, record.match); dirty = true; } for (const [target, subtree] of record.observedTargets) targets.set(target, targets.get(target) || subtree); } observeGeometry(targets); if (dirty) schedule(); }, destroy() { if (frame !== null) windowRef.cancelAnimationFrame(frame); if (stylesheetTimer !== null) windowRef.clearInterval(stylesheetTimer); for (const media of styleMedia.values()) media.removeEventListener("change", layoutChanged); for (const [animation, record] of animationWatches) unwatchAnimation(animation, record); resize?.disconnect(); geometry.disconnect(); for (const target of motionRoots) unwatchMotion(target); windowRef.removeEventListener("scroll", schedule, true); root.removeEventListener("scroll", schedule, true); windowRef.removeEventListener("resize", layoutChanged); layer.remove(); }, }; } function createSourceHighlighter(windowRef, documentRef, highlightName, fallbackRoot = documentRef.body) { const matches = new Map(); let fallback = null; let publishedHighlight = null; // Publication is the only part of this that the document paints, so a caller // that must not appear in a picture of the page suspends exactly that. let suspended = 0; function clearRenderedHighlight() { const highlights = windowRef.CSS && windowRef.CSS.highlights; if (publishedHighlight && highlights?.get(highlightName) === publishedHighlight) { highlights.delete(highlightName); } publishedHighlight = null; } // Each source's text when its candidate was first painted. A pointer scan's // sources are the sentence's text nodes, so the match is located through // this snapshot and only the sources it covers have to be unchanged; text // edited elsewhere in the sentence leaves the highlight in place. const sourceSnapshots = new WeakMap(); function createMatchRanges(candidate, matchedText) { const matchLength = typeof matchedText === "string" ? matchedText.length : 0; if (matchLength <= 0 || !Array.isArray(candidate.sourceElements)) { return null; } const startOffset = Math.max(0, candidate.matchOffset); const endOffset = Math.min(candidate.sentence.length, startOffset + matchLength); if (endOffset <= startOffset) { return null; } // Sources are elements or, for a pointer scan, the sentence's own text nodes. const sourceElements = candidate.sourceElements; if (sourceElements.some((element) => !(element instanceof windowRef.Node))) { return null; } let snapshot = sourceSnapshots.get(candidate); if (!snapshot) { snapshot = sourceElements.map((element) => element.textContent || ""); if (snapshot.join("") !== candidate.sentence) { return null; } sourceSnapshots.set(candidate, snapshot); } const showText = windowRef.NodeFilter ? windowRef.NodeFilter.SHOW_TEXT : 4; const ranges = []; let elementStart = 0; for (const [index, element] of sourceElements.entries()) { const elementEnd = elementStart + snapshot[index].length; if (elementEnd <= startOffset || elementStart >= endOffset) { elementStart = elementEnd; continue; } if (!element.isConnected || (element.textContent || "") !== snapshot[index]) { return null; } const textNodes = []; if (element.nodeType === 3) { textNodes.push(element); } else { const walker = documentRef.createTreeWalker(element, showText); let node = walker.nextNode(); while (node) { textNodes.push(node); node = walker.nextNode(); } } function findBoundary(offset, preferFollowingNode) { let consumed = 0; for (let index = 0; index < textNodes.length; index += 1) { const textNode = textNodes[index]; const length = (textNode.nodeValue || "").length; const nodeEnd = consumed + length; if ( offset < nodeEnd || ( offset === nodeEnd && (!preferFollowingNode || index === textNodes.length - 1) ) ) { return { node: textNode, offset: Math.max(0, Math.min(length, offset - consumed)), }; } consumed = nodeEnd; } return null; } const localStart = Math.max(0, startOffset - elementStart); const localEnd = Math.min(elementEnd, endOffset) - elementStart; const start = findBoundary(localStart, true); const end = findBoundary(localEnd, false); if (start && end) { try { const range = documentRef.createRange(); range.setStart(start.node, start.offset); range.setEnd(end.node, end.offset); ranges.push(range); } catch { // Without an exact range this source fragment stays unpainted. } } elementStart = elementEnd; } return { ranges }; } function render() { clearRenderedHighlight(); const highlights = windowRef.CSS && windowRef.CSS.highlights; const HighlightImpl = windowRef.Highlight; const canUseRanges = Boolean( highlights && typeof highlights.set === "function" && HighlightImpl ); const ranges = []; for (const { match } of matches.values()) { ranges.push(...match.ranges); } if (canUseRanges && ranges.length > 0 && suspended === 0) { try { const next = new HighlightImpl(...ranges); highlights.set(highlightName, next); publishedHighlight = next; } catch { // The same exact ranges supply owned fallback paint when unavailable. } } if (!publishedHighlight && matches.size > 0) { fallback ??= createHighlightFallback(windowRef, documentRef, fallbackRoot); fallback.update([...matches.values()]); } else { fallback?.destroy(); fallback = null; } } function observeSource(record) { record.observer.disconnect(); const targets = new Map(); for (const source of record.candidate.sourceElements) { targets.set(source, true); // Direct ancestor child lists detect a removed/moved source without // observing unrelated page subtrees. Cross an owned shadow root too. for (let parent = source.parentNode; parent; parent = parent.parentNode || parent.host) { if (!targets.has(parent)) targets.set(parent, false); } } for (const [target, subtree] of targets) { record.observer.observe(target, { childList: true, characterData: subtree, subtree }); } record.observedTargets = targets; } function sourceChanged(record, changes) { return record.candidate.sourceElements.some(source => !source.isConnected || changes.some(change => source.contains(change.target) || [...change.addedNodes, ...change.removedNodes].some(node => record.observedTargets.has(node)))); } function refreshSource(key, record) { if (matches.get(key) !== record) return; const match = createMatchRanges(record.candidate, record.matchedText); if (!match) { clearFor(key); return; } record.match = match; observeSource(record); render(); } function applyFor(key, candidate, matchedText) { const previous = matches.get(key); if (previous?.candidate === candidate && previous.matchedText === matchedText) return; const match = createMatchRanges(candidate, matchedText); previous?.observer.disconnect(); if (!match) { clearFor(key); return; } const record = { candidate, matchedText, match }; record.observer = new windowRef.MutationObserver(changes => { if (sourceChanged(record, changes)) refreshSource(key, record); }); matches.set(key, record); observeSource(record); render(); } function clearFor(key) { matches.get(key)?.observer.disconnect(); if (matches.delete(key)) { render(); } } return { apply(candidate, matchedText) { applyFor("default", candidate, matchedText); }, clear() { clearFor("default"); }, refresh() { fallback?.schedule(); }, // Nothing is published, and nothing that arrives meanwhile is published // either, until every suspension is released; the ranges are kept, so // releasing repaints exactly what was there. suspend() { suspended += 1; clearRenderedHighlight(); let released = false; return () => { if (released) return; released = true; suspended -= 1; if (suspended === 0) render(); }; }, scope(key) { return { apply(candidate, matchedText) { applyFor(key, candidate, matchedText); }, clear() { clearFor(key); }, refresh() { fallback?.schedule(); }, }; }, clearAll() { for (const record of matches.values()) record.observer.disconnect(); matches.clear(); clearRenderedHighlight(); fallback?.destroy(); fallback = null; }, }; } function collectGlossaryDictionaries(results) { const dictionaries = []; const seen = new Set(); for (const result of results) { for (const glossary of result.term.glossaries) { if (!seen.has(glossary.dictionary)) { seen.add(glossary.dictionary); dictionaries.push(glossary.dictionary); } } } return dictionaries; } function normaliseDictionaryTab(value) { if (typeof value?.dictionary === "string") return { dictionary: value.dictionary }; if (typeof value?.groupId === "string") return { groupId: value.groupId }; return value?.favourites === true ? { favourites: true } : null; } function dictionaryTabKey(selection) { if (typeof selection?.dictionary === "string") return `dictionary:${selection.dictionary}`; if (typeof selection?.groupId === "string") return `group:${selection.groupId}`; return selection?.favourites === true ? "favourites" : "all"; } function sameTabMembers(left, right, dictionaries) { return dictionaries.every(dictionary => (left.size === 0 || left.has(dictionary)) === (right.size === 0 || right.has(dictionary))); } function updateLabel(element, label) { if (element.textContent === label) return false; element.textContent = label; return true; } function createDictionaryTabs(dictionaries, renderContext) { const presentation = Array.isArray(renderContext.dictionaryPresentation) ? renderContext.dictionaryPresentation : []; const groups = Array.isArray(renderContext.dictionaryTabGroups) ? renderContext.dictionaryTabGroups : []; const dictionaryDisplayNames = createDictionaryDisplayNames(dictionaries, presentation); const available = new Set(dictionaries); const grouped = new Set(groups.flatMap(({ dictionaries: members }) => Array.isArray(members) ? members : [])); const availableGroups = groups.flatMap((group) => { const members = Array.isArray(group.dictionaries) ? group.dictionaries.filter((title) => available.has(title)) : []; return members.length > 0 ? [{ ...group, dictionaries: members }] : []; }); const favourites = presentation .filter(({ favorite, title }) => favorite === true && available.has(title) && !grouped.has(title)) .map(({ title }) => title); const usedLabels = new Set(); function tab(label, title, selection, members, qualifier) { let uniqueLabel = label; let suffix = 1; while (usedLabels.has(uniqueLabel)) { uniqueLabel = `${label} (${qualifier}${suffix === 1 ? "" : ` ${suffix}`})`; suffix += 1; } usedLabels.add(uniqueLabel); return { key: dictionaryTabKey(selection), label: uniqueLabel, title, ...selection, dictionaries: new Set(members), }; } const tabs = [ tab("All", "All dictionaries", null, [], "tab"), ...availableGroups.map((group) => tab( group.name, `Tab group: ${group.name}`, { groupId: group.id }, group.dictionaries, "group", )), ...favourites.map((dictionary) => tab( dictionaryDisplayNames.get(dictionary) || dictionary, dictionary, { dictionary }, [dictionary], "dictionary", )), ]; return { tabs, dictionaryDisplayNames }; } function isRecord(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function parseCompactDefinitionValue(rawGlossary) { if (typeof rawGlossary !== "string" || !rawGlossary) { return null; } try { return JSON.parse(rawGlossary); } catch { return rawGlossary; } } function getCompactDefinitionMarker(value) { return isRecord(value?.data) && typeof value.data.content === "string" ? value.data.content.trim().toLowerCase() : ""; } // Sanseido-style dictionaries (三省堂国語辞典, 新明解国語辞典 and their // bilingual conversions) name sections with data.name instead of markers. function getCompactDefinitionName(value) { return isRecord(value?.data) && typeof value.data.name === "string" ? value.data.name : ""; } function isIgnoredCompactDefinitionSection(value) { // ルビG is Sanseido furigana, left out like rt. if (getCompactDefinitionName(value) === "ルビG") return true; const marker = getCompactDefinitionMarker(value); return marker && marker !== "glossary" && ( marker.startsWith("part-of-speech") || marker === "redirect-glossary" || marker === "source" || marker.startsWith("source-") || marker === "attribution" || marker.startsWith("attribution-") || marker === "example" || marker === "examples" || marker.startsWith("example-") || marker === "form" || marker === "forms" || marker.startsWith("forms-") ); } function* compactDefinitionItemsFromText(parts) { const textRun = `[^\\s\\u2022]{1,${COMPACT_DEFINITION_MAX_CHARACTERS + 1}}`; const firstText = new RegExp(textRun, "gu"); const nextText = new RegExp(`\\u2022|${textRun}`, "gu"); const buffer = { text: "", characters: 0 }; let pendingSpace = false; for (const text of parts) { let index = 0; while (index < text.length) { const matcher = buffer.characters > 0 ? nextText : firstText; matcher.lastIndex = index; const match = matcher.exec(text); if (!match) { pendingSpace = buffer.characters > 0; break; } pendingSpace ||= buffer.characters > 0 && match.index > index; index = matcher.lastIndex; if (match[0] === "\u2022") { yield buffer.text; buffer.text = ""; buffer.characters = 0; pendingSpace = false; continue; } if (pendingSpace) { buffer.text += " "; buffer.characters += 1; } pendingSpace = false; if (buffer.characters <= COMPACT_DEFINITION_MAX_CHARACTERS) { appendCompactDefinitionText(buffer, match[0]); } // One extra normalized point proves truncation. Earlier accepted items // are at most 240 points, so this cannot falsely match a seen duplicate. if (buffer.characters > COMPACT_DEFINITION_MAX_CHARACTERS) { yield buffer.text; return; } } } if (buffer.characters > 0) yield buffer.text; } function appendCompactDefinitionText(buffer, text) { // The native matcher already bounds this run to 241 points. Count internal // surrogate pairs without building a point array unless truncation needs it. const characters = text.length - (text.match(/[\ud800-\udbff][\udc00-\udfff]/g)?.length || 0); const previous = buffer.text.charCodeAt(buffer.text.length - 1); const first = text.charCodeAt(0); const joined = previous >= 0xd800 && previous <= 0xdbff && first >= 0xdc00 && first <= 0xdfff ? 1 : 0; const available = COMPACT_DEFINITION_MAX_CHARACTERS + 1 - buffer.characters + joined; buffer.text += characters > available ? Array.from(text).slice(0, available).join("") : text; buffer.characters += Math.min(characters, available) - joined; } // Typed wrappers select their payload before incidental tags, as in the full // renderer. Discovery, collection and leading-image selection share this rule. function compactDefinitionTag(value) { if (value.type === "text" || value.type === "structured-content") return ""; if (value.type === "image") return "img"; return typeof value.tag === "string" ? value.tag.toLowerCase() : ""; } function compactDefinitionContent(value) { return value.type === "text" && Object.hasOwn(value, "text") ? value.text : value.content; } function isCompactDefinitionBlock(value) { return isRecord(value) && COMPACT_DEFINITION_BLOCK_TAGS.has(compactDefinitionTag(value)); } function* collectCompactDefinitionText(value, state, depth = 0) { if ( state.nodes >= COMPACT_DEFINITION_MAX_NODES || depth > COMPACT_DEFINITION_MAX_DEPTH ) { return; } state.nodes += 1; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { const text = String(value); if (text) yield text; return; } if (Array.isArray(value)) { let hasText = false; let previousWasBlock = false; for (const child of value) { if (state.nodes >= COMPACT_DEFINITION_MAX_NODES) break; let childIsBlock = false; let childHasText = false; for (const text of collectCompactDefinitionText(child, state, depth + 1)) { if (!childHasText) { childIsBlock = isCompactDefinitionBlock(child); if (hasText && (previousWasBlock || childIsBlock)) yield " "; } childHasText = true; yield text; } if (childHasText) { hasText = true; previousWasBlock = childIsBlock; } } return; } if (!isRecord(value) || isIgnoredCompactDefinitionSection(value)) { return; } const tag = compactDefinitionTag(value); if (COMPACT_DEFINITION_IGNORED_TAGS.has(tag)) { return; } if (tag === "br") { yield " "; return; } const content = compactDefinitionContent(value); if (content !== undefined) yield* collectCompactDefinitionText(content, state, depth + 1); } function findCompactDefinitionNodes(value, predicate, state, depth = 0) { if ( state.nodes >= COMPACT_DEFINITION_MAX_NODES || depth > COMPACT_DEFINITION_MAX_DEPTH ) { return []; } state.nodes += 1; if (Array.isArray(value)) { const matches = []; for (const child of value) { matches.push(...findCompactDefinitionNodes( child, predicate, state, depth + 1 )); if (state.nodes >= COMPACT_DEFINITION_MAX_NODES) break; } return matches; } if (!isRecord(value) || isIgnoredCompactDefinitionSection(value)) { return []; } const tag = compactDefinitionTag(value); if (tag === "br" || COMPACT_DEFINITION_IGNORED_TAGS.has(tag)) return []; if (predicate(value, tag)) { return [value]; } const content = compactDefinitionContent(value); return content !== undefined ? findCompactDefinitionNodes(content, predicate, state, depth + 1) : []; } function isCompactDefinitionList(_value, tag) { return tag === "ul" || tag === "ol"; } /** Block nodes with no block descendants: the smallest sense-sized chunks. */ function findCompactDefinitionLeafBlocks(root, state = { nodes: 0 }) { return findCompactDefinitionNodes( root, (value, tag) => COMPACT_DEFINITION_BLOCK_TAGS.has(tag) && findCompactDefinitionNodes( value.content, (_child, childTag) => COMPACT_DEFINITION_BLOCK_TAGS.has(childTag), { nodes: 0 } ).length === 0, state ); } function* compactDefinitionItemsFromNodes(nodes) { let found = false; let inspected = 0; for (const node of nodes) { if (inspected >= COMPACT_DEFINITION_MAX_NODES) break; inspected += 1; for (const item of compactDefinitionItemsFromText(collectCompactDefinitionText(node, { nodes: 0 }))) { found = true; yield item; } } // The first nonempty semantic list owns the preview, even if deduplication // leaves fewer snippets than requested. Empty lists still fall through. return found; } function compactDefinitionItemsFromList(list) { const rawChildren = Array.isArray(list.content) ? list.content : [list.content]; const children = rawChildren.slice(0, COMPACT_DEFINITION_MAX_NODES); const listItems = []; for (const child of children) { if (isRecord(child) && compactDefinitionTag(child) === "li") { listItems.push(child); } } return compactDefinitionItemsFromNodes( listItems.length > 0 ? listItems : children ); } function* compactDefinitionItemsFromMarkedNode(node) { const tag = compactDefinitionTag(node); if (tag === "ul" || tag === "ol") { yield* compactDefinitionItemsFromList(node); return; } const content = compactDefinitionContent(node); const nestedLists = findCompactDefinitionNodes( content, isCompactDefinitionList, { nodes: 0 } ); if (nestedLists.length > 0) { for (const list of nestedLists) yield* compactDefinitionItemsFromList(list); return; } const leafBlocks = findCompactDefinitionLeafBlocks(content); yield* leafBlocks.length > 0 ? compactDefinitionItemsFromNodes(leafBlocks) : compactDefinitionItemsFromNodes([node]); } // Sanseido index entries list sub-headwords as bare links, not definitions. function isCompactDefinitionLinkOnly(sense) { const node = isRecord(sense) && sense.type === "structured-content" ? sense.content : sense; // The raw tag test spares ordinary senses a tag normalization. return isRecord(node) && /^a$/i.test(node.tag) && compactDefinitionTag(node) === "a"; } function isCompactDefinitionSense(value) { const name = getCompactDefinitionName(value); return name === "語義" || name === "語釈"; } // Bilingual Sanseido conversions write a 語釈 as English, a bare " " child, // then the original Japanese. English-only and monolingual glosses have no // such child, and a bare space between English nodes is followed by ASCII // letters, so only a space that opens Japanese text ends the English half. function compactDefinitionEnglishHalf(gloss) { const parts = Array.isArray(gloss.content) ? gloss.content : [gloss.content]; const separator = parts.lastIndexOf(" "); if (separator < 0) return parts; let rest = ""; for (const text of collectCompactDefinitionText(parts.slice(separator + 1), { nodes: 0 })) { rest += text; if (rest.length > 64) break; } const lead = COMPACT_DEFINITION_LETTER.exec(rest)?.[0]; return lead && !/[A-Za-z]/.test(lead) && COMPACT_DEFINITION_JAPANESE.test(rest) ? parts.slice(0, separator) : parts; } function* compactDefinitionFallbackNodes(parsed) { // Top-level glossary-array entries are separate senses, unlike inline // content arrays. Expand blocks within each sense without dropping siblings. const discovery = { nodes: 0 }; for (const sense of Array.isArray(parsed) ? parsed : [parsed]) { if (isCompactDefinitionLinkOnly(sense)) continue; const leafBlocks = findCompactDefinitionLeafBlocks(sense, discovery); if (leafBlocks.length > 0) yield* leafBlocks; else yield sense; } } function* extractCompactDefinitionItems(parsed) { if (parsed === null) return; // One discovery pass finds marked glossary sections and Sanseido senses. const marked = findCompactDefinitionNodes( parsed, (value) => getCompactDefinitionMarker(value) === "glossary" || isCompactDefinitionSense(value), { nodes: 0 } ); const glossaryNodes = marked.filter((node) => getCompactDefinitionMarker(node) === "glossary"); if (glossaryNodes.length > 0) { for (const node of glossaryNodes) yield* compactDefinitionItemsFromMarkedNode(node); return; } // A Sanseido 語義 sense holds its number, labels, 語釈 gloss and examples; // 副義 sub-senses nest their own 語釈. A sense without one is a ⇨ reference. if (marked.length > 0) { for (const sense of marked) { const glosses = getCompactDefinitionName(sense) === "語釈" ? [sense] : findCompactDefinitionNodes( compactDefinitionContent(sense), (value) => getCompactDefinitionName(value) === "語釈", { nodes: 0 } ); yield* compactDefinitionItemsFromNodes(glosses.map(compactDefinitionEnglishHalf)); } return; } const semanticLists = findCompactDefinitionNodes( parsed, isCompactDefinitionList, { nodes: 0 } ); for (const list of semanticLists) { if (yield* compactDefinitionItemsFromList(list)) return; } yield* compactDefinitionItemsFromNodes(compactDefinitionFallbackNodes(parsed)); } // null means no visible content; false means text or another non-image lead. // Stop at that first meaningful token, not at an image later in a definition. function leadingCompactDefinitionImage(value, state, depth = 0) { if (state.nodes >= COMPACT_DEFINITION_MAX_NODES || depth > COMPACT_DEFINITION_MAX_DEPTH) return false; state.nodes += 1; if (Array.isArray(value)) { for (const child of value) { const leading = leadingCompactDefinitionImage(child, state, depth + 1); if (leading !== null) return leading; } return null; } if (!isRecord(value)) return value != null && /\S/u.test(String(value)) ? false : null; if (isIgnoredCompactDefinitionSection(value)) return null; const tag = compactDefinitionTag(value); if (tag === "img") return value; if (tag === "br" || COMPACT_DEFINITION_IGNORED_TAGS.has(tag)) return null; return leadingCompactDefinitionImage(compactDefinitionContent(value), state, depth + 1); } function extractCompactDefinitionSummary( glossaries, preferredDictionary = null, maximumItems = DEFAULT_COMPACT_DEFINITION_SUMMARY_COUNT ) { const itemLimit = Number.isInteger(maximumItems) && maximumItems >= MIN_COMPACT_DEFINITION_SUMMARY_COUNT && maximumItems <= MAX_COMPACT_DEFINITION_SUMMARY_COUNT ? maximumItems : DEFAULT_COMPACT_DEFINITION_SUMMARY_COUNT; const byDictionary = new Map(); for (const glossary of Array.isArray(glossaries) ? glossaries : []) { if (!byDictionary.has(glossary.dictionary)) { byDictionary.set(glossary.dictionary, []); } byDictionary.get(glossary.dictionary).push(glossary.glossary); } const dictionaries = [...byDictionary.keys()]; if (preferredDictionary !== null && byDictionary.has(preferredDictionary)) { dictionaries.splice(dictionaries.indexOf(preferredDictionary), 1); dictionaries.unshift(preferredDictionary); } for (const dictionary of dictionaries) { const rawGlossaries = byDictionary.get(dictionary); const items = []; const seen = new Set(); let characterCount = 0; let leading = null; for (const rawGlossary of rawGlossaries) { const parsed = parseCompactDefinitionValue(rawGlossary); if (leading === null) leading = leadingCompactDefinitionImage(parsed, { nodes: 0 }); for (const item of extractCompactDefinitionItems(parsed)) { if (!item || seen.has(item)) continue; const remaining = COMPACT_DEFINITION_MAX_CHARACTERS - characterCount; const codePoints = []; for (const character of item) { codePoints.push(character); if (codePoints.length > remaining) break; } const bounded = codePoints.length <= remaining ? item : remaining === 1 ? "\u2026" : `${codePoints.slice(0, remaining - 1).join("")}\u2026`; items.push(bounded); seen.add(item); characterCount += Math.min(codePoints.length, remaining); if (bounded !== item || items.length >= itemLimit || characterCount >= COMPACT_DEFINITION_MAX_CHARACTERS) break; } if ( items.length >= itemLimit || characterCount >= COMPACT_DEFINITION_MAX_CHARACTERS ) { break; } } if (items.length > 0) return { dictionary, items, image: leading || null }; } return null; } // A popup that cancels browser zoom measures its own lengths in unzoomed // pixels; page geometry (client rects, the viewport) converts into them. function scaleRect(rect, factor) { return { left: rect.left * factor, top: rect.top * factor, right: rect.right * factor, bottom: rect.bottom * factor, width: rect.width * factor, height: rect.height * factor }; } function popupCoordinateScale(pageZoom, scalePercent) { return pageZoom * 100 / scalePercent; } function calculatePopupPosition(anchorRect, popupSize, viewport, { gap = 4, padding = 6, vertical = false } = {}) { const width = Math.min(popupSize.width, Math.max(1, viewport.width - padding * 2)); const height = Math.min(popupSize.height, Math.max(1, viewport.height - padding * 2)); const clamp = (value, minimum, maximum) => Math.max(minimum, Math.min(value, maximum)); let left; let top; let placement; if (vertical) { const spaceRight = viewport.width - anchorRect.right - gap; const spaceLeft = anchorRect.left - gap; left = spaceRight >= width || spaceRight >= spaceLeft ? anchorRect.right + gap : anchorRect.left - gap - width; top = anchorRect.top; placement = "beside"; } else { const spaceBelow = Math.max(0, viewport.height - padding - anchorRect.bottom - gap); const spaceAbove = Math.max(0, anchorRect.top - gap - padding); const placeAbove = spaceAbove >= height || (spaceBelow < height && spaceAbove >= spaceBelow); top = placeAbove ? anchorRect.top - gap - height : anchorRect.bottom + gap; left = anchorRect.left; placement = placeAbove ? "above" : "below"; } return { height, left: clamp(Math.round(left), padding, viewport.width - width - padding), placement, top: clamp(Math.round(top), padding, viewport.height - height - padding), width, }; } function createPopupView(options) { const documentRef = options.document; const windowRef = options.window; const popup = options.popup; const getPageZoom = options.getPageZoom ?? (() => 1); const getCoordinateScale = () => popupCoordinateScale(getPageZoom(), options.getPopupScalePercent?.() ?? 100); const contentScroll = documentRef.createElement("div"); contentScroll.className = "gsm-hoshidicts-content-scroll"; const resizeHandle = options.onResizeStart ? documentRef.createElement("div") : null; if (resizeHandle) { resizeHandle.className = "gsm-hoshidicts-resize-handle"; resizeHandle.title = "Resize popup"; resizeHandle.addEventListener("pointerdown", options.onResizeStart); resizeHandle.addEventListener("pointermove", options.onResizeMove); for (const event of ["pointerup", "pointercancel", "lostpointercapture"]) { resizeHandle.addEventListener(event, options.onResizeEnd); } popup.appendChild(resizeHandle); } const appendExpressionRuby = options.appendExpressionRuby; const buildPitchAccentMorae = options.buildPitchAccentMorae; const appendTextOnlyGlossary = options.appendTextOnlyGlossary; const appendStructuredImage = options.appendStructuredImage; const parseTagList = options.parseTagList; const positionPopup = () => { options.positionPopup(); sourceHighlighter.refresh(); }; // LookupKanji carries onyomi/kunyomi/tags as space-separated strings, but a // caller that already normalized them hands over arrays. Accept both. const tokenList = (value) => Array.isArray(value) ? value : parseTagList(value); const getPopupColumns = typeof options.getPopupColumns === "function" ? options.getPopupColumns : () => 1; const onKanjiClick = typeof options.onKanjiClick === "function" ? options.onKanjiClick : () => {}; const onAddCustomEntry = typeof options.onAddCustomEntry === "function" ? options.onAddCustomEntry : async () => {}; const onNoteEditingChange = typeof options.onNoteEditingChange === "function" ? options.onNoteEditingChange : () => {}; const onBeforeResultsRendered = typeof options.onBeforeResultsRendered === "function" ? options.onBeforeResultsRendered : () => {}; const onResultsRendered = typeof options.onResultsRendered === "function" ? options.onResultsRendered : () => {}; const onResultsExpanded = typeof options.onResultsExpanded === "function" ? options.onResultsExpanded : () => {}; const idPrefix = typeof options.idPrefix === "string" && options.idPrefix ? options.idPrefix : "gsm-hoshidicts"; const initialResultCount = Number.isInteger(options.initialResultCount) ? Math.max(1, options.initialResultCount) : DEFAULT_INITIAL_RESULT_COUNT; const maxMetadataTags = Number.isInteger(options.maxMetadataTags) ? Math.max(1, options.maxMetadataTags) : DEFAULT_MAX_METADATA_TAGS; const popupRoot = popup.getRootNode(); const sourceHighlighter = options.sourceHighlighter || createSourceHighlighter( windowRef, documentRef, options.highlightName || DEFAULT_HIGHLIGHT_NAME, popupRoot instanceof windowRef.ShadowRoot ? popupRoot : documentRef.body ); let definitionBlurState = "revealed"; let sourceHighlightEnabled = options.sourceHighlightEnabled === true; let currentSourceHighlight = null; let toolbarPosition = options.toolbarPosition === "bottom" ? "bottom" : "top"; let currentToolbar = null; let currentFeedback = null; let currentLookupFailure = null; let customButtons = Array.isArray(options.customButtons) ? options.customButtons : (options.customLinks || []).map((link, index) => ({ id: `legacy-link-${index + 1}`, type: "link", ...link, })); let currentNoteControls = null; let renderRevision = 0; let currentResultPanel = null; let captureTermView = null; let pendingScrollRestoration = null; let currentPresentationUpdate = null; let pendingPresentation = null; let imagePreview = null; const renderedImages = new Set(); let masonryFrame = null; const masonryObserver = typeof windowRef.ResizeObserver === "function" ? new windowRef.ResizeObserver(() => scheduleMasonry()) : null; popup.dataset.toolbarPosition = toolbarPosition; function hideImagePreview(owner = null) { if (!imagePreview || (owner && imagePreview.owner !== owner)) return; imagePreview.element?.remove(); imagePreview = null; } function positionImagePreview(anchorRect = imagePreview.image.getBoundingClientRect()) { const preview = imagePreview.element; const zoom = getCoordinateScale(); preview.firstElementChild.style.maxWidth = `${Math.max(1, windowRef.innerWidth * zoom - 16)}px`; preview.firstElementChild.style.maxHeight = `${Math.max(1, windowRef.innerHeight * zoom - 16)}px`; const position = calculatePopupPosition(scaleRect(anchorRect, zoom), scaleRect(preview.getBoundingClientRect(), zoom), { width: windowRef.innerWidth * zoom, height: windowRef.innerHeight * zoom, }, { gap: 8, padding: 8, vertical: true }); preview.style.left = `${position.left}px`; preview.style.top = `${position.top}px`; } function refreshImagePreview(link, image) { // Image completion resumes only the most recent interaction. It must // not steal another image's focus or revive a dismissed pending preview. if (imagePreview?.owner !== link) return; const source = image.currentSrc || image.src; if (image.hidden || !source) { imagePreview.element?.remove(); imagePreview.element = null; imagePreview.source = null; return; } if (imagePreview.source === source) return; imagePreview.element?.remove(); const preview = documentRef.createElement("div"); preview.className = "gsm-hoshidicts-image-hover-preview"; preview.setAttribute("aria-hidden", "true"); preview.dataset.appearance = link.dataset.appearance; preview.dataset.imageRendering = link.dataset.imageRendering; const expanded = documentRef.createElement("img"); expanded.src = source; expanded.alt = image.alt; expanded.decoding = "async"; expanded.draggable = false; preview.appendChild(expanded); // A sibling in the same shadow root retains the palette while escaping // the glossary card's paint containment and the popup's scroll clipping. popup.parentNode.appendChild(preview); imagePreview.source = source; imagePreview.element = preview; positionImagePreview(); } function requestImagePreview(link, image) { if (imagePreview?.owner !== link) { hideImagePreview(); imagePreview = { owner: link, image, source: null, element: null }; } refreshImagePreview(link, image); } const onPopupScroll = () => { if (!imagePreview) return; const { owner, image, element } = imagePreview; if (owner.getRootNode().activeElement !== owner || !element) { hideImagePreview(); return; } const anchorRect = image.getBoundingClientRect(); const bounds = (contentScroll.contains(image) ? contentScroll : currentToolbar || popup).getBoundingClientRect(); if (anchorRect.bottom <= bounds.top || anchorRect.top >= bounds.bottom || anchorRect.right <= bounds.left || anchorRect.left >= bounds.right) { hideImagePreview(); return; } // Native keyboard focus may scroll its image into view after focus. // Retain that focused preview while closing ordinary hover previews. positionImagePreview(anchorRect); }; popup.addEventListener("scroll", onPopupScroll, true); // Keybind navigation after Yomitan's Display: the current entry changes // only when a keybind focuses an entry or the reader clicks one. let currentEntry = null; const entryNodes = () => [...contentScroll.querySelectorAll( ":scope > .gsm-hoshidicts-tab-panel > .gsm-hoshidicts-entry, :scope > .gsm-hoshidicts-kanji-entry")]; contentScroll.addEventListener("click", event => { const entry = event.button === 0 && event.target instanceof windowRef.Element ? event.target.closest(".gsm-hoshidicts-entry, .gsm-hoshidicts-kanji-entry") : null; if (entry && entryNodes().includes(entry)) currentEntry = entry; }); function currentEntryIndex(nodes = entryNodes()) { return Math.max(0, nodes.indexOf(currentEntry)); } function scrollToEntry(nodes, index, target = nodes[index]) { currentEntry = nodes[index]; const top = index === 0 && target === currentEntry ? 0 : (target.getBoundingClientRect().top - contentScroll.getBoundingClientRect().top) * getCoordinateScale() + contentScroll.scrollTop; contentScroll.scrollTo({ top, behavior: "instant" }); return true; } // Yomitan renders every entry; later entries here wait behind Show more. function expandEntries() { const showMore = contentScroll.querySelector(":scope > .gsm-hoshidicts-tab-panel > .gsm-hoshidicts-show-more"); showMore?.click(); return Boolean(showMore); } // Yomitan starts from the current entry's most visible definition and moves // to the nearest definition from another dictionary; here each dictionary // is one glossary card. Kanji views have no such definitions. function focusEntryWithDifferentDictionary(nodes, index, sign) { const cardsOf = node => node.classList.contains("gsm-hoshidicts-entry") ? [...node.querySelectorAll(".gsm-hoshidicts-glossary-grid > .gsm-hoshidicts-glossary-card")] : []; const dictionaryOf = card => card.querySelector(":scope > .gsm-hoshidicts-glossary-card-title")?.title ?? ""; const cards = cardsOf(nodes[index]); const view = contentScroll.getBoundingClientRect(); let visible = null, coverage = 0; for (const card of sign > 0 ? cards : [...cards].reverse()) { const { top, bottom } = card.getBoundingClientRect(); const shown = Math.min(bottom, view.bottom) - Math.max(top, view.top); if (shown > coverage) { visible = card; coverage = shown; } } if (!visible) return false; const dictionary = dictionaryOf(visible); const search = entries => { for (let i = index; i >= 0 && i < entries.length; i += sign) { const ordered = sign > 0 ? cardsOf(entries[i]) : cardsOf(entries[i]).reverse(); const start = i === index ? ordered.indexOf(visible) + 1 : 0; const target = ordered.slice(start).find(card => dictionaryOf(card) !== dictionary); if (target) return scrollToEntry(entries, i, target); } return false; }; return search(nodes) || (sign > 0 && expandEntries() && search(entryNodes())); } function focusEntry(target) { let nodes = entryNodes(); if (nodes.length === 0) return false; const index = currentEntryIndex(nodes); if (target.dictionary) return focusEntryWithDifferentDictionary(nodes, index, Math.sign(target.dictionary)); const next = target === "first" ? 0 : target === "last" ? Infinity : index + target.offset; if (next >= nodes.length && expandEntries()) nodes = entryNodes(); return scrollToEntry(nodes, Math.max(0, Math.min(nodes.length - 1, next))); } function resetMasonry(grid) { grid.classList.remove("gsm-hoshidicts-glossary-grid-masonry"); grid.style.height = ""; for (const card of grid.children) { card.style.width = ""; card.style.transform = ""; card.style.visibility = ""; } } function layoutMasonry() { const requestedColumns = Math.max(1, Math.trunc(getPopupColumns())); for (const grid of popup.querySelectorAll(".gsm-hoshidicts-glossary-grid")) { const cards = Array.from(grid.children); const columns = Math.min(requestedColumns, cards.length); if (columns <= 1 || grid.clientWidth <= 0) { resetMasonry(grid); continue; } grid.classList.add("gsm-hoshidicts-glossary-grid-masonry"); const columnWidth = (grid.clientWidth - MASONRY_GAP_PX * (columns - 1)) / columns; for (const card of cards) card.style.width = `${columnWidth}px`; // Measure after every width is set, before placement writes begin. const cardHeights = cards.map(card => card.offsetHeight); const columnHeights = Array.from({ length: columns }, () => 0); cards.forEach((card, index) => { const column = columnHeights.indexOf(Math.min(...columnHeights)); const x = column * (columnWidth + MASONRY_GAP_PX); const y = columnHeights[column]; card.style.transform = `translate(${x}px, ${y}px)`; card.style.visibility = "visible"; columnHeights[column] += cardHeights[index] + MASONRY_GAP_PX; }); grid.style.height = `${Math.max(...columnHeights) - MASONRY_GAP_PX}px`; } const restoreScroll = pendingScrollRestoration; pendingScrollRestoration = null; restoreScroll?.(); } function scheduleMasonry() { if (options.queueMasonry) { options.queueMasonry(layoutMasonry); return; } if (masonryFrame !== null) { return; } masonryFrame = windowRef.requestAnimationFrame(() => { masonryFrame = null; layoutMasonry(); positionPopup(); }); } const onWindowResize = () => { hideImagePreview(); scheduleMasonry(); }; windowRef.addEventListener("resize", onWindowResize); function applyToolbarLayout() { if (!currentToolbar && !currentLookupFailure) return; const noteForm = currentNoteControls?.form ?? null; const bottom = toolbarPosition === "bottom"; const controls = (bottom ? [currentLookupFailure, noteForm, currentFeedback, currentToolbar] : [currentToolbar, currentFeedback, noteForm, currentLookupFailure]).filter(Boolean); const edge = controls[bottom ? controls.length - 1 : 0]; const atEdge = controls.every((node, index) => !controls[index + 1] || node.nextElementSibling === controls[index + 1]) && (bottom ? popup.lastElementChild === edge : popup.firstElementChild === edge); if (atEdge) return; const focused = popup.getRootNode().activeElement; const children = [...popup.children]; const focusedOwner = children.find(child => child.contains(focused)); const retainedForm = noteForm?.parentNode === popup ? noteForm : null; const anchor = focusedOwner || retainedForm; if (anchor) { // Move siblings around the focused subtree or retained Note form: // detaching either interrupts keyboard interaction and draft ownership. const controlSet = new Set(controls); const content = children.filter(child => !controlSet.has(child)); const ordered = bottom ? [...content, ...controls] : [...controls, ...content]; const anchorIndex = ordered.indexOf(anchor); ordered.forEach((child, index) => { if (index < anchorIndex) popup.insertBefore(child, anchor); else if (index > anchorIndex) popup.append(child); }); } else { if (bottom) popup.append(...controls); else popup.prepend(...controls); } } function setRenderedToolbar(toolbar, feedback = null) { currentToolbar = toolbar; currentFeedback = feedback && feedback.parentNode === popup ? feedback : null; applyToolbarLayout(); } function setToolbarPosition(value) { toolbarPosition = value === "bottom" ? "bottom" : "top"; popup.dataset.toolbarPosition = toolbarPosition; applyToolbarLayout(); return toolbarPosition; } function applyDefinitionBlurState(element) { if (DEFINITION_BLUR_STATES.has(definitionBlurState)) { element.dataset.definitionBlurState = definitionBlurState; } else { delete element.dataset.definitionBlurState; } } function setDefinitionBlurState(state) { definitionBlurState = DEFINITION_BLUR_STATES.has(state) ? state : "revealed"; if (definitionBlurState === "revealed") { delete popup.dataset.definitionBlurState; } else { popup.dataset.definitionBlurState = definitionBlurState; } for (const definitions of popup.querySelectorAll(".gsm-hoshidicts-definitions")) { applyDefinitionBlurState(definitions); } return definitionBlurState; } function clear(preserveViewControls = false) { currentPresentationUpdate = null; pendingPresentation = null; hideImagePreview(); renderedImages.clear(); renderRevision += 1; currentResultPanel = null; captureTermView = null; pendingScrollRestoration = null; currentEntry = null; if (!preserveViewControls) { currentNoteControls?.close(false); currentNoteControls = null; } sourceHighlighter.clear(); currentSourceHighlight = null; currentToolbar = null; currentFeedback = null; currentLookupFailure = null; masonryObserver?.disconnect(); const retainedForm = currentNoteControls?.form; // Keep the scroller mounted so a retained repaint does not discard its // viewport. Keep a retained live form mounted for focus and selection too. contentScroll.replaceChildren(); for (const child of [...popup.childNodes]) { if (child !== contentScroll && child !== retainedForm && child !== resizeHandle) child.remove(); } // A hidden retirement needs no layout; the next visible render resets it. if (!popup.hidden && !preserveViewControls) contentScroll.scrollTop = 0; setDefinitionBlurState("revealed"); } function mountResultChrome(toolbar, content, feedback = null) { contentScroll.append(content); if (contentScroll.parentNode !== popup) popup.append(contentScroll); if (feedback) popup.append(feedback); popup.append(toolbar); setRenderedToolbar(toolbar, feedback); } function retainedFocus(preserveViewControls) { if (!preserveViewControls) return null; const focused = popup.getRootNode().activeElement; if (!popup.contains(focused)) return null; if (focused.matches('[role="tab"]')) return '[role="tab"][aria-selected="true"]'; if (focused.matches(".gsm-hoshidicts-kanji-back")) return ".gsm-hoshidicts-kanji-back"; return focused; } function restoreRetainedFocus(focused) { const control = typeof focused === "string" ? popup.querySelector(focused) : focused; if (!control || !popup.contains(control)) return; if (popup.getRootNode().activeElement !== control) control.focus(); control.scrollIntoView?.({ block: "nearest", inline: "nearest" }); } function canProjectPresentation() { const form = currentNoteControls?.form; if (form && (!form.hidden || form.getAttribute("aria-busy") === "true")) return false; if (options.canProjectDictionaryPresentation?.() === false) return false; const focused = popup.getRootNode().activeElement; return !popup.contains(focused) || focused.matches('[role="tab"]') || currentNoteControls?.actions.contains(focused); } function flushDictionaryPresentation() { if (!pendingPresentation || !currentPresentationUpdate) return; const pending = pendingPresentation; if (currentPresentationUpdate(pending) && pendingPresentation === pending) pendingPresentation = null; } function onPresentationFocusOut() { if (pendingPresentation) windowRef.queueMicrotask(flushDictionaryPresentation); } popup.addEventListener("focusout", onPresentationFocusOut); function configureEntryActions(actions, label) { actions.className = "gsm-hoshidicts-entry-actions"; actions.setAttribute("role", "group"); actions.setAttribute("aria-label", label); actions.addEventListener("focusin", event => { const item = [...actions.children].find(child => child === event.target || child.contains(event.target)); if (!item || actions.scrollWidth <= actions.clientWidth) return; const padding = 2; const left = item.offsetLeft; const right = left + item.offsetWidth; if (left < actions.scrollLeft + padding) { actions.scrollLeft = Math.max(0, left - padding); } else if (right > actions.scrollLeft + actions.clientWidth - padding) { actions.scrollLeft = right - actions.clientWidth + padding; } }); return actions; } function runRenderAction(isCurrent, renderContext, action) { if (!isCurrent()) return; try { return action(); } catch (error) { if (!isCurrent()) return; clear(); renderContext.onRenderError?.(error); } } function ownsResultPanel(panel, renderContext) { return currentResultPanel === panel && renderContext.isCurrentRequest?.() !== false; } function ownsDisplayedPanel(panel, renderContext) { const isCurrent = renderContext.isCurrentView || renderContext.isCurrentRequest; return currentResultPanel === panel && isCurrent?.() !== false; } function createNoteControls(readPrefill, renderContext = {}) { if (currentNoteControls) { currentNoteControls.setPrefillReader(readPrefill, renderContext); return currentNoteControls; } const button = documentRef.createElement("button"); button.type = "button"; button.className = "gsm-hoshidicts-note-button"; button.title = "Edit personal dictionary"; button.setAttribute("aria-label", button.title); button.setAttribute("aria-expanded", "false"); const icon = documentRef.createElement("span"); icon.className = "gsm-hoshidicts-note-icon hd-icon"; icon.setAttribute("aria-hidden", "true"); icon.dataset.icon = "edit"; button.appendChild(icon); const actions = documentRef.createElement("div"); configureEntryActions(actions, "Lookup actions"); actions.appendChild(button); const buttonNodes = new Map(); function createCustomButton(value) { const custom = documentRef.createElement("button"); custom.type = "button"; custom.dataset.customButtonId = value.id; const label = documentRef.createElement("span"); label.className = "gsm-hoshidicts-text-action-label"; custom.appendChild(label); if (value.type === "link") { custom.className = "gsm-hoshidicts-external-link-button gsm-hoshidicts-text-action-button"; const activate = event => { if (event.defaultPrevented || event.button !== (event.type === "auxclick" ? 1 : 0)) return; event.preventDefault(); event.stopPropagation(); const link = customButtons.find(candidate => candidate.id === custom.dataset.customButtonId && candidate.type === "link"); if (!link || !custom.isConnected || popup.hidden || currentNoteControls?.actions !== actions || renderContext.isCurrentRequest?.() === false) return; const prefill = readPrefill() || {}; const url = windowRef.HDExternalLinks.expandCustomLinkUrl(link.url, { word: prefill.term, reading: prefill.reading, sentence: prefill.sentence, }); if (url) options.onCustomLinkClick?.({ url, active: event.shiftKey || !(event.button === 1 || event.ctrlKey || event.metaKey) }); }; custom.addEventListener("click", activate); custom.addEventListener("auxclick", activate); } else { custom.className = "gsm-hoshidicts-custom-anki-button gsm-hoshidicts-text-action-button"; custom.disabled = true; } return custom; } function updateButtons() { const retained = new Set(); const ordered = []; for (const value of customButtons) { let custom = buttonNodes.get(value.id); const expectedType = value.type === "anki" ? "anki" : "link"; if (custom && custom.dataset.customButtonType !== expectedType) { custom.remove(); buttonNodes.delete(value.id); custom = null; } if (!custom) { custom = createCustomButton(value); custom.dataset.customButtonType = expectedType; buttonNodes.set(value.id, custom); } custom.firstElementChild.textContent = value.label; custom.dataset.customButtonLabel = value.label; custom.title = value.type === "anki" ? `Send to Anki with ${value.label}` : value.label; custom.setAttribute("aria-label", custom.title); if (value.type === "anki") custom.dataset.ankiTemplateId = value.templateId; else delete custom.dataset.ankiTemplateId; retained.add(value.id); ordered.push(custom); } for (const [id, removed] of buttonNodes) { if (retained.has(id)) continue; const focused = popup.getRootNode().activeElement === removed; removed.remove(); buttonNodes.delete(id); if (focused) button.focus(); } actions.append(...ordered); } updateButtons(); let editor = null; button.addEventListener("click", () => { if (!editor) { editor = createNoteForm(button, () => readPrefill()); applyToolbarLayout(); } if (editor.form.hidden) editor.open(); else editor.close(); }); return { actions, button, close: (restoreFocus) => editor?.close(restoreFocus) ?? false, updateButtons, setPrefillReader(value, context) { readPrefill = value; renderContext = context; }, get form() { return editor?.form ?? null; }, }; } function setCustomButtons(value) { const next = value || []; if (JSON.stringify(customButtons) === JSON.stringify(next)) return; customButtons = next; currentNoteControls?.updateButtons(); positionPopup(); } function setCustomLinks(value) { setCustomButtons((value || []).map((link, index) => ({ id: `legacy-link-${index + 1}`, type: "link", ...link, }))); } function createNoteForm(button, readPrefill) { const form = documentRef.createElement("form"); form.className = "gsm-hoshidicts-note-form"; form.id = `${idPrefix}-note-form`; form.hidden = true; button.setAttribute("aria-controls", form.id); function createField(labelText, name, multiline = false) { const label = documentRef.createElement("label"); label.className = "gsm-hoshidicts-note-field"; const labelValue = documentRef.createElement("span"); labelValue.textContent = labelText; const control = multiline ? documentRef.createElement("textarea") : documentRef.createElement("input"); control.id = `${idPrefix}-note-${name}`; control.name = name; control.className = `gsm-hoshidicts-note-${name}`; control.required = true; if (!multiline) control.autocomplete = "off"; label.htmlFor = control.id; label.append(labelValue, control); form.appendChild(label); return control; } const term = createField("Term", "term"); const reading = createField("Reading", "reading"); const definition = createField("Definition", "definition", true); const error = documentRef.createElement("div"); error.className = "gsm-hoshidicts-note-error"; error.setAttribute("role", "alert"); error.hidden = true; form.appendChild(error); const formActions = documentRef.createElement("div"); formActions.className = "gsm-hoshidicts-note-actions"; const cancel = documentRef.createElement("button"); cancel.type = "button"; cancel.className = "gsm-hoshidicts-note-cancel"; cancel.textContent = "Cancel"; const save = documentRef.createElement("button"); save.type = "submit"; save.className = "gsm-hoshidicts-note-save"; save.textContent = "Save"; formActions.append(cancel, save); form.appendChild(formActions); let editing = false; let accepted = false; function close(restoreFocus = true) { if (form.hidden) return false; form.hidden = true; button.setAttribute("aria-expanded", "false"); error.hidden = true; error.textContent = ""; if (editing) { editing = false; onNoteEditingChange(false); } if (restoreFocus && button.isConnected) button.focus(); positionPopup(); flushDictionaryPresentation(); return true; } function open() { accepted = false; const prefill = readPrefill() || {}; term.value = String(prefill.term || ""); reading.value = String(prefill.reading || ""); definition.value = String(prefill.definition || ""); error.hidden = true; error.textContent = ""; form.hidden = false; button.setAttribute("aria-expanded", "true"); if (!editing) { editing = true; onNoteEditingChange(true); } positionPopup(); form.scrollTop = 0; term.focus(); term.select(); } cancel.addEventListener("click", () => close()); form.addEventListener("keydown", (event) => { if (event.key === "Escape" && close()) { event.preventDefault(); event.stopPropagation(); } }); form.addEventListener("submit", (event) => { event.preventDefault(); if (accepted) return; const entry = { term: term.value, reading: reading.value, definition: definition.value, }; if (Object.values(entry).some((value) => value.trim() === "")) { error.textContent = "Complete the term, reading, and definition."; error.hidden = false; positionPopup(); return; } error.hidden = true; error.textContent = ""; try { onAddCustomEntry(entry); accepted = true; close(); } catch (appendError) { error.textContent = typeof appendError?.message === "string" ? appendError.message : String(appendError); error.hidden = false; positionPopup(); } }); return { close, open, form }; } function setSourceHighlightEnabled(enabled) { sourceHighlightEnabled = enabled === true; if (!sourceHighlightEnabled) { sourceHighlighter.clear(); } else if (currentSourceHighlight) { sourceHighlighter.apply( currentSourceHighlight.candidate, currentSourceHighlight.matchedText ); } return sourceHighlightEnabled; } function setLookupStats(element, payload) { const lookedUp = formatLookupCount( "Looked up", payload && payload.lookupCount ); element.textContent = lookedUp ?? ""; element.hidden = lookedUp === null; if (!element.hidden) { positionPopup(); } } // Not named `chrome`: this runs in the content script's isolated world, where // that identifier is the extension API. function createResultChrome(primaryHeader, metadataStrip = null) { const wrapper = documentRef.createElement("div"); wrapper.className = "gsm-hoshidicts-result-chrome"; wrapper.appendChild(primaryHeader); if (metadataStrip) { wrapper.appendChild(metadataStrip); } return wrapper; } function renderNotice(message, candidate, renderContext = {}) { clear(); const notice = documentRef.createElement("div"); notice.className = "gsm-hoshidicts-lookup-notice"; notice.setAttribute("role", "status"); notice.textContent = message; currentNoteControls = createNoteControls(() => ({ term: candidate?.query || "", reading: "", definition: "", sentence: candidate?.sentence || "", }), renderContext); const primaryHeader = documentRef.createElement("header"); primaryHeader.className = "gsm-hoshidicts-entry-header gsm-hoshidicts-primary-header"; primaryHeader.appendChild(currentNoteControls.actions); mountResultChrome(createResultChrome(primaryHeader), notice); } function renderLookupFailure(state, { preserveView = false } = {}) { currentLookupFailure?.remove(); currentLookupFailure = null; if (!preserveView) clear(); const failure = documentRef.createElement("div"); failure.className = "gsm-hoshidicts-lookup-failure"; failure.dataset.kind = state.kind; failure.setAttribute("role", "alert"); const copy = documentRef.createElement("div"); copy.className = "gsm-hoshidicts-lookup-failure-copy"; const title = documentRef.createElement("strong"); title.className = "gsm-hoshidicts-lookup-failure-title"; title.textContent = state.title; const detail = documentRef.createElement("span"); detail.className = "gsm-hoshidicts-lookup-failure-detail"; detail.textContent = state.detail; copy.append(title, detail); failure.appendChild(copy); if (typeof state.onAction === "function" && typeof state.actionLabel === "string") { const action = documentRef.createElement("button"); action.type = "button"; action.className = "gsm-hoshidicts-lookup-failure-action gsm-hoshidicts-text-action-button"; action.textContent = state.actionLabel; action.addEventListener("click", () => { action.disabled = true; Promise.resolve(state.onAction()).catch(() => { if (action.isConnected) action.disabled = false; }); }); failure.appendChild(action); } currentLookupFailure = failure; popup.appendChild(failure); applyToolbarLayout(); return failure; } function appendMetadata( entry, result, dictionaryPresentation = [], { includeFrequency = true, includePitch = true, averageFrequency = false, showFrequencyDictionaryNames = false, imageContext, isCurrent, onLayoutChange, } = {} ) { const frequencyRow = documentRef.createElement("div"); frequencyRow.className = "gsm-hoshidicts-metadata gsm-hoshidicts-frequency-metadata"; const pitchRow = documentRef.createElement("div"); pitchRow.className = "gsm-hoshidicts-metadata gsm-hoshidicts-pitch-metadata"; const ipaRow = documentRef.createElement("div"); ipaRow.className = "gsm-hoshidicts-metadata gsm-hoshidicts-ipa-metadata"; let frequencyCount = 0; function updateFrequency(context) { const frequencyTags = includeFrequency ? createFrequencyTags( documentRef, result, context.dictionaryPresentation || [], maxMetadataTags, context.averageFrequency === true, context.showFrequencyDictionaryNames === true ) : []; const countChanged = frequencyCount !== frequencyTags.length; frequencyCount = frequencyTags.length; frequencyRow.replaceChildren(...frequencyTags); return countChanged; } function updatePitch(context) { pitchRow.replaceChildren(); if (context.showPitchAccentBadge !== true) return; const names = createDictionaryDisplayNames(result.term.pitches.map(({ dictionary }) => dictionary), context.dictionaryPresentation); const seen = new Set(); let count = frequencyCount; for (const group of result.term.pitches) { for (const pitch of group.pitches) { const reading = String( result.term.reading || result.term.expression || "" ).trim(); const key = JSON.stringify([ group.dictionary, reading, pitch.position, pitch.pattern, ]); if (!seen.has(key) && count < maxMetadataTags) { seen.add(key); pitchRow.appendChild(createPitchTag( documentRef, group, names.get(group.dictionary) || group.dictionary, pitch, reading, buildPitchAccentMorae )); count += 1; } } } } const ipaGroups = result.term.pitches.filter(group => group.transcriptions.length > 0); let fillOpenIpa = () => {}; function appendTranscriptions(target) { const names = createDictionaryDisplayNames(ipaGroups.map(({ dictionary }) => dictionary), imageContext.dictionaryPresentation); for (const group of ipaGroups) { const body = group.transcriptions.join(" · "); target.appendChild(createPronunciationTag(documentRef, group, names.get(group.dictionary) || group.dictionary, body, "ipa")); } } if (ipaGroups.length > maxMetadataTags) { // Reuse the existing metadata display budget as a lazy threshold, not // a data limit. Opening the disclosure still renders every source. const overflow = documentRef.createElement("details"); overflow.className = "gsm-hoshidicts-ipa-overflow"; const summary = documentRef.createElement("summary"); summary.textContent = `Phonetic transcriptions (${ipaGroups.length})`; const body = documentRef.createElement("div"); body.className = "gsm-hoshidicts-metadata"; overflow.append(summary, body); fillOpenIpa = () => { if (overflow.open && !body.hasChildNodes()) appendTranscriptions(body); }; overflow.addEventListener("toggle", () => { if (!isCurrent()) return; fillOpenIpa(); onLayoutChange(); }); ipaRow.appendChild(overflow); } else appendTranscriptions(ipaRow); const context = { dictionaryPresentation, averageFrequency, showFrequencyDictionaryNames, showPitchAccentBadge: includePitch }; updateFrequency(context); updatePitch(context); entry.append(frequencyRow, pitchRow, ipaRow); return { updateFrequency, updatePitch, fillOpenIpa, rows: [frequencyRow, pitchRow, ipaRow] }; } function collectGrammarMetadata(result) { const metadata = []; const seen = new Set(); const append = (text, description, kind) => { const value = String(text || "").trim(); if (!value || seen.has(value)) { return; } seen.add(value); metadata.push({ description, kind, text: value }); }; for (const step of deinflectionSteps(result)) { append(step.name, step.description, "deinflection"); } for (const tag of [ ...parseTagList(result.term.rules), ...result.term.glossaries.flatMap((glossary) => parseTagList(glossary.termTags) ), ]) { append(tag, "", "term"); } return metadata; } function renderGrammarRow(row, result, hideGrammarTags) { row.replaceChildren(); if (hideGrammarTags) return; for (const item of collectGrammarMetadata(result)) { row.appendChild(createTag(documentRef, item.text, item.description, item.kind)); } } function renderPrimaryMetadataCapsule( capsule, result, dictionaryPresentation, hideGrammarTags, averageFrequency, showFrequencyDictionaryNames, { frequencyChanged = true, grammarChanged = true } = {} ) { if (frequencyChanged) { capsule.querySelector(".gsm-hoshidicts-primary-frequencies")?.remove(); const frequencyTags = createFrequencyTags( documentRef, result, dictionaryPresentation, maxMetadataTags, averageFrequency, showFrequencyDictionaryNames ); if (frequencyTags.length > 0) { const frequencies = documentRef.createElement("span"); frequencies.className = "gsm-hoshidicts-primary-frequencies"; frequencies.append(...frequencyTags); capsule.prepend(frequencies); } } if (grammarChanged) { capsule.querySelector(".gsm-hoshidicts-primary-grammar")?.remove(); if (!hideGrammarTags) { const grammarMetadata = collectGrammarMetadata(result); if (grammarMetadata.length > 0) { const grammar = documentRef.createElement("span"); grammar.className = "gsm-hoshidicts-primary-grammar"; for (const item of grammarMetadata) { const tag = documentRef.createElement("span"); tag.className = `gsm-hoshidicts-primary-grammar-tag ` + `gsm-hoshidicts-primary-grammar-tag-${item.kind}`; tag.textContent = item.text; if (item.description) { tag.title = item.description; } grammar.appendChild(tag); } capsule.appendChild(grammar); } } } capsule.hidden = capsule.childNodes.length === 0; } function updateCompactSummary(headword, result, context, media) { const previous = headword.querySelector(".gsm-hoshidicts-compact-definition-summary"); if (previous) { const imageLink = previous.querySelector(".gloss-image-link"); if (imageLink) hideImagePreview(imageLink); previous.remove(); } if (context.showCompactDefinitionSummary !== true) return Boolean(previous); const compact = extractCompactDefinitionSummary(result.term.glossaries, context.compactDefinitionSummaryDictionary, context.compactDefinitionSummaryCount); if (!compact) return Boolean(previous); const summary = documentRef.createElement("div"); summary.className = "gsm-hoshidicts-compact-definition-summary"; summary.dataset.hoshidictsDictionary = compact.dictionary; // Establish ownership before synchronous media admission. Replacing this // summary retires its consumer without retiring the complete card's one. headword.querySelector(".gsm-hoshidicts-expression").after(summary); if (compact.image) { const thumbnail = documentRef.createElement("span"); thumbnail.className = "gsm-hoshidicts-compact-definition-image"; summary.appendChild(thumbnail); appendStructuredImage(documentRef, thumbnail, { ...compact.image, preferredWidth: 36, preferredHeight: 36, sizeUnits: "px", collapsed: false }, { isCurrent: () => headword.contains(summary) && media.isCurrent(), isImageCurrent: () => summary.isConnected && headword.contains(summary) && media.isCurrentLink(), dictionary: compact.dictionary, imageContext: media.imageContext, imageSourceLabelHost: summary, onImageCreated: media.onImageCreated, onLayoutChange: media.onLayoutChange, onImageError: () => thumbnail.remove(), onImageStart: () => { if (!summary.contains(thumbnail)) summary.prepend(thumbnail); }, requestImagePreview, refreshImagePreview, hideImagePreview, resolveMedia: typeof media.resolveMedia === "function" ? query => media.resolveMedia({ ...query, dictionary: compact.dictionary, generation: media.generation }) : null, }); if (!thumbnail.hasChildNodes()) thumbnail.remove(); } const items = documentRef.createElement("ul"); items.className = "gsm-hoshidicts-compact-definition-items"; for (const item of compact.items) { const li = documentRef.createElement("li"); li.textContent = item; items.appendChild(li); } summary.appendChild(items); return true; } function createEntryHeader( result, candidate, { element = null, primary = false, showCompactDefinitionSummary = false, compactDefinitionSummaryCount = DEFAULT_COMPACT_DEFINITION_SUMMARY_COUNT, compactDefinitionSummaryDictionary = null, summaryMedia = null, showPitchAccentFurigana = true, pitchAccentFuriganaDictionary = null, onBack = null, onClose = null, noteControls = null, feedback = null, onDeinflectionToggle = null, } = {} ) { const header = element || documentRef.createElement("header"); header.className = primary ? "gsm-hoshidicts-entry-header gsm-hoshidicts-primary-header" : "gsm-hoshidicts-entry-header"; header.replaceChildren(); const headword = documentRef.createElement("div"); headword.className = "gsm-hoshidicts-headword"; const expression = documentRef.createElement("span"); expression.className = "gsm-hoshidicts-expression"; const expressionText = String(result.term.expression || "").trim(); const readingText = String(result.term.reading || "").trim(); function populateRuby() { expression.replaceChildren(); appendExpressionRuby( documentRef, expression, expressionText, readingText, (character, sourceLink) => onKanjiClick(character, result, candidate, sourceLink), { enabled: showPitchAccentFurigana, groups: result.term.pitches, dictionary: pitchAccentFuriganaDictionary, } ); } populateRuby(); expression.setAttribute( "aria-label", readingText && readingText !== expressionText ? `${expressionText}, ${readingText}` : expressionText ); headword.appendChild(expression); if (showCompactDefinitionSummary === true) { updateCompactSummary(headword, result, { showCompactDefinitionSummary, compactDefinitionSummaryCount, compactDefinitionSummaryDictionary }, summaryMedia); } const deinflection = buildDeinflectionDisclosure(documentRef, result, windowRef.navigator.language); if (deinflection) { deinflection.addEventListener("toggle", onDeinflectionToggle); headword.appendChild(deinflection); } let navigationAction = null; if (primary && (typeof onBack === "function" || typeof onClose === "function")) { navigationAction = documentRef.createElement("button"); navigationAction.type = "button"; if (typeof onClose === "function") { navigationAction.className = "gsm-hoshidicts-popup-close"; navigationAction.setAttribute("aria-label", "Close lookup"); navigationAction.addEventListener("click", onClose); } else { navigationAction.className = "gsm-hoshidicts-kanji-back"; navigationAction.textContent = "Back"; navigationAction.setAttribute("aria-label", "Back to previous results"); navigationAction.addEventListener("click", onBack); } } header.appendChild(headword); const actions = primary && noteControls ? noteControls.actions : documentRef.createElement("div"); if (!primary || !noteControls) configureEntryActions(actions, "Entry actions"); if (primary && noteControls) actions.querySelector(".gsm-hoshidicts-audio-control")?.remove(); for (const previous of actions.querySelectorAll( ":scope > .gsm-hoshidicts-popup-close, :scope > .gsm-hoshidicts-kanji-back" )) previous.remove(); const audio = documentRef.createElement("div"); audio.className = "gsm-hoshidicts-audio-control"; const button = documentRef.createElement("button"); button.type = "button"; button.className = "gsm-hoshidicts-audio-button"; button.title = "Play pronunciation; Shift-click, right-click or press Down for choices"; button.setAttribute("aria-label", `Play pronunciation for ${expressionText}`); button.setAttribute("aria-haspopup", "dialog"); button.setAttribute("aria-expanded", "false"); audio.append(button); actions.prepend(audio); const existingMiningAction = actions.querySelector(":scope > .gsm-hoshidicts-mine-button"); if (existingMiningAction) actions.prepend(existingMiningAction); if (navigationAction) actions.prepend(navigationAction); header.append(actions); return { element: header, audio: { button, result }, mining: { actions, feedback, result }, updateRuby(context) { const enabled = context.showPitchAccentFurigana !== false; const dictionary = typeof context.pitchAccentFuriganaDictionary === "string" ? context.pitchAccentFuriganaDictionary : null; if (enabled === showPitchAccentFurigana && dictionary === pitchAccentFuriganaDictionary) return false; const appearanceChanged = enabled !== showPitchAccentFurigana || enabled; // A kanji button is part of this ruby. Keep its identity until blur; // Note, disclosure and glossary focus need no such deferral. if (appearanceChanged && expression.contains(popup.getRootNode().activeElement)) return null; showPitchAccentFurigana = enabled; pitchAccentFuriganaDictionary = dictionary; if (appearanceChanged) populateRuby(); return appearanceChanged; }, }; } function projectResults(results, dictionaries) { if (dictionaries.size === 0) { return results; } const projected = []; for (const result of results) { const glossaries = result.term.glossaries.filter( (glossary) => dictionaries.has(glossary.dictionary) ); if (glossaries.length === 0) { continue; } projected.push({ ...result, term: { ...result.term, glossaries, }, }); } return projected; } function renderResultPanel( panel, results, candidate, renderContext, { dictionaryDisplayNames, imageContext, feedback, primaryHeader, primaryMetadataCapsule, } = {} ) { const revision = ++renderRevision; const isCurrent = () => revision === renderRevision && ownsResultPanel(panel, renderContext); const isCurrentLink = () => revision === renderRevision && ownsDisplayedPanel(panel, renderContext); const positionIfCurrent = () => { if (isCurrent()) positionPopup(); }; const onImageCreated = handle => renderedImages.add(handle); const resolveImage = query => imageContext.resolveMedia(query); const summaryMedia = { isCurrent, isCurrentLink, generation: renderContext.generation, imageContext, onImageCreated, resolveMedia: typeof imageContext.resolveMedia === "function" ? resolveImage : null, onLayoutChange: positionIfCurrent }; hideImagePreview(); renderedImages.clear(); panel.replaceChildren(); if (feedback) { feedback.hidden = true; feedback.textContent = ""; delete feedback.dataset.kind; } const deferredGlossaryFills = []; const entryMetadata = []; const audioButtons = []; const miningActions = []; let appliedMetadata = metadataOptions(imageContext); let appliedFrequencyModes = frequencyModes(imageContext); let appliedDictionaryPresentation = imageContext.dictionaryPresentation; let lookupStats = null; let expanded = renderContext.expandAll === true; let restoreScrollTop = renderContext.restoreScrollTop; let restoreDisclosures = renderContext.restoreDisclosures; function restoreViewportAfterFill() { if (restoreDisclosures) { const details = [...popup.querySelectorAll("details")]; if (details.length === restoreDisclosures.length && details.every((node, index) => node.className === restoreDisclosures[index].className)) { details.forEach((node, index) => { node.open = restoreDisclosures[index].open; }); // Native toggle delivery is deferred. Populate restored lazy IPA // now so the first restored layout includes all its text. for (const { metadata } of entryMetadata) metadata.fillOpenIpa(); } restoreDisclosures = undefined; } if (restoreScrollTop === undefined) return; const savedScrollTop = restoreScrollTop; restoreScrollTop = undefined; // Back's scroll height is meaningful only after the deferred bodies // and masonry are laid out. A newer projection or deliberate scroll // takes precedence over this one-shot restoration. pendingScrollRestoration = () => { // Back's fresh render reset scroll to zero. Reading it earlier, // while the panel is empty, would force an unnecessary layout. if (isCurrent() && contentScroll.scrollTop === 0) contentScroll.scrollTop = savedScrollTop; }; scheduleMasonry(); } function appendResult(result, resultIndex) { Object.assign(renderContext, metadataOptions(imageContext)); const entry = documentRef.createElement("article"); entry.className = "gsm-hoshidicts-entry"; entry.dataset.expression = result.term.expression; const renderedHeader = createEntryHeader(result, candidate, { element: resultIndex === 0 ? primaryHeader : null, primary: resultIndex === 0, showCompactDefinitionSummary: renderContext.showCompactDefinitionSummary === true, compactDefinitionSummaryCount: renderContext.compactDefinitionSummaryCount, compactDefinitionSummaryDictionary: typeof renderContext.compactDefinitionSummaryDictionary === "string" ? renderContext.compactDefinitionSummaryDictionary : null, summaryMedia, showPitchAccentFurigana: renderContext.showPitchAccentFurigana !== false, pitchAccentFuriganaDictionary: typeof renderContext.pitchAccentFuriganaDictionary === "string" ? renderContext.pitchAccentFuriganaDictionary : null, onBack: resultIndex === 0 ? renderContext.onBack : null, onClose: resultIndex === 0 ? renderContext.onClose : null, noteControls: resultIndex === 0 ? renderContext.noteControls : null, feedback, onDeinflectionToggle: positionIfCurrent, }); audioButtons.push(renderedHeader.audio); miningActions.push(renderedHeader.mining); if (resultIndex !== 0) { entry.appendChild(renderedHeader.element); } // The lookup count and the frequency share one line. const primaryMetadataRow = resultIndex === 0 ? documentRef.createElement("div") : null; if (primaryMetadataRow) { primaryMetadataRow.className = "gsm-hoshidicts-primary-metadata-row"; entry.appendChild(primaryMetadataRow); } if (resultIndex === 0 && renderContext.lookupStatsSlot === true) { lookupStats = documentRef.createElement("div"); lookupStats.className = "gsm-hoshidicts-lookup-stats"; lookupStats.setAttribute("role", "status"); lookupStats.setAttribute("aria-live", "polite"); lookupStats.hidden = true; primaryMetadataRow.appendChild(lookupStats); } if (resultIndex === 0 && primaryMetadataCapsule) { renderPrimaryMetadataCapsule( primaryMetadataCapsule, result, Array.isArray(renderContext.dictionaryPresentation) ? renderContext.dictionaryPresentation : [], renderContext.hidePopupGrammarTags !== false, renderContext.averageFrequency === true, renderContext.showFrequencyDictionaryNames === true ); primaryMetadataRow.appendChild(primaryMetadataCapsule); } const metadata = appendMetadata( entry, result, Array.isArray(imageContext.dictionaryPresentation) ? imageContext.dictionaryPresentation : [], { includeFrequency: resultIndex !== 0, imageContext, isCurrent: isCurrentLink, onLayoutChange: scheduleMasonry, includePitch: renderContext.showPitchAccentBadge === true, averageFrequency: renderContext.averageFrequency === true, showFrequencyDictionaryNames: renderContext.showFrequencyDictionaryNames === true, } ); const grammarRow = resultIndex === 0 ? null : documentRef.createElement("div"); if (grammarRow) { grammarRow.className = "gsm-hoshidicts-tags"; renderGrammarRow(grammarRow, result, renderContext.hidePopupGrammarTags !== false); entry.appendChild(grammarRow); } entryMetadata.push({ header: renderedHeader, metadata, grammarRow }); const groupedGlossaries = new Map(); for (const glossary of result.term.glossaries) { if (!groupedGlossaries.has(glossary.dictionary)) { groupedGlossaries.set(glossary.dictionary, []); } groupedGlossaries.get(glossary.dictionary).push(glossary); } const glossaryGrid = documentRef.createElement("div"); glossaryGrid.className = "gsm-hoshidicts-glossary-grid"; for (const [dictionary, glossaries] of groupedGlossaries) { // Always open, as in Yomitan: only disclosures a dictionary authors // inside its own content collapse. const card = documentRef.createElement("div"); card.className = "gsm-hoshidicts-glossary-card"; const title = documentRef.createElement("div"); title.className = "gsm-hoshidicts-glossary-card-title"; title.textContent = dictionaryDisplayNames?.get(dictionary) || dictionary; title.title = dictionary; card.appendChild(title); const definitions = documentRef.createElement("ol"); definitions.className = "gsm-hoshidicts-definitions"; if (glossaries.length === 1) { definitions.classList.add("gsm-hoshidicts-definitions-single"); } applyDefinitionBlurState(definitions); for (const [definitionIndex, glossary] of glossaries.entries()) { const definition = documentRef.createElement("li"); const definitionTags = parseTagList(glossary.definitionTags); if (definitionTags.length > 0) { const definitionTagRow = documentRef.createElement("div"); definitionTagRow.className = "gsm-hoshidicts-definition-tags"; for (const tag of definitionTags) { definitionTagRow.appendChild( createTag(documentRef, tag, "", "definition") ); } definition.appendChild(definitionTagRow); } const content = documentRef.createElement("div"); content.className = "gsm-hoshidicts-glossary-content"; content.dataset.hoshidictsDictionary = dictionary; const fillContent = () => { try { appendTextOnlyGlossary( documentRef, content, glossary.glossary, { dictionary, generation: renderContext.generation, isCurrent, isCurrentLink, onExternalLink: renderContext.onExternalLink, onInternalLink: renderContext.onInternalLink, onLayoutChange: positionIfCurrent, requestImagePreview, refreshImagePreview, hideImagePreview, imageContext, onImageCreated, resolveMedia: typeof imageContext.resolveMedia === "function" ? resolveImage : null, } ); } catch (error) { const contextual = structuredContentRenderError(error, { definitionIndex, dictionary, dictionaryId: dictionaryStableId( dictionary, imageContext.dictionaryPresentation ), resultIndex, term: result.term, }); // A dictionary-authored glossary that exhausts the traversal // budget must not hide healthy cards from the same lookup. // Unexpected renderer failures still use the view-wide error // boundary so programming errors are not silently swallowed. if (contextual === error) throw error; content.replaceChildren(); console.warn("hachidori: omitted dictionary definition after render failure", contextual); } }; // Glossary bodies are most of a render. Only the first entry is // visible in the popup, so fill the rest after it has painted. if (resultIndex === 0) { fillContent(); } else { deferredGlossaryFills.push(fillContent); } definition.appendChild(content); definitions.appendChild(definition); } card.appendChild(definitions); glossaryGrid.appendChild(card); } entry.appendChild(glossaryGrid); // Fixed-width masonry cards cannot signal a change to their container. masonryObserver?.observe(glossaryGrid); for (const card of glossaryGrid.children) { masonryObserver?.observe(card); } scheduleMasonry(); panel.appendChild(entry); } // Fills the queued glossaries on the next task, once the first entry has // had a chance to paint. Fills inline without a timer available. function flushDeferredGlossaries(immediate = false) { if (deferredGlossaryFills.length === 0) { restoreViewportAfterFill(); return; } const fills = deferredGlossaryFills.splice(0); const run = () => { for (const fill of fills) { if (!isCurrent()) return; fill(); } if (!immediate) positionIfCurrent(); restoreViewportAfterFill(); }; if (!immediate && typeof windowRef.setTimeout === "function") { windowRef.setTimeout(() => runRenderAction(isCurrent, renderContext, run), 0); } else { run(); } } const visibleCount = renderContext.expandAll === true ? results.length : initialResultCount; results.slice(0, visibleCount).forEach(appendResult); flushDeferredGlossaries(); if (results.length > visibleCount) { let nextResultIndex = visibleCount; const showMore = documentRef.createElement("button"); showMore.type = "button"; showMore.className = "gsm-hoshidicts-show-more"; showMore.textContent = `Show ${results.length - visibleCount} more`; showMore.addEventListener("click", () => runRenderAction( () => ownsDisplayedPanel(panel, renderContext), renderContext, () => { if (!isCurrent()) { onBeforeResultsRendered({ expandAll: true }); return; } showMore.remove(); expanded = true; while (nextResultIndex < results.length) { appendResult(results[nextResultIndex], nextResultIndex++); } flushDeferredGlossaries(); onResultsExpanded({ audioButtons, miningActions }); positionPopup(); })); panel.appendChild(showMore); function scheduleNextResult() { windowRef.requestAnimationFrame(() => { windowRef.setTimeout(() => runRenderAction(isCurrent, renderContext, () => { if (nextResultIndex >= results.length) return; const deadline = windowRef.performance.now() + 8; do { appendResult(results[nextResultIndex], nextResultIndex++); flushDeferredGlossaries(true); } while (nextResultIndex < results.length && windowRef.performance.now() < deadline); expanded = true; onResultsExpanded({ audioButtons, miningActions }); if (nextResultIndex === results.length) showMore.remove(); else { showMore.textContent = `Show ${results.length - nextResultIndex} more`; panel.appendChild(showMore); scheduleNextResult(); } positionPopup(); }), 0); }); } scheduleNextResult(); } currentSourceHighlight = { candidate, matchedText: renderContext.highlightText || results[0].matched || results[0].term.expression, }; if (sourceHighlightEnabled) { sourceHighlighter.apply( currentSourceHighlight.candidate, currentSourceHighlight.matchedText ); } function updateMetadataLabels(container, result) { let changed = false; for (const [kind, groups] of [["frequency", result.term.frequencies], ["pitch", result.term.pitches], ["ipa", result.term.pitches]]) { if (kind === "frequency" && imageContext.averageFrequency === true) continue; const names = createDictionaryDisplayNames(groups.map(({ dictionary }) => dictionary), imageContext.dictionaryPresentation); if (kind !== "frequency") { for (const tag of container.querySelectorAll(`.gsm-hoshidicts-tag-${kind}`)) { changed = updatePronunciationLabel(tag, names.get(tag.dataset.dictionary) || tag.dataset.dictionary) || changed; } continue; } for (const source of container.querySelectorAll(`.gsm-hoshidicts-${kind}-source`)) { const dictionary = source.parentNode.dataset.dictionary; changed = updateLabel(source, names.get(dictionary) || dictionary) || changed; } } return changed; } return { lookupStats, audioButtons, miningActions, isExpanded: () => expanded, updateMetadata() { const nextModes = frequencyModes(imageContext); const labelsChanged = JSON.stringify(imageContext.dictionaryPresentation) !== JSON.stringify(appliedDictionaryPresentation); const frequencyChanged = ["averageFrequency", "showFrequencyDictionaryNames"] .some(key => imageContext[key] !== appliedMetadata[key]) || nextModes !== appliedFrequencyModes; const grammarChanged = imageContext.hidePopupGrammarTags !== appliedMetadata.hidePopupGrammarTags; const pitchChanged = imageContext.showPitchAccentBadge !== appliedMetadata.showPitchAccentBadge; let changed = false; let deferred = false; if (labelsChanged) changed = updateMetadataLabels(primaryMetadataCapsule, results[0]); if (frequencyChanged || grammarChanged) { renderPrimaryMetadataCapsule(primaryMetadataCapsule, results[0], imageContext.dictionaryPresentation || [], imageContext.hidePopupGrammarTags !== false, imageContext.averageFrequency === true, imageContext.showFrequencyDictionaryNames === true, { frequencyChanged, grammarChanged }); changed = true; } entryMetadata.forEach(({ header, metadata, grammarRow }, index) => { const countChanged = frequencyChanged && index > 0 && metadata.updateFrequency(imageContext); if (pitchChanged || countChanged) { metadata.updatePitch(imageContext); changed = true; } if (grammarChanged && grammarRow) renderGrammarRow(grammarRow, results[index], imageContext.hidePopupGrammarTags !== false); if (labelsChanged) { for (const row of metadata.rows) changed = updateMetadataLabels(row, results[index]) || changed; } const rubyChanged = header.updateRuby(imageContext); deferred ||= rubyChanged === null; changed ||= rubyChanged === true; }); appliedMetadata = metadataOptions(imageContext); appliedFrequencyModes = nextModes; appliedDictionaryPresentation = imageContext.dictionaryPresentation; return { changed, deferred }; }, updateImages() { let changed = false; for (const handle of renderedImages) { if (!handle.isCurrent()) renderedImages.delete(handle); else changed = handle.updatePresentation(imageContext) || changed; } return changed; }, updateDictionaryPresentation(context, names, summaryChanged) { summaryChanged &&= isCurrent(); const labelsChanged = names.size !== dictionaryDisplayNames.size || [...names].some(([dictionary, label]) => dictionaryDisplayNames.get(dictionary) !== label); Object.assign(renderContext, context); dictionaryDisplayNames = names; if (!labelsChanged && !summaryChanged) return false; let changed = false; if (summaryChanged) { changed = updateCompactSummary(primaryHeader.querySelector(".gsm-hoshidicts-headword"), results[0], renderContext, summaryMedia) || changed; } const entries = panel.querySelectorAll(":scope > .gsm-hoshidicts-entry"); entries.forEach((entry, index) => { if (index > 0 && summaryChanged) { changed = updateCompactSummary(entry.querySelector(".gsm-hoshidicts-headword"), results[index], renderContext, summaryMedia) || changed; } if (labelsChanged) { for (const title of entry.querySelectorAll(":scope > .gsm-hoshidicts-glossary-grid > .gsm-hoshidicts-glossary-card > .gsm-hoshidicts-glossary-card-title")) { changed = updateLabel(title, names.get(title.title) || title.title) || changed; } } }); return changed; }, }; } function renderKanji(kanji, candidate, renderOptions = {}) { renderOptions = { ...renderOptions }; const focused = retainedFocus(renderOptions.preserveViewControls); clear(renderOptions.preserveViewControls); const dictionaries = [...new Set(kanji.entries.map(({ dictionary }) => dictionary))]; let { tabs, dictionaryDisplayNames } = createDictionaryTabs(dictionaries, renderOptions); const requestedKey = dictionaryTabKey(renderOptions.selectedDictionaryTab); let selected = tabs.find((tab) => tab.key === requestedKey) || tabs[0]; renderOptions.onDictionaryTabSelected?.(normaliseDictionaryTab(selected)); const noteControls = createNoteControls(() => ({ term: kanji.character, reading: "", definition: "", sentence: candidate?.sentence || "", }), renderOptions); currentNoteControls = noteControls; const primaryHeader = documentRef.createElement("header"); primaryHeader.className = "gsm-hoshidicts-entry-header gsm-hoshidicts-primary-header"; const navigation = documentRef.createElement("div"); navigation.className = "gsm-hoshidicts-kanji-navigation"; let back = null; if (typeof renderOptions.onBack === "function") { back = documentRef.createElement("button"); back.type = "button"; back.className = "gsm-hoshidicts-kanji-back"; back.textContent = "Back"; back.setAttribute("aria-label", "Back to previous results"); back.addEventListener("click", renderOptions.onBack); } const glyph = documentRef.createElement("div"); glyph.className = "gsm-hoshidicts-kanji-glyph"; glyph.textContent = kanji.character; navigation.appendChild(glyph); for (const previous of noteControls.actions.querySelectorAll( ":scope > .gsm-hoshidicts-popup-close, :scope > .gsm-hoshidicts-kanji-back" )) previous.remove(); if (back) noteControls.actions.prepend(back); primaryHeader.append(navigation, noteControls.actions); const toolbar = createResultChrome(primaryHeader); function renderEntries() { const entries = documentRef.createDocumentFragment(); for (const kanjiEntry of kanji.entries) { if (selected.dictionaries.size > 0 && !selected.dictionaries.has(kanjiEntry.dictionary)) continue; const entry = documentRef.createElement("article"); entry.className = "gsm-hoshidicts-kanji-entry"; entry.dataset.dictionary = kanjiEntry.dictionary; const dictionary = documentRef.createElement("h3"); dictionary.className = "gsm-hoshidicts-kanji-dictionary"; dictionary.textContent = dictionaryDisplayNames.get( kanjiEntry.dictionary ) || kanjiEntry.dictionary; dictionary.title = kanjiEntry.dictionary; dictionary.setAttribute("aria-label", kanjiEntry.dictionary); entry.appendChild(dictionary); const kanjiTags = tokenList(kanjiEntry.tags); if (kanjiTags.length > 0) { const tags = documentRef.createElement("div"); tags.className = "gsm-hoshidicts-tags"; for (const tag of kanjiTags) { tags.appendChild(createTag(documentRef, tag, "", "term")); } entry.appendChild(tags); } const readings = documentRef.createElement("div"); readings.className = "gsm-hoshidicts-kanji-readings"; for (const [label, values] of [ ["On", tokenList(kanjiEntry.onyomi)], ["Kun", tokenList(kanjiEntry.kunyomi)], ]) { if (values.length === 0) continue; const group = documentRef.createElement("div"); group.className = "gsm-hoshidicts-kanji-reading-group"; const heading = documentRef.createElement("strong"); heading.textContent = label; group.appendChild(heading); const value = documentRef.createElement("span"); value.textContent = values.join(" · "); group.appendChild(value); readings.appendChild(group); } if (readings.childNodes.length > 0) entry.appendChild(readings); const definitions = Array.isArray(kanjiEntry.definitions) ? kanjiEntry.definitions : []; if (definitions.length > 0) { const meaningsHeading = documentRef.createElement("h4"); meaningsHeading.textContent = "Meanings"; entry.appendChild(meaningsHeading); const meanings = documentRef.createElement("ol"); meanings.className = "gsm-hoshidicts-kanji-meanings"; for (const meaning of definitions) { const item = documentRef.createElement("li"); item.textContent = meaning; meanings.appendChild(item); } entry.appendChild(meanings); } const stats = Array.isArray(kanjiEntry.stats) ? kanjiEntry.stats : []; if (stats.length > 0) { const details = documentRef.createElement("details"); details.className = "gsm-hoshidicts-kanji-stats"; const summary = documentRef.createElement("summary"); summary.textContent = "Details"; details.appendChild(summary); const list = documentRef.createElement("dl"); for (const stat of stats) { const name = documentRef.createElement("dt"); name.textContent = stat.name; const value = documentRef.createElement("dd"); value.textContent = stat.value; list.append(name, value); } details.appendChild(list); entry.appendChild(details); } entries.appendChild(entry); } return entries; } mountResultChrome(toolbar, renderEntries()); const ownsKanji = () => currentToolbar === toolbar && renderOptions.isCurrentView?.() !== false; currentPresentationUpdate = (context) => runRenderAction(ownsKanji, renderOptions, () => { const next = createDictionaryTabs(dictionaries, context); const nextSelected = next.tabs.find(tab => tab.key === selected.key) || next.tabs[0]; const sameMembers = sameTabMembers(selected.dictionaries, nextSelected.dictionaries, dictionaries); if (!sameMembers && (renderOptions.isCurrentRequest?.() === false || !canProjectPresentation())) return false; Object.assign(renderOptions, context); dictionaryDisplayNames = next.dictionaryDisplayNames; if (selected.key !== nextSelected.key) renderOptions.onDictionaryTabSelected?.(normaliseDictionaryTab(nextSelected)); selected = nextSelected; let changed = false; if (sameMembers) { for (const heading of contentScroll.querySelectorAll(":scope > .gsm-hoshidicts-kanji-entry > h3")) { changed = updateLabel(heading, dictionaryDisplayNames.get(heading.title) || heading.title) || changed; } } else { contentScroll.replaceChildren(renderEntries()); changed = true; } if (changed) scheduleMasonry(); return true; }); currentSourceHighlight = { candidate, matchedText: renderOptions.highlightText || kanji.character }; if (sourceHighlightEnabled) sourceHighlighter.apply(candidate, currentSourceHighlight.matchedText); if (focused) { positionPopup(); restoreRetainedFocus(focused); } } function renderResults(results, candidate, renderContext = {}) { renderContext = { ...renderContext }; // Visual preferences are independent of tab/text changes that Note or a // child may defer. Carry their current context through local tabs. const imageContext = { popupImageSources: renderContext.popupImageSources ?? null, dictionaryPresentation: renderContext.dictionaryPresentation, resolveMedia: renderContext.resolveMedia, ...metadataOptions(renderContext) }; const focused = retainedFocus(renderContext.preserveViewControls); clear(renderContext.preserveViewControls); setDefinitionBlurState(renderContext.definitionBlurState); const dictionaries = collectGlossaryDictionaries(results); let { tabs: tabDescriptors, dictionaryDisplayNames } = createDictionaryTabs(dictionaries, renderContext); const tabList = tabDescriptors.length > 1 ? documentRef.createElement("div") : null; if (tabList) { tabList.className = "gsm-hoshidicts-tab-list"; tabList.setAttribute("role", "tablist"); tabList.setAttribute("aria-label", "Dictionaries"); tabList.setAttribute("aria-orientation", "horizontal"); } const metadataStrip = tabList ? documentRef.createElement("div") : null; if (metadataStrip) { metadataStrip.className = "gsm-hoshidicts-metadata-strip"; metadataStrip.appendChild(tabList); } const primaryMetadataCapsule = documentRef.createElement("div"); primaryMetadataCapsule.className = "gsm-hoshidicts-primary-metadata-capsule"; primaryMetadataCapsule.hidden = true; primaryMetadataCapsule.setAttribute("role", "group"); primaryMetadataCapsule.setAttribute("aria-label", "Entry metadata"); const feedback = documentRef.createElement("div"); feedback.className = "gsm-hoshidicts-mining-feedback"; feedback.setAttribute("role", "status"); feedback.setAttribute("aria-live", "polite"); feedback.hidden = true; const panel = documentRef.createElement("div"); currentResultPanel = panel; const ownsView = () => ownsResultPanel(panel, renderContext); panel.id = `${idPrefix}-tab-panel`; panel.className = "gsm-hoshidicts-tab-panel"; if (tabList) { panel.setAttribute("role", "tabpanel"); } const primaryHeader = documentRef.createElement("header"); primaryHeader.className = "gsm-hoshidicts-entry-header gsm-hoshidicts-primary-header"; let projectedPrimary = null; const noteControls = createNoteControls(() => ({ // An exact selection adds what was highlighted, not the headword it matched. term: candidate?.exactSelection === true ? candidate.query : projectedPrimary?.term?.expression || "", reading: candidate?.exactSelection !== true || candidate.query === projectedPrimary?.term?.expression ? projectedPrimary?.term?.reading || "" : "", definition: "", sentence: candidate?.sentence || "", }), renderContext); currentNoteControls = noteControls; const toolbar = createResultChrome(primaryHeader, metadataStrip); mountResultChrome(toolbar, panel, feedback); let tabButtons = []; let nextTabId = 0; const requestedKey = dictionaryTabKey(renderContext.selectedDictionaryTab); const requestedTabIndex = tabDescriptors.findIndex((descriptor) => descriptor.key === requestedKey); let focusedIndex = Math.max(0, requestedTabIndex); let selectedIndex = focusedIndex; let hasRendered = false; let rendered = null; function updateTabState() { tabButtons.forEach((button, buttonIndex) => { const selected = buttonIndex === selectedIndex; button.setAttribute("aria-selected", String(selected)); button.tabIndex = buttonIndex === focusedIndex ? 0 : -1; }); const selectedButton = tabButtons[selectedIndex]; if (selectedButton) { panel.setAttribute("aria-labelledby", selectedButton.id); } else { panel.removeAttribute("aria-labelledby"); } } function activateTab(index, focusButton = false) { const tablessAllView = tabButtons.length === 0 && index === 0; if (!tablessAllView && (index < 0 || index >= tabButtons.length)) { return; } const previousIndex = selectedIndex; focusedIndex = index; selectedIndex = index; updateTabState(); const button = tabButtons[index]; if (focusButton && button) { button.focus(); } const selectionChanged = previousIndex !== selectedIndex; if ((!hasRendered || selectionChanged) && typeof renderContext.onDictionaryTabSelected === "function") { const descriptor = tabDescriptors[selectedIndex]; renderContext.onDictionaryTabSelected(normaliseDictionaryTab(descriptor)); } if (hasRendered && !selectionChanged) { if (!ownsView()) { onBeforeResultsRendered(); return; } if ( button && !popup.hidden && typeof button.scrollIntoView === "function" ) { button.scrollIntoView({ block: "nearest", inline: "nearest" }); } return; } if (hasRendered) { if (onBeforeResultsRendered() === false) return; } if (hasRendered || !renderContext.preserveViewControls) contentScroll.scrollTop = 0; renderProjection(!hasRendered && renderContext.expandAll === true); hasRendered = true; positionPopup(); } function renderProjection(expandAll) { if (hasRendered) masonryObserver?.disconnect(); const selectedDictionaries = tabDescriptors[selectedIndex].dictionaries; const projectedResults = projectResults(results, selectedDictionaries); const saved = !hasRendered && renderContext.disclosures; const matchingDisclosures = saved && (saved.results === results || JSON.stringify(saved.results) === JSON.stringify(results)) && sameTabMembers(new Set(saved.dictionaries), selectedDictionaries, dictionaries); projectedPrimary = projectedResults[0] || null; rendered = renderResultPanel( panel, projectedResults, candidate, { ...renderContext, ...metadataOptions(imageContext), dictionaryPresentation: imageContext.dictionaryPresentation, noteControls, expandAll, restoreScrollTop: !hasRendered ? renderContext.restoreScrollTop : undefined, restoreDisclosures: matchingDisclosures ? saved.states : undefined, // Lookup statistics describe the first unfiltered result. Keep the // slot on the All tab so a dictionary projection cannot attach the // original term's count to a different expression. The owner paints // or hides it, so a count setting never reprojects definitions. lookupStatsSlot: selectedDictionaries.size === 0, }, { dictionaryDisplayNames, feedback, imageContext, primaryHeader, primaryMetadataCapsule, } ); onResultsRendered(rendered); } function activateTabFromEvent(index, focusButton = false) { runRenderAction(() => ownsDisplayedPanel(panel, renderContext), renderContext, () => activateTab(index, focusButton)); } function createTabButton(descriptor) { const button = documentRef.createElement("button"); button.type = "button"; button.id = `${idPrefix}-tab-${nextTabId++}`; button.className = "gsm-hoshidicts-tab"; button.setAttribute("role", "tab"); button.setAttribute("aria-controls", panel.id); if (descriptor.groupId) button.dataset.groupId = descriptor.groupId; if (descriptor.dictionary) { button.dataset.dictionary = descriptor.dictionary; } if (descriptor.favourites) button.dataset.favourites = "true"; button.addEventListener("click", () => activateTabFromEvent(tabButtons.indexOf(button))); button.addEventListener("keydown", (event) => { const index = tabButtons.indexOf(button); if (index < 0) return; let nextIndex = null; if (event.key === "ArrowRight") { nextIndex = (index + 1) % tabButtons.length; } else if (event.key === "ArrowLeft") { nextIndex = (index - 1 + tabButtons.length) % tabButtons.length; } else if (event.key === "Home") { nextIndex = 0; } else if (event.key === "End") { nextIndex = tabButtons.length - 1; } if (nextIndex !== null) { event.preventDefault(); event.stopPropagation(); activateTabFromEvent(nextIndex, true); } }); return button; } function syncTabButtons(previousDescriptors = []) { if (!tabList) return false; const previous = new Map(previousDescriptors.map((descriptor, index) => [descriptor.key, tabButtons[index]])); const focused = popup.getRootNode().activeElement; const focusedKey = previousDescriptors[tabButtons.indexOf(focused)]?.key; let changed = false; tabButtons = tabDescriptors.map(descriptor => { const button = previous.get(descriptor.key) || createTabButton(descriptor); changed = updateLabel(button, descriptor.label) || changed; button.title = descriptor.title; button.setAttribute("aria-label", descriptor.title); previous.delete(descriptor.key); return button; }); for (const button of previous.values()) { button.remove(); changed = true; } // Move the other buttons around the focused one, never detach it. let next = null; for (let index = tabButtons.length - 1; index >= 0; index -= 1) { const button = tabButtons[index]; if (button !== focused && (button.parentNode !== tabList || button.nextSibling !== next)) { tabList.insertBefore(button, next); changed = true; } next = button; } focusedIndex = focusedKey ? tabDescriptors.findIndex(tab => tab.key === focusedKey) : selectedIndex; if (focusedIndex < 0) focusedIndex = selectedIndex; if (hasRendered) updateTabState(); if (focusedKey && !tabButtons.includes(focused)) tabButtons[focusedIndex]?.focus(); return changed; } syncTabButtons(); tabList?.addEventListener("wheel", (event) => { if ( Math.abs(event.deltaY) > Math.abs(event.deltaX) && tabList.scrollWidth > tabList.clientWidth ) { const maximumScrollLeft = tabList.scrollWidth - tabList.clientWidth; const nextScrollLeft = Math.max( 0, Math.min(maximumScrollLeft, tabList.scrollLeft + event.deltaY) ); if (nextScrollLeft !== tabList.scrollLeft) { tabList.scrollLeft = nextScrollLeft; event.preventDefault(); } } }, { passive: false }); activateTab(selectedIndex); currentPresentationUpdate = (context) => runRenderAction( () => ownsDisplayedPanel(panel, renderContext), renderContext, () => { const summaryChanged = ["showCompactDefinitionSummary", "compactDefinitionSummaryCount", "compactDefinitionSummaryDictionary"] .some(key => Object.hasOwn(context, key) && context[key] !== renderContext[key]); // Image-only changes may proceed while Note, focus or a child keeps // the old tab/text projection mounted. Enter the same connected // request boundary before admitting new asynchronous image work. const imagesChanged = Object.hasOwn(context, "popupImageSources") && context.popupImageSources !== imageContext.popupImageSources; if ((imagesChanged || summaryChanged) && ownsView() && options.canUpdateCompactSummary?.() === false) return true; const focused = popup.getRootNode().activeElement; const next = createDictionaryTabs(dictionaries, context); const previous = tabDescriptors; const selectedKey = previous[selectedIndex].key; let index = next.tabs.findIndex(tab => tab.key === selectedKey); if (index < 0) index = 0; const sameMembers = sameTabMembers(previous[selectedIndex].dictionaries, next.tabs[index].dictionaries, dictionaries); const projectionDeferred = (summaryChanged && popup.contains(focused) && focused.closest(".gsm-hoshidicts-compact-definition-summary")) || (!sameMembers && (!ownsView() || !canProjectPresentation())); // New cards and summaries use the latest route. Only refresh handles // after replacing their owners, unless the projection is protected. for (const key of ["popupImageSources", "dictionaryPresentation", "resolveMedia", ...METADATA_OPTION_KEYS]) { if (Object.hasOwn(context, key)) imageContext[key] = context[key]; } if (projectionDeferred) { const metadata = rendered.updateMetadata(); if (rendered.updateImages() || metadata.changed) scheduleMasonry(); return false; } Object.assign(renderContext, context); tabDescriptors = next.tabs; dictionaryDisplayNames = next.dictionaryDisplayNames; selectedIndex = index; let changed = syncTabButtons(previous); let metadataDeferred = false; if (selectedKey !== tabDescriptors[index].key) { renderContext.onDictionaryTabSelected?.(normaliseDictionaryTab(tabDescriptors[index])); } if (sameMembers) { const metadata = rendered.updateMetadata(); changed = metadata.changed || changed; metadataDeferred = metadata.deferred; changed = rendered.updateDictionaryPresentation(context, dictionaryDisplayNames, summaryChanged) || changed; changed = rendered.updateImages() || changed; } else { const focused = retainedFocus(true); renderProjection(rendered.isExpanded()); if (focused && typeof focused !== "string") { positionPopup(); restoreRetainedFocus(focused); } changed = true; } if (changed) scheduleMasonry(); return !metadataDeferred; }); restoreRetainedFocus(focused); captureTermView = () => ({ expandAll: rendered.isExpanded(), restoreScrollTop: contentScroll.scrollTop, disclosures: { results, dictionaries: [...tabDescriptors[selectedIndex].dictionaries], states: [...popup.querySelectorAll("details")].map(node => ({ className: node.className, open: node.open })), }, }); return rendered; } return { scrollElement: contentScroll, clear, hideImagePreview, closeNoteForm() { return currentNoteControls?.close() === true; }, renderLookupFailure, renderNotice, renderResults, renderKanji, captureTermView: () => captureTermView?.(), currentEntryIndex: () => currentEntryIndex(), focusEntry, setDefinitionBlurState, setLookupStats, setSourceHighlightEnabled, setCustomButtons, setCustomLinks, setToolbarPosition, scheduleMasonry, updateDictionaryPresentation(context) { if (!currentPresentationUpdate) return; pendingPresentation = context; flushDictionaryPresentation(); }, flushDictionaryPresentation, destroy() { sourceHighlighter.clear(); currentPresentationUpdate = null; pendingPresentation = null; hideImagePreview(); renderedImages.clear(); renderRevision += 1; currentResultPanel = null; captureTermView = null; pendingScrollRestoration = null; currentLookupFailure = null; options.cancelMasonry?.(layoutMasonry); if (masonryFrame !== null) { windowRef.cancelAnimationFrame(masonryFrame); masonryFrame = null; } masonryObserver?.disconnect(); windowRef.removeEventListener("resize", onWindowResize); popup.removeEventListener("scroll", onPopupScroll, true); popup.removeEventListener("focusout", onPresentationFocusOut); }, }; } // Automatic follows horizontal root placement; side panes retain their edge. function resolveToolbarPosition(preference, placement, current = "top") { if (preference === "top" || preference === "bottom") return preference; if (placement === "above") return "bottom"; if (placement === "below") return "top"; return current; } return { createPopupAppearance, createCustomPopupStyle, resolveToolbarPosition, calculatePopupPosition, scaleRect, popupCoordinateScale, createDictionaryDisplayNames, createFrequencyTags, createPitchTag, createPopupView, createSourceHighlighter, createTag, normaliseDictionaryTab, extractCompactDefinitionSummary, formatCompactFrequencyNumber, formatFrequencyValue, metadataOptions, }; }));