mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
- Add backend selection, setup gating, Anki integration, and external host support - Add launcher flags, documentation, packaging, and focused tests - Open on-demand overlay modals on the first attempt
4233 lines
174 KiB
JavaScript
4233 lines
174 KiB
JavaScript
/*
|
||
* 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,
|
||
};
|
||
}));
|