Files
SubMiner/vendor/hachidori/extension/startup-practice.js
T
sudacode d9fdc7ef6d 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
2026-09-22 00:21:19 -07:00

159 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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;
},
};
}