mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
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:
+282
@@ -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 });
|
||||
}());
|
||||
Reference in New Issue
Block a user