feat(dictionary): add Hachidori backend support

- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
This commit is contained in:
2026-09-22 00:21:19 -07:00
parent 1508863dbb
commit d9fdc7ef6d
446 changed files with 109060 additions and 244 deletions
+282
View File
@@ -0,0 +1,282 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Static, local sample data; the view itself is the production popup renderer.
(function () {
"use strict";
HDVisualNovel.initialize(document.querySelector(".vn-scene"));
const host = document.getElementById("preview-host");
const shadow = host.attachShadow({ mode: "open" });
const appearance = HDPopup.createPopupAppearance(host);
const customStyle = HDPopup.createCustomPopupStyle(shadow);
const stylesheet = document.createElement("link");
stylesheet.rel = "stylesheet";
stylesheet.href = "render/reader.css";
const popup = document.createElement("div");
popup.className = "gsm-hoshidicts-popup";
const iconStylesheet = document.createElement("link");
iconStylesheet.rel = "stylesheet";
iconStylesheet.href = "icons.css";
shadow.append(stylesheet, iconStylesheet, popup);
const source = document.getElementById("preview-source");
const candidate = { query: "食べる", sentence: source.textContent,
sourceElements: [source], matchOffset: source.textContent.indexOf("食べる") };
let options = { ...HDReaderOptions.DEFAULT_OPTIONS };
let state;
let sample;
let sampleKey;
let updateKey;
let imageSources = null;
let kanjiCharacter = null;
let kanjiSource = null;
let termView;
let selectedDictionaryTab = null;
let sampleMedia = null;
let sampleLookupStats = null;
let clickedKanjiIndex = 0;
// Fixed mature word, count of 3 and native frequency samples; the shared
// rules, real hover and real delay decide the preview's blur. Nothing is
// recorded, looked up or sent to Anki.
const SAMPLE_LOOKUP_COUNT = 3;
let sampleRevealed = false;
let sampleBlurTimer = null;
let sampleTermView = false;
const DEFINITION_BLUR_KEYS = [
"showLookupCounts", "definitionBlurEnabled", "definitionBlurAnkiMature",
"definitionBlurFrequencyEnabled", "definitionBlurFrequencyDictionary",
"definitionBlurFrequencyOrder", "definitionBlurFrequencyThreshold",
"definitionBlurDirection", "definitionBlurThreshold", "definitionBlurReveal", "definitionBlurDelayMs",
];
function sampleBlurState() {
const count = options.showLookupCounts ? SAMPLE_LOOKUP_COUNT : null;
const frequency = HDReaderOptions.definitionBlurFrequencyEvidence(options,
sample?.results?.[0]?.term?.frequencies, sample?.dictionaryPresentation);
return !sampleRevealed && HDReaderOptions.definitionBlurQualifies(options, count, true, frequency.qualified)
? "blurred" : "revealed";
}
function clearSampleBlurTimer() {
if (sampleBlurTimer === null) return;
clearTimeout(sampleBlurTimer);
sampleBlurTimer = null;
}
function revealSample() {
sampleRevealed = true;
clearSampleBlurTimer();
view.setDefinitionBlurState("revealed");
}
function armSampleBlur() {
clearSampleBlurTimer();
if (sampleBlurState() !== "blurred" || options.definitionBlurReveal !== "timed") return;
sampleBlurTimer = setTimeout(() => { sampleBlurTimer = null; revealSample(); }, options.definitionBlurDelayMs);
}
popup.addEventListener("mouseover", (event) => {
if (sampleBlurState() === "blurred" && event.target instanceof Element
&& event.target.closest(".gsm-hoshidicts-definitions, .gsm-hoshidicts-compact-definition-summary")) revealSample();
});
function positionPopup(resetToolbar = false) {
const factor = HDPopup.popupCoordinateScale(1, options.popupScalePercent);
const position = HDPopup.calculatePopupPosition(HDPopup.scaleRect(source.getBoundingClientRect(), factor),
{ width: options.popupWidthPx, height: options.popupHeightPx }, { width: innerWidth * factor, height: innerHeight * factor });
for (const key of ["left", "top", "width", "height"]) popup.style[key] = `${position[key]}px`;
const edge = HDPopup.resolveToolbarPosition(options.popupToolbarPosition, position.placement,
resetToolbar ? "top" : popup.dataset.toolbarPosition);
if (popup.dataset.toolbarPosition !== edge) view.setToolbarPosition(edge);
}
const view = HDPopup.createPopupView({ document, window, popup,
appendExpressionRuby: HDGlossary.appendExpressionRuby,
buildPitchAccentMorae: HDGlossary.buildPitchAccentMorae,
appendTextOnlyGlossary: HDGlossary.appendTextOnlyGlossary,
appendStructuredImage: HDGlossary.appendStructuredImage,
parseTagList: HDGlossary.parseTagList,
getPopupColumns: () => options.popupColumns,
getPopupScalePercent: () => options.popupScalePercent,
customButtons: options.customButtons,
positionPopup, sourceHighlightEnabled: true,
onKanjiClick(character, result, anchor, link) {
if (!kanjiCharacter) {
clickedKanjiIndex = [...popup.querySelectorAll(".gsm-hoshidicts-kanji-link")].indexOf(link);
termView = { ...view.captureTermView(), selectedDictionaryTab };
}
kanjiCharacter = character;
renderSample();
popup.querySelector(".gsm-hoshidicts-kanji-back").focus({ preventScroll: true });
},
onAddCustomEntry() { throw new Error("This is a preview. Notes are not saved."); },
onCustomLinkClick(link) {
void (globalThis.browser ?? globalThis.chrome).runtime.sendMessage({ target: "hoshidicts-worker", type: "hd_open_external", ...link })
.catch(error => console.debug("hachidori: preview link could not be opened", error));
},
onResultsRendered({ lookupStats }) {
sampleLookupStats = lookupStats;
paintSampleLookupStats();
updateSampleAudio();
},
});
function updateSampleAudio() {
const available = options.audioSources.some(source => source.enabled
&& (source.type.startsWith("text-to-speech") || source.url.trim()));
for (const control of popup.querySelectorAll(".gsm-hoshidicts-audio-control")) {
if (control.hidden !== !available) control.hidden = !available;
}
}
// A static sample count; the switch only paints or hides the rendered slot.
function paintSampleLookupStats() {
if (!sampleLookupStats?.isConnected) return;
view.setLookupStats(sampleLookupStats, options.showLookupCounts ? { lookupCount: 3, seenCount: null } : null);
}
function createSample() {
const enabled = state.dictionaries.filter(entry => entry.enabled);
const definitions = enabled.filter(entry => entry.termCount > 0);
const first = definitions[0]?.title || "Sample dictionary";
const preferred = definitions.find(entry => entry.title === options.compactDefinitionSummaryDictionary)?.title;
const second = preferred && preferred !== first ? preferred : definitions[1]?.title || "Sample usage";
const pitch = enabled.find(entry => entry.title === options.pitchAccentFuriganaDictionary && entry.pitchCount > 0)?.title
|| enabled.find(entry => entry.pitchCount > 0)?.title || "Sample pitch";
const installedFrequencies = enabled
.filter(entry => entry.frequencyCount > 0 && !["Sample ranks", "Sample corpus"].includes(entry.title))
.map(entry => ({ dictionary: entry.title, frequencies: [{
value: entry.frequencyMode === "rank-based" ? 120 : 18240,
displayValue: entry.frequencyMode === "rank-based" ? "120" : "18,240",
}] }));
const glossary = (dictionary, items) => ({ dictionary, glossary: JSON.stringify(items), definitionTags: "v1 vt", termTags: "common" });
const results = [{ matched: "食べる", deinflected: "食べる", trace: [], preprocessorSteps: 0,
term: { expression: "食べる", reading: "たべる", rules: "v1", score: 0,
glossaries: [
glossary(first, ["to eat", "to live on (e.g. a salary)", "to have a meal"]),
glossary(second, [{ type: "structured-content", content: [
{ tag: "p", content: "朝ごはんを食べる。 — To eat breakfast." },
{ tag: "img", path: "sample-meal.svg", width: 160, height: 80, title: "A bowl of rice and chopsticks" },
{ tag: "details", content: [{ tag: "summary", content: "Usage note" },
{ tag: "p", content: "食べる is an ichidan verb. Its polite form is 食べます。" }] },
] }]),
glossary("Sample collocations", ["ご飯を食べる — to eat a meal", "外で食べる — to eat out"]),
glossary("Sample expressions", ["食べてみる — to try a food", "食べ終わる — to finish eating"]),
],
frequencies: [
{ dictionary: "Sample ranks", frequencies: [{ value: 120, displayValue: "120" }, { value: 240, displayValue: "240" }] },
{ dictionary: "Sample corpus", frequencies: [{ value: 18240, displayValue: "18,240" }] },
...installedFrequencies,
],
pitches: [{ dictionary: pitch, pitches: [{ position: 2, pattern: "LHL", nasal: [], devoice: [] }], transcriptions: ["ta̠be̞ɾɯ̟ᵝ"] }],
} }];
return { results, dictionaryPresentation: [
{ title: "Sample ranks", frequencyMode: "rank-based", frequencyCount: 2 },
{ title: "Sample corpus", frequencyMode: "occurrence-based", frequencyCount: 1 }, ...enabled,
] };
}
function context() {
return { ...HDPopup.metadataOptions(options),
definitionBlurState: sampleBlurState(),
showCompactDefinitionSummary: options.showCompactDefinitionSummary,
compactDefinitionSummaryCount: options.compactDefinitionSummaryCount,
compactDefinitionSummaryDictionary: options.compactDefinitionSummaryDictionary,
dictionaryPresentation: sample.dictionaryPresentation,
dictionaryTabGroups: state.groups.map(group => ({ ...group, dictionaries: group.dictionaryIds
.map(id => state.dictionaries.find(entry => entry.id === id && entry.enabled)?.title).filter(Boolean) })),
popupImageSources: imageSources,
async resolveMedia(request) {
const dictionary = imageSources === null ? request.dictionary : imageSources[0];
if (!dictionary) throw new Error("No enabled image source in this selection.");
request.onResolvedSource?.(dictionary);
sampleMedia ??= fetch("sample-meal.svg").then(response => response.blob()).then(blob => URL.createObjectURL(blob));
return sampleMedia;
},
};
}
function renderSample(preserveViewControls = false) {
if (kanjiCharacter) {
const capability = HDReaderOptions.resolveKanjiDictionary(options.kanjiClickDictionary, state.dictionaries);
kanjiSource = capability;
const renderContext = { ...context(), preserveViewControls, highlightText: candidate.query, onBack() {
kanjiCharacter = null;
renderSample();
popup.querySelectorAll(".gsm-hoshidicts-kanji-link")[clickedKanjiIndex]?.focus({ preventScroll: true });
} };
if (capability?.kind === "term") {
view.renderResults([{ matched: kanjiCharacter, trace: [], term: {
expression: kanjiCharacter, reading: "しょく", glossaries: [{ dictionary: capability.title,
glossary: JSON.stringify(["food; eating — sample single-kanji entry"]) }], frequencies: [], pitches: [],
} }], candidate, renderContext);
sampleTermView = true;
armSampleBlur();
} else {
// Native kanji is outside term blur.
sampleTermView = false;
clearSampleBlurTimer();
view.renderKanji({ character: kanjiCharacter, entries: [{ dictionary: capability?.title || "Sample kanji",
onyomi: "ショク ジキ", kunyomi: "た.べる く.う", tags: "常用", definitions: ["eat", "food"],
stats: [{ name: "strokes", value: "9" }, { name: "grade", value: "2" }],
}] }, candidate, { ...renderContext, definitionBlurState: "revealed" });
}
} else {
view.renderResults(sample.results, candidate, { ...context(), preserveViewControls,
onDictionaryTabSelected(selection) { selectedDictionaryTab = selection; },
selectedDictionaryTab: termView?.selectedDictionaryTab, expandAll: termView?.expandAll,
restoreScrollTop: termView?.restoreScrollTop, disclosures: termView?.disclosures,
});
termView = null;
sampleTermView = true;
armSampleBlur();
}
}
window.HDDesignPreview = { update(nextOptions, nextState) {
const toolbarChanged = !state || options.popupToolbarPosition !== nextOptions.popupToolbarPosition;
const geometryChanged = !state || options.popupColumns !== nextOptions.popupColumns
|| options.popupWidthPx !== nextOptions.popupWidthPx || options.popupHeightPx !== nextOptions.popupHeightPx
|| options.popupScalePercent !== nextOptions.popupScalePercent;
if (!state || options.sourceHighlightEnabled !== nextOptions.sourceHighlightEnabled) {
view.setSourceHighlightEnabled(nextOptions.sourceHighlightEnabled);
}
appearance.update(nextOptions);
const cssChanged = customStyle.update(nextOptions.customPopupCss);
const countsChanged = !state || options.showLookupCounts !== nextOptions.showLookupCounts;
// A blur edit restarts the sample decision so its effect is visible.
const blurChanged = !state || DEFINITION_BLUR_KEYS.some(key => options[key] !== nextOptions[key]);
options = { ...nextOptions };
view.setCustomButtons(options.customButtons);
updateSampleAudio();
if (geometryChanged) view.hideImagePreview();
if (geometryChanged || toolbarChanged) positionPopup(toolbarChanged);
if (geometryChanged || cssChanged) view.scheduleMasonry();
if (countsChanged) paintSampleLookupStats();
if (blurChanged) {
sampleRevealed = false;
if (sampleTermView) {
view.setDefinitionBlurState(sampleBlurState());
armSampleBlur();
}
}
const key = JSON.stringify([HDPopup.metadataOptions(nextOptions),
nextOptions.showCompactDefinitionSummary, nextOptions.compactDefinitionSummaryCount,
nextOptions.compactDefinitionSummaryDictionary, nextOptions.popupImageSource, nextOptions.kanjiClickDictionary, nextState.revision]);
if (key === updateKey) return;
updateKey = key;
state = nextState;
const nextSources = HDReaderOptions.resolvePopupImageSources(options.popupImageSource, state.dictionaries, state.groups);
if (JSON.stringify(nextSources) !== JSON.stringify(imageSources)) imageSources = nextSources;
const nextSample = createSample();
const nextSampleKey = JSON.stringify(nextSample.results);
sample = nextSample;
const changed = kanjiCharacter
? JSON.stringify(kanjiSource) !== JSON.stringify(HDReaderOptions.resolveKanjiDictionary(options.kanjiClickDictionary, state.dictionaries))
: sampleKey !== nextSampleKey;
sampleKey = nextSampleKey;
if (changed) {
if (!kanjiCharacter) termView = { ...view.captureTermView(), selectedDictionaryTab };
renderSample(true);
} else view.updateDictionaryPresentation(context());
} };
stylesheet.addEventListener("load", () => { appearance.refreshHighlight(); view.scheduleMasonry(); });
window.addEventListener("pagehide", () => { clearSampleBlurTimer(); customStyle.destroy(); appearance.destroy(); view.destroy(); }, { once: true });
}());