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
+158
View File
@@ -0,0 +1,158 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
import { createLocalFileAccessController } from "./local-file-access.js";
// One readiness decision supplies the page heading, probe precondition and
// practice recovery. A library with disabled terms needs enabling, not a reimport.
export function practiceReadiness(options, dictionaries, outcome = null, readerFailed = false) {
const available = dictionaries.some(entry => entry.enabled !== false && entry.termCount > 0);
if (!available) {
const installed = dictionaries.some(entry => entry.termCount > 0);
return {
canProbe: false,
heading: installed ? "Enable a dictionary to try Hachidori" : "Add a dictionary to try Hachidori",
message: installed ? "Your term dictionaries are turned off. Enable one to look up Japanese words." : "Add a term dictionary to look up Japanese words. The recommended dictionaries are a good place to start.",
href: installed ? "settings.html#dictionaries" : "settings.html#add-dictionaries",
action: installed ? "Open Library" : "Add dictionaries",
};
}
if (!options.hoverEnabled) {
return { canProbe: false, heading: "Turn on lookups to try Hachidori",
message: "Lookups are turned off. Enable them in Reading to try the sample.",
href: "settings.html#lookup", action: "Open Reading" };
}
if (readerFailed || outcome === "unavailable") {
return { canProbe: true, heading: "The lookup exercise is unavailable",
message: readerFailed ? "The reader could not load. Reload this page, or review your reading settings." : "The dictionary engine could not answer. Reload this page, or review your reading settings.",
href: "settings.html#lookup", action: "Open Reading" };
}
if (outcome === "missing") {
return { canProbe: true, heading: "Add a dictionary for this sample",
message: "The installed dictionaries do not have the words in this sample yet. Add another term dictionary to try it.",
href: "settings.html#add-dictionaries", action: "Add dictionaries" };
}
return { canProbe: true, heading: outcome === "ready" || outcome === "passage" ? "Youre ready." : "Preparing your first lookup…" };
}
function practiceInstruction(options, enabled, probing, unavailable, shortcut) {
if (enabled) {
const ending = shortcut ? ", or use the lookup button." : ".";
return options.lookupMode !== "hover"
? `Try looking up a word below. Hold ${options.activationKey} and hover over a word${ending}`
: `Try looking up a word below. Hover over Japanese text${ending}`;
}
if (probing) return "Checking what the installed dictionaries can answer…";
if (unavailable) {
return options.lookupMode !== "hover"
? `Hold ${options.activationKey} and hover over Japanese text on any webpage to look it up.`
: "Hover over Japanese text on any webpage to look it up.";
}
return "You can finish setup now and try a lookup later.";
}
// Keep the exercise's text nodes alive while options/inventory updates arrive:
// the ordinary reader anchors its selection, popup and Note draft to them.
export function createPracticeView({ document, onDismiss, loadReader, onReaderSettled = () => {} }) {
const node = document.createElement("div");
node.className = "setup-practice";
node.innerHTML = `
<p class="hint" id="setup-practice-instruction"></p>
<section class="setup-practice-scene vn-scene" id="setup-practice-scene" aria-label="Visual novel practice scene">
<div class="setup-practice-dialogue vn-dialogue" lang="ja">
<p class="vn-speaker">ひなた</p>
<p class="setup-practice-text vn-line" id="setup-practice-text" lang="ja" tabindex="-1">踏切の向こうから蝉の声が響く。喧騒を離れて路地に佇むと、古びた<span id="setup-practice-word">辞書</span>で見つけた言葉が、目の前の景色と少しずつ結びついていく。</p>
</div>
</section>
<div class="setup-practice-tools" id="setup-practice-tools">
<button type="button" class="ghost" id="setup-practice-lookup">Look up <span lang="ja">辞書</span></button>
<span class="hint">You can also select text to look it up.</span>
</div>
<div class="setup-practice-recovery" id="setup-practice-recovery" hidden></div>
<div id="setup-file-access"></div>`;
const find = id => node.querySelector(`#${id}`);
const instruction = find("setup-practice-instruction");
const recovery = find("setup-practice-recovery");
const lookup = find("setup-practice-lookup");
const text = find("setup-practice-text");
const recoveryMessage = document.createElement("p");
recoveryMessage.className = "hint";
const recoveryLink = document.createElement("a");
recoveryLink.className = "primary-button";
recovery.append(recoveryMessage, recoveryLink);
let readerStarted = false;
let readerFailed = false;
let readerReady = false;
let reader = null;
let currentOptions;
let currentDictionaries;
let currentOutcome;
function selectLookupWord() {
// Selection is the reader's existing keyboard/precise-lookup route. No
// synthetic lookup result or separate renderer is involved.
text.focus({ preventScroll: true });
const range = document.createRange();
range.selectNodeContents(find("setup-practice-word"));
const selection = document.defaultView.getSelection();
selection.removeAllRanges();
selection.addRange(range);
reader.scanSelectedText();
}
lookup.addEventListener("click", selectLookupWord);
createLocalFileAccessController({ document, container: find("setup-file-access"), onDismiss });
function startReader() {
if (readerStarted) return;
readerStarted = true;
void loadReader().then((loadedReader) => {
reader = loadedReader;
readerReady = true;
update(currentOptions, currentDictionaries, currentOutcome);
onReaderSettled();
}, () => {
readerFailed = true;
update(currentOptions, currentDictionaries, currentOutcome);
onReaderSettled();
});
}
function update(options, dictionaries, outcome = null) {
currentOptions = options;
currentDictionaries = dictionaries;
currentOutcome = outcome;
const available = dictionaries.some(entry => entry.enabled !== false && entry.termCount > 0);
const readiness = practiceReadiness(options, dictionaries, outcome, readerFailed);
const probing = readiness.canProbe && outcome === null;
const answerable = outcome === "ready" || outcome === "passage";
const enabled = available && options.hoverEnabled && answerable && !readerFailed;
const lookupFocused = document.activeElement === lookup;
const practiceFocused = lookupFocused || document.activeElement === text;
const recoveryFocused = document.activeElement === recoveryLink;
find("setup-practice-scene").hidden = !available || (options.hoverEnabled && !answerable);
find("setup-practice-tools").hidden = !enabled;
lookup.hidden = outcome !== "ready";
lookup.disabled = !readerReady || lookup.hidden;
recovery.hidden = enabled || probing;
instruction.textContent = practiceInstruction(options, enabled, probing,
available && options.hoverEnabled && outcome === "unavailable", !lookup.hidden);
if (enabled) {
startReader();
if (recoveryFocused || (lookupFocused && lookup.hidden)) text.focus({ preventScroll: true });
} else {
recoveryLink.href = readiness.href ?? "settings.html#lookup";
recoveryLink.textContent = readiness.action ?? "Open Reading";
recoveryMessage.textContent = readiness.message ?? "";
if (practiceFocused && !probing) recoveryLink.focus();
}
return readiness;
}
return {
node,
update,
lookup() {
if (lookup.disabled || lookup.hidden || !node.isConnected) return false;
selectLookupWord();
return true;
},
};
}