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
283 lines
15 KiB
JavaScript
283 lines
15 KiB
JavaScript
// 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 });
|
|
}());
|