]*)>/gi;
let match;
while (match = sourcePattern.exec(html)) {
const src = getHtmlAttribute(match[1] ?? "", "src");
if (src) urls.push(new URL(src, baseUrl).href);
}
return uniqueAudioUrls(urls);
}
function getHtmlAttribute(attributes, name) {
const match = new RegExp(`\\b${escapeRegExp$1(name)}\\s*=\\s*(["'])([\\s\\S]*?)\\1`, "i").exec(attributes);
return match ? decodeHtmlAttribute(match[2]) : null;
}
function decodeHtmlAttribute(value) {
return value.replace(/&/g, "&").replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/(\d+);/g, (_, code) => String.fromCodePoint(Number(code))).replace(/([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)));
}
function stripHtml$2(value) {
return decodeHtmlAttribute(value.replace(/<[^>]+>/g, ""));
}
function isValidCommonsAudioFilename(filename, fileUser, term, source) {
if (!filename) return false;
if (source === "lingua-libre") {
return new RegExp(`^File:LL-Q\\d+\\s+\\(jpn\\)-${escapeRegExp$1(fileUser)}-${escapeRegExp$1(term)}\\.wav$`, "i").test(filename);
}
return new RegExp(`^File:ja(-\\w\\w)?-${escapeRegExp$1(term)}\\d*\\.ogg$`, "i").test(filename);
}
function normalizeAudioUrl(value, sourceUrl) {
try {
const nested = new URL(value);
if (sourceUrl) alignLoopbackAudioUrl(nested, new URL(sourceUrl));
return normalizeAudioUrlSlashes(nested.href);
} catch {
return normalizeAudioUrlSlashes(value);
}
}
function alignLoopbackAudioUrl(nested, source) {
if (!shouldAlignLoopbackAudioUrl(nested, source)) return;
nested.protocol = source.protocol;
nested.hostname = source.hostname;
}
function shouldAlignLoopbackAudioUrl(nested, source) {
return isLoopbackAudioHost(nested.hostname) && !isLoopbackAudioHost(source.hostname) && nested.port === source.port;
}
function isLoopbackAudioHost(hostname) {
return LOOPBACK_AUDIO_HOSTS.has(hostname);
}
function normalizeAudioUrlSlashes(value) {
return value.replace(/\\/g, "/");
}
function normalizeAttemptedAudioUrl(value) {
try {
const url = new URL(value, location.href);
url.hash = "";
return url.href;
} catch {
return value;
}
}
function isLikelyAudioRecord(record) {
return typeof record.url === "string" && audioRecordHasPlayableSignal(record);
}
function audioRecordHasPlayableSignal(record) {
return isLikelyAudioUrl(String(record.url)) || ["audio", "audioSource"].includes(String(record.type ?? "")) || typeof record.name === "string";
}
function isLikelyAudioUrl(value) {
if (value.startsWith("data:audio/")) return true;
try {
const url = new URL(value, location.href);
const pathname = url.pathname.toLowerCase();
return /\.(mp3|m4a|aac|wav|ogg|oga|opus|flac|webm)$/.test(pathname) || /(^|[-_/])(audio|sound|voice|pronunciation)([-_/]|$)/i.test(pathname);
} catch {
return /\.(mp3|m4a|aac|wav|ogg|oga|opus|flac|webm)(?:$|[?#])/i.test(value);
}
}
function uniqueAudioUrls(urls) {
const seen = /* @__PURE__ */ new Set();
return urls.filter((url) => {
const key = normalizeAttemptedAudioUrl(url);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function uniqueStrings$3(values) {
return [...new Set(values)];
}
function shouldFetchCandidateAsBlob(candidate, audioViaBlob) {
if (!canFetchAudioCandidateAsBlob(candidate, audioViaBlob)) return false;
return isBlobFetchableAudioCandidate(candidate);
}
function canFetchAudioCandidateAsBlob(candidate, audioViaBlob) {
return audioViaBlob && !candidate.url.startsWith("blob:") && !candidate.url.startsWith("data:audio/");
}
function isBlobFetchableAudioCandidate(candidate) {
return /^https?:\/\//i.test(candidate.url) || isAppleMobileBrowser() || isJapanesePod101Url(candidate.url) || isJapanesePod101Url(candidate.sourceUrl);
}
function isAppleMobileBrowser() {
const userAgent = navigator.userAgent;
const platform = navigator.platform;
return /iPad|iPhone|iPod/i.test(userAgent) || platform === "MacIntel" && navigator.maxTouchPoints > 1;
}
function preconnectAudioUrl(value) {
const origin = audioPreconnectOrigin(value);
if (!origin || preconnectedAudioOrigins.has(origin)) return;
preconnectedAudioOrigins.add(origin);
appendAudioPreconnectLinks(origin);
}
function audioPreconnectOrigin(value) {
try {
return new URL(value, location.href).origin;
} catch {
return null;
}
}
function appendAudioPreconnectLinks(origin) {
for (const rel of AUDIO_PRECONNECT_RELS) appendAudioPreconnectLink(origin, rel);
}
function appendAudioPreconnectLink(origin, rel) {
const link = document.createElement("link");
link.rel = rel;
link.href = origin;
if (rel === "preconnect") link.crossOrigin = "anonymous";
document.head?.append(link);
}
function escapeRegExp$1(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function isYomuNewTabUrl(value) {
const url = parseNewTabUrl(value);
return url ? isYomuNewTabUrlObject(url) : false;
}
function parseNewTabUrl(value) {
try {
return new URL(value);
} catch {
return null;
}
}
function isYomuNewTabUrlObject(url) {
const path = normalizedNewTabPath(url);
return url.searchParams.has("yomu-newtab") || isHostedNewTabPath(url, path) || isLocalNewTabPath(url, path) || isRepositoryNewTabPath(path);
}
function normalizedNewTabPath(url) {
return url.pathname.replace(/\/index\.html$/, "/");
}
function isHostedNewTabPath(url, path) {
return url.hostname === "hrussellzfac023.github.io" && path === `/${APP_REPOSITORY_NAME}/newtab/`;
}
function isLocalNewTabPath(url, path) {
return /^(127\.0\.0\.1|localhost|\[::1\])$/.test(url.hostname) && path.endsWith("/newtab/");
}
function isRepositoryNewTabPath(path) {
return path.endsWith(`/${APP_REPOSITORY_NAME}/newtab/`) || path.endsWith("/newtab/");
}
const LOCAL_HOSTS = /^(127\.0\.0\.1|localhost|\[::1\])$/;
function isYomuHostedAppUrl(value) {
const appUrl = readYomuAppUrl(value);
return appUrl ? isYomuHostedAppRoute(value, appUrl) : false;
}
function isYomuHostedPassivePage(value) {
const appUrl = readYomuAppUrl(value);
return appUrl ? isPassiveYomuRepositoryPage(value, appUrl) : false;
}
function readYomuAppUrl(value) {
try {
const url = new URL(value);
return { url, path: normalizedPath(url.pathname) };
} catch {
return null;
}
}
function isYomuHostedAppRoute(value, appUrl) {
return isYomuActiveAppRoute(value, appUrl) || isYomuRepositoryAppUrl(appUrl);
}
function isPassiveYomuRepositoryPage(value, appUrl) {
return isYomuRepositoryAppUrl(appUrl) && !isYomuActiveAppRoute(value, appUrl);
}
function isYomuActiveAppRoute(value, appUrl) {
return isYomuNewTabUrl(value) || isYomuVideoPlayerPath(appUrl.path);
}
function isYomuRepositoryAppUrl(appUrl) {
return isHostedRepositoryAppUrl(appUrl) || isLocalRepositoryAppUrl(appUrl);
}
function isHostedRepositoryAppUrl(appUrl) {
return appUrl.url.origin === GITHUB_PAGES_ORIGIN && appUrl.path.startsWith(`/${APP_REPOSITORY_NAME}/`);
}
function isLocalRepositoryAppUrl(appUrl) {
return isYomuLocalAppPath(appUrl.path) && (appUrl.url.protocol === "file:" || LOCAL_HOSTS.test(appUrl.url.hostname));
}
function normalizedPath(pathname) {
return pathname.replace(/\/index\.html$/, "/");
}
function isYomuVideoPlayerPath(path) {
return path.endsWith("/video-player/");
}
function isYomuLocalAppPath(path) {
return path.startsWith(`/${APP_REPOSITORY_NAME}/`) || path.endsWith("/newtab/") || isYomuVideoPlayerPath(path);
}
const POS_LABELS = {
adj: "adjective",
adv: "adverb",
aux: "auxiliary",
"aux-v": "auxiliary verb",
conj: "conjunction",
cop: "copula",
ctr: "counter",
exp: "expression",
int: "interjection",
n: "noun",
num: "number",
pn: "pronoun",
pref: "prefix",
prt: "particle",
suf: "suffix",
unc: "unclassified",
vi: "intransitive verb",
vt: "transitive verb",
v1: "ichidan verb",
v5: "godan verb",
v5aru: "aru ending",
v5b: "bu ending",
v5g: "gu ending",
v5k: "ku ending",
v5m: "mu ending",
v5n: "nu ending",
v5r: "ru ending",
v5s: "su ending",
v5t: "tsu ending",
v5u: "u ending",
vk: "kuru verb",
vs: "suru verb",
vz: "zuru verb"
};
function formatPartOfSpeech(tags = []) {
const labels = tags.map((tag) => POS_LABELS[tag.toLowerCase()] ?? tag).filter(Boolean);
return [...new Set(labels)].join(", ");
}
function formatPartOfSpeechDetails(tags = []) {
return tags.length ? tags.join(", ").toUpperCase() : "";
}
const GODAN_ROWS = [
{ ending: "う", a: "わ", i: "い", e: "え", o: "お", te: "って", ta: "った", rules: ["v5u", "v5"] },
{ ending: "く", a: "か", i: "き", e: "け", o: "こ", te: "いて", ta: "いた", rules: ["v5k", "v5"] },
{ ending: "ぐ", a: "が", i: "ぎ", e: "げ", o: "ご", te: "いで", ta: "いだ", rules: ["v5g", "v5"] },
{ ending: "す", a: "さ", i: "し", e: "せ", o: "そ", te: "して", ta: "した", rules: ["v5s", "v5"] },
{ ending: "つ", a: "た", i: "ち", e: "て", o: "と", te: "って", ta: "った", rules: ["v5t", "v5"] },
{ ending: "ぬ", a: "な", i: "に", e: "ね", o: "の", te: "んで", ta: "んだ", rules: ["v5n", "v5"] },
{ ending: "ぶ", a: "ば", i: "び", e: "べ", o: "ぼ", te: "んで", ta: "んだ", rules: ["v5b", "v5"] },
{ ending: "む", a: "ま", i: "み", e: "め", o: "も", te: "んで", ta: "んだ", rules: ["v5m", "v5"] },
{ ending: "る", a: "ら", i: "り", e: "れ", o: "ろ", te: "って", ta: "った", rules: ["v5r", "v5"] }
];
const ICHIDAN_RULES = [
["ました", "る", "polite past"],
["ませんでした", "る", "polite negative past"],
["ません", "る", "polite negative"],
["ましょう", "る", "polite volitional"],
["ます", "る", "polite"],
["なかった", "る", "negative past"],
["なくて", "る", "negative te-form"],
["なければ", "る", "negative conditional"],
["ない", "る", "negative"],
["たかった", "る", "desiderative past"],
["たくなかった", "る", "desiderative negative past"],
["たくない", "る", "desiderative negative"],
["たい", "る", "desiderative"],
["られなかった", "る", "potential/passive negative past"],
["られない", "る", "potential/passive negative"],
["られて", "る", "potential/passive te-form"],
["られた", "る", "potential/passive past"],
["られる", "る", "potential/passive"],
["させられた", "る", "causative passive past"],
["させられる", "る", "causative passive"],
["させない", "る", "causative negative"],
["させて", "る", "causative te-form"],
["させた", "る", "causative past"],
["させる", "る", "causative"],
["れば", "る", "conditional"],
["よう", "る", "volitional"],
["ろ", "る", "imperative"],
["て", "る", "te-form"],
["た", "る", "past"]
];
const I_ADJECTIVE_RULES = [
["くなかった", "い", "negative past"],
["くありませんでした", "い", "polite negative past"],
["くありません", "い", "polite negative"],
["かった", "い", "past"],
["くない", "い", "negative"],
["くて", "い", "te-form"],
["ければ", "い", "conditional"],
["そう", "い", "looks"],
["すぎる", "い", "excessive"],
["く", "い", "adverbial"]
];
const SURU_RULES = [
["しませんでした", "する", "polite negative past"],
["しません", "する", "polite negative"],
["しました", "する", "polite past"],
["しましょう", "する", "polite volitional"],
["します", "する", "polite"],
["しなかった", "する", "negative past"],
["しなくて", "する", "negative te-form"],
["しなければ", "する", "negative conditional"],
["しない", "する", "negative"],
["された", "する", "passive past"],
["されて", "する", "passive te-form"],
["される", "する", "passive"],
["させた", "する", "causative past"],
["させて", "する", "causative te-form"],
["させる", "する", "causative"],
["できなかった", "する", "potential negative past"],
["できない", "する", "potential negative"],
["できた", "する", "potential past"],
["できて", "する", "potential te-form"],
["できる", "する", "potential"],
["すれば", "する", "conditional"],
["しよう", "する", "volitional"],
["しろ", "する", "imperative"],
["せよ", "する", "imperative"],
["した", "する", "past"],
["して", "する", "te-form"]
];
const KURU_RULES = [
["来ませんでした", "来る", "polite negative past"],
["来ません", "来る", "polite negative"],
["来ました", "来る", "polite past"],
["来ます", "来る", "polite"],
["来なかった", "来る", "negative past"],
["来なくて", "来る", "negative te-form"],
["来ない", "来る", "negative"],
["来られた", "来る", "potential/passive past"],
["来られて", "来る", "potential/passive te-form"],
["来られる", "来る", "potential/passive"],
["来れば", "来る", "conditional"],
["来よう", "来る", "volitional"],
["来い", "来る", "imperative"],
["来た", "来る", "past"],
["来て", "来る", "te-form"],
["きませんでした", "くる", "polite negative past"],
["きません", "くる", "polite negative"],
["きました", "くる", "polite past"],
["きます", "くる", "polite"],
["こなかった", "くる", "negative past"],
["こなくて", "くる", "negative te-form"],
["こない", "くる", "negative"],
["こられた", "くる", "potential/passive past"],
["こられて", "くる", "potential/passive te-form"],
["こられる", "くる", "potential/passive"],
["くれば", "くる", "conditional"],
["こよう", "くる", "volitional"],
["こい", "くる", "imperative"],
["きた", "くる", "past"],
["きて", "くる", "te-form"]
];
const RULES = [
...ICHIDAN_RULES.map(([from, to, reason]) => ({ from, to, reason, rules: ["v1"] })),
...I_ADJECTIVE_RULES.map(([from, to, reason]) => ({ from, to, reason, rules: ["adj-i", "i-adj"] })),
...SURU_RULES.map(([from, to, reason]) => ({ from, to, reason, rules: ["vs", "vs-s", "suru"] })),
...KURU_RULES.map(([from, to, reason]) => ({ from, to, reason, rules: ["vk", "kuru"] })),
...GODAN_ROWS.flatMap((row) => godanRules(row)),
{ from: "行って", to: "行く", reason: "te-form", rules: ["v5k", "v5"] },
{ from: "行った", to: "行く", reason: "past", rules: ["v5k", "v5"] }
];
function deinflectJapaneseTerm(source) {
const results = [{ term: source, rules: [], reasons: [], depth: 0 }];
const seen = /* @__PURE__ */ new Set([candidateKey(results[0])]);
const queue = [results[0]];
expandDeinflectionQueue(queue, results, seen);
const sorted = sortDeinflectedTerms(results);
return sorted;
}
function expandDeinflectionQueue(queue, results, seen) {
for (let index = 0; index < queue.length; index++) {
expandDeinflectedTerm(queue[index], queue, results, seen);
}
}
function expandDeinflectedTerm(current, queue, results, seen) {
if (isDeinflectionDepthLimitReached(current)) return;
for (const rule of RULES) {
rememberExpandedDeinflection(current, rule, queue, results, seen);
}
}
function isDeinflectionDepthLimitReached(current) {
return current.depth >= 2;
}
function rememberExpandedDeinflection(current, rule, queue, results, seen) {
const next = deinflectedCandidate(current, rule);
if (!next) return;
if (!rememberDeinflectedCandidate(next, seen)) return;
results.push(next);
queue.push(next);
}
function sortDeinflectedTerms(results) {
return results.sort((a, b) => a.depth - b.depth || b.term.length - a.term.length || a.term.localeCompare(b.term));
}
function deinflectedCandidate(current, rule) {
if (!canApplyDeinflectionRule(current.term, rule)) return null;
const term = `${current.term.slice(0, -rule.from.length)}${rule.to}`;
if (!term || term === current.term) return null;
return {
term,
rules: rule.rules,
reasons: [...current.reasons, rule.reason],
depth: current.depth + 1
};
}
function canApplyDeinflectionRule(term, rule) {
return term.endsWith(rule.from) && (term.length > rule.from.length || rule.to.length > 0);
}
function rememberDeinflectedCandidate(candidate, seen) {
const key = candidateKey(candidate);
if (seen.has(key)) return false;
seen.add(key);
return true;
}
function termRulesMatch(entryRules, candidateRules) {
if (!candidateRules.length) return true;
const entryRuleSet = entryRulesSet(entryRules);
return entryRuleSet.size > 0 && candidateRules.some((rule) => termRuleMatches(rule, entryRuleSet));
}
function entryRulesSet(entryRules) {
return new Set((entryRules ?? "").split(/\s+/).filter(Boolean));
}
function termRuleMatches(rule, entryRuleSet) {
return TERM_RULE_MATCHERS.some((matches) => matches(rule, entryRuleSet));
}
const TERM_RULE_MATCHERS = [
(rule, entryRuleSet) => entryRuleSet.has(rule),
(rule, entryRuleSet) => rule.startsWith("v5") && entryRuleSet.has("v5"),
(rule, entryRuleSet) => rule === "v5" && [...entryRuleSet].some((entryRule) => entryRule.startsWith("v5")),
(rule, entryRuleSet) => rule === "i-adj" && entryRuleSet.has("adj-i"),
(rule, entryRuleSet) => rule === "adj-i" && entryRuleSet.has("i-adj")
];
function godanRules(row) {
const rules = row.rules;
return [
{ from: row.te, to: row.ending, reason: "te-form", rules },
{ from: row.ta, to: row.ending, reason: "past", rules },
{ from: `${row.a}なかった`, to: row.ending, reason: "negative past", rules },
{ from: `${row.a}なくて`, to: row.ending, reason: "negative te-form", rules },
{ from: `${row.a}なければ`, to: row.ending, reason: "negative conditional", rules },
{ from: `${row.a}ない`, to: row.ending, reason: "negative", rules },
{ from: `${row.i}ませんでした`, to: row.ending, reason: "polite negative past", rules },
{ from: `${row.i}ません`, to: row.ending, reason: "polite negative", rules },
{ from: `${row.i}ました`, to: row.ending, reason: "polite past", rules },
{ from: `${row.i}ましょう`, to: row.ending, reason: "polite volitional", rules },
{ from: `${row.i}ます`, to: row.ending, reason: "polite", rules },
{ from: `${row.i}たかった`, to: row.ending, reason: "desiderative past", rules },
{ from: `${row.i}たくなかった`, to: row.ending, reason: "desiderative negative past", rules },
{ from: `${row.i}たくない`, to: row.ending, reason: "desiderative negative", rules },
{ from: `${row.i}たい`, to: row.ending, reason: "desiderative", rules },
{ from: `${row.e}ば`, to: row.ending, reason: "conditional", rules },
{ from: `${row.o}う`, to: row.ending, reason: "volitional", rules },
{ from: `${row.e}なかった`, to: row.ending, reason: "potential negative past", rules },
{ from: `${row.e}ない`, to: row.ending, reason: "potential negative", rules },
{ from: `${row.e}た`, to: row.ending, reason: "potential past", rules },
{ from: `${row.e}て`, to: row.ending, reason: "potential te-form", rules },
{ from: `${row.e}る`, to: row.ending, reason: "potential", rules },
{ from: `${row.a}れなかった`, to: row.ending, reason: "passive negative past", rules },
{ from: `${row.a}れない`, to: row.ending, reason: "passive negative", rules },
{ from: `${row.a}れて`, to: row.ending, reason: "passive te-form", rules },
{ from: `${row.a}れた`, to: row.ending, reason: "passive past", rules },
{ from: `${row.a}れる`, to: row.ending, reason: "passive", rules },
{ from: `${row.a}せない`, to: row.ending, reason: "causative negative", rules },
{ from: `${row.a}せて`, to: row.ending, reason: "causative te-form", rules },
{ from: `${row.a}せた`, to: row.ending, reason: "causative past", rules },
{ from: `${row.a}せる`, to: row.ending, reason: "causative", rules },
{ from: row.e, to: row.ending, reason: "imperative", rules }
];
}
function candidateKey(candidate) {
return `${candidate.term}
${candidate.rules.join(" ")}
${candidate.depth}`;
}
async function readDexieTableRowCounts(file) {
const head = await readBlobText(file.slice(0, Math.min(file.size, 1024 * 1024)));
const tables = readDexieTablesArray(head);
return tables ? dexieTableRowCounts(tables) : {};
}
function readDexieTablesArray(head) {
const tablesIndex = head.indexOf('"tables"');
if (tablesIndex < 0) return null;
const arrayStart = head.indexOf("[", tablesIndex);
if (arrayStart < 0) return null;
const arrayEnd = findJsonArrayEnd(head, arrayStart);
if (arrayEnd < 0) return null;
return JSON.parse(head.slice(arrayStart, arrayEnd + 1));
}
function dexieTableRowCounts(tables) {
const counts = {};
for (const table of tables) {
const count = dexieTableRowCount(table);
if (count) counts[count.name] = count.rowCount;
}
return counts;
}
function dexieTableRowCount(table) {
if (!table || typeof table !== "object") return null;
const record = table;
return typeof record.name === "string" && typeof record.rowCount === "number" ? { name: record.name, rowCount: record.rowCount } : null;
}
async function streamDexieTables(file, handlers, onTable) {
if (typeof file.stream !== "function" || typeof TextDecoderStream === "undefined") {
await streamDexieTablesFromText(await readBlobText(file), handlers, onTable);
return;
}
const reader = file.stream().pipeThrough(new TextDecoderStream()).getReader();
const state = {
buffer: "",
mode: "seek-table",
tableName: "",
rowStart: -1,
depth: 0,
inString: false,
escaped: false
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
state.buffer += value;
await processDexieStreamBuffer(state, handlers, onTable);
}
}
function readBlobText(blob) {
if (typeof blob.text === "function") return blob.text();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error("Could not read file."));
reader.onload = () => resolve(String(reader.result ?? ""));
reader.readAsText(blob);
});
}
async function processDexieStreamBuffer(state, handlers, onTable) {
let progress = true;
while (progress) {
progress = await processDexieStreamStep(state, handlers, onTable);
}
}
async function processDexieStreamStep(state, handlers, onTable) {
if (state.mode === "seek-table") return seekDexieTable(state);
if (state.mode === "seek-rows") return seekDexieRows(state, onTable);
if (state.mode === "rows") return await readDexieRows(state, handlers);
return false;
}
function seekDexieTable(state) {
const tableIndex = state.buffer.indexOf('"tableName"');
if (tableIndex < 0) {
state.buffer = state.buffer.slice(-32);
return false;
}
const colon = state.buffer.indexOf(":", tableIndex);
const quote = colon >= 0 ? state.buffer.indexOf('"', colon) : -1;
const end = quote >= 0 ? findJsonStringEnd(state.buffer, quote) : -1;
if (end < 0) return false;
state.tableName = JSON.parse(state.buffer.slice(quote, end + 1));
state.buffer = state.buffer.slice(end + 1);
state.mode = "seek-rows";
return true;
}
function seekDexieRows(state, onTable) {
const rowsIndex = state.buffer.indexOf('"rows"');
if (rowsIndex < 0) {
state.buffer = state.buffer.slice(-32);
return false;
}
const arrayIndex = state.buffer.indexOf("[", rowsIndex);
if (arrayIndex < 0) return false;
state.buffer = state.buffer.slice(arrayIndex + 1);
state.mode = "rows";
resetDexieRowState(state);
onTable?.(state.tableName);
return true;
}
function resetDexieRowState(state) {
state.rowStart = -1;
state.depth = 0;
state.inString = false;
state.escaped = false;
}
async function readDexieRows(state, handlers) {
let progress = false;
let index = 0;
while (index < state.buffer.length) {
const step = await readDexieRowStep(state, handlers, index, progress);
if (step.done) return true;
progress = step.progress;
index = step.nextIndex;
}
if (!progress) compactDexieRowBuffer(state);
return progress;
}
async function readDexieRowStep(state, handlers, index, progress) {
const action = readDexieRowCharacter(state, index);
if (action === "continue") return { done: false, progress, nextIndex: index + 1 };
const result = await applyDexieRowReadAction(state, handlers, action, index, progress);
return {
done: result.done,
progress: result.progress,
nextIndex: result.restart ? 0 : index + 1
};
}
async function applyDexieRowReadAction(state, handlers, action, index, progress) {
if (action === "close-array") return { done: true, progress, restart: false };
if (action !== "finish-row") return { done: false, progress, restart: false };
const nextProgress = await finishDexieRow(state, handlers, index) || progress;
return { done: false, progress: nextProgress, restart: nextProgress && state.rowStart === -1 };
}
function readDexieRowCharacter(state, index) {
const char = state.buffer[index];
if (advanceStringState(state, char)) return "continue";
if (openDexieRowObject(state, index, char)) return "continue";
if (char === "}") return "finish-row";
return closeDexieRowsArray(state, index, char) ? "close-array" : "scan";
}
function openDexieRowObject(state, index, char) {
if (char !== "{") return false;
if (state.depth === 0) state.rowStart = index;
state.depth++;
return true;
}
function closeDexieRowsArray(state, index, char) {
if (state.depth !== 0 || char !== "]") return false;
state.buffer = state.buffer.slice(index + 1);
state.mode = "seek-table";
state.tableName = "";
return true;
}
async function finishDexieRow(state, handlers, index) {
state.depth--;
if (state.depth !== 0 || state.rowStart < 0) return false;
const handler = handlers[state.tableName];
if (handler) await handler(JSON.parse(state.buffer.slice(state.rowStart, index + 1)));
state.buffer = state.buffer.slice(index + 1);
state.rowStart = -1;
return true;
}
function advanceStringState(state, char) {
if (state.inString) return advanceOpenStringState(state, char);
return enterStringState(state, char);
}
function enterStringState(state, char) {
if (char !== '"') return false;
state.inString = true;
return true;
}
function advanceOpenStringState(state, char) {
if (state.escaped) state.escaped = false;
else if (char === "\\") state.escaped = true;
else if (char === '"') state.inString = false;
return true;
}
function compactDexieRowBuffer(state) {
if (state.rowStart > 0) {
state.buffer = state.buffer.slice(state.rowStart);
state.rowStart = 0;
} else if (state.depth === 0 && state.buffer.length > 4096) {
state.buffer = state.buffer.slice(-4096);
}
}
function findJsonArrayEnd(text2, start) {
const state = createJsonArrayScanState();
for (let index = start; index < text2.length; index++) {
if (scanJsonArrayCharacter(state, text2[index])) return index;
}
return -1;
}
function createJsonArrayScanState() {
return { depth: 0, inString: false, escaped: false };
}
function scanJsonArrayCharacter(state, char) {
if (state.inString) {
scanJsonArrayStringCharacter(state, char);
return false;
}
if (char === '"') {
state.inString = true;
return false;
}
if (char === "[") state.depth += 1;
if (char !== "]") return false;
state.depth -= 1;
return state.depth === 0;
}
function scanJsonArrayStringCharacter(state, char) {
if (state.escaped) {
state.escaped = false;
return;
}
if (char === "\\") state.escaped = true;
if (char === '"') state.inString = false;
}
async function streamDexieTablesFromText(text2, handlers, onTable) {
let offset = 0;
while (true) {
const table = nextDexieTableScan(text2, offset);
if (!table) return;
onTable?.(table.tableName);
offset = await streamDexieRowsFromText(text2, table.arrayStart, table.tableName, handlers);
}
}
async function streamDexieRowsFromText(text2, arrayStart, tableName, handlers) {
const handler = handlers[tableName];
const state = { depth: 0, rowStart: -1, inString: false, escaped: false };
for (let index = arrayStart + 1; index < text2.length; index++) {
const endOffset = await scanDexieRowCharacter(text2, state, index, handler);
if (endOffset !== null) return endOffset;
}
return text2.length;
}
function nextDexieTableScan(text2, offset) {
const tableIndex = text2.indexOf('"tableName"', offset);
if (tableIndex < 0) return null;
const quote = dexieTableNameQuoteIndex(text2, tableIndex);
if (quote < 0) return null;
const end = findJsonStringEnd(text2, quote);
if (end < 0) return null;
const arrayStart = dexieRowsArrayStart(text2, end);
if (arrayStart < 0) return null;
return { tableName: JSON.parse(text2.slice(quote, end + 1)), arrayStart };
}
function dexieTableNameQuoteIndex(text2, tableIndex) {
const colon = text2.indexOf(":", tableIndex);
return colon >= 0 ? text2.indexOf('"', colon) : -1;
}
function dexieRowsArrayStart(text2, offset) {
const rowsIndex = text2.indexOf('"rows"', offset);
return rowsIndex >= 0 ? text2.indexOf("[", rowsIndex) : -1;
}
async function scanDexieRowCharacter(text2, state, index, handler) {
const char = text2[index];
if (consumeDexieStringCharacter(state, char)) return null;
if (openDexieString(state, char)) return null;
if (char === "{") beginDexieRow(state, index);
if (char === "}") await finishDexieArrayRow(text2, state, index, handler);
return dexieArrayEndOffset(state, char, index);
}
function dexieArrayEndOffset(state, char, index) {
return state.depth === 0 && char === "]" ? index + 1 : null;
}
function consumeDexieStringCharacter(state, char) {
if (!state.inString) return false;
if (state.escaped) state.escaped = false;
else if (char === "\\") state.escaped = true;
else if (char === '"') state.inString = false;
return true;
}
function openDexieString(state, char) {
if (char !== '"') return false;
state.inString = true;
return true;
}
function beginDexieRow(state, index) {
if (state.depth === 0) state.rowStart = index;
state.depth++;
}
async function finishDexieArrayRow(text2, state, index, handler) {
state.depth--;
if (state.depth === 0 && state.rowStart >= 0 && handler) await handler(JSON.parse(text2.slice(state.rowStart, index + 1)));
}
function findJsonStringEnd(value, quoteIndex) {
let escaped = false;
for (let index = quoteIndex + 1; index < value.length; index++) {
const char = value[index];
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') return index;
}
return -1;
}
function dictionaryRank(preferences) {
const rank = new Map(normalizeDictionaryPreferences(preferences).map((item) => [item.name, item]));
return rank;
}
function dictionaryEnabled(dictionary, rank) {
return rank.get(dictionary)?.enabled ?? true;
}
function dictionaryPriority(dictionary, rank) {
return rank.get(dictionary)?.priority ?? 9999;
}
function compareMetaEntries(a, b, rank) {
return compareMetaModes(a, b) || compareMetaEntriesWithinMode(a, b, rank);
}
function compareMetaModes(a, b) {
return metaModePriority(a) - metaModePriority(b);
}
function metaModePriority(entry) {
return entry.mode === "freq" ? 0 : 1;
}
function compareMetaEntriesWithinMode(a, b, rank) {
return a.mode === "freq" && b.mode === "freq" ? compareFrequencyMetaEntries(a, b, rank) : compareDictionaryMetaEntries(a, b, rank);
}
function compareFrequencyMetaEntries(a, b, rank) {
return jpdbFrequencyPriority(a) - jpdbFrequencyPriority(b) || compareDictionaryPriority(a, b, rank) || frequencyRank(a.data) - frequencyRank(b.data) || compareDictionaryName(a, b);
}
function jpdbFrequencyPriority(entry) {
return isJpdbFrequencyDictionary(entry.dictionary) ? 0 : 1;
}
function compareDictionaryMetaEntries(a, b, rank) {
return compareDictionaryPriority(a, b, rank) || compareDictionaryName(a, b);
}
function compareDictionaryPriority(a, b, rank) {
return dictionaryPriority(a.dictionary, rank) - dictionaryPriority(b.dictionary, rank);
}
function compareDictionaryName(a, b) {
return a.dictionary.localeCompare(b.dictionary);
}
function extractFrequency(value) {
const rank = frequencyRank(value);
return Number.isFinite(rank) ? rank : void 0;
}
function nonOverlappingMatches(matches, limit) {
const selected = [];
const occupied = [];
const overlaps = (match) => occupied.some(([start, end]) => match.start < end && match.end > start);
for (const match of matches.sort(
(a, b) => b.end - b.start - (a.end - a.start) || (a.deinflected?.depth ?? 0) - (b.deinflected?.depth ?? 0) || a.start - b.start || a.entry.dictionary.localeCompare(b.entry.dictionary) || (b.entry.score ?? 0) - (a.entry.score ?? 0)
)) {
if (overlaps(match)) continue;
selected.push(match);
occupied.push([match.start, match.end]);
if (selected.length >= limit) break;
}
const result = selected.sort((a, b) => a.start - b.start);
return result;
}
function isJpdbFrequencyDictionary(dictionary) {
return /jpdb/i.test(dictionary);
}
function frequencyRank(value) {
if (typeof value === "number") return value;
if (typeof value === "string") return rankFromFrequencyString(value);
const nested = nestedFrequencyValue(value);
return nested === void 0 ? Number.POSITIVE_INFINITY : frequencyRank(nested);
}
function rankFromFrequencyString(value) {
return Number(value.replace(/[^\d.]/g, "")) || Number.POSITIVE_INFINITY;
}
function nestedFrequencyValue(value) {
if (!value || typeof value !== "object") return void 0;
const record = value;
return record.frequency ?? record.value ?? record.displayValue;
}
const STRUCTURED_CONTENT_TAGS = /* @__PURE__ */ new Set([
"br",
"ruby",
"rt",
"rp",
"thead",
"tbody",
"tfoot",
"tr",
"th",
"td",
"div",
"span",
"ol",
"ul",
"li",
"details",
"summary"
]);
const STRUCTURED_STYLE_PROPERTIES = {
fontStyle: "font-style",
fontWeight: "font-weight",
fontSize: "font-size",
color: "color",
background: "background",
backgroundColor: "background-color",
textDecorationStyle: "text-decoration-style",
textDecorationColor: "text-decoration-color",
borderColor: "border-color",
borderStyle: "border-style",
borderRadius: "border-radius",
borderWidth: "border-width",
clipPath: "clip-path",
verticalAlign: "vertical-align",
textAlign: "text-align",
textEmphasis: "text-emphasis",
textShadow: "text-shadow",
margin: "margin",
marginTop: "margin-top",
marginLeft: "margin-left",
marginRight: "margin-right",
marginBottom: "margin-bottom",
padding: "padding",
paddingTop: "padding-top",
paddingLeft: "padding-left",
paddingRight: "padding-right",
paddingBottom: "padding-bottom",
wordBreak: "word-break",
whiteSpace: "white-space",
cursor: "cursor",
listStyleType: "list-style-type"
};
const STRUCTURED_NUMERIC_EM_STYLES = /* @__PURE__ */ new Set(["marginTop", "marginLeft", "marginRight", "marginBottom"]);
function glossaryValueToText(value) {
const primitiveText = primitiveGlossaryText(value);
if (primitiveText !== void 0) return primitiveText;
if (Array.isArray(value)) return value.map(glossaryValueToText).filter(Boolean).join(" ");
return isRecord$3(value) ? glossaryRecordToText(value) : "";
}
function primitiveGlossaryText(value) {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return void 0;
}
function glossaryRecordToText(record) {
if (typeof record.text === "string") return record.text;
if ("content" in record) return glossaryValueToText(record.content);
const values = glossaryRecordTextValues(record);
if (values.length) return values.join(" ");
if ("path" in record) return glossaryPathRecordText(record);
return "";
}
function glossaryPathRecordText(record) {
return String(record.description || record.alt || "");
}
function renderStructuredGlossaryHtml(value, dictionary = "", options = {}) {
return renderGlossaryValue(value, {
dictionary,
internalSearchLinks: options.internalSearchLinks ?? false
});
}
function renderGlossaryValue(value, context) {
if (value == null) return "";
if (isStructuredPrimitive(value)) return escapeHtml(String(value));
if (Array.isArray(value)) return renderGlossaryArray(value, context);
if (!isRecord$3(value)) return "";
return renderGlossaryRecord(value, context);
}
function isStructuredPrimitive(value) {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
}
function renderGlossaryArray(value, context) {
return value.map((item) => renderGlossaryValue(item, context)).filter(Boolean).join("");
}
function renderGlossaryRecord(record, context) {
return renderDirectGlossaryRecord(record, context) ?? renderTaggedGlossaryRecord(record, context);
}
const DIRECT_GLOSSARY_RECORD_RENDERERS = [
renderTextGlossaryRecord,
renderStructuredContentGlossaryRecord,
renderImageGlossaryRecord,
renderTextContentGlossaryRecord
];
function renderDirectGlossaryRecord(record, context) {
for (const render of DIRECT_GLOSSARY_RECORD_RENDERERS) {
const html = render(record, context);
if (html !== null) return html;
}
return null;
}
function renderTextGlossaryRecord(record) {
return typeof record.text === "string" ? escapeHtml(record.text) : null;
}
function renderStructuredContentGlossaryRecord(record, context) {
return record.type === "structured-content" ? renderStructuredContent(record, context) : null;
}
function renderImageGlossaryRecord(record, context) {
return isStructuredImageRecord(record) ? renderStructuredImage(record, context.dictionary) : null;
}
function renderTextContentGlossaryRecord(record, context) {
return record.type === "text" && "content" in record ? renderGlossaryValue(record.content, context) : null;
}
function renderStructuredContent(record, context) {
const dictionaryAttr = context.dictionary ? ` data-dictionary="${escapeHtml(context.dictionary)}"` : "";
return `${renderGlossaryValue(record.content, context)} `;
}
function renderTaggedGlossaryRecord(record, context) {
const tag = structuredRecordTag(record);
if (!tag) return renderRecordValues(record, context);
return renderKnownTaggedGlossaryRecord(record, tag, context) ?? structuredFallbackContent(record, taggedRecordContent(record, tag, context));
}
function renderKnownTaggedGlossaryRecord(record, tag, context) {
if (tag === "a") return renderStructuredLink(record, context);
if (tag === "img") return renderStructuredImage(record, context.dictionary);
const content = taggedRecordContent(record, tag, context);
if (tag === "table") return renderStructuredTable(record, content, context.dictionary);
if (STRUCTURED_CONTENT_TAGS.has(tag)) return renderStructuredElement(record, tag, content, context.dictionary);
return null;
}
function taggedRecordContent(record, tag, context) {
return tag === "br" ? "" : renderGlossaryValue(record.content, context);
}
function structuredFallbackContent(record, content) {
return content || escapeHtml(glossaryValueToText(record));
}
function structuredRecordTag(record) {
if (typeof record.tag === "string") return record.tag.toLowerCase();
return "content" in record ? "span" : "";
}
function renderRecordValues(record, context) {
return Object.values(record).map((item) => renderGlossaryValue(item, context)).filter(Boolean).join("");
}
function renderStructuredTable(record, content, dictionary) {
return ``;
}
function renderStructuredElement(record, tag, content, dictionary) {
const attrs = renderStructuredElementAttributes(record, tag, dictionary);
return tag === "br" ? ` ` : `<${tag}${attrs}>${content}${tag}>`;
}
function renderStructuredElementAttributes(record, tag, dictionary) {
return [
` class="gloss-sc-${escapeHtml(tag)}"`,
dictionaryDataAttribute(dictionary),
renderStructuredDataAttributes(record.data),
renderDirectDataAttributes(record),
structuredStyleAttribute(record.style),
structuredStringAttribute("title", record.title),
structuredStringAttribute("lang", record.lang),
...structuredStateAttributes(record, tag)
].filter(Boolean).join("");
}
function dictionaryDataAttribute(dictionary) {
return dictionary ? ` data-dictionary="${escapeHtml(dictionary)}"` : "";
}
function structuredStyleAttribute(value) {
const style = renderStructuredStyle(value);
return style ? ` style="${escapeHtml(style)}"` : "";
}
function structuredStringAttribute(name, value) {
return typeof value === "string" ? ` ${name}="${escapeHtml(value)}"` : "";
}
function structuredStateAttributes(record, tag) {
return [
tag === "details" && record.open === true ? " open" : "",
tableCellSpanAttribute(record, tag, "colSpan", "colspan"),
tableCellSpanAttribute(record, tag, "rowSpan", "rowspan")
];
}
function tableCellSpanAttribute(record, tag, key, attr) {
const value = Number(record[key]);
return isTableCellTag(tag) && Number.isFinite(value) ? ` ${attr}="${value}"` : "";
}
function isTableCellTag(tag) {
return tag === "td" || tag === "th";
}
function renderStructuredDataAttributes(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
return Object.entries(value).map(([key, rawValue]) => renderStructuredDataAttribute(key, rawValue)).filter(Boolean).join("");
}
function renderStructuredDataAttribute(key, rawValue) {
return key && isStructuredAttributeValue(rawValue) ? ` data-sc-${camelToKebabCase(key)}="${escapeHtml(String(rawValue))}"` : "";
}
function isStructuredAttributeValue(value) {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
}
function renderDirectDataAttributes(record) {
return Object.entries(record).map(renderDirectDataAttribute).filter(Boolean).join("");
}
function renderDirectDataAttribute([key, value]) {
return isDirectDataAttribute(key, value) ? ` ${key}="${escapeHtml(String(value))}"` : "";
}
function isDirectDataAttribute(key, value) {
return key.startsWith("data-") && isStructuredAttributeValue(value);
}
function renderStructuredStyle(value) {
const style = structuredStyleRecord(value);
if (!style) return "";
const declarations = [];
const decoration = structuredTextDecoration(style.textDecorationLine);
if (decoration) declarations.push(decoration);
for (const [key, property] of Object.entries(STRUCTURED_STYLE_PROPERTIES)) {
const declaration = structuredStyleDeclaration(key, property, style[key]);
if (declaration) declarations.push(declaration);
}
return declarations.join("");
}
function structuredStyleRecord(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
}
function structuredTextDecoration(value) {
if (typeof value === "string") return `text-decoration:${value};`;
if (Array.isArray(value)) return `text-decoration:${value.map(String).join(" ")};`;
return "";
}
function structuredStyleDeclaration(key, property, rawValue) {
if (typeof rawValue === "string") return `${property}:${rawValue};`;
if (typeof rawValue === "number" && STRUCTURED_NUMERIC_EM_STYLES.has(key)) return `${property}:${rawValue}em;`;
return "";
}
function renderStructuredLink(record, context) {
const content = renderGlossaryValue(record.content, context) || escapeHtml(glossaryValueToText(record));
const link = structuredLinkModel(record, context);
const icon = link.external ? ' ' : "";
return `${content} ${icon} `;
}
function structuredLinkModel(record, context) {
const rawHref = typeof record.href === "string" ? record.href : "";
const searchReference = structuredLinkSearchReference(rawHref, context);
const kanjiReference = structuredLinkKanjiReference(rawHref, context);
const href = structuredLinkHref(rawHref, searchReference, kanjiReference);
return {
href,
external: isExternalStructuredHref(href),
searchReference,
kanjiReference
};
}
function structuredLinkSearchReference(rawHref, context) {
return context.internalSearchLinks ? parseStructuredSearchReference(rawHref) : null;
}
function structuredLinkKanjiReference(rawHref, context) {
return context.internalSearchLinks ? parseStructuredKanjiReference(rawHref) : null;
}
function structuredLinkHref(rawHref, searchReference, kanjiReference) {
if (searchReference) return "#jpdb-reader-dictionary-lookup";
if (kanjiReference) return "#jpdb-reader-kanji-lookup";
return normalizeStructuredHref(rawHref);
}
function isExternalStructuredHref(href) {
return Boolean(href && !href.startsWith(locationOrigin()) && !href.startsWith("#"));
}
function structuredLinkAttrs(link, dictionary, lang) {
return [
' class="gloss-link"',
` data-external="${link.external}"`,
dictionaryAttribute(dictionary),
kanjiReferenceActionAttribute(link),
searchReferenceQueryAttribute(link),
searchReferenceReadingAttribute(link),
hrefAttribute(link.href),
externalLinkAttributes(link.external),
langAttribute(lang)
].join("");
}
function kanjiReferenceActionAttribute(link) {
return link.kanjiReference ? ` data-action="kanji" data-kanji="${escapeHtml(link.kanjiReference.kanji)}"` : "";
}
function dictionaryAttribute(dictionary) {
return dictionary ? ` data-dictionary="${escapeHtml(dictionary)}"` : "";
}
function searchReferenceQueryAttribute(link) {
return link.searchReference ? ` data-dictionary-lookup="${escapeHtml(link.searchReference.query)}"` : "";
}
function searchReferenceReadingAttribute(link) {
return link.searchReference?.reading ? ` data-dictionary-reading="${escapeHtml(link.searchReference.reading)}"` : "";
}
function hrefAttribute(href) {
return href ? ` href="${escapeHtml(href)}"` : "";
}
function externalLinkAttributes(external) {
return external ? ' target="_blank" rel="noopener noreferrer"' : "";
}
function langAttribute(lang) {
return typeof lang === "string" ? ` lang="${escapeHtml(lang)}"` : "";
}
function renderStructuredImage(record, dictionary) {
const path = typeof record.path === "string" ? record.path : "";
const title = typeof record.title === "string" ? record.title : "";
const description = structuredImageDescription(record);
const metrics = structuredImageMetrics(record);
return `${renderStructuredImageLink(record, dictionary, path, title, metrics)}${renderStructuredImageDescription(description)}`;
}
function renderStructuredImageLink(record, dictionary, path, title, metrics) {
return `${renderStructuredImageContainer(record, title, metrics)}Image `;
}
function renderStructuredImageAttributes(record, dictionary, path) {
return [
` class="gloss-image-link"`,
dictionaryAttribute(dictionary),
structuredImagePathAttribute(path),
...structuredImageStateAttributes(record),
...structuredImageOptionalAttributes(record)
].join("");
}
function structuredImagePathAttribute(path) {
return path ? ` data-path="${escapeHtml(path)}"` : "";
}
function structuredImageStateAttributes(record) {
return [
` data-image-load-state="unloaded"`,
` data-has-aspect-ratio="true"`,
` data-image-rendering="${escapeHtml(structuredImageRendering(record))}"`,
` data-appearance="${escapeHtml(String(record.appearance || "auto"))}"`,
structuredImageBooleanAttribute(record, "background", true),
structuredImageBooleanAttribute(record, "collapsed", false),
structuredImageBooleanAttribute(record, "collapsible", true)
];
}
function structuredImageOptionalAttributes(record) {
return [
typeof record.verticalAlign === "string" ? ` data-vertical-align="${escapeHtml(record.verticalAlign)}"` : "",
typeof record.sizeUnits === "string" ? ` data-size-units="${escapeHtml(record.sizeUnits)}"` : ""
];
}
function structuredImageBooleanAttribute(record, key, fallback) {
const value = typeof record[key] === "boolean" ? record[key] : fallback;
return ` data-${kebabCase(key)}="${value}"`;
}
function kebabCase(value) {
return value.replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`);
}
function renderStructuredImageContainer(record, title, metrics) {
const containerTitle = title ? ` title="${escapeHtml(title)}"` : "";
return `${renderStructuredImageFrame(metrics)} `;
}
function renderStructuredImageContainerStyle(record, usedWidth) {
return [
`width:${formatCssNumber(usedWidth)}em;`,
typeof record.border === "string" ? `border:${record.border};` : "",
typeof record.borderRadius === "string" ? `border-radius:${record.borderRadius};` : ""
].join("");
}
function renderStructuredImageFrame(metrics) {
return ` `;
}
function renderStructuredImageDescription(description) {
return description ? `${escapeHtml(description)} ` : "";
}
function structuredImageDescription(record) {
if (typeof record.description === "string") return record.description;
return typeof record.alt === "string" ? record.alt : "";
}
function structuredImageMetrics(record) {
const preferredWidth = numericRecordValue(record, "preferredWidth");
const preferredHeight = numericRecordValue(record, "preferredHeight");
const { width, height } = structuredImageNaturalSize(record, preferredWidth, preferredHeight);
const invAspectRatio = height > 0 && width > 0 ? height / width : 1;
const usedWidth = structuredImageUsedWidth(width, invAspectRatio, preferredWidth, preferredHeight);
return { invAspectRatio, usedWidth };
}
function structuredImageNaturalSize(record, preferredWidth, preferredHeight) {
return {
width: preferredWidth ?? numericRecordValue(record, "width") ?? 100,
height: preferredHeight ?? numericRecordValue(record, "height") ?? 100
};
}
function structuredImageUsedWidth(width, invAspectRatio, preferredWidth, preferredHeight) {
return preferredWidth ?? (preferredHeight ? preferredHeight / invAspectRatio : width);
}
function structuredImageRendering(record) {
return String(record.imageRendering || (record.pixelated ? "pixelated" : "auto"));
}
function isStructuredImageRecord(record) {
return record.type === "image" || "path" in record;
}
function normalizeStructuredHref(href) {
if (!href) return "";
if (/^https?:\/\//i.test(href) || href.startsWith("#")) return href;
if (href.startsWith("?")) return `https://jpdb.io/search${href}`;
return "";
}
function parseStructuredSearchReference(href) {
if (!href.startsWith("?")) return null;
const params = structuredSearchParams(href);
return params ? structuredSearchReferenceFromParams(params) : null;
}
function parseStructuredKanjiReference(href) {
const match = /^(?:https:\/\/jpdb\.io)?\/kanji\/([^/?#]+)/i.exec(href.trim());
if (!match) return null;
const value = decodeStructuredPathSegment(match[1]);
const kanji = Array.from(value).find(isStructuredKanjiCharacter) ?? "";
return kanji ? { kanji } : null;
}
function decodeStructuredPathSegment(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function isStructuredKanjiCharacter(value) {
const code = value.codePointAt(0) ?? 0;
return code >= 13312 && code <= 40959;
}
function structuredSearchParams(href) {
try {
return new URLSearchParams(href.slice(1));
} catch {
return null;
}
}
function structuredSearchReferenceFromParams(params) {
const query = (params.get("query") ?? "").trim();
return query ? { query, reading: (params.get("primary_reading") ?? "").trim() } : null;
}
function locationOrigin() {
try {
return location.origin;
} catch {
return "";
}
}
function glossaryRecordTextValues(record) {
const textKeys = /* @__PURE__ */ new Set(["text", "content", "description", "alt", "title"]);
const values = [];
for (const [key, childValue] of Object.entries(record)) {
if (!textKeys.has(key) && !key.startsWith("data-")) continue;
const childText = glossaryValueToText(childValue);
if (childText) values.push(childText);
}
return values;
}
function numericRecordValue(record, key) {
const value = record[key];
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
}
function formatCssNumber(value) {
return Number.isFinite(value) ? Number(value.toFixed(4)).toString() : "0";
}
function camelToKebabCase(value) {
return value.replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`);
}
function isRecord$3(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function escapeHtml(value) {
return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
}
function glossaryToText(value) {
return glossaryValueToText(value);
}
function glossaryToHtml(value, dictionary = "", options = {}) {
const html = renderStructuredGlossaryHtml(value, dictionary, options);
return html;
}
function renderDictionaryScopedStyles(dictionaries, preferences = []) {
const rank = dictionaryRank(preferences);
const css = dictionaries.filter((dictionary) => dictionaryEnabled(dictionary.title, rank)).map((dictionary) => {
const styles = dictionary.styles?.trim();
if (!styles) return "";
return scopeDictionaryStyles(styles, dictionaryScopeSelector(dictionary.title));
}).filter(Boolean).join("\n");
return css;
}
function dictionaryScopeSelector(dictionary) {
return `[data-dictionary="${dictionary.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
}
function scopeDictionaryStyles(styles, scope) {
return splitTopLevelCssBlocks(styles).map((block) => scopeDictionaryStyleBlock(block, scope)).filter(Boolean).join("\n");
}
function scopeDictionaryStyleBlock(block, scope) {
const openIndex = block.indexOf("{");
const closeIndex = block.lastIndexOf("}");
if (openIndex < 0 || closeIndex <= openIndex) return "";
const selector = block.slice(0, openIndex).trim();
const declarations = block.slice(openIndex + 1, closeIndex).trim();
if (!hasCssRuleParts(selector, declarations)) return "";
if (selector.startsWith("@")) {
const scopedInner = splitTopLevelCssBlocks(declarations).map((innerBlock) => scopeDictionaryStyleBlock(innerBlock, scope)).filter(Boolean).join("\n");
return renderScopedAtRule(selector, declarations, scopedInner);
}
const scopedSelectors = splitSelectorList(selector).map((part) => `${scope} ${part.trim()}`).join(", ");
return `${scopedSelectors} { ${declarations} }`;
}
function hasCssRuleParts(selector, declarations) {
return Boolean(selector && declarations);
}
function renderScopedAtRule(selector, declarations, scopedInner) {
return scopedInner ? `${selector} {
${scopedInner}
}` : `${selector} { ${declarations} }`;
}
function splitTopLevelCssBlocks(styles) {
const state = { blocks: [], depth: 0, start: 0, inString: null, escaped: false };
for (let index = 0; index < styles.length; index++) {
const character = styles[index];
if (consumeCssBlockStringCharacter(state, character)) continue;
if (openCssBlockString(state, character)) continue;
if (openCssBlock(state, styles, index, character)) continue;
closeCssBlock(state, styles, index, character);
}
return state.blocks;
}
function consumeCssBlockStringCharacter(state, character) {
if (!state.inString) return false;
if (state.escaped) state.escaped = false;
else if (character === "\\") state.escaped = true;
else if (character === state.inString) state.inString = null;
return true;
}
function openCssBlockString(state, character) {
if (character !== '"' && character !== "'") return false;
state.inString = character;
return true;
}
function openCssBlock(state, styles, index, character) {
if (character !== "{") return false;
if (state.depth === 0) state.start = findSelectorStart(styles, index);
state.depth++;
return true;
}
function closeCssBlock(state, styles, index, character) {
if (character !== "}" || state.depth === 0) return;
state.depth--;
if (state.depth > 0) return;
state.blocks.push(styles.slice(state.start, index + 1).trim());
state.start = index + 1;
}
function splitSelectorList(selector) {
const state = { selectors: [], start: 0, bracketDepth: 0, parenDepth: 0, inString: null, escaped: false };
for (let index = 0; index < selector.length; index++) {
const character = selector[index];
if (consumeSelectorStringCharacter(state, character)) continue;
if (openSelectorString(state, character)) continue;
updateSelectorDepth(state, character);
if (!isSelectorSeparator(state, character)) continue;
state.selectors.push(selector.slice(state.start, index).trim());
state.start = index + 1;
}
state.selectors.push(selector.slice(state.start).trim());
return state.selectors.filter(Boolean);
}
function consumeSelectorStringCharacter(state, character) {
if (!state.inString) return false;
if (state.escaped) state.escaped = false;
else if (character === "\\") state.escaped = true;
else if (character === state.inString) state.inString = null;
return true;
}
function openSelectorString(state, character) {
if (character !== '"' && character !== "'") return false;
state.inString = character;
return true;
}
function updateSelectorDepth(state, character) {
if (character === "[") state.bracketDepth++;
if (character === "]") state.bracketDepth = Math.max(0, state.bracketDepth - 1);
if (character === "(") state.parenDepth++;
if (character === ")") state.parenDepth = Math.max(0, state.parenDepth - 1);
}
function isSelectorSeparator(state, character) {
return character === "," && state.bracketDepth === 0 && state.parenDepth === 0;
}
function findSelectorStart(styles, openIndex) {
const separators = ["}", ";"];
let start = 0;
for (let index = openIndex - 1; index >= 0; index--) {
if (!separators.includes(styles[index])) continue;
start = index + 1;
break;
}
return start;
}
var u8 = Uint8Array, u16 = Uint16Array, i32 = Int32Array;
var fleb = new u8([
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
2,
2,
2,
2,
3,
3,
3,
3,
4,
4,
4,
4,
5,
5,
5,
5,
0,
/* unused */
0,
0,
/* impossible */
0
]);
var fdeb = new u8([
0,
0,
0,
0,
1,
1,
2,
2,
3,
3,
4,
4,
5,
5,
6,
6,
7,
7,
8,
8,
9,
9,
10,
10,
11,
11,
12,
12,
13,
13,
/* unused */
0,
0
]);
var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
var freb = function(eb, start) {
var b = new u16(31);
for (var i = 0; i < 31; ++i) {
b[i] = start += 1 << eb[i - 1];
}
var r = new i32(b[30]);
for (var i = 1; i < 30; ++i) {
for (var j = b[i]; j < b[i + 1]; ++j) {
r[j] = j - b[i] << 5 | i;
}
}
return { b, r };
};
var _a = freb(fleb, 2), fl = _a.b, revfl = _a.r;
fl[28] = 258, revfl[258] = 28;
var _b = freb(fdeb, 0), fd = _b.b;
var rev = new u16(32768);
for (var i = 0; i < 32768; ++i) {
var x = (i & 43690) >> 1 | (i & 21845) << 1;
x = (x & 52428) >> 2 | (x & 13107) << 2;
x = (x & 61680) >> 4 | (x & 3855) << 4;
rev[i] = ((x & 65280) >> 8 | (x & 255) << 8) >> 1;
}
var hMap = function(cd, mb, r) {
var s = cd.length;
var i = 0;
var l = new u16(mb);
for (; i < s; ++i) {
if (cd[i])
++l[cd[i] - 1];
}
var le = new u16(mb);
for (i = 1; i < mb; ++i) {
le[i] = le[i - 1] + l[i - 1] << 1;
}
var co;
{
co = new u16(1 << mb);
var rvb = 15 - mb;
for (i = 0; i < s; ++i) {
if (cd[i]) {
var sv = i << 4 | cd[i];
var r_1 = mb - cd[i];
var v = le[cd[i] - 1]++ << r_1;
for (var m = v | (1 << r_1) - 1; v <= m; ++v) {
co[rev[v] >> rvb] = sv;
}
}
}
}
return co;
};
var flt = new u8(288);
for (var i = 0; i < 144; ++i)
flt[i] = 8;
for (var i = 144; i < 256; ++i)
flt[i] = 9;
for (var i = 256; i < 280; ++i)
flt[i] = 7;
for (var i = 280; i < 288; ++i)
flt[i] = 8;
var fdt = new u8(32);
for (var i = 0; i < 32; ++i)
fdt[i] = 5;
var flrm = /* @__PURE__ */ hMap(flt, 9);
var fdrm = /* @__PURE__ */ hMap(fdt, 5);
var max = function(a) {
var m = a[0];
for (var i = 1; i < a.length; ++i) {
if (a[i] > m)
m = a[i];
}
return m;
};
var bits = function(d, p, m) {
var o = p / 8 | 0;
return (d[o] | d[o + 1] << 8) >> (p & 7) & m;
};
var bits16 = function(d, p) {
var o = p / 8 | 0;
return (d[o] | d[o + 1] << 8 | d[o + 2] << 16) >> (p & 7);
};
var shft = function(p) {
return (p + 7) / 8 | 0;
};
var slc = function(v, s, e) {
if (e == null || e > v.length)
e = v.length;
return new u8(v.subarray(s, e));
};
var ec = [
"unexpected EOF",
"invalid block type",
"invalid length/literal",
"invalid distance",
"stream finished",
"no stream handler",
,
"no callback",
"invalid UTF-8 data",
"extra field too long",
"date not in range 1980-2099",
"filename too long",
"stream finishing",
"invalid zip data"
// determined by unknown compression method
];
var err = function(ind, msg, nt) {
var e = new Error(msg || ec[ind]);
e.code = ind;
if (Error.captureStackTrace)
Error.captureStackTrace(e, err);
if (!nt)
throw e;
return e;
};
var inflt = function(dat, st, buf, dict) {
var sl = dat.length, dl = 0;
if (!sl || st.f && !st.l)
return buf || new u8(0);
var noBuf = !buf;
var resize = noBuf || st.i != 2;
var noSt = st.i;
if (noBuf)
buf = new u8(sl * 3);
var cbuf = function(l2) {
var bl = buf.length;
if (l2 > bl) {
var nbuf = new u8(Math.max(bl * 2, l2));
nbuf.set(buf);
buf = nbuf;
}
};
var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n;
var tbts = sl * 8;
do {
if (!lm) {
final = bits(dat, pos, 1);
var type = bits(dat, pos + 1, 3);
pos += 3;
if (!type) {
var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l;
if (t > sl) {
if (noSt)
err(0);
break;
}
if (resize)
cbuf(bt + l);
buf.set(dat.subarray(s, t), bt);
st.b = bt += l, st.p = pos = t * 8, st.f = final;
continue;
} else if (type == 1)
lm = flrm, dm = fdrm, lbt = 9, dbt = 5;
else if (type == 2) {
var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4;
var tl = hLit + bits(dat, pos + 5, 31) + 1;
pos += 14;
var ldt = new u8(tl);
var clt = new u8(19);
for (var i = 0; i < hcLen; ++i) {
clt[clim[i]] = bits(dat, pos + i * 3, 7);
}
pos += hcLen * 3;
var clb = max(clt), clbmsk = (1 << clb) - 1;
var clm = hMap(clt, clb);
for (var i = 0; i < tl; ) {
var r = clm[bits(dat, pos, clbmsk)];
pos += r & 15;
var s = r >> 4;
if (s < 16) {
ldt[i++] = s;
} else {
var c = 0, n = 0;
if (s == 16)
n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1];
else if (s == 17)
n = 3 + bits(dat, pos, 7), pos += 3;
else if (s == 18)
n = 11 + bits(dat, pos, 127), pos += 7;
while (n--)
ldt[i++] = c;
}
}
var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
lbt = max(lt);
dbt = max(dt);
lm = hMap(lt, lbt);
dm = hMap(dt, dbt);
} else
err(1);
if (pos > tbts) {
if (noSt)
err(0);
break;
}
}
if (resize)
cbuf(bt + 131072);
var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
var lpos = pos;
for (; ; lpos = pos) {
var c = lm[bits16(dat, pos) & lms], sym = c >> 4;
pos += c & 15;
if (pos > tbts) {
if (noSt)
err(0);
break;
}
if (!c)
err(2);
if (sym < 256)
buf[bt++] = sym;
else if (sym == 256) {
lpos = pos, lm = null;
break;
} else {
var add = sym - 254;
if (sym > 264) {
var i = sym - 257, b = fleb[i];
add = bits(dat, pos, (1 << b) - 1) + fl[i];
pos += b;
}
var d = dm[bits16(dat, pos) & dms], dsym = d >> 4;
if (!d)
err(3);
pos += d & 15;
var dt = fd[dsym];
if (dsym > 3) {
var b = fdeb[dsym];
dt += bits16(dat, pos) & (1 << b) - 1, pos += b;
}
if (pos > tbts) {
if (noSt)
err(0);
break;
}
if (resize)
cbuf(bt + 131072);
var end = bt + add;
if (bt < dt) {
var shift = dl - dt, dend = Math.min(dt, end);
if (shift + bt < 0)
err(3);
for (; bt < dend; ++bt)
buf[bt] = dict[shift + bt];
}
for (; bt < end; ++bt)
buf[bt] = buf[bt - dt];
}
}
st.l = lm, st.p = lpos, st.b = bt, st.f = final;
if (lm)
final = 1, st.m = lbt, st.d = dm, st.n = dbt;
} while (!final);
return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt);
};
var et = /* @__PURE__ */ new u8(0);
function inflateSync(data, opts) {
return inflt(data, { i: 2 }, opts, opts);
}
var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
var tds = 0;
try {
td.decode(et, { stream: true });
tds = 1;
} catch (e) {
}
const ZIP_END_SIGNATURE = 101010256;
const ZIP_CENTRAL_SIGNATURE = 33639248;
const ZIP_LOCAL_SIGNATURE = 67324752;
const ZIP_UTF8_FLAG = 2048;
const ZIP_ENCRYPTED_FLAG = 1;
const ZIP_STORE_METHOD = 0;
const ZIP_DEFLATE_METHOD = 8;
const ZIP64_MARKER_16 = 65535;
const ZIP64_MARKER_32 = 4294967295;
const MAX_ZIP_COMMENT_BYTES = 65535;
const textDecoder = new TextDecoder();
class ZipArchive {
constructor(bytes, files) {
this.bytes = bytes;
this.files = files;
}
entries() {
return [...this.files.values()].map(({ name, compressedSize, uncompressedSize }) => ({ name, compressedSize, uncompressedSize }));
}
async text(name, onProgress) {
const entry = this.files.get(name);
if (!entry) throw new Error(`${name} not found.`);
onProgress?.({ name, loaded: 0, total: zipEntryProgressTotal(entry) });
const bytes = await this.fileBytes(entry);
onProgress?.({ name, loaded: bytes.byteLength, total: zipEntryProgressTotal(entry) });
return textDecoder.decode(bytes);
}
async fileBytes(entry) {
if (entry.encrypted) throw new Error(`Encrypted ZIP entries are not supported: ${entry.name}`);
const compressed = localFileBytes(this.bytes, entry);
if (entry.compressionMethod === ZIP_STORE_METHOD) return compressed;
if (entry.compressionMethod === ZIP_DEFLATE_METHOD) return inflateRaw(compressed);
throw new Error(`Unsupported ZIP compression method ${entry.compressionMethod}: ${entry.name}`);
}
}
async function readZipArchive(file, onProgress) {
const bytes = await readBlobBytes(file, onProgress);
const files = readZipCentralDirectory(bytes);
onProgress?.({ phase: "directory", loaded: bytes.byteLength, total: file.size || bytes.byteLength, entries: files.size });
return new ZipArchive(bytes, files);
}
async function readBlobBytes(file, onProgress) {
const total = file.size;
if (!onProgress || typeof file.stream !== "function") {
const bytes2 = new Uint8Array(await readBlobArrayBuffer(file));
onProgress?.({ phase: "read", loaded: bytes2.byteLength, total: total || bytes2.byteLength });
return bytes2;
}
const reader = file.stream().getReader();
const chunks = [];
let loaded = 0;
onProgress({ phase: "read", loaded, total });
for (; ; ) {
const { value, done } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.byteLength;
onProgress({ phase: "read", loaded, total });
}
const bytes = new Uint8Array(loaded);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
function readZipCentralDirectory(bytes) {
const view = dataView(bytes);
const endOffset = findZipEndRecord(view);
const entryCount = view.getUint16(endOffset + 10, true);
const directorySize = view.getUint32(endOffset + 12, true);
const directoryOffset = view.getUint32(endOffset + 16, true);
if (entryCount === ZIP64_MARKER_16 || directorySize === ZIP64_MARKER_32 || directoryOffset === ZIP64_MARKER_32) {
throw new Error("ZIP64 dictionaries are not supported.");
}
const files = /* @__PURE__ */ new Map();
const directoryEnd = directoryOffset + directorySize;
let offset = directoryOffset;
for (let index = 0; index < entryCount && offset < directoryEnd; index++) {
const entry = readCentralEntry(bytes, view, offset);
offset = entry.nextOffset;
if (!entry.file.name.endsWith("/")) files.set(entry.file.name, entry.file);
}
return files;
}
function findZipEndRecord(view) {
const minOffset = Math.max(0, view.byteLength - MAX_ZIP_COMMENT_BYTES - 22);
for (let offset = view.byteLength - 22; offset >= minOffset; offset--) {
if (view.getUint32(offset, true) === ZIP_END_SIGNATURE) return offset;
}
throw new Error("Invalid ZIP archive: end record not found.");
}
function readCentralEntry(bytes, view, offset) {
assertSignature(view, offset, ZIP_CENTRAL_SIGNATURE, "central directory entry");
const flags = view.getUint16(offset + 8, true);
const nameLength = view.getUint16(offset + 28, true);
const extraLength = view.getUint16(offset + 30, true);
const commentLength = view.getUint16(offset + 32, true);
const nameStart = offset + 46;
const name = decodeZipName(bytes.subarray(nameStart, nameStart + nameLength), flags);
return {
file: {
name,
compressionMethod: view.getUint16(offset + 10, true),
encrypted: Boolean(flags & ZIP_ENCRYPTED_FLAG),
compressedSize: view.getUint32(offset + 20, true),
uncompressedSize: view.getUint32(offset + 24, true),
localHeaderOffset: view.getUint32(offset + 42, true)
},
nextOffset: nameStart + nameLength + extraLength + commentLength
};
}
function localFileBytes(bytes, entry) {
const view = dataView(bytes);
assertSignature(view, entry.localHeaderOffset, ZIP_LOCAL_SIGNATURE, "local file header");
const nameLength = view.getUint16(entry.localHeaderOffset + 26, true);
const extraLength = view.getUint16(entry.localHeaderOffset + 28, true);
const start = entry.localHeaderOffset + 30 + nameLength + extraLength;
const end = start + entry.compressedSize;
if (end > bytes.length) throw new Error(`Invalid ZIP entry bounds: ${entry.name}`);
return bytes.subarray(start, end);
}
function zipEntryProgressTotal(entry) {
return entry.uncompressedSize || entry.compressedSize;
}
async function inflateRaw(bytes) {
if (typeof DecompressionStream === "function") {
try {
return await inflateRawWithStream(bytes);
} catch {
}
}
try {
return inflateSync(bytes);
} catch (error) {
throw error instanceof Error ? new Error(`This browser could not import compressed ZIP dictionaries: ${error.message}`) : new Error("This browser could not import compressed ZIP dictionaries.");
}
}
async function inflateRawWithStream(bytes) {
const stream = new Blob([arrayBufferSlice(bytes)]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
function assertSignature(view, offset, expected, label) {
if (offset < 0 || offset + 4 > view.byteLength || view.getUint32(offset, true) !== expected) {
throw new Error(`Invalid ZIP archive: ${label} not found.`);
}
}
function decodeZipName(bytes, flags) {
return new TextDecoder(flags & ZIP_UTF8_FLAG ? "utf-8" : void 0).decode(bytes);
}
function dataView(bytes) {
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
function arrayBufferSlice(bytes) {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}
function readBlobArrayBuffer(blob) {
if (typeof blob.arrayBuffer === "function") return blob.arrayBuffer();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error("Could not read file."));
reader.onload = () => resolve(reader.result);
reader.readAsArrayBuffer(blob);
});
}
const log$t = Logger.scope("YomitanSettingsImport");
function parseYomitanSettingsExport(value, language = "en") {
const done = log$t.time("Yomitan settings export parse");
const profileOptions = getYomitanProfileOptions(value);
if (!profileOptions) {
done();
log$t.warn("Yomitan settings export rejected", { reason: "missing-profile-options" });
throw new Error(uiText(language, "yomitanSettingsInvalid"));
}
const settings = {};
const sections = readYomitanProfileSections(profileOptions);
applyAudioSettings(settings, sections.audio);
applyGeneralSettings(settings, sections.general);
applyScanningSettings(settings, sections.scanning);
applyAnkiSettings(settings, sections.anki);
const dictionaryPreferences = readDictionaryPreferences$1(profileOptions);
applyDictionarySettings(settings, dictionaryPreferences);
const dictionaryNames = dictionaryPreferences.filter((item) => item.enabled).map((item) => item.name);
settings.yomitanSettingsBackup = value;
applyInputShortcuts(settings, sections.inputs);
done();
log$t.info("Yomitan settings import parsed", {
hasAudioSources: Boolean(settings.audioSources?.length),
parseSelection: settings.parseSelection,
autoScanJapanese: settings.autoScanJapanese,
theme: settings.theme
});
return { settings, dictionaryNames };
}
function readYomitanProfileSections(profileOptions) {
return {
audio: profileOptions.audio,
general: profileOptions.general,
scanning: profileOptions.scanning,
anki: profileOptions.anki,
inputs: profileOptions.inputs
};
}
function applyAudioSettings(settings, audio) {
if (typeof audio?.enabled === "boolean") settings.audioEnabled = audio.enabled;
if (typeof audio?.autoPlay === "boolean") settings.autoPlayAudio = audio.autoPlay;
if (typeof audio?.enableDefaultAudioSources === "boolean") settings.audioEnableDefaultSources = audio.enableDefaultAudioSources;
if (typeof audio?.fallbackSoundType === "string") settings.audioFallbackChimeEnabled = audio.fallbackSoundType !== "none";
if (!Array.isArray(audio?.sources)) return;
settings.audioSources = audio.sources.map(normalizeAudioSource).filter((source) => source !== null);
settings.audioSourceUrl = settings.audioSources.find((source) => source.url)?.url;
}
function applyGeneralSettings(settings, general) {
const language = importedInterfaceLanguage(general?.language);
if (language) settings.interfaceLanguage = language;
const theme = importedPopupTheme(general);
if (theme) settings.theme = theme;
if (hasPositiveNumber(general?.popupWidth)) settings.popoverWidth = clampNumber$4(general?.popupWidth, 280, 900);
if (hasPositiveNumber(general?.popupHeight)) settings.popoverHeight = clampNumber$4(general?.popupHeight, 220, 900);
if (hasPositiveNumber(general?.popupVerticalOffset)) settings.subtitleBottomOffset = importedPopupVerticalOffset(general);
if (typeof general?.maxResults === "number") settings.localDictionaryMaxResults = Math.max(1, Math.min(64, general.maxResults));
const pitchEnabled = importedPitchDisplayEnabled(general);
if (typeof pitchEnabled === "boolean") settings.showPitchAccent = pitchEnabled;
}
function importedInterfaceLanguage(value) {
return value === "en" || value === "ja" || value === "auto" ? value : "";
}
function importedPopupTheme(general) {
return general?.popupTheme === "dark" || general?.popupTheme === "light" ? general.popupTheme : "";
}
function hasPositiveNumber(value) {
return typeof value === "number" && value > 0;
}
function importedPopupVerticalOffset(general) {
return Math.max(6, Math.min(24, Math.round(Number(general?.popupVerticalOffset) || 12)));
}
function importedPitchDisplayEnabled(general) {
const values = [
general?.showPitchAccentDownstepNotation,
general?.showPitchAccentPositionNotation,
general?.showPitchAccentGraph
].filter((value) => typeof value === "boolean");
return values.length ? values.some(Boolean) : void 0;
}
function applyScanningSettings(settings, scanning) {
if (typeof scanning?.selectText === "boolean") settings.parseSelection = scanning.selectText;
if (typeof scanning?.scanWithoutMousemove === "boolean") settings.autoScanJapanese = scanning.scanWithoutMousemove;
if (typeof scanning?.delay === "number") settings.hoverOpenDelayMs = clampNumber$4(scanning.delay, 0, 1500);
if (typeof scanning?.hideDelay === "number") settings.hoverCloseDelayMs = clampNumber$4(scanning.hideDelay, 0, 3e3);
applyScanInputSettings(settings, scanning);
}
function applyAnkiSettings(settings, anki) {
if (typeof anki?.enable === "boolean") settings.ankiEnabled = anki.enable;
if (typeof anki?.server === "string" && anki.server.trim()) settings.ankiConnectUrl = anki.server.trim();
if (Array.isArray(anki?.tags)) settings.ankiTags = anki.tags.map((tag) => String(tag).trim()).filter(Boolean).join(" ");
const cardFormat = firstYomitanTermCardFormat(anki?.cardFormats);
if (cardFormat) {
if (typeof cardFormat.deck === "string" && cardFormat.deck.trim()) settings.ankiDeck = cardFormat.deck.trim();
if (typeof cardFormat.model === "string" && cardFormat.model.trim()) settings.ankiModel = cardFormat.model.trim();
}
if (isObjectRecord(anki?.screenshot)) settings.ankiCaptureScreenshot = true;
}
function firstYomitanTermCardFormat(value) {
if (!Array.isArray(value)) return null;
return value.find((item) => isObjectRecord(item) && (item.type === "term" || item.type == null)) ?? null;
}
function applyDictionarySettings(settings, preferences) {
if (!preferences.length) return;
settings.dictionaryPreferences = normalizeDictionaryPreferences(preferences);
}
function applyInputShortcuts(settings, inputs) {
applyYomitanShortcut(settings, inputs, "playAudio", "playAudio");
applyYomitanShortcut(settings, inputs, "close", "closePopup");
}
function applyYomitanShortcut(settings, inputs, action, target) {
const hotkey = inputs?.hotkeys?.find((item) => item.action === action && item.enabled !== false);
if (!hotkey) return;
const key = String(hotkey.key || "").replace(/^Key/, "");
const modifiers = Array.isArray(hotkey.modifiers) ? hotkey.modifiers.map((v) => String(v)) : [];
settings.shortcuts = {
...settings.shortcuts,
[target]: [...modifiers.map(capitalize), key].filter(Boolean).join("+")
};
}
function readDictionaryPreferences$1(profileOptions) {
const dictionaries = Array.isArray(profileOptions.dictionaries) ? profileOptions.dictionaries : [];
return dictionaries.map((item, index) => {
const name = typeof item.name === "string" ? item.name.trim() : "";
if (!name) return null;
return {
name,
alias: typeof item.alias === "string" && item.alias.trim() ? item.alias.trim() : name,
enabled: item.enabled !== false,
priority: index,
allowSecondarySearches: item.allowSecondarySearches === true
};
}).filter((item) => item !== null);
}
function applyScanInputSettings(settings, scanning) {
const scanInput = firstScanInput(scanning);
if (!scanInput) return;
const include = String(scanInput.include ?? "").toLowerCase();
const modifier = ["shift", "alt", "ctrl", "meta"].find((key) => include.includes(key));
if (modifier) {
settings.lookupOnHover = true;
settings.popupActivationMode = "modifier";
settings.scanModifierKey = modifier;
settings.shortcuts = { ...settings.shortcuts, hoverLookup: capitalize(modifier) };
return;
}
const options = scanInput.options;
if (shouldEnablePlainHoverScan(options, include)) {
settings.lookupOnHover = true;
settings.popupActivationMode = "hover";
settings.shortcuts = { ...settings.shortcuts, hoverLookup: "" };
}
}
function firstScanInput(scanning) {
if (!Array.isArray(scanning?.inputs)) return null;
return scanning.inputs.find(isRecordScanInput) ?? null;
}
function isRecordScanInput(input2) {
return Boolean(input2 && typeof input2 === "object");
}
function isObjectRecord(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function shouldEnablePlainHoverScan(options, include) {
return options?.scanOnPenHover === true || options?.scanOnTouchTap === true || include === "";
}
function getYomitanProfileOptions(value) {
if (!value || typeof value !== "object") return null;
const record = value;
return profileOptionsFromRoot(record.options) ?? profileOptionsFromProfiles(record.profiles, record);
}
function profileOptionsFromRoot(rootOptions) {
if (!rootOptions || typeof rootOptions !== "object") return null;
const rootOptionRecord = rootOptions;
return nestedProfileOptions(rootOptionRecord.profiles, rootOptionRecord.profileCurrent) ?? rootOptionRecord;
}
function profileOptionsFromProfiles(profilesValue, fallback) {
const profile = selectedProfileRecord(profilesValue, fallback.profileCurrent) ?? fallback;
const options = profile.options;
return options && typeof options === "object" ? options : null;
}
function nestedProfileOptions(profilesValue, profileCurrent) {
const options = selectedProfileRecord(profilesValue, profileCurrent)?.options;
return options && typeof options === "object" ? options : null;
}
function selectedProfileRecord(value, profileCurrent) {
if (!Array.isArray(value)) return null;
const index = Number(profileCurrent);
const selected = Number.isInteger(index) && index >= 0 && index < value.length ? value[index] : null;
const profile = selected && typeof selected === "object" ? selected : value.find((item) => item && typeof item === "object");
return profile ? profile : null;
}
function capitalize(value) {
return value ? `${value[0].toUpperCase()}${value.slice(1).toLowerCase()}` : value;
}
function clampNumber$4(value, min, max2) {
const number = Number(value);
return Number.isFinite(number) ? Math.max(min, Math.min(max2, number)) : min;
}
const DB_NAME = "jpdb-popup-reader-yomitan";
const DB_VERSION = 4;
const DEXIE_IMPORT_BATCH_SIZE = 5e3;
const DICTIONARY_DELETE_BATCH_SIZE = 5e3;
const DEXIE_PROGRESS_INTERVAL = DEXIE_IMPORT_BATCH_SIZE;
const STORE_WRITE_BATCH_SIZE = 1e3;
const ZIP_IMPORT_FLUSH_ENTRY_LIMIT = 1e4;
const HOT_LOOKUP_CACHE_TTL_MS = 2e3;
const TOP_TERM_EXPRESSION_ENTRY_LIMIT = 500;
const TERM_SEARCH_INDEX_BATCH_SIZE = 5e3;
const TERM_SEARCH_INDEX_MAX_TOKENS_PER_TERM = 40;
const TERM_SEARCH_INDEX_MIN_TOKEN_LENGTH = 2;
const TERM_SEARCH_INDEX_MIN_SUFFIX_LENGTH = 3;
const TERM_SEARCH_LEGACY_FALLBACK_MAX_ROWS = 12e3;
const TERM_SEARCH_LEGACY_FALLBACK_MAX_MS = 140;
const TERM_KANJI_INDEX_BATCH_SIZE = 5e3;
const TERM_KANJI_INDEX_FALLBACK_MAX_ROWS = 12e3;
const TERM_KANJI_INDEX_FALLBACK_MAX_MS = 140;
const DB_DELETE_BLOCKED_TIMEOUT_MS = 12e3;
const DB_FACTORY_RESET_DELETE_TIMEOUT_MS = 2500;
const JAPANESE_RE$3 = /[\u3040-\u30ff\u3400-\u9fff]/u;
const JAPANESE_CHARACTER_RE = /[\u3040-\u30ff\u3400-\u9fff]/u;
const log$s = Logger.scope("Yomitan");
class YomitanDictionaryStore {
constructor(getCorsProxyUrl = () => "", getInterfaceLanguage = () => "en") {
this.getCorsProxyUrl = getCorsProxyUrl;
this.getInterfaceLanguage = getInterfaceLanguage;
}
dbPromise;
dictionaryInfoPromise;
summaryPromise;
dictionaryStyleCssCache = /* @__PURE__ */ new Map();
termSearchIndexPromise;
termKanjiIndexPromise;
termKanjiIndexReady = false;
termIndexGeneration = 0;
hotLookupCache = /* @__PURE__ */ new Map();
text(key) {
return uiText(this.getInterfaceLanguage(), key);
}
async warm(preferences = []) {
const done = log$s.time("Dictionary store warmup", { dictionaries: preferences.length });
try {
await this.summary();
if (preferences.length) await this.dictionaryStyleCss(preferences);
} catch (error) {
log$s.warn("Dictionary store warmup failed", { error });
throw error;
} finally {
done();
}
}
prepareTermSearchIndex() {
if (this.termSearchIndexPromise) return this.termSearchIndexPromise;
const promise = this.db().then((db) => this.ensureTermSearchIndex(db)).catch((error) => {
log$s.warn("Term search index preparation failed", { error });
}).finally(() => {
if (this.termSearchIndexPromise === promise) this.termSearchIndexPromise = void 0;
});
this.termSearchIndexPromise = promise;
return this.termSearchIndexPromise;
}
hotLookupCacheKey(kind, values, preferences) {
return JSON.stringify([kind, ...values, normalizeDictionaryPreferences(preferences)]);
}
getHotLookup(key, factory) {
const now = performance.now();
const cached = this.hotLookupCache.get(key);
if (cached && cached.expiresAt > now) return cached.promise;
const entry = {
expiresAt: Number.POSITIVE_INFINITY,
promise: Promise.resolve().then(factory).then(
(value) => {
entry.expiresAt = performance.now() + HOT_LOOKUP_CACHE_TTL_MS;
return value;
},
(error) => {
if (this.hotLookupCache.get(key) === entry) this.hotLookupCache.delete(key);
throw error;
}
)
};
this.hotLookupCache.set(key, entry);
return entry.promise;
}
async lookup(expression, reading, limit, preferences = []) {
return this.getHotLookup(
this.hotLookupCacheKey("lookup", [expression, reading, limit], preferences),
async () => {
const done = log$s.time("Term lookup", { expression, reading, limit, dictionaries: preferences.length });
try {
const db = await this.db();
const entries = await this.getTermLookupEntries(
db,
expression,
reading && reading !== expression ? reading : "",
Math.max(limit * 40, 500),
Math.max(limit * 20, 250)
);
const rank = dictionaryRank(preferences);
const seen = /* @__PURE__ */ new Set();
const results = entries.filter((entry) => dictionaryEnabled(entry.dictionary, rank)).sort(
(a, b) => dictionaryPriority(a.dictionary, rank) - dictionaryPriority(b.dictionary, rank) || Number(b.expression === expression) - Number(a.expression === expression) || Number(b.reading === reading) - Number(a.reading === reading) || (b.score ?? 0) - (a.score ?? 0)
).filter((entry) => {
const key = termLookupDedupKey(entry);
if (seen.has(key)) return false;
seen.add(key);
return true;
}).slice(0, limit);
return results;
} catch (error) {
log$s.warn("Term lookup failed", { expression, reading, error });
throw error;
} finally {
done();
}
}
);
}
async searchTerms(query, limit, preferences = []) {
const normalizedQuery = normalizeTermSearchQuery(query);
const done = log$s.time("Term search", { query: normalizedQuery, limit, dictionaries: preferences.length });
if (!normalizedQuery) {
done();
return [];
}
try {
const db = await this.db();
const rank = dictionaryRank(preferences);
const [indexedEntries, glossaryCandidates] = await Promise.all([
this.getIndexedTermSearchEntries(db, normalizedQuery, Math.max(limit * 12, 120)),
shouldSearchTermGlossaries(normalizedQuery) ? this.getGlossaryTermSearchCandidates(db, normalizedQuery, Math.max(limit * 80, 800), rank) : Promise.resolve([])
]);
const candidates = [
...indexedEntries.map((entry) => ({ entry, rank: indexedTermSearchRank(entry, normalizedQuery) })),
...glossaryCandidates
];
return rankedTermSearchResults(candidates, normalizedQuery, limit, rank);
} catch (error) {
log$s.warn("Term search failed", { query: normalizedQuery, error });
throw error;
} finally {
done();
}
}
async lookupKanji(text2, limit, preferences = []) {
return this.getHotLookup(
this.hotLookupCacheKey("lookupKanji", [text2, limit], preferences),
async () => {
const done = log$s.time("Kanji lookup", { length: text2.length, limit, dictionaries: preferences.length });
try {
const db = await this.db();
const rank = dictionaryRank(preferences);
const characters = [...new Set(Array.from(text2).filter(isKanji))];
const entries = await this.getManyByIndex(db, "kanji", "character", characters, limit);
const results = entries.filter((entry) => dictionaryEnabled(entry.dictionary, rank)).sort((a, b) => dictionaryPriority(a.dictionary, rank) - dictionaryPriority(b.dictionary, rank)).slice(0, limit);
return results;
} catch (error) {
log$s.warn("Kanji lookup failed", { length: text2.length, error });
throw error;
} finally {
done();
}
}
);
}
async listKanjiCharacters(limit, preferences = []) {
const done = log$s.time("Kanji character list", { limit, dictionaries: preferences.length });
try {
if (limit <= 0) return [];
const db = await this.db();
const rank = dictionaryRank(preferences);
return await this.getKanjiCharacters(db, limit, rank);
} catch (error) {
log$s.warn("Kanji character list failed", { error });
throw error;
} finally {
done();
}
}
async lookupTermMeta(expression, limit, preferences = []) {
return this.getHotLookup(
this.hotLookupCacheKey("lookupTermMeta", [expression, limit], preferences),
async () => {
const done = log$s.time("Term metadata lookup", { expression, limit, dictionaries: preferences.length });
try {
const db = await this.db();
const rank = dictionaryRank(preferences);
const entries = await this.getByIndex(db, "termMeta", "expression", expression, Math.max(limit * 8, 80));
const results = entries.filter((entry) => dictionaryEnabled(entry.dictionary, rank)).sort((a, b) => compareMetaEntries(a, b, rank)).slice(0, limit);
return results;
} catch (error) {
log$s.warn("Term metadata lookup failed", { expression, error });
throw error;
} finally {
done();
}
}
);
}
async lookupSimilarTermsByKanji(character, limit, preferences = []) {
return this.getHotLookup(
this.hotLookupCacheKey("lookupSimilarTermsByKanji", [character, limit], preferences),
async () => {
const done = log$s.time("Similar terms by kanji lookup", { character, limit, dictionaries: preferences.length });
try {
const db = await this.db();
const rank = dictionaryRank(preferences);
const entries = await this.getSimilarTermEntriesByKanji(db, character, Math.max(limit * 8, 80), rank);
const results = entries.sort(
(a, b) => dictionaryPriority(a.dictionary, rank) - dictionaryPriority(b.dictionary, rank) || (b.score ?? 0) - (a.score ?? 0) || a.expression.length - b.expression.length
).slice(0, limit);
return results;
} catch (error) {
log$s.warn("Similar terms by kanji lookup failed", { character, error });
throw error;
} finally {
done();
}
}
);
}
async findTermMatches(text2, limit = 32, preferences = []) {
const done = log$s.time("Inline term match search", { length: text2.length, limit, dictionaries: preferences.length });
const source = text2.slice(0, 240);
if (!source.trim()) {
done();
return [];
}
const candidates = this.collectTermMatchCandidates(source);
if (!candidates.size) {
done();
return [];
}
try {
const matches = await this.lookupTermMatchCandidates(candidates, preferences);
const results = nonOverlappingMatches(matches, limit);
return results;
} catch (error) {
log$s.warn("Inline term match search failed", { length: source.length, candidates: candidates.size, error });
throw error;
} finally {
done();
}
}
collectTermMatchCandidates(source) {
const candidates = /* @__PURE__ */ new Map();
const maxLength = Math.min(18, source.length);
for (let start = 0; start < source.length; start++) {
if (!JAPANESE_CHARACTER_RE.test(source[start])) continue;
this.collectTermMatchCandidatesAt(source, start, maxLength, candidates);
}
return candidates;
}
collectTermMatchCandidatesAt(source, start, maxLength, candidates) {
for (let length = Math.min(maxLength, source.length - start); length > 0; length--) {
const surface = source.slice(start, start + length);
if (!isSearchableJapaneseSurface(surface)) continue;
this.addDeinflectedTermCandidates(surface, start, candidates);
}
}
addDeinflectedTermCandidates(surface, start, candidates) {
for (const deinflected of deinflectJapaneseTerm(surface)) {
if (!JAPANESE_RE$3.test(deinflected.term)) continue;
const positions = candidates.get(deinflected.term) ?? [];
positions.push({ start, end: start + surface.length, surface, deinflected });
candidates.set(deinflected.term, positions);
}
}
async lookupTermMatchCandidates(candidates, preferences) {
const db = await this.db();
const rank = dictionaryRank(preferences);
return await new Promise((resolve, reject) => {
const tx = db.transaction("terms", "readonly");
const store = tx.objectStore("terms");
const expressionIndex = store.index("expression");
const readingIndex = store.index("reading");
const results = [];
const expressions = sortedTermMatchExpressions(candidates);
let pending = expressions.length * 2;
const finish = () => {
if (--pending <= 0) resolve(results);
};
const addMatches = (expression, foundEntries) => {
results.push(...termMatchesForEntries(expression, foundEntries, candidates, rank));
};
for (const expression of expressions) {
requestTermMatchIndex(expressionIndex, expression, addMatches, finish, reject);
requestTermMatchIndex(readingIndex, expression, addMatches, finish, reject);
}
tx.onerror = () => reject(tx.error);
});
}
async summary() {
const done = log$s.time("Dictionary summary");
try {
if (this.summaryPromise) {
const summary2 = await this.summaryPromise;
return summary2;
}
const db = await this.db();
this.summaryPromise = Promise.all([
this.getAllDictionaryInfo(db),
this.countStore(db, "terms"),
this.countStore(db, "kanji"),
this.countStore(db, "termMeta"),
this.countStore(db, "kanjiMeta")
]).then(([dictionaries, terms, kanji, termMeta, kanjiMeta]) => ({ dictionaries, terms, kanji, termMeta, kanjiMeta })).catch((error) => {
this.summaryPromise = void 0;
throw error;
});
const summary = await this.summaryPromise;
return summary;
} catch (error) {
log$s.warn("Dictionary summary failed", { error });
throw error;
} finally {
done();
}
}
async countEntries() {
const summary = await this.summary();
return summary.terms + summary.kanji + summary.termMeta + summary.kanjiMeta;
}
async listRandomTerms(limit, preferences = []) {
const done = log$s.time("Random term listing", { limit, dictionaries: preferences.length });
try {
const db = await this.db();
const rank = dictionaryRank(preferences);
const reservoir = [];
const seen = /* @__PURE__ */ new Set();
let count = 0;
await new Promise((resolve, reject) => {
const tx = db.transaction("terms", "readonly");
const request = tx.objectStore("terms").openCursor();
request.onerror = () => reject(request.error ?? new Error("Could not list dictionary terms."));
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve();
return;
}
const entry = cursor.value;
if (entry.expression && JAPANESE_RE$3.test(entry.expression) && entry.expression.length <= 6 && dictionaryEnabled(entry.dictionary, rank)) {
const key = `${entry.expression}
${entry.reading}`;
if (!seen.has(key)) {
seen.add(key);
count++;
if (reservoir.length < limit) {
reservoir.push(entry);
} else {
const index = Math.floor(Math.random() * count);
if (index < limit) reservoir[index] = entry;
}
}
}
cursor.continue();
};
});
return reservoir;
} catch (error) {
log$s.warn("Random term listing failed", { limit, error });
return [];
} finally {
done();
}
}
async listRandomTopTerms(limit, maxRank, preferences = [], options = {}) {
const done = log$s.time("Random top term listing", { limit, maxRank, dictionaries: preferences.length });
try {
const db = await this.db();
const rank = dictionaryRank(preferences);
const topTerms = await this.collectTopFrequencyTerms(db, maxRank, rank);
const results = await this.randomTopTermResults(db, topTerms, limit, rank, preferences);
if (options.fallbackToRandom !== false && this.shouldFallbackToRandomTerms(topTerms, results)) {
return await this.listRandomTerms(limit, preferences);
}
return results;
} catch (error) {
log$s.warn("Random top term listing failed", { limit, error });
return [];
} finally {
done();
}
}
async randomTopTermResults(db, topTerms, limit, rank, preferences) {
return topTerms.size ? await this.entriesForRandomExpressions(db, topTerms, limit, preferences) : await this.listRandomCommonTerms(db, limit, rank);
}
shouldFallbackToRandomTerms(topTerms, results) {
return !topTerms.size && !results.length;
}
async collectTopFrequencyTerms(db, maxRank, rank) {
const expressions = /* @__PURE__ */ new Map();
await new Promise((resolve, reject) => {
const tx = db.transaction("termMeta", "readonly");
const request = tx.objectStore("termMeta").openCursor();
request.onerror = () => reject(request.error ?? new Error("Could not list dictionary term meta."));
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve();
return;
}
const entry = cursor.value;
if (entry.mode === "freq" && entry.expression && dictionaryEnabled(entry.dictionary, rank)) {
const freq = extractFrequency(entry.data);
if (freq !== void 0 && freq <= maxRank) {
expressions.set(entry.expression, Math.min(freq, expressions.get(entry.expression) ?? Number.POSITIVE_INFINITY));
}
}
cursor.continue();
};
});
return expressions;
}
async entriesForRandomExpressions(db, expressions, limit, preferences) {
const sampled = reservoirSample([...expressions.keys()], limit);
const rank = dictionaryRank(preferences);
const entriesByExpression = await this.getEntriesForExpressions(db, sampled, TOP_TERM_EXPRESSION_ENTRY_LIMIT);
return sampled.flatMap((expression) => {
const entry = bestTermLookupEntry(entriesByExpression.get(expression) ?? [], expression, rank);
return entry ? [{ ...entry, jpdbFrequency: expressions.get(expression) }] : [];
});
}
async getEntriesForExpressions(db, expressions, limit) {
if (!expressions.length) return /* @__PURE__ */ new Map();
return new Promise((resolve, reject) => {
const results = /* @__PURE__ */ new Map();
const tx = db.transaction("terms", "readonly");
const index = tx.objectStore("terms").index("expression");
let pending = expressions.length;
const finish = () => {
if (--pending <= 0) resolve(results);
};
const fail = (error) => reject(error ?? new Error("Could not load top dictionary terms."));
for (const expression of expressions) {
const range = IDBKeyRange.only(expression);
if (typeof index.getAll === "function") {
const request2 = index.getAll(range, limit);
request2.onsuccess = () => {
results.set(expression, request2.result);
finish();
};
request2.onerror = () => fail(request2.error);
continue;
}
const entries = [];
let count = 0;
const request = index.openCursor(range);
request.onsuccess = () => {
const cursor = request.result;
if (!cursor || count >= limit) {
results.set(expression, entries);
finish();
return;
}
entries.push(cursor.value);
count++;
cursor.continue();
};
request.onerror = () => fail(request.error);
}
tx.onerror = () => fail(tx.error);
});
}
async listRandomCommonTerms(db, limit, rank) {
const reservoir = [];
const seen = /* @__PURE__ */ new Set();
let count = 0;
await new Promise((resolve, reject) => {
const tx = db.transaction("terms", "readonly");
const request = tx.objectStore("terms").openCursor();
request.onerror = () => reject(request.error ?? new Error("Could not list dictionary terms."));
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve();
return;
}
const entry = cursor.value;
if (isCommonDictionaryTerm(entry, rank)) {
const key = `${entry.expression}
${entry.reading}`;
if (!seen.has(key)) {
seen.add(key);
count++;
if (reservoir.length < limit) {
reservoir.push(entry);
} else {
const index = Math.floor(Math.random() * count);
if (index < limit) reservoir[index] = entry;
}
}
}
cursor.continue();
};
});
return reservoir;
}
async importFile(file, onProgress, sourceUrl = "") {
const done = log$s.time("Dictionary file import", fileSummary(file, sourceUrl));
try {
log$s.info("Dictionary file import started", fileSummary(file, sourceUrl));
const summary = /\.zip$/i.test(file.name) ? await this.importZip(file, onProgress, sourceUrl) : await this.importJson(file, onProgress);
log$s.info("Dictionary file import completed", summary);
return summary;
} catch (error) {
log$s.warn("Dictionary file import failed", { ...fileSummary(file, sourceUrl), error });
throw error;
} finally {
done();
}
}
async importFromUrl(url, filename = filenameFromUrl(url), onProgress) {
log$s.info("Dictionary URL import started", { filename, host: safeHost$3(url) });
onProgress?.(`${this.text("dictionaryDownloading")}: ${filename}...`);
const blob = await requestBlob$3(url, this.getCorsProxyUrl(), onProgress, this.getInterfaceLanguage());
const file = namedBlobFile(blob, filename, blob.type || "application/zip");
const summary = await this.importFile(file, onProgress, url);
log$s.info("Dictionary URL import completed", { filename, host: safeHost$3(url), ...summary });
return summary;
}
async importZip(file, onProgress, sourceUrl = "") {
const language = this.getInterfaceLanguage();
onProgress?.(`${this.text("dictionaryReadingZip")} ${formatBytes(file.size)}...`);
const zip = await readZipArchive(file, (progress) => {
if (progress.phase === "read") {
onProgress?.(`${this.text("dictionaryReadingZip")} ${formatPercent(progress.loaded, progress.total)} (${formatBytes(progress.loaded)} / ${formatBytes(progress.total)})...`);
return;
}
onProgress?.(`${this.text("dictionaryReadingZip")} ${progress.entries?.toLocaleString() ?? "0"} files found. ${uiText(language, "dictionaryCheckingIndex")}`);
});
const zipEntries = zip.entries();
onProgress?.(`${this.text("dictionaryReadingZip")} ${zipEntries.length.toLocaleString()} files found. ${uiText(language, "dictionaryCheckingIndex")}`);
const index = await readYomitanZipIndex(zip, this.getInterfaceLanguage());
const dictionary = yomitanZipDictionaryName(index, file.name);
const version = yomitanZipVersion(index);
const bankCount = countYomitanZipBanks(zipEntries);
onProgress?.(`${this.text("dictionaryImporting")} ${dictionary}: ${formatUiTemplate$1(uiText(language, "dictionaryBanksFound"), {
count: bankCount.toLocaleString(),
plural: bankCount === 1 ? "" : "s"
})}`);
onProgress?.(`${this.text("dictionaryImporting")} ${dictionary}: ${uiText(language, "dictionaryRemovingExisting")}...`);
await this.deleteDictionary(dictionary);
onProgress?.(`${this.text("dictionaryImporting")} ${dictionary}: preparing storage...`);
const db = await this.db();
const info = await yomitanZipDictionaryInfo(zip, index, dictionary, sourceUrl);
const summary = { dictionaries: [dictionary], dictionaryTypes: {}, entries: 0, terms: 0, kanji: 0, termMeta: 0, kanjiMeta: 0 };
let clearedTermIndexesForImport = false;
let importedTerms = false;
const importBank = async (pattern, label, store, normalize) => {
const files = zip.entries().filter((entry) => pattern.test(entry.name)).sort((a, b) => a.name.localeCompare(b.name, void 0, { numeric: true }));
let pending = [];
let saved = 0;
const flush = async () => {
if (!pending.length) return;
if (store === "terms" && !clearedTermIndexesForImport) {
await this.clearDerivedTermIndexes(db);
clearedTermIndexesForImport = true;
}
const entries = pending;
const parsed = summary[label];
pending = [];
onProgress?.(`${this.text("dictionaryImporting")} ${dictionary}: ${uiText(language, "dictionarySavingBank")} ${label} ${saved.toLocaleString()} / ${parsed.toLocaleString()} ${this.text("dictionaryEntries")}...`);
await this.addToStore(store, entries, false, store !== "terms", (written) => {
onProgress?.(`${this.text("dictionaryImporting")} ${dictionary}: ${uiText(language, "dictionarySavingBank")} ${label} ${(saved + written).toLocaleString()} / ${parsed.toLocaleString()} ${this.text("dictionaryEntries")}...`);
});
saved += entries.length;
if (store === "terms") importedTerms = true;
};
for (const [index2, bankFile] of files.entries()) {
onProgress?.(`${this.text("dictionaryImporting")} ${dictionary}: ${uiText(language, "dictionaryReadingBank")} ${bankFile.name} (${index2 + 1}/${files.length}, ${formatBytes(bankFile.uncompressedSize)})...`);
const bankText = await zip.text(bankFile.name, (progress) => {
if (progress.loaded <= 0) return;
onProgress?.(`${this.text("dictionaryImporting")} ${dictionary}: ${uiText(language, "dictionaryReadingBank")} ${bankFile.name} (${index2 + 1}/${files.length}, ${formatBytes(progress.loaded)} / ${formatBytes(progress.total)})...`);
});
onProgress?.(`${this.text("dictionaryImporting")} ${dictionary}: ${uiText(language, "dictionaryParsingBank")} ${bankFile.name} (${index2 + 1}/${files.length})...`);
const rows = JSON.parse(bankText);
for (const row of rows) {
const entry = normalize(row);
if (!entry) continue;
pending.push(entry);
summary[label]++;
summary.entries++;
if (pending.length >= ZIP_IMPORT_FLUSH_ENTRY_LIMIT) await flush();
}
await flush();
}
await flush();
if (files.length) {
onProgress?.(`${this.text("dictionaryImporting")} ${dictionary}: ${label} ${saved.toLocaleString()} ${this.text("dictionaryEntries")} saved...`);
}
};
await importBank(/^term_bank_\d+\.json$/i, "terms", "terms", (row) => normalizeZipTermRow(row, dictionary));
await importBank(/^kanji_bank_\d+\.json$/i, "kanji", "kanji", (row) => normalizeZipKanjiRow(row, dictionary, version));
await importBank(/^term_meta_bank_\d+\.json$/i, "termMeta", "termMeta", (row) => normalizeZipTermMetaRow(row, dictionary));
await importBank(/^kanji_meta_bank_\d+\.json$/i, "kanjiMeta", "kanjiMeta", (row) => normalizeZipKanjiMetaRow(row, dictionary));
if (summary.entries === 0) throw new Error(this.text("dictionaryNoSupportedBanks"));
if (importedTerms) await this.clearDerivedTermIndexes(db);
info.counts = dictionaryCountsFromSummary(summary);
info.type = dictionaryTypeFromCounts(info.counts);
summary.dictionaryTypes = { [dictionary]: info.type };
await this.putDictionaryInfo(info);
log$s.info("ZIP dictionary import parsed", summary);
return summary;
}
async importJson(file, onProgress) {
const head = await readBlobText(file.slice(0, 4096));
if (head.includes('"formatName":"dexie"') || head.includes('"formatName": "dexie"')) {
return this.importDexieJson(file, onProgress);
}
const json = JSON.parse(await readBlobText(file));
if (isReaderDictionaryExport$1(json)) {
return this.importReaderJson(json);
}
throw new Error(this.text("dictionaryUnsupportedJson"));
}
async importReaderJson(json) {
await this.clear();
const terms = readerExportTerms(json);
const dictionaryTypes = dictionaryTypesFromReaderExport(json);
const dictionaryNames = readerExportDictionaryNames(json, terms);
const dictionaries = readerExportDictionaryInfo(json, dictionaryNames, dictionaryTypes);
await Promise.all([
this.addToStore("dictionaryInfo", dictionaries, true),
this.addToStore("terms", terms, false, false),
this.addToStore("kanji", json.kanji ?? []),
this.addToStore("termMeta", json.termMeta ?? []),
this.addToStore("kanjiMeta", json.kanjiMeta ?? [])
]);
const summary = readerExportSummary(json, terms, dictionaryNames, dictionaryTypes);
log$s.info("JSON dictionary import parsed", summary);
return summary;
}
async importDexieJson(file, onProgress) {
onProgress?.("Streaming Yomitan dictionary export...");
await this.clear();
const rowCounts = await readDexieTableRowCounts(file).catch(() => ({}));
const totalRows = importEntryStores().reduce((total, store) => total + (rowCounts[store] ?? 0), 0);
if (totalRows > 0) onProgress?.(`${this.text("dictionaryPreparingImport")} ${totalRows.toLocaleString()} ${this.text("dictionaryRecords")}...`);
const dictionaries = /* @__PURE__ */ new Set();
const dictionaryInfo = /* @__PURE__ */ new Map();
const dictionaryCounts = /* @__PURE__ */ new Map();
const summary = { dictionaries: [], dictionaryTypes: {}, entries: 0, terms: 0, kanji: 0, termMeta: 0, kanjiMeta: 0 };
const batches = { terms: [], kanji: [], termMeta: [], kanjiMeta: [] };
const progressAt = { terms: 0, kanji: 0, termMeta: 0, kanjiMeta: 0 };
const reportProgress = (store, force = false) => {
if (!store) {
if (totalRows > 0) onProgress?.(`${this.text("dictionaryImported")} ${summary.entries.toLocaleString()} / ${totalRows.toLocaleString()} ${this.text("dictionaryRecords")}...`);
else onProgress?.(`${this.text("dictionaryImported")} ${summary.entries.toLocaleString()} ${this.text("dictionaryRecords")}...`);
return;
}
const imported = summary[store];
const tableTotal = rowCounts[store] ?? 0;
if (!force && imported < progressAt[store]) return;
progressAt[store] = imported + DEXIE_PROGRESS_INTERVAL;
if (tableTotal > 0 && totalRows > 0) {
onProgress?.(`${this.text("dictionaryImporting")} ${store}: ${imported.toLocaleString()} / ${tableTotal.toLocaleString()} ${this.text("dictionaryEntries")} (${summary.entries.toLocaleString()} / ${totalRows.toLocaleString()} ${this.text("dictionaryTotal")})...`);
return;
}
onProgress?.(`${this.text("dictionaryImporting")} ${store}: ${imported.toLocaleString()} ${this.text("dictionaryEntries")}...`);
};
const flush = async (store, forceProgress = false) => {
const batch = batches[store];
if (!batch.length) return;
await this.addToStore(store, batch, false, store !== "terms");
batches[store] = [];
reportProgress(store, forceProgress);
};
const addBatch = async (store, entry) => {
batches[store].push(entry);
summary[store]++;
summary.entries++;
const dictionary = entry.dictionary;
if (typeof dictionary === "string") {
dictionaries.add(dictionary);
const counts = dictionaryCounts.get(dictionary) ?? {};
counts[store] = (counts[store] ?? 0) + 1;
dictionaryCounts.set(dictionary, counts);
}
if (batches[store].length >= DEXIE_IMPORT_BATCH_SIZE) {
await flush(store);
}
};
await streamDexieTables(file, {
dictionaries: async (row) => {
const info = normalizeDexieDictionaryRow(row);
if (!info) return;
dictionaries.add(info.title);
dictionaryInfo.set(info.title, info);
},
terms: async (row) => {
const entry = normalizeDexieTermRow(row);
if (entry) await addBatch("terms", entry);
},
kanji: async (row) => {
const entry = normalizeDexieKanjiRow(row);
if (entry) await addBatch("kanji", entry);
},
termMeta: async (row) => {
const entry = normalizeDexieTermMetaRow(row);
if (entry) await addBatch("termMeta", entry);
},
kanjiMeta: async (row) => {
const entry = normalizeDexieKanjiMetaRow(row);
if (entry) await addBatch("kanjiMeta", entry);
}
}, (table) => {
if (isEntryStoreName(table)) {
reportProgress(table, true);
return;
}
onProgress?.(`${this.text("dictionaryImporting")} Yomitan ${table}...`);
});
await Promise.all(importEntryStores().map((store) => flush(store, true)));
reportProgress(void 0, true);
summary.dictionaries = [...dictionaries];
summary.dictionaryTypes = {};
await Promise.all(summary.dictionaries.map((dictionary) => {
const counts = dictionaryCounts.get(dictionary) ?? {};
const info = dictionaryInfo.get(dictionary) ?? {
title: dictionary,
alias: dictionary,
enabled: true,
priority: dictionaryInfo.size,
importDate: Date.now()
};
info.counts = { ...info.counts ?? {}, ...counts };
info.type = dictionaryTypeFromCounts(info.counts);
summary.dictionaryTypes[dictionary] = info.type;
return this.putDictionaryInfo(info);
}));
log$s.info("Dexie dictionary import parsed", summary);
return summary;
}
async exportJson() {
const done = log$s.time("Dictionary export");
try {
const db = await this.db();
const [dictionaries, terms, kanji, termMeta, kanjiMeta] = await Promise.all([
this.getAllFromStore(db, "dictionaryInfo"),
this.getAllFromStore(db, "terms"),
this.getAllFromStore(db, "kanji"),
this.getAllFromStore(db, "termMeta"),
this.getAllFromStore(db, "kanjiMeta")
]);
log$s.info("Dictionary export prepared", {
dictionaries: dictionaries.length,
terms: terms.length,
kanji: kanji.length,
termMeta: termMeta.length,
kanjiMeta: kanjiMeta.length
});
return new Blob([JSON.stringify({
formatName: "yomu-yomitan-dictionaries",
formatVersion: 2,
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
dictionaries,
terms,
kanji,
termMeta,
kanjiMeta
})], { type: "application/json" });
} catch (error) {
log$s.warn("Dictionary export failed", { error });
throw error;
} finally {
done();
}
}
async dictionaryStyleCss(preferences = []) {
try {
const cacheKey = JSON.stringify(normalizeDictionaryPreferences(preferences));
const cached = this.dictionaryStyleCssCache.get(cacheKey);
if (cached !== void 0) {
return cached;
}
const db = await this.db();
const dictionaries = await this.getAllDictionaryInfo(db);
const css = renderDictionaryScopedStyles(dictionaries, preferences);
this.dictionaryStyleCssCache.set(cacheKey, css);
return css;
} catch (error) {
log$s.warn("Dictionary stylesheet render failed", { error });
throw error;
}
}
async clear() {
const done = log$s.time("Dictionary store clear");
try {
const db = await this.db();
await this.clearDictionaryStores(db);
this.invalidateCaches();
log$s.info("Dictionary store cleared");
} catch (error) {
log$s.warn("Dictionary store clear failed", { error });
throw error;
} finally {
done();
}
}
async resetDatabase(options = {}) {
const done = log$s.time("Dictionary database factory reset");
let cleared = false;
try {
await this.clear();
cleared = true;
await this.deleteDatabase({ timeoutMs: options.deleteTimeoutMs ?? DB_FACTORY_RESET_DELETE_TIMEOUT_MS });
return { cleared, deleted: true };
} catch (error) {
if (!cleared) {
log$s.warn("Dictionary database factory reset failed before clearing entries", { error });
throw error;
}
log$s.warn("Dictionary database delete did not complete after clearing entries; continuing reset with empty stores", { error });
return { cleared, deleted: false };
} finally {
done();
}
}
async invalidateForFactoryReset() {
const dbPromise = this.dbPromise;
this.dbPromise = void 0;
this.invalidateCaches();
if (!dbPromise) return;
try {
const db = await dbPromise;
db.close();
log$s.info("Dictionary database connection closed for factory reset", { name: DB_NAME });
} catch {
}
}
async deleteDatabase(options = {}) {
const done = log$s.time("Dictionary database delete");
try {
const timeoutMs = options.timeoutMs ?? DB_DELETE_BLOCKED_TIMEOUT_MS;
const db = this.dbPromise ? await this.dbPromise.catch(() => void 0) : void 0;
db?.close();
this.dbPromise = void 0;
this.invalidateCaches();
await new Promise((resolve, reject) => {
let blocked = false;
let settled = false;
const timeout = globalThis.setTimeout(() => {
if (settled) return;
settled = true;
reject(new Error(blocked ? "Dictionary database reset is still waiting on another open Yomu tab. Reload the other Yomu tabs, then try again." : "Dictionary database reset timed out."));
}, timeoutMs);
const settle = (callback) => {
if (settled) return;
settled = true;
globalThis.clearTimeout(timeout);
callback();
};
const request = indexedDB.deleteDatabase(DB_NAME);
request.onsuccess = () => settle(resolve);
request.onerror = () => settle(() => reject(request.error ?? new Error("Dictionary database reset failed.")));
request.onblocked = () => {
blocked = true;
log$s.warn("Dictionary database delete is blocked; waiting for other Yomu tabs to close their dictionary connection", { name: DB_NAME });
};
});
log$s.info("Dictionary database deleted", { name: DB_NAME });
} catch (error) {
log$s.warn("Dictionary database delete failed", { error });
throw error;
} finally {
done();
}
}
async deleteDictionary(dictionary) {
const done = log$s.time("Dictionary delete", { dictionary });
try {
const db = await this.db();
const dictionaries = await this.getAllDictionaryInfo(db);
if (!dictionaries.some((item) => item.title === dictionary)) {
log$s.info("Dictionary delete skipped; dictionary is not installed", { dictionary });
return;
}
if (dictionaries.length === 1) {
await this.clearDictionaryStores(db);
this.invalidateCaches();
log$s.info("Only installed dictionary cleared", { dictionary });
return;
}
const stores = existingStores(db, ["terms", "kanji", "termMeta", "kanjiMeta"]);
for (const store of stores) {
await deleteByDictionary(db, store, dictionary);
}
await new Promise((resolve, reject) => {
const tx = db.transaction("dictionaryInfo", "readwrite");
tx.objectStore("dictionaryInfo").delete(dictionary);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(transactionError(tx, `Could not remove ${dictionary} from dictionary metadata.`));
tx.onabort = () => reject(transactionError(tx, `Could not remove ${dictionary} from dictionary metadata.`));
});
await this.clearDerivedTermIndexes(db);
this.invalidateCaches();
log$s.info("Dictionary deleted", { dictionary });
} catch (error) {
log$s.warn("Dictionary delete failed", { dictionary, error });
throw error;
} finally {
done();
}
}
async putDictionaryInfo(info) {
await this.addToStore("dictionaryInfo", [info], true);
}
async clearDictionaryStores(db) {
this.termIndexGeneration++;
await clearStores(db, existingStores(db, ["terms", "kanji", "termMeta", "kanjiMeta", "dictionaryInfo", "termSearch", "termKanji"]));
this.termKanjiIndexReady = false;
}
async addToStore(storeName, entries, put = false, clearTermIndexes = true, onChunk) {
if (!entries.length) return;
const db = await this.db();
if (storeName === "terms" && clearTermIndexes) await this.clearDerivedTermIndexes(db);
let written = 0;
for (let start = 0; start < entries.length; start += STORE_WRITE_BATCH_SIZE) {
const chunk = entries.slice(start, start + STORE_WRITE_BATCH_SIZE);
await this.addStoreChunk(db, storeName, chunk, put);
written += chunk.length;
onChunk?.(written, entries.length);
await nextTask();
}
}
addStoreChunk(db, storeName, entries, put) {
return new Promise((resolve, reject) => {
const tx = readwriteTransaction(db, storeName);
const store = tx.objectStore(storeName);
for (const entry of entries) {
put ? store.put(entry) : store.add(entry);
}
tx.oncomplete = () => {
this.invalidateCaches();
resolve();
};
tx.onerror = () => reject(transactionError(tx, `Could not add entries to ${storeName}.`));
tx.onabort = () => reject(transactionError(tx, `Could not add entries to ${storeName}.`));
commitTransaction(tx);
});
}
async getByIndex(db, storeName, indexName, value, limit) {
return new Promise((resolve, reject) => {
const index = db.transaction(storeName, "readonly").objectStore(storeName).index(indexName);
const query = IDBKeyRange.only(value);
if (typeof index.getAll === "function") {
const request2 = index.getAll(query, limit);
request2.onsuccess = () => resolve(request2.result);
request2.onerror = () => reject(request2.error);
return;
}
const results = [];
const request = index.openCursor(query);
request.onsuccess = () => {
const cursor = request.result;
if (!cursor || results.length >= limit) {
resolve(results);
return;
}
results.push(cursor.value);
cursor.continue();
};
request.onerror = () => reject(request.error);
});
}
async getManyByIndex(db, storeName, indexName, values, limit) {
if (!values.length) return [];
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, "readonly");
const index = tx.objectStore(storeName).index(indexName);
const results = [];
let pending = values.length;
const finish = () => {
if (--pending <= 0) resolve(results);
};
const fail = (error) => reject(error ?? new Error(`Could not read ${storeName} entries.`));
for (const value of values) {
const query = IDBKeyRange.only(value);
if (typeof index.getAll === "function") {
const request2 = index.getAll(query, limit);
request2.onsuccess = () => {
results.push(...request2.result);
finish();
};
request2.onerror = () => fail(request2.error);
continue;
}
let count = 0;
const request = index.openCursor(query);
request.onsuccess = () => {
const cursor = request.result;
if (!cursor || count >= limit) {
finish();
return;
}
results.push(cursor.value);
count++;
cursor.continue();
};
request.onerror = () => fail(request.error);
}
tx.onerror = () => fail(tx.error);
});
}
async getTermLookupEntries(db, expression, reading, expressionLimit, readingLimit) {
return new Promise((resolve, reject) => {
const tx = db.transaction("terms", "readonly");
const store = tx.objectStore("terms");
const queries = [
{ indexName: "expression", value: expression, limit: expressionLimit },
...reading ? [{ indexName: "reading", value: reading, limit: readingLimit }] : []
];
const entries = [];
let pending = queries.length;
const finish = () => {
if (--pending <= 0) resolve(entries);
};
const fail = (error) => reject(error ?? new Error("Could not search local dictionary terms."));
for (const query of queries) {
const index = store.index(query.indexName);
const range = IDBKeyRange.only(query.value);
if (typeof index.getAll === "function") {
const request2 = index.getAll(range, query.limit);
request2.onsuccess = () => {
entries.push(...request2.result);
finish();
};
request2.onerror = () => fail(request2.error);
continue;
}
let count = 0;
const request = index.openCursor(range);
request.onsuccess = () => {
const cursor = request.result;
if (!cursor || count >= query.limit) {
finish();
return;
}
entries.push(cursor.value);
count++;
cursor.continue();
};
request.onerror = () => fail(request.error);
}
tx.onerror = () => fail(tx.error);
});
}
async getSimilarTermEntriesByKanji(db, character, candidateLimit, rank) {
if (hasStore(db, "termKanji")) {
await this.ensureTermKanjiIndex(db);
return this.getTermKanjiIndexEntries(db, character, candidateLimit, rank);
}
return this.getSimilarTermCursorEntries(db, character, candidateLimit, rank, {
maxRows: TERM_KANJI_INDEX_FALLBACK_MAX_ROWS,
maxMs: TERM_KANJI_INDEX_FALLBACK_MAX_MS
});
}
async getTermKanjiIndexEntries(db, character, candidateLimit, rank) {
return new Promise((resolve, reject) => {
const entries = [];
const seen = /* @__PURE__ */ new Set();
const request = db.transaction("termKanji", "readonly").objectStore("termKanji").index("character").openCursor(IDBKeyRange.only(character));
request.onerror = () => reject(request.error ?? new Error("Could not search local dictionary kanji index."));
request.onsuccess = () => {
const cursor = request.result;
if (!cursor || entries.length >= candidateLimit) {
resolve(entries);
return;
}
const row = cursor.value;
if (dictionaryEnabled(row.dictionary, rank)) {
const entry = termEntryFromKanjiEntry(row);
const key = `${entry.expression}
${entry.reading}`;
if (!seen.has(key)) {
seen.add(key);
entries.push(entry);
}
}
cursor.continue();
};
});
}
async getSimilarTermCursorEntries(db, character, candidateLimit, rank, options = {}) {
return new Promise((resolve, reject) => {
const entries = [];
const seen = /* @__PURE__ */ new Set();
const startedAt = performance.now();
let visited = 0;
const request = db.transaction("terms", "readonly").objectStore("terms").openCursor();
request.onerror = () => reject(request.error ?? new Error("Could not search local dictionaries."));
request.onsuccess = () => {
const cursor = request.result;
if (!cursor || entries.length >= candidateLimit) {
resolve(entries);
return;
}
if (options.maxRows && visited >= options.maxRows || options.maxMs && performance.now() - startedAt >= options.maxMs) {
resolve(entries);
return;
}
visited++;
const entry = cursor.value;
if (entry.expression?.includes(character) && dictionaryEnabled(entry.dictionary, rank)) {
const key = `${entry.expression}
${entry.reading}`;
if (!seen.has(key)) {
seen.add(key);
entries.push(entry);
}
}
cursor.continue();
};
});
}
async getIndexedTermSearchEntries(db, query, limit) {
return new Promise((resolve, reject) => {
const tx = db.transaction("terms", "readonly");
const store = tx.objectStore("terms");
const entries = [];
const queries = [
{ indexName: "expression", exact: true },
{ indexName: "reading", exact: true },
{ indexName: "expression", exact: false },
{ indexName: "reading", exact: false }
];
let pending = queries.length;
const finish = () => {
if (--pending <= 0) resolve(entries);
};
const fail = (error) => reject(error ?? new Error("Could not search local dictionary terms."));
for (const item of queries) {
const index = store.index(item.indexName);
const range = item.exact ? IDBKeyRange.only(query) : termSearchPrefixRange(query);
const request = index.openCursor(range);
let count = 0;
request.onsuccess = () => {
const cursor = request.result;
if (!cursor || count >= limit) {
finish();
return;
}
entries.push(cursor.value);
count++;
cursor.continue();
};
request.onerror = () => fail(request.error);
}
tx.onerror = () => fail(tx.error);
});
}
async getGlossaryTermSearchCandidates(db, query, candidateLimit, rank) {
if (hasStore(db, "termSearch")) {
const indexed = await this.getGlossaryTermSearchIndexCandidates(db, query, candidateLimit, rank);
if (indexed.length) return indexed;
const building = Boolean(this.termSearchIndexPromise);
const indexedCount = await this.countStore(db, "termSearch");
if (indexedCount > 0 && !building) return indexed;
if (!building) {
void this.prepareTermSearchIndex();
}
return this.getGlossaryTermCursorSearchCandidates(db, query, candidateLimit, rank, {
maxRows: TERM_SEARCH_LEGACY_FALLBACK_MAX_ROWS,
maxMs: TERM_SEARCH_LEGACY_FALLBACK_MAX_MS
});
}
return this.getGlossaryTermCursorSearchCandidates(db, query, candidateLimit, rank);
}
async getGlossaryTermCursorSearchCandidates(db, query, candidateLimit, rank, options = {}) {
return new Promise((resolve, reject) => {
const candidates = [];
const startedAt = performance.now();
let visited = 0;
const request = db.transaction("terms", "readonly").objectStore("terms").openCursor();
request.onerror = () => reject(request.error ?? new Error("Could not search local dictionary glossaries."));
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve(candidates);
return;
}
if (options.maxRows && visited >= options.maxRows || options.maxMs && performance.now() - startedAt >= options.maxMs) {
resolve(candidates);
return;
}
visited++;
const entry = cursor.value;
if (dictionaryEnabled(entry.dictionary, rank)) {
const searchRank = glossaryTermSearchRank(entry.glossary, query);
if (searchRank < Number.POSITIVE_INFINITY) {
candidates.push({ entry, rank: searchRank });
trimTermSearchCandidates(candidates, candidateLimit, query, rank);
}
}
cursor.continue();
};
});
}
async getGlossaryTermSearchIndexCandidates(db, query, candidateLimit, rank) {
const token = termSearchIndexToken(query);
if (!token) return [];
return new Promise((resolve, reject) => {
const candidates = [];
const request = db.transaction("termSearch", "readonly").objectStore("termSearch").index("token").openCursor(termSearchPrefixRange(token));
request.onerror = () => reject(request.error ?? new Error("Could not search local dictionary glossary index."));
request.onsuccess = () => {
const cursor = request.result;
if (!cursor || candidates.length >= candidateLimit) {
resolve(candidates);
return;
}
const entry = cursor.value;
if (dictionaryEnabled(entry.dictionary, rank)) {
const searchRank = glossaryTermSearchRank(entry.glossary, query);
if (searchRank < Number.POSITIVE_INFINITY) {
candidates.push({ entry: termEntryFromSearchEntry(entry), rank: searchRank });
}
}
cursor.continue();
};
});
}
async getAllDictionaryInfo(db) {
this.dictionaryInfoPromise ??= this.getAllFromStore(db, "dictionaryInfo").then((items) => items.sort((a, b) => a.priority - b.priority || a.title.localeCompare(b.title))).catch((error) => {
this.dictionaryInfoPromise = void 0;
throw error;
});
return this.dictionaryInfoPromise;
}
async getAllFromStore(db, storeName) {
return new Promise((resolve, reject) => {
const results = [];
const request = db.transaction(storeName, "readonly").objectStore(storeName).openCursor();
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve(results);
return;
}
results.push(cursor.value);
cursor.continue();
};
request.onerror = () => reject(request.error);
});
}
async getKanjiCharacters(db, limit, rank) {
return new Promise((resolve, reject) => {
const characters = [];
const seen = /* @__PURE__ */ new Set();
const request = db.transaction("kanji", "readonly").objectStore("kanji").openCursor();
request.onsuccess = () => {
const cursor = request.result;
if (!cursor || characters.length >= limit) {
resolve(characters);
return;
}
const entry = cursor.value;
if (dictionaryEnabled(entry.dictionary, rank) && isKanji(entry.character) && !seen.has(entry.character)) {
seen.add(entry.character);
characters.push(entry.character);
}
cursor.continue();
};
request.onerror = () => reject(request.error);
});
}
async ensureTermSearchIndex(db) {
if (!hasStore(db, "termSearch")) return;
const [terms, indexed] = await Promise.all([
this.countStore(db, "terms"),
this.countStore(db, "termSearch")
]);
if (!terms || indexed) return;
await this.rebuildTermSearchIndex(db);
}
async ensureTermKanjiIndex(db) {
if (!hasStore(db, "termKanji") || this.termKanjiIndexReady) return;
const [terms, indexed] = await Promise.all([
this.countStore(db, "terms"),
this.countStore(db, "termKanji")
]);
if (!terms || indexed) {
this.termKanjiIndexReady = true;
return;
}
if (!this.termKanjiIndexPromise) {
this.termKanjiIndexPromise = this.rebuildTermKanjiIndex(db).then(() => {
this.termKanjiIndexReady = true;
}).finally(() => {
this.termKanjiIndexPromise = void 0;
});
}
await this.termKanjiIndexPromise;
}
async rebuildTermSearchIndex(db) {
const done = log$s.time("Term search index rebuild");
const generation = this.termIndexGeneration;
try {
await this.clearTermSearchIndex(db);
let indexedTerms = 0;
let lastKey;
for (; ; ) {
if (generation !== this.termIndexGeneration) return;
const chunk = await this.getTermSearchIndexSourceChunk(db, lastKey, TERM_SEARCH_INDEX_BATCH_SIZE);
if (!chunk.terms.length) break;
if (generation !== this.termIndexGeneration) return;
await this.addTermSearchIndexChunk(db, chunk.terms);
indexedTerms += chunk.terms.length;
if (chunk.done) break;
lastKey = chunk.lastKey;
}
log$s.info("Term search index rebuilt", { terms: indexedTerms });
} finally {
done();
}
}
async rebuildTermKanjiIndex(db) {
const done = log$s.time("Term kanji index rebuild");
const generation = this.termIndexGeneration;
try {
await this.clearTermKanjiIndex(db);
let indexedTerms = 0;
let lastKey;
for (; ; ) {
if (generation !== this.termIndexGeneration) return;
const chunk = await this.getTermSearchIndexSourceChunk(db, lastKey, TERM_KANJI_INDEX_BATCH_SIZE);
if (!chunk.terms.length) break;
if (generation !== this.termIndexGeneration) return;
await this.addTermKanjiIndexChunk(db, chunk.terms);
indexedTerms += chunk.terms.length;
if (chunk.done) break;
lastKey = chunk.lastKey;
}
log$s.info("Term kanji index rebuilt", { terms: indexedTerms });
} finally {
done();
}
}
getTermSearchIndexSourceChunk(db, afterKey, limit) {
return new Promise((resolve, reject) => {
const terms = [];
let lastKey = afterKey;
const range = afterKey == null ? void 0 : IDBKeyRange.lowerBound(afterKey, true);
const request = db.transaction("terms", "readonly").objectStore("terms").openCursor(range);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve({ terms, lastKey, done: true });
return;
}
terms.push(cursor.value);
lastKey = cursor.key;
if (terms.length >= limit) {
resolve({ terms, lastKey, done: false });
return;
}
cursor.continue();
};
});
}
clearTermSearchIndex(db) {
return new Promise((resolve, reject) => {
const tx = db.transaction("termSearch", "readwrite");
tx.objectStore("termSearch").clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
clearTermKanjiIndex(db) {
return new Promise((resolve, reject) => {
const tx = db.transaction("termKanji", "readwrite");
tx.objectStore("termKanji").clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async clearDerivedTermIndexes(db) {
this.termIndexGeneration++;
const stores = existingStores(db, ["termSearch", "termKanji"]);
if (!stores.length) return;
await clearStores(db, stores);
this.termKanjiIndexReady = false;
}
addTermSearchIndexChunk(db, terms) {
return new Promise((resolve, reject) => {
const tx = db.transaction("termSearch", "readwrite");
const store = tx.objectStore("termSearch");
for (const term of terms) {
for (const row of termSearchEntries(term)) store.add(row);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
addTermKanjiIndexChunk(db, terms) {
return new Promise((resolve, reject) => {
const tx = db.transaction("termKanji", "readwrite");
const store = tx.objectStore("termKanji");
for (const term of terms) {
for (const row of termKanjiEntries(term)) store.add(row);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
countStore(db, storeName) {
return new Promise((resolve, reject) => {
if (!db.objectStoreNames.contains(storeName)) {
resolve(0);
return;
}
const request = db.transaction(storeName, "readonly").objectStore(storeName).count();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
db() {
this.dbPromise ??= new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = request.result;
const tx = request.transaction;
log$s.info("Upgrading dictionary database", { oldVersion: event.oldVersion, newVersion: DB_VERSION });
const terms = ensureStore(db, tx, "terms");
ensureIndex(terms, "expression", "expression");
ensureIndex(terms, "reading", "reading");
ensureIndex(terms, "dictionary", "dictionary");
const kanji = ensureStore(db, tx, "kanji");
ensureIndex(kanji, "character", "character");
ensureIndex(kanji, "dictionary", "dictionary");
const termMeta = ensureStore(db, tx, "termMeta");
ensureIndex(termMeta, "expression", "expression");
ensureIndex(termMeta, "dictionary", "dictionary");
const kanjiMeta = ensureStore(db, tx, "kanjiMeta");
ensureIndex(kanjiMeta, "character", "character");
ensureIndex(kanjiMeta, "dictionary", "dictionary");
if (!db.objectStoreNames.contains("dictionaryInfo")) {
db.createObjectStore("dictionaryInfo", { keyPath: "title" });
}
const termSearch = ensureStore(db, tx, "termSearch");
ensureIndex(termSearch, "token", "token");
ensureIndex(termSearch, "dictionary", "dictionary");
const termKanji = ensureStore(db, tx, "termKanji");
ensureIndex(termKanji, "character", "character");
ensureIndex(termKanji, "dictionary", "dictionary");
};
request.onsuccess = () => {
const db = request.result;
this.installVersionChangeHandler(db);
resolve(db);
};
request.onerror = () => {
log$s.warn("Dictionary database open failed", { error: request.error });
reject(request.error);
};
});
return this.dbPromise;
}
installVersionChangeHandler(db) {
db.onversionchange = (event) => {
log$s.info("Dictionary database version change requested; closing open connection", {
name: DB_NAME,
oldVersion: event.oldVersion,
newVersion: event.newVersion
});
db.close();
this.dbPromise = void 0;
this.invalidateCaches();
};
}
invalidateCaches() {
this.dictionaryInfoPromise = void 0;
this.summaryPromise = void 0;
this.dictionaryStyleCssCache.clear();
this.hotLookupCache.clear();
this.termKanjiIndexReady = false;
}
}
function isSearchableJapaneseSurface(surface) {
return JAPANESE_RE$3.test(surface) && !/\s/.test(surface);
}
function sortedTermMatchExpressions(candidates) {
return Array.from(candidates.keys()).sort((a, b) => b.length - a.length || a.localeCompare(b));
}
function requestTermMatchIndex(index, expression, addMatches, finish, reject) {
const request = index.getAll(IDBKeyRange.only(expression), 8);
request.onsuccess = () => {
addMatches(expression, request.result);
finish();
};
request.onerror = () => reject(request.error);
}
function termMatchesForEntries(expression, foundEntries, candidates, rank) {
const entries = sortTermMatchEntries(deduplicateTermMatchEntries(foundEntries), rank);
if (!entries.length) return [];
return (candidates.get(expression) ?? []).map((position) => termMatchForPosition(position, entries)).filter((match) => Boolean(match));
}
function deduplicateTermMatchEntries(entries) {
const seen = /* @__PURE__ */ new Set();
return entries.filter((item) => {
const key = `${item.id ?? ""}
${item.dictionary}
${item.expression}
${item.reading}
${item.sequence ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function sortTermMatchEntries(entries, rank) {
return entries.filter((item) => dictionaryEnabled(item.dictionary, rank)).sort((a, b) => dictionaryPriority(a.dictionary, rank) - dictionaryPriority(b.dictionary, rank) || (b.score ?? 0) - (a.score ?? 0));
}
function termMatchForPosition(position, entries) {
const entry = entries.find((item) => termRulesMatch(item.rules, position.deinflected.rules));
return entry ? {
entry,
...position,
deinflected: position.deinflected.depth > 0 ? position.deinflected : void 0
} : null;
}
function importEntryStores() {
return ["terms", "kanji", "termMeta", "kanjiMeta"];
}
function isEntryStoreName(value) {
return value === "terms" || value === "kanji" || value === "termMeta" || value === "kanjiMeta";
}
function dictionaryCountsFromSummary(summary) {
return {
terms: summary.terms,
kanji: summary.kanji,
termMeta: summary.termMeta,
kanjiMeta: summary.kanjiMeta
};
}
function dictionaryTypeFromCounts(counts = {}) {
return DICTIONARY_TYPE_COUNT_PRIORITY.find(({ key }) => Number(counts[key] ?? 0) > 0)?.type ?? "terms";
}
const DICTIONARY_TYPE_COUNT_PRIORITY = [
{ key: "terms", type: "terms" },
{ key: "termMeta", type: "frequency" },
{ key: "kanji", type: "kanji" },
{ key: "kanjiMeta", type: "metadata" }
];
function readerExportTerms(json) {
return json.terms ?? json.entries ?? [];
}
function readerExportDictionaryNames(json, terms = readerExportTerms(json)) {
return uniqueDictionaryNames([
...json.dictionaries?.map((item) => item.title) ?? [],
...terms.map((entry) => entry.dictionary),
...(json.kanji ?? []).map((entry) => entry.dictionary),
...(json.termMeta ?? []).map((entry) => entry.dictionary),
...(json.kanjiMeta ?? []).map((entry) => entry.dictionary)
]);
}
function readerExportDictionaryInfo(json, dictionaryNames, dictionaryTypes) {
return json.dictionaries?.length ? json.dictionaries.map((info) => ({ ...info, type: info.type ?? dictionaryTypes[info.title] })) : dictionaryNames.map((title, index) => ({ title, alias: title, enabled: true, priority: index, type: dictionaryTypes[title] }));
}
function readerExportSummary(json, terms, dictionaryNames, dictionaryTypes) {
const kanji = json.kanji ?? [];
const termMeta = json.termMeta ?? [];
const kanjiMeta = json.kanjiMeta ?? [];
return {
dictionaries: dictionaryNames,
dictionaryTypes,
entries: terms.length + kanji.length + termMeta.length + kanjiMeta.length,
terms: terms.length,
kanji: kanji.length,
termMeta: termMeta.length,
kanjiMeta: kanjiMeta.length
};
}
function dictionaryTypesFromReaderExport(json) {
const counts = /* @__PURE__ */ new Map();
addDictionaryTypeCounts(counts, readerExportTerms(json), "terms");
addDictionaryTypeCounts(counts, json.kanji ?? [], "kanji");
addDictionaryTypeCounts(counts, json.termMeta ?? [], "termMeta");
addDictionaryTypeCounts(counts, json.kanjiMeta ?? [], "kanjiMeta");
return Object.fromEntries([
...configuredReaderDictionaryTypes(json),
...observedReaderDictionaryTypes(counts)
]);
}
function addDictionaryTypeCounts(counts, entries, store) {
for (const entry of entries) {
const item = counts.get(entry.dictionary) ?? { terms: 0, kanji: 0, termMeta: 0, kanjiMeta: 0 };
item[store]++;
counts.set(entry.dictionary, item);
}
}
function configuredReaderDictionaryTypes(json) {
return (json.dictionaries ?? []).map((info) => [info.title, info.type ?? dictionaryTypeFromCounts(info.counts)]);
}
function observedReaderDictionaryTypes(counts) {
return [...counts].map(([name, value]) => [name, dictionaryTypeFromCounts(value)]);
}
async function readYomitanZipIndex(zip, language = "en") {
return JSON.parse(await readZipText(zip, "index.json").catch(() => {
throw new Error(uiText(language, "dictionaryZipMissingIndex"));
}));
}
function countYomitanZipBanks(entries) {
return entries.filter((entry) => /^(term|kanji|term_meta|kanji_meta)_bank_\d+\.json$/i.test(entry.name)).length;
}
function formatPercent(loaded, total) {
if (total <= 0) return "100%";
return `${Math.max(0, Math.min(100, Math.round(loaded / total * 100)))}%`;
}
function formatBytes(value) {
if (!Number.isFinite(value) || value <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let size = value;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit++;
}
const precision = unit === 0 || size >= 10 ? 0 : 1;
return `${size.toFixed(precision)} ${units[unit]}`;
}
function formatUiTemplate$1(template, values) {
return Object.entries(values).reduce((value, [key, replacement]) => value.replaceAll(`{${key}}`, replacement), template);
}
function yomitanZipDictionaryName(index, filename) {
return index.title?.trim() || filename.replace(/\.zip$/i, "");
}
function yomitanZipVersion(index) {
return index.format ?? index.version ?? 3;
}
async function yomitanZipDictionaryInfo(zip, index, dictionary, sourceUrl) {
return {
title: dictionary,
alias: dictionary,
enabled: true,
priority: 0,
styles: await readOptionalZipText(zip, "styles.css"),
revision: typeof index.revision === "string" ? index.revision : void 0,
downloadUrl: sourceUrl || void 0,
importDate: Date.now()
};
}
async function readOptionalZipText(zip, name) {
return readZipText(zip, name).catch(() => "");
}
async function readZipText(zip, name) {
return zip.text(name);
}
function normalizeZipTermRow(row, dictionary) {
if (!Array.isArray(row)) return null;
const [expression, reading, definitionTags, rules, score, glossary, sequence, termTags] = row;
if (typeof expression !== "string") return null;
return {
expression,
reading: zipTermReading(reading, expression),
definitionTags: zipStringField(definitionTags),
rules: zipStringField(rules),
score: zipNumberField(score, 0),
glossary: zipGlossaryField(glossary),
sequence: zipOptionalNumberField(sequence),
termTags: zipStringField(termTags),
dictionary
};
}
function zipTermReading(value, expression) {
return typeof value === "string" && value ? value : expression;
}
function zipStringField(value) {
return typeof value === "string" ? value : "";
}
function zipNumberField(value, fallback) {
return typeof value === "number" ? value : fallback;
}
function zipOptionalNumberField(value) {
return typeof value === "number" ? value : void 0;
}
function zipGlossaryField(value) {
return Array.isArray(value) ? value : [];
}
function normalizeZipKanjiRow(row, dictionary, version) {
if (!Array.isArray(row)) return null;
const [character, onyomi, kunyomi, tags, meaningsOrFirst, stats] = row;
if (typeof character !== "string") return null;
const meanings = version === 1 ? row.slice(4) : meaningsOrFirst;
return {
character,
onyomi: splitTags(onyomi),
kunyomi: splitTags(kunyomi),
tags: splitTags(tags),
meanings: Array.isArray(meanings) ? meanings.map(String) : [],
stats,
dictionary
};
}
function normalizeZipTermMetaRow(row, dictionary) {
if (!Array.isArray(row)) return null;
const [expression, mode, data] = row;
return typeof expression === "string" && typeof mode === "string" ? { expression, mode, data, dictionary } : null;
}
function normalizeZipKanjiMetaRow(row, dictionary) {
if (!Array.isArray(row)) return null;
const [character, mode, data] = row;
return typeof character === "string" && typeof mode === "string" ? { character, mode, data, dictionary } : null;
}
function normalizeDexieTermRow(row) {
const record = dexieRowRecord(row);
if (!record) return null;
if (typeof record.expression !== "string" || typeof record.dictionary !== "string") return null;
return {
expression: record.expression,
reading: dexieStringField(record, "reading", record.expression),
definitionTags: dexieStringField(record, "definitionTags"),
rules: dexieStringField(record, "rules"),
score: dexieNumberField(record, "score", 0),
glossary: dexieGlossaryField(record),
sequence: dexieOptionalNumberField(record, "sequence"),
termTags: dexieStringField(record, "termTags"),
dictionary: record.dictionary
};
}
function dexieStringField(record, key, fallback = "") {
const value = record[key];
return typeof value === "string" && value ? value : fallback;
}
function dexieNumberField(record, key, fallback) {
const value = record[key];
return typeof value === "number" ? value : fallback;
}
function dexieOptionalNumberField(record, key) {
const value = record[key];
return typeof value === "number" ? value : void 0;
}
function dexieGlossaryField(record) {
return Array.isArray(record.glossary) ? record.glossary : [];
}
function termLookupDedupKey(entry) {
const glossaryKey = JSON.stringify(entry.glossary);
return entry.sequence !== void 0 ? `${entry.dictionary}
sequence:${entry.sequence}
${glossaryKey}` : `${entry.dictionary}
${entry.expression}
${entry.reading}
${glossaryKey}`;
}
function bestTermLookupEntry(entries, expression, rank) {
const seen = /* @__PURE__ */ new Set();
for (const entry of [...entries].sort((a, b) => compareTermLookupEntries(a, b, expression, rank))) {
if (!dictionaryEnabled(entry.dictionary, rank)) continue;
const key = termLookupDedupKey(entry);
if (seen.has(key)) continue;
seen.add(key);
return entry;
}
return null;
}
function compareTermLookupEntries(a, b, expression, rank) {
return dictionaryPriority(a.dictionary, rank) - dictionaryPriority(b.dictionary, rank) || Number(b.expression === expression) - Number(a.expression === expression) || (b.score ?? 0) - (a.score ?? 0);
}
function normalizeTermSearchQuery(value) {
return value.replace(/\s+/g, " ").trim().slice(0, 80);
}
function shouldSearchTermGlossaries(query) {
return !JAPANESE_RE$3.test(query);
}
function termSearchIndexToken(query) {
return glossaryWords(normalizeGlossarySearchText(query)).find((word) => word.length >= TERM_SEARCH_INDEX_MIN_TOKEN_LENGTH) ?? "";
}
function termSearchPrefixRange(query) {
return IDBKeyRange.bound(query, `${query}`, false, false);
}
function rankedTermSearchResults(candidates, query, limit, rank) {
const seen = /* @__PURE__ */ new Set();
return candidates.filter((candidate) => dictionaryEnabled(candidate.entry.dictionary, rank)).sort(
(a, b) => a.rank - b.rank || dictionaryPriority(a.entry.dictionary, rank) - dictionaryPriority(b.entry.dictionary, rank) || (b.entry.score ?? 0) - (a.entry.score ?? 0) || Number(b.entry.expression === query) - Number(a.entry.expression === query) || a.entry.expression.length - b.entry.expression.length
).filter((candidate) => {
const key = termLookupDedupKey(candidate.entry);
if (seen.has(key)) return false;
seen.add(key);
return true;
}).map((candidate) => candidate.entry).slice(0, limit);
}
function indexedTermSearchRank(entry, query) {
if (entry.expression === query) return 0;
if (entry.reading === query) return 4;
if (entry.expression.startsWith(query)) return 10;
if (entry.reading.startsWith(query)) return 12;
if (entry.expression.includes(query)) return 24;
if (entry.reading.includes(query)) return 26;
return 60;
}
function glossaryTermSearchRank(glossary, query) {
const normalizedQuery = normalizeGlossarySearchText(query);
if (!normalizedQuery) return Number.POSITIVE_INFINITY;
const text2 = normalizeGlossarySearchText(glossarySearchText(glossary));
if (!text2) return Number.POSITIVE_INFINITY;
if (text2 === normalizedQuery) return 30;
if (glossaryHasExactWord(text2, normalizedQuery)) return 34;
if (glossaryHasWordPrefix(text2, normalizedQuery)) return 44;
if (text2.includes(normalizedQuery)) return 68;
return Number.POSITIVE_INFINITY;
}
function normalizeGlossarySearchText(value) {
return value.normalize("NFKC").toLocaleLowerCase().replace(/[^\p{L}\p{N}\s'-]+/gu, " ").replace(/\s+/g, " ").trim();
}
function glossaryHasExactWord(text2, query) {
return glossaryWords(text2).some((word) => word === query);
}
function glossaryHasWordPrefix(text2, query) {
return glossaryWords(text2).some((word) => word.startsWith(query));
}
function glossaryWords(text2) {
return text2.split(/\s+/u).filter(Boolean);
}
function termSearchEntries(entry) {
const { id: _id, ...entryWithoutId } = entry;
return glossarySearchTokens(entry.glossary).map((token) => ({
...entryWithoutId,
token
}));
}
function termEntryFromSearchEntry(entry) {
const { id: _id, token: _token, ...term } = entry;
return term;
}
function termKanjiEntries(entry) {
const { id: _id, ...entryWithoutId } = entry;
return uniqueExpressionKanji(entry.expression).map((character) => ({
...entryWithoutId,
character
}));
}
function termEntryFromKanjiEntry(entry) {
const { id: _id, character: _character, ...term } = entry;
return term;
}
function uniqueExpressionKanji(expression) {
const seen = /* @__PURE__ */ new Set();
return Array.from(expression).filter((character) => {
if (!isKanji(character) || seen.has(character)) return false;
seen.add(character);
return true;
});
}
function glossarySearchTokens(glossary) {
return uniqueSearchTokens(glossaryWords(normalizeGlossarySearchText(glossarySearchText(glossary))).flatMap(glossaryWordSearchTokens)).slice(0, TERM_SEARCH_INDEX_MAX_TOKENS_PER_TERM);
}
function glossaryWordSearchTokens(word) {
const tokens = [];
const variants = uniqueSearchTokens([
word,
word.endsWith("'s") ? word.slice(0, -2) : "",
word.endsWith("s") ? word.slice(0, -1) : ""
]);
for (const variant of variants) {
tokens.push(variant);
for (let start = 1; start <= variant.length - TERM_SEARCH_INDEX_MIN_SUFFIX_LENGTH; start++) {
tokens.push(variant.slice(start));
}
}
return tokens;
}
function uniqueSearchTokens(tokens) {
const seen = /* @__PURE__ */ new Set();
return tokens.filter((token) => {
if (token.length < TERM_SEARCH_INDEX_MIN_TOKEN_LENGTH || seen.has(token)) return false;
seen.add(token);
return true;
});
}
function glossarySearchText(value) {
if (value == null) return "";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) return value.map(glossarySearchText).filter(Boolean).join(" ");
if (!isRecord$2(value)) return "";
if (typeof value.text === "string") return value.text;
if ("content" in value) return glossarySearchText(value.content);
return ["description", "alt", "title"].map((key) => glossarySearchText(value[key])).filter(Boolean).join(" ");
}
function isRecord$2(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function trimTermSearchCandidates(candidates, candidateLimit, query, rank) {
if (candidates.length <= candidateLimit * 2) return;
candidates.sort(
(a, b) => a.rank - b.rank || dictionaryPriority(a.entry.dictionary, rank) - dictionaryPriority(b.entry.dictionary, rank) || (b.entry.score ?? 0) - (a.entry.score ?? 0) || Number(b.entry.expression === query) - Number(a.entry.expression === query) || a.entry.expression.length - b.entry.expression.length
);
candidates.length = candidateLimit;
}
function normalizeDexieKanjiRow(row) {
const record = dexieKanjiRecord(row);
return record ? {
character: record.character,
onyomi: dexieStringList(record.onyomi),
kunyomi: dexieStringList(record.kunyomi),
tags: dexieStringList(record.tags),
meanings: Array.isArray(record.meanings) ? record.meanings.map(String) : [],
stats: record.stats,
dictionary: record.dictionary
} : null;
}
function dexieKanjiRecord(row) {
const record = dexieRowRecord(row);
return record && typeof record.character === "string" && typeof record.dictionary === "string" ? record : null;
}
function dexieStringList(value) {
return Array.isArray(value) ? value.map(String) : splitTags(value);
}
function normalizeDexieTermMetaRow(row) {
const record = dexieTermMetaRecord(row);
return record ? { expression: record.expression, mode: record.mode, data: record.data, dictionary: record.dictionary } : null;
}
function normalizeDexieKanjiMetaRow(row) {
const record = dexieKanjiMetaRecord(row);
return record ? { character: record.character, mode: record.mode, data: record.data, dictionary: record.dictionary } : null;
}
function dexieTermMetaRecord(row) {
const record = dexieRowRecord(row);
return record && typeof record.expression === "string" && typeof record.mode === "string" && typeof record.dictionary === "string" ? record : null;
}
function dexieKanjiMetaRecord(row) {
const record = dexieRowRecord(row);
return record && typeof record.character === "string" && typeof record.mode === "string" && typeof record.dictionary === "string" ? record : null;
}
function normalizeDexieDictionaryRow(row) {
const record = dexieDictionaryRecord(row);
if (!record) return null;
if (typeof record.title !== "string") return null;
return {
title: record.title,
alias: dictionaryAlias(record, record.title),
enabled: dictionaryInfoEnabled(record.enabled),
priority: dictionaryInfoPriority(record.priority),
counts: record.counts,
type: dictionaryInfoType(record.type),
styles: stringField(record.styles) ?? "",
revision: stringField(record.revision),
downloadUrl: stringField(record.downloadUrl),
importDate: numberField(record.importDate)
};
}
function dexieDictionaryRecord(row) {
return dexieRowRecord(row);
}
function dictionaryInfoEnabled(value) {
return typeof value === "boolean" ? value : true;
}
function dictionaryInfoPriority(value) {
return Number.isFinite(Number(value)) ? Number(value) : 0;
}
function dictionaryAlias(record, fallback) {
return typeof record.alias === "string" && record.alias ? record.alias : fallback;
}
function dictionaryInfoType(value) {
return value === "terms" || value === "kanji" || value === "frequency" || value === "metadata" ? value : void 0;
}
function stringField(value) {
return typeof value === "string" ? value : void 0;
}
function numberField(value) {
return typeof value === "number" ? value : void 0;
}
function unwrapDexieRow(row) {
if (row && typeof row === "object" && "$" in row) {
const value = row.$;
return Array.isArray(value) ? value.find((item) => item && typeof item === "object" && !Array.isArray(item)) : value;
}
return row;
}
function dexieRowRecord(row) {
const candidate = unwrapDexieRow(row);
return candidate && typeof candidate === "object" ? candidate : null;
}
function isReaderDictionaryExport$1(value) {
const record = readerDictionaryExportRecord(value);
return Boolean(record && isReaderDictionaryExportFormat(record) && hasReaderDictionaryExportRows(record));
}
function readerDictionaryExportRecord(value) {
return value && typeof value === "object" ? value : null;
}
function isReaderDictionaryExportFormat(record) {
return record.formatName === "yomu-yomitan-dictionaries" || record.formatName === "jpdb-reader-yomitan-dictionaries";
}
function hasReaderDictionaryExportRows(record) {
return Array.isArray(record.entries) || Array.isArray(record.terms) || Array.isArray(record.kanji) || Array.isArray(record.termMeta) || Array.isArray(record.kanjiMeta);
}
function uniqueDictionaryNames(names) {
return [...new Set(names.filter((name) => typeof name === "string" && Boolean(name)))];
}
function filenameFromUrl(url) {
try {
const parsed = new URL(url);
const pathName = parsed.pathname.split("/").filter(Boolean).pop();
return pathName && /\.zip$/i.test(pathName) ? decodeURIComponent(pathName) : "dictionary.zip";
} catch {
return "dictionary.zip";
}
}
function fileSummary(file, sourceUrl = "") {
return {
name: file.name,
size: file.size,
type: file.type,
sourceHost: sourceUrl ? safeHost$3(sourceUrl) : ""
};
}
function safeHost$3(url) {
try {
return new URL(url, location.href).host;
} catch {
return "";
}
}
function namedBlobFile(blob, name, type) {
if (typeof File === "function") return new File([blob], name, { type });
Object.defineProperty(blob, "name", { value: name, configurable: true });
Object.defineProperty(blob, "lastModified", { value: Date.now(), configurable: true });
return blob;
}
async function requestBlob$3(url, proxyUrl, onProgress, language = "en") {
const done = log$s.time("Dictionary download", { host: safeHost$3(url) });
const userscriptRequest = getUserscriptHttpRequest();
if (userscriptRequest) return requestBlobViaUserscript(url, userscriptRequest, done, onProgress, language);
return await requestBlobViaFetch(url, proxyUrl, done, onProgress, language);
}
function requestBlobViaUserscript(url, userscriptRequest, done, onProgress, language = "en") {
return new Promise((resolve, reject) => {
const handleLoad = (response) => {
if (response.response instanceof Blob && (response.status === 0 || response.status >= 200 && response.status < 300)) {
log$s.info("Dictionary download completed", { host: safeHost$3(url), status: response.status, size: response.response.size });
done();
resolve(response.response);
return;
}
if (response.status < 200 || response.status >= 300) {
log$s.warn("Dictionary download returned HTTP error", { host: safeHost$3(url), status: response.status });
done();
reject(new Error(formatDictionaryDownloadFailed(language, response.status)));
return;
}
log$s.warn("Dictionary download returned unexpected payload", { host: safeHost$3(url), status: response.status });
done();
reject(new Error(uiText(language, "dictionaryDownloadNotZip")));
};
const result = userscriptRequest({
method: "GET",
url,
headers: { accept: "application/zip,application/octet-stream,*/*" },
responseType: "blob",
timeout: 12e4,
onprogress: (event) => {
if (event.lengthComputable && event.total > 0) {
onProgress?.(`${uiText(language, "dictionaryDownloadProgress")} ${Math.round(event.loaded / event.total * 100)}%...`);
}
},
onload: handleLoad,
onerror: () => {
log$s.warn("Dictionary download failed", { host: safeHost$3(url) });
done();
reject(new Error(uiText(language, "dictionaryDownloadFailed")));
},
ontimeout: () => {
log$s.warn("Dictionary download timed out", { host: safeHost$3(url) });
done();
reject(new Error(uiText(language, "dictionaryDownloadTimedOut")));
}
});
if (result && typeof result.then === "function") {
result.then(handleLoad, () => {
log$s.warn("Dictionary download failed", { host: safeHost$3(url) });
done();
reject(new Error(uiText(language, "dictionaryDownloadFailed")));
});
}
});
}
async function requestBlobViaFetch(url, proxyUrl, done, onProgress, language) {
const downloadUrl = dictionaryDownloadUrl(url);
if (!downloadUrl) return throwMissingDictionaryDownloadBridge(done, language);
try {
return await fetchDictionaryBlob(url, downloadUrl, proxyUrl, done, onProgress, language);
} catch (error) {
return handleDictionaryFetchError(url, downloadUrl, error, done, language);
}
}
function throwMissingDictionaryDownloadBridge(done, language) {
done();
throw new Error(uiText(language, "dictionaryDownloadNeedsBridge"));
}
async function fetchDictionaryBlob(url, downloadUrl, proxyUrl, done, onProgress, language) {
const response = await fetchWithCorsFallbacks(downloadUrl, proxyUrl, { credentials: "omit", redirect: "follow", referrerPolicy: "no-referrer", timeoutMs: 12e4 });
if (!response.ok) throwDictionaryHttpError(url, response.status, language);
const blob = await responseBlobWithProgress(response, onProgress, language);
log$s.info("Dictionary download completed", { host: safeHost$3(url), status: response.status, size: blob.size });
done();
return blob;
}
async function responseBlobWithProgress(response, onProgress, language) {
if (!response.body || !onProgress) return response.blob();
const total = Number(response.headers.get("content-length") ?? 0);
const type = response.headers.get("content-type") || "application/zip";
const reader = response.body.getReader();
const chunks = [];
let loaded = 0;
for (; ; ) {
const { value, done } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.byteLength;
onProgress(formatDictionaryDownloadProgress(language, loaded, total));
}
const bytes = new Uint8Array(loaded);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new Blob([bytes.buffer.slice(0)], { type });
}
function formatDictionaryDownloadProgress(language, loaded, total) {
const label = uiText(language, "dictionaryDownloadProgress");
if (total > 0) return `${label} ${formatPercent(loaded, total)} (${formatBytes(loaded)} / ${formatBytes(total)})...`;
return `${label} ${formatBytes(loaded)}...`;
}
function throwDictionaryHttpError(url, status, language) {
log$s.warn("Dictionary download returned HTTP error", { host: safeHost$3(url), status });
throw new Error(formatDictionaryDownloadFailed(language, status));
}
function handleDictionaryFetchError(url, downloadUrl, error, done, language) {
const host = safeHost$3(url);
if (isDictionaryCorsError(error)) {
log$s.warn("Dictionary download failed due cross-origin restriction", { host, downloadUrl });
done();
throw new Error(uiText(language, "dictionaryDownloadBlocked"));
}
log$s.warn("Dictionary download fetch failed", { host, error });
done();
throw language === "ja" ? new Error(uiText(language, "dictionaryDownloadFailed")) : error;
}
function formatDictionaryDownloadFailed(language, status) {
return language === "ja" ? `${uiText(language, "dictionaryDownloadFailed")}(${status})` : `Dictionary download failed (${status}).`;
}
function isDictionaryCorsError(error) {
return error instanceof Error && error.name === "TypeError";
}
function dictionaryDownloadUrl(url) {
try {
const target = new URL(url, location.href);
const current = new URL(location.href);
if (target.origin === current.origin) return target.href;
if (target.protocol === "https:" || target.protocol === "http:") return target.href;
return null;
} catch {
return url;
}
}
function splitTags(value) {
if (Array.isArray(value)) return value.map(String).filter(Boolean);
return typeof value === "string" ? value.split(/\s+/).filter(Boolean) : [];
}
function reservoirSample(items, limit) {
const reservoir = [];
let count = 0;
for (const item of items) {
count++;
if (reservoir.length < limit) {
reservoir.push(item);
} else {
const index = Math.floor(Math.random() * count);
if (index < limit) reservoir[index] = item;
}
}
return reservoir;
}
function isCommonDictionaryTerm(entry, rank) {
return isCommonDictionaryTermCandidate(entry, rank) && (hasCommonDictionaryTags(entry) || hasCommonDictionaryScore(entry));
}
function isCommonDictionaryTermCandidate(entry, rank) {
return Boolean(entry.expression && JAPANESE_RE$3.test(entry.expression) && entry.expression.length <= 8 && dictionaryEnabled(entry.dictionary, rank));
}
function hasCommonDictionaryTags(entry) {
return /\b(common|ichi1|news1|spec1|gai1|freq|popular)\b/.test(dictionaryTermTags(entry));
}
function dictionaryTermTags(entry) {
return `${entry.definitionTags ?? ""} ${entry.termTags ?? ""} ${entry.rules ?? ""}`.toLowerCase();
}
function hasCommonDictionaryScore(entry) {
return typeof entry.score === "number" && entry.score >= 5;
}
function isKanji(value) {
const code = value.codePointAt(0) ?? 0;
return code >= 13312 && code <= 40959;
}
function ensureStore(db, tx, name) {
return db.objectStoreNames.contains(name) ? tx.objectStore(name) : db.createObjectStore(name, { keyPath: "id", autoIncrement: true });
}
function hasStore(db, name) {
return db.objectStoreNames.contains(name);
}
function ensureIndex(store, name, keyPath) {
if (!store.indexNames.contains(name)) store.createIndex(name, keyPath);
}
function existingStores(db, names) {
return names.filter((name) => db.objectStoreNames.contains(name));
}
function readwriteTransaction(db, storeNames) {
try {
return db.transaction(storeNames, "readwrite", { durability: "relaxed" });
} catch {
return db.transaction(storeNames, "readwrite");
}
}
function commitTransaction(tx) {
try {
tx.commit?.();
} catch {
}
}
function clearStores(db, stores) {
if (!stores.length) return Promise.resolve();
return new Promise((resolve, reject) => {
const tx = readwriteTransaction(db, stores);
for (const store of stores) tx.objectStore(store).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(transactionError(tx, `Could not clear dictionary stores: ${stores.join(", ")}.`));
tx.onabort = () => reject(transactionError(tx, `Could not clear dictionary stores: ${stores.join(", ")}.`));
commitTransaction(tx);
});
}
async function deleteByDictionary(db, storeName, dictionary) {
while (await deleteDictionaryBatch(db, storeName, dictionary, DICTIONARY_DELETE_BATCH_SIZE) >= DICTIONARY_DELETE_BATCH_SIZE) {
await nextTask();
}
}
function deleteDictionaryBatch(db, storeName, dictionary, limit) {
return new Promise((resolve, reject) => {
let deleted = 0;
const tx = readwriteTransaction(db, storeName);
const index = tx.objectStore(storeName).index("dictionary");
const request = index.openCursor(IDBKeyRange.only(dictionary));
request.onsuccess = () => {
const cursor = request.result;
if (!cursor || deleted >= limit) return;
cursor.delete();
deleted++;
if (deleted >= limit) return;
cursor.continue();
};
request.onerror = () => reject(request.error ?? new Error(`Could not delete ${dictionary} entries from ${storeName}.`));
tx.oncomplete = () => resolve(deleted);
tx.onerror = () => reject(transactionError(tx, `Could not delete ${dictionary} entries from ${storeName}.`));
tx.onabort = () => reject(transactionError(tx, `Could not delete ${dictionary} entries from ${storeName}.`));
});
}
function transactionError(tx, fallback) {
return tx.error ?? new Error(fallback);
}
function nextTask() {
return new Promise((resolve) => window.setTimeout(resolve, 0));
}
const ANKI_VERSION = 6;
const log$r = Logger.scope("Anki");
const ANKI_EASE_BY_GRADE = {
nothing: 1,
fail: 1,
something: 2,
hard: 2,
okay: 3,
pass: 3,
easy: 4
};
const YOMU_MODEL_FIELDS = [
"Expression",
"Reading",
"Meaning",
"Sentence",
"Url",
"Frequency",
"PartOfSpeech",
"Image",
"Audio",
"JPDB",
"Status",
"Pitch",
"DictionaryDefinitions",
"Kanji",
"Source"
];
class AnkiConnectClient {
constructor(getSettings) {
this.getSettings = getSettings;
}
lookupCache = /* @__PURE__ */ new Map();
unavailableUntil = 0;
async isConnected() {
try {
await this.invoke("version");
return true;
} catch (error) {
log$r.warnOnce("connection-unavailable", "AnkiConnect unavailable", error);
return false;
}
}
async deckNames() {
if (canUseMobileAnkiHandoff(this.getSettings())) return [];
const decks = await this.invoke("deckNames");
return decks;
}
async modelNames() {
const models = await this.invoke("modelNames");
return models;
}
async findExistingCards(card) {
const empty = emptyAnkiLookupResult();
if (canUseMobileAnkiHandoff(this.getSettings())) return empty;
if (this.isLookupCoolingDown()) return empty;
const cacheKey = this.lookupCacheKey(card);
const cached = this.readLookupCache(cacheKey);
if (cached) return cached;
return await this.findExistingCardsUncached(card, cacheKey, empty);
}
lookupCacheKey(card) {
return `${card.spelling}|${card.reading}`;
}
isLookupCoolingDown() {
if (Date.now() >= this.unavailableUntil) return false;
return true;
}
async findExistingCardsUncached(card, cacheKey, empty) {
try {
const done = log$r.time("findExistingCards", { term: card.spelling });
const noteIds = await this.findCandidateNoteIds(card);
if (!noteIds.size) {
this.writeLookupCache(cacheKey, empty);
done();
return empty;
}
const { existing } = await this.loadExistingNotes(card, noteIds);
if (!existing.length) {
this.writeLookupCache(cacheKey, empty);
done();
return empty;
}
const result = {
state: stateFromExistingNotes(existing),
notes: existing,
primary: pickPrimaryExistingNote(existing)
};
this.writeLookupCache(cacheKey, result);
done();
return result;
} catch (error) {
log$r.warn("Anki lookup failed; entering cooldown", { term: card.spelling }, error);
this.unavailableUntil = Date.now() + 3e4;
return empty;
}
}
readLookupCache(cacheKey) {
const cached = this.lookupCache.get(cacheKey);
if (!cached || Date.now() - cached.at >= 45e3) return null;
return cached.result;
}
writeLookupCache(cacheKey, result) {
this.lookupCache.set(cacheKey, { at: Date.now(), result });
}
async findCandidateNoteIds(card) {
const noteIds = /* @__PURE__ */ new Set();
for (const term of unique$2([card.spelling, card.reading].filter(Boolean))) {
const ids = await this.invoke("findNotes", { query: quoteAnkiSearch(term) }).catch(() => []);
ids.forEach((id) => noteIds.add(id));
}
return noteIds;
}
async loadExistingNotes(card, noteIds) {
const notes = await this.invoke("notesInfo", { notes: [...noteIds] });
const matchingNotes = notes.filter((note) => noteLooksLikeCard(note, card));
const cardsByNote = await this.loadCardsByNote(matchingNotes);
return {
existing: matchingNotes.map((note) => ankiExistingNoteFromInfo(note, cardsByNote.get(note.noteId) ?? [])),
candidateNotes: notes.length
};
}
async loadCardsByNote(notes) {
const cardIds = unique$2(notes.flatMap((note) => note.cards ?? []));
const cards = cardIds.length ? await this.invoke("cardsInfo", { cards: cardIds }).catch(() => []) : [];
return cardsByNoteId(cards);
}
async answerCard(cardId, grade) {
const ease = ankiEaseFromGrade(grade);
log$r.info("Answering Anki card", { cardId, grade, ease });
await this.invoke("answerCards", { answers: [{ cardId, ease }] });
}
async browseNote(noteId) {
log$r.info("Opening Anki note browser", { noteId });
await this.invoke("guiBrowse", { query: `nid:${noteId}` });
}
async mediaFileDataUrl(filename) {
const cleanFilename = filename.trim();
if (!cleanFilename) throw new Error(this.text("ankiAudioFileNotFound"));
const data = await this.invoke("retrieveMediaFile", { filename: cleanFilename });
if (!data) throw new Error(this.text("ankiAudioFileNotFound"));
return `data:${ankiMediaMimeType(cleanFilename)};base64,${data}`;
}
async mergeYomuData(noteId, card, sentence = "", options = {}) {
const settings = this.getSettings();
if (canUseMobileAnkiHandoff(settings)) throw new Error(this.text("ankiMergeNeedsDesktop"));
const [note] = await this.invoke("notesInfo", { notes: [noteId] });
if (!note) throw new Error(this.text("ankiNoteNotFound"));
const merge = this.buildYomuNoteMerge(note, card, sentence, options);
if (!merge.updatedFields.length && !merge.audioAdded && !merge.imageAdded) {
return merge;
}
await this.invoke("updateNoteFields", { note: merge.note });
this.lookupCache.delete(this.lookupCacheKey(card));
return merge;
}
async addCard(card, sentence = "", options = {}) {
const settings = this.getSettings();
if (!settings.ankiEnabled) {
return null;
}
const note = this.buildAnkiNote(card, sentence, settings, options);
this.logAnkiNoteAdd(card, note);
if (this.openMobileHandoffIfPreferred(settings, note, card)) return null;
try {
return await this.addNoteViaConnect(note, card);
} catch (error) {
return this.addCardWithFallback(error, settings, note, card);
}
}
buildAnkiNote(card, sentence, settings, options) {
const note = {
deckName: this.ankiDeckName(options, settings),
modelName: settings.ankiModel || "よむ Japanese",
fields: buildYomuAnkiFields(card, sentence, this.ankiFieldContext(options, settings)),
tags: tagsFromString(settings.ankiTags),
options: {
allowDuplicate: false,
duplicateScope: "collection"
}
};
this.attachAnkiNoteImage(note, options.imageDataUrl, card);
this.attachAnkiNoteAudio(note, options, card);
return note;
}
buildYomuNoteMerge(note, card, sentence, options) {
const settings = this.getSettings();
const fieldNames = Object.keys(note.fields ?? {});
const existingFields = flattenNoteFields(note.fields);
const yomuFields = buildYomuAnkiFields(card, sentence, this.ankiFieldContext(options, settings));
const canOwnYomuFields = noteLooksLikeYomuModel(note.modelName, settings, fieldNames);
const fields = mergedYomuFields(fieldNames, existingFields, yomuFields, canOwnYomuFields);
const audio = mergeAudioFilesForNote(fieldNames, options, card);
const picture = mergePictureFilesForNote(fieldNames, existingFields, options, card, canOwnYomuFields);
applyMediaFieldClears(fields, audio, picture, options.audioMergeMode, canOwnYomuFields);
return {
noteId: note.noteId,
modelName: note.modelName,
updatedFields: Object.keys(fields),
audioAdded: Boolean(audio.length),
imageAdded: Boolean(picture.length),
note: {
id: note.noteId,
fields,
...audio.length ? { audio } : {},
...picture.length ? { picture } : {}
}
};
}
ankiDeckName(options, settings) {
return options.deckName?.trim() || settings.ankiDeck || "よむ";
}
ankiFieldContext(options, settings) {
return {
...options,
sourceUrl: options.sourceUrl ?? safeLocationHref(),
sourceTitle: options.sourceTitle ?? safeDocumentTitle(),
dictionaryPreferences: options.dictionaryPreferences ?? settings.dictionaryPreferences,
interfaceLanguage: options.interfaceLanguage ?? settings.interfaceLanguage
};
}
attachAnkiNoteImage(note, imageDataUrl, card) {
const image = imageDataUrl ? imageFromDataUrl(imageDataUrl, card) : null;
if (image) note.picture = [image];
}
attachAnkiNoteAudio(note, options, card) {
const audio = audioFilesFromContext(options, card);
if (audio.length) note.audio = audio;
}
logAnkiNoteAdd(card, note) {
log$r.info("Adding Anki note", {
term: card.spelling,
deck: note.deckName,
model: note.modelName,
hasImage: Boolean(note.picture?.length),
hasAudio: Boolean(note.audio?.length),
tags: note.tags
});
}
openMobileHandoffIfPreferred(settings, note, card) {
if (!canUseMobileAnkiHandoff(settings)) return false;
log$r.info("Opening mobile Anki handoff", { term: card.spelling });
if (!openMobileAnkiHandoff(note)) throw new Error(this.text("ankiHandoffCancelled"));
return true;
}
async addNoteViaConnect(note, card) {
await this.ensureDeckAndModel(note.deckName);
const noteId = await this.invoke("addNote", { note });
log$r.info("Anki note added", { term: card.spelling, noteId });
await this.refreshLookupCacheAfterAdd(card, noteId);
return noteId;
}
async refreshLookupCacheAfterAdd(card, noteId) {
const cacheKey = this.lookupCacheKey(card);
if (!noteId) {
this.lookupCache.delete(cacheKey);
return;
}
try {
const { existing } = await this.loadExistingNotes(card, /* @__PURE__ */ new Set([noteId]));
const result = {
state: stateFromExistingNotes(existing),
notes: existing,
primary: pickPrimaryExistingNote(existing)
};
this.writeLookupCache(cacheKey, result);
} catch (error) {
log$r.warn("Anki lookup refresh after add failed", { term: card.spelling, noteId }, error);
this.lookupCache.delete(cacheKey);
}
}
addCardWithFallback(error, settings, note, card) {
if (!settings.ankiMobileHandoff || !isMobileUserAgent()) throw error;
log$r.warn("AnkiConnect add failed; trying mobile handoff", { term: card.spelling }, error);
if (!openMobileAnkiHandoff(note)) throw new Error(this.text("ankiHandoffCancelled"));
return null;
}
async ensureDeckAndModel(deckOverride) {
const settings = this.getSettings();
const deckName = resolvedAnkiDeckName(deckOverride, settings);
const modelName = resolvedAnkiModelName(settings);
await this.ensureDeck(deckName);
const modelNames = await this.modelNames().catch(() => []);
await this.ensureYomuModel(modelNames, modelName, settings);
}
async ensureDeck(deckName) {
await this.invoke("createDeck", { deck: deckName }).catch(() => {
return null;
});
}
async updateExistingModel(modelName, settings) {
await this.ensureModelFields(modelName);
await this.invoke("updateModelTemplates", { model: { name: modelName, templates: yomuCardTemplates(settings) } });
await this.invoke("updateModelStyling", { model: { name: modelName, css: yomuCardCss() } });
}
async ensureYomuModel(modelNames, modelName, settings) {
return modelNames.includes(modelName) ? await this.updateExistingModel(modelName, settings) : await this.createYomuModel(modelName, settings);
}
async createYomuModel(modelName, settings) {
await this.invoke("createModel", {
modelName,
inOrderFields: YOMU_MODEL_FIELDS,
css: yomuCardCss(),
cardTemplates: Object.entries(yomuCardTemplates(settings)).map(([Name, template]) => ({ Name, ...template }))
});
log$r.info("Anki model created", { modelName });
}
async ensureModelFields(modelName) {
const fieldNames = await this.invoke("modelFieldNames", { modelName }).catch(() => []);
const existing = new Set(fieldNames);
for (const fieldName of YOMU_MODEL_FIELDS) {
if (!existing.has(fieldName)) {
await this.invoke("modelFieldAdd", { modelName, fieldName });
}
}
}
async invoke(action, params = {}) {
const settings = this.getSettings();
const url = settings.ankiConnectUrl || "http://127.0.0.1:8765";
const body = JSON.stringify({ action, version: ANKI_VERSION, params });
const response = await postJson$1(url, body).catch((error) => {
throw this.localizedConnectError(error);
});
if (response.error) {
log$r.warn("AnkiConnect action returned error", { action, error: response.error });
throw new Error(resolveUiLanguage(settings.interfaceLanguage) === "ja" ? this.text("ankiConnectActionFailed") : response.error);
}
return response.result;
}
text(key) {
return uiText(this.getSettings().interfaceLanguage, key);
}
localizedConnectError(error) {
const language = this.getSettings().interfaceLanguage;
if (resolveUiLanguage(language) !== "ja") return error instanceof Error ? error : new Error(this.text("ankiConnectRequestFailed"));
if (error instanceof Error && /timed out/i.test(error.message)) return new Error(this.text("ankiConnectTimedOut"));
const status = error instanceof Error ? error.message.match(/\((\d{3})\)/)?.[1] : "";
const suffix = status ? `(${status})` : "";
return new Error(`${this.text("ankiConnectRequestFailed")}${suffix}`);
}
}
function captureActiveVideoFrame() {
const video = Array.from(document.querySelectorAll("video")).filter((item) => item.readyState >= 2 && item.videoWidth > 0 && item.videoHeight > 0).sort((a, b) => visibleArea(b) - visibleArea(a))[0];
if (!video) {
return void 0;
}
try {
const canvas = document.createElement("canvas");
const maxWidth = 960;
const scale = Math.min(1, maxWidth / video.videoWidth);
canvas.width = Math.max(1, Math.round(video.videoWidth * scale));
canvas.height = Math.max(1, Math.round(video.videoHeight * scale));
const context = canvas.getContext("2d");
if (!context) return void 0;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL("image/jpeg", 0.84);
return dataUrl;
} catch (error) {
log$r.warn("Active video frame capture failed", error);
return void 0;
}
}
function postJson$1(url, body) {
const userscriptRequest = getUserscriptHttpRequest();
if (userscriptRequest) {
return new Promise((resolve, reject) => {
const handleLoad = (response) => {
if (response.status >= 200 && response.status < 300) resolve(response.response);
else reject(new Error(`AnkiConnect request failed (${response.status}).`));
};
const result = userscriptRequest({
method: "POST",
url,
headers: { "Content-Type": "application/json" },
data: body,
responseType: "json",
timeout: 5e3,
onload: handleLoad,
onerror: reject,
ontimeout: () => reject(new Error("AnkiConnect timed out."))
});
if (result && typeof result.then === "function") {
result.then(handleLoad, reject);
}
});
}
if (!canFetchAnkiConnect(url)) {
return Promise.reject(new Error("AnkiConnect needs the userscript request bridge on content pages."));
}
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body
}).then(async (response) => {
if (!response.ok) throw new Error(`AnkiConnect request failed (${response.status}).`);
return response.json();
});
}
function resolvedAnkiDeckName(deckOverride, settings) {
return deckOverride?.trim() || settings.ankiDeck || "よむ";
}
function resolvedAnkiModelName(settings) {
return settings.ankiModel || "よむ Japanese";
}
function canFetchAnkiConnect(url) {
return canFetchAnkiConnectFrom(url, safeLocationHref());
}
function canFetchAnkiConnectFrom(url, currentHref) {
const current = readAnkiUrl(currentHref);
if (!current) return false;
const target = readAnkiUrl(url, current.href);
if (!target) return false;
if (target.origin === current.origin) return true;
if (isLoopbackHostname(current.hostname)) return true;
return isYomuHostedAppUrl(current.href) && isHttpUrl(target);
}
function needsHostedAnkiConnectSetupHint(url, currentHref = safeLocationHref()) {
if (getUserscriptHttpRequest()) return false;
const current = readAnkiUrl(currentHref);
if (!current || current.origin !== GITHUB_PAGES_ORIGIN || !isYomuHostedAppUrl(current.href)) return false;
const target = readAnkiUrl(url, current.href);
return Boolean(target && target.origin !== current.origin && isHttpUrl(target));
}
function readAnkiUrl(value, base) {
try {
return new URL(value, base);
} catch {
return null;
}
}
function isHttpUrl(url) {
return url.protocol === "http:" || url.protocol === "https:";
}
function isLoopbackHostname(hostname) {
return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(hostname);
}
function buildYomuAnkiFields(card, sentence = "", context = {}) {
const fieldContext = ankiFieldContext(context);
const jpdbUrl = jpdbVocabularyUrl(card);
return {
Expression: escapeHtml$1(card.spelling),
Reading: renderCardReading(card),
Meaning: renderJpdbMeanings(card),
Sentence: renderSentence(sentence, card.spelling),
Url: escapeHtml$1(fieldContext.sourceUrl),
Frequency: renderFrequency(card, fieldContext.metaEntries, fieldContext.dictionaryPreferences),
PartOfSpeech: renderPartOfSpeech(card.partOfSpeech),
Image: "",
Audio: "",
JPDB: renderJpdbLink(jpdbUrl, fieldContext.interfaceLanguage),
Status: renderCardStatus(card, fieldContext.interfaceLanguage),
Pitch: renderPitchField(card, fieldContext.metaEntries, fieldContext.dictionaryPreferences),
DictionaryDefinitions: renderDictionaryDefinitions(fieldContext.localEntries, fieldContext.dictionaryPreferences),
Kanji: renderKanjiDefinitions$1(fieldContext.kanjiEntries, fieldContext.dictionaryPreferences, fieldContext.interfaceLanguage),
Source: renderSource(fieldContext.sourceUrl, fieldContext.sourceTitle)
};
}
function renderCardReading(card) {
return card.reading && card.reading !== card.spelling ? escapeHtml$1(card.reading) : "";
}
function renderPartOfSpeech(partOfSpeech) {
return escapeHtml$1(formatPartOfSpeech(partOfSpeech) || formatPartOfSpeechDetails(partOfSpeech));
}
function renderJpdbLink(jpdbUrl, language) {
return jpdbUrl ? `${escapeHtml$1(uiText(language, "openOnJpdb"))} ` : "";
}
function ankiFieldContext(context) {
return {
localEntries: fallbackArray(context.localEntries),
kanjiEntries: fallbackArray(context.kanjiEntries),
metaEntries: fallbackArray(context.metaEntries),
dictionaryPreferences: fallbackArray(context.dictionaryPreferences),
sourceUrl: fallbackString(context.sourceUrl),
sourceTitle: fallbackString(context.sourceTitle),
interfaceLanguage: context.interfaceLanguage ?? "en"
};
}
function fallbackArray(value) {
return value ?? [];
}
function fallbackString(value) {
return value ?? "";
}
function jpdbVocabularyUrl(card) {
return card.source === "local" || card.source === "anki" ? "" : `https://jpdb.io/vocabulary/${card.vid}/${encodeURIComponent(card.spelling)}/${encodeURIComponent(card.reading)}`;
}
function renderCardStatus(card, language) {
if (card.source === "local") return `${escapeHtml$1(uiText(language, "ankiLocalDictionaryStatus"))} `;
if (card.source === "anki") return 'Anki ';
return card.cardState.map((state) => `${escapeHtml$1(state)} `).join(" ");
}
function tagsFromString(value) {
return value.split(/[,\s]+/).map((tag) => tag.trim()).filter(Boolean);
}
function imageFromDataUrl(dataUrl, card) {
const parsed = parseAnkiImageDataUrl(dataUrl);
if (!parsed) return null;
return {
filename: `yomu_${safeAnkiMediaName(card)}_${Date.now()}.${parsed.extension}`,
data: parsed.data,
fields: ["Image"]
};
}
function mergedYomuFields(fieldNames, existingFields, yomuFields, canOwnYomuFields) {
const fields = {};
for (const fieldName of fieldNames) {
const value = yomuValueForExistingField(fieldName, yomuFields);
if (!value) continue;
if (!canOwnYomuFields && existingFields[fieldName]) continue;
fields[fieldName] = value;
}
return fields;
}
function yomuValueForExistingField(fieldName, yomuFields) {
return yomuFields[fieldName] ?? yomuFields[yomuFieldAlias(fieldName)] ?? "";
}
function yomuFieldAlias(fieldName) {
const normalized = fieldName.replace(/[_\s-]+/g, "").toLowerCase();
return YOMU_FIELD_ALIASES[normalized] ?? "";
}
const YOMU_FIELD_ALIASES = {
word: "Expression",
vocab: "Expression",
vocabulary: "Expression",
term: "Expression",
front: "Expression",
readings: "Reading",
kana: "Reading",
yomi: "Reading",
definition: "Meaning",
definitions: "Meaning",
glossary: "Meaning",
translation: "Meaning",
translation1: "Meaning",
back: "Meaning",
example: "Sentence",
sentenceexpression: "Sentence",
sourceurl: "Url",
url: "Url",
pos: "PartOfSpeech",
partofspeech: "PartOfSpeech",
pitchaccent: "Pitch",
dictionary: "DictionaryDefinitions",
dictionaries: "DictionaryDefinitions",
dictionarydefinition: "DictionaryDefinitions",
dictionarydefinitions: "DictionaryDefinitions"
};
function noteLooksLikeYomuModel(modelName, settings, fieldNames) {
const configuredModel = resolvedAnkiModelName(settings);
if (modelName === configuredModel) return true;
const fieldSet = new Set(fieldNames);
return ["Expression", "Meaning", "Sentence", "DictionaryDefinitions"].every((field) => fieldSet.has(field));
}
function mergeAudioFilesForNote(fieldNames, options, card) {
if (options.audioMergeMode === "theirs") return [];
const fieldName = mediaFieldName(fieldNames, ["Audio", "audio", "Sound", "sound", "Voice", "Pronunciation"]);
if (!fieldName) return [];
return retargetMediaFiles(audioFilesFromContext(options, card), fieldName);
}
function mergePictureFilesForNote(fieldNames, existingFields, options, card, canOwnYomuFields) {
const fieldName = mediaFieldName(fieldNames, ["Image", "image", "Picture", "picture", "Screenshot", "screenshot"]);
if (!fieldName || !options.imageDataUrl) return [];
if (!canOwnYomuFields && existingFields[fieldName]) return [];
const image = imageFromDataUrl(options.imageDataUrl, card);
return image ? [{ ...image, fields: [fieldName] }] : [];
}
function applyMediaFieldClears(fields, audio, picture, audioMergeMode, canOwnYomuFields) {
if (audio.length && audioMergeMode === "ours") fields[audio[0].fields[0]] = "";
if (picture.length && canOwnYomuFields) fields[picture[0].fields[0]] = "";
}
function mediaFieldName(fieldNames, preferredNames) {
const exact = preferredNames.find((name) => fieldNames.includes(name));
if (exact) return exact;
const preferredLower = new Set(preferredNames.map((name) => name.toLowerCase()));
return fieldNames.find((name) => preferredLower.has(name.toLowerCase())) ?? "";
}
function retargetMediaFiles(files, fieldName) {
return files.map((file) => ({ ...file, fields: [fieldName] }));
}
function audioFilesFromContext(options, card) {
const files = [
audioFromMedia({ dataUrl: options.wordAudioDataUrl, url: options.wordAudioUrl, kind: "word" }, card),
audioFromMedia({ dataUrl: options.audioDataUrl, url: options.audioUrl, kind: "context" }, card)
].filter((file) => Boolean(file));
return uniqueAnkiAudioFiles(files);
}
function audioFromMedia(media, card) {
const fromData = media.dataUrl ? audioFromDataUrl(media.dataUrl, card, media.kind) : null;
if (fromData) return fromData;
return media.url ? audioFromUrl(media.url, card, media.kind) : null;
}
function audioFromDataUrl(dataUrl, card, kind) {
const parsed = parseAnkiAudioDataUrl(dataUrl);
if (!parsed) return null;
return {
filename: `yomu_${safeAnkiMediaName(card)}_${kind}_${Date.now()}.${parsed.extension}`,
data: parsed.data,
fields: ["Audio"]
};
}
function audioFromUrl(url, card, kind) {
const cleanUrl = url.trim();
if (!/^https?:\/\//i.test(cleanUrl)) return null;
return {
filename: `yomu_${safeAnkiMediaName(card)}_${kind}_${Date.now()}${audioUrlExtension(cleanUrl)}`,
url: cleanUrl,
fields: ["Audio"]
};
}
function uniqueAnkiAudioFiles(files) {
const seen = /* @__PURE__ */ new Set();
return files.filter((file) => {
const key = file.data ? `data:${file.data}` : `url:${file.url ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function parseAnkiImageDataUrl(dataUrl) {
const match = /^data:image\/(png|jpeg|jpg|webp|svg\+xml)(?:;[^,]*)?;base64,(.+)$/i.exec(dataUrl);
return match ? { extension: ankiImageExtension(match[1]), data: match[2] } : null;
}
function parseAnkiAudioDataUrl(dataUrl) {
const match = /^data:audio\/([a-z0-9.+-]+)(?:;[^,]*)?;base64,(.+)$/i.exec(dataUrl);
return match ? { extension: ankiAudioExtension(match[1]), data: match[2] } : null;
}
function ankiImageExtension(rawExtension) {
const extension = rawExtension.toLowerCase();
if (extension === "jpeg") return "jpg";
return extension === "svg+xml" ? "svg" : extension;
}
function ankiAudioExtension(rawExtension) {
const extension = rawExtension.toLowerCase();
if (extension === "mpeg" || extension === "mp3") return "mp3";
if (extension === "wav" || extension === "wave" || extension === "x-wav") return "wav";
if (extension === "ogg" || extension === "oga") return "ogg";
if (extension === "webm") return "webm";
if (extension === "mp4" || extension === "aac" || extension === "flac") return extension;
return "mp3";
}
function audioUrlExtension(url) {
try {
const pathname = new URL(url, location.href).pathname;
const match = /\.([a-z0-9]+)$/i.exec(pathname);
if (match) return `.${ankiAudioExtension(match[1])}`;
} catch {
}
return ".mp3";
}
function ankiMediaMimeType(filename) {
const extension = filename.split(".").pop()?.toLowerCase() ?? "";
if (extension === "mp3") return "audio/mpeg";
if (extension === "wav") return "audio/wav";
if (extension === "ogg" || extension === "oga" || extension === "opus") return "audio/ogg";
if (extension === "webm") return "audio/webm";
if (extension === "m4a" || extension === "mp4" || extension === "aac") return "audio/mp4";
if (extension === "flac") return "audio/flac";
return "audio/mpeg";
}
function safeAnkiMediaName(card) {
return card.spelling.replace(/[^\p{L}\p{N}-]+/gu, "_").slice(0, 24) || "yomu";
}
function isMobileUserAgent() {
const userAgent = typeof navigator === "undefined" ? "" : navigator.userAgent;
return /iPad|iPhone|iPod|Android/i.test(userAgent) || isIpadOSDesktopUserAgent();
}
function isMobileAnkiHandoffEnvironment() {
const userAgent = typeof navigator === "undefined" ? "" : navigator.userAgent;
return /iPad|iPhone|iPod/i.test(userAgent) || isIpadOSDesktopUserAgent() || /Android/i.test(userAgent) && /Chrome|Firefox|Firefox\/|FxiOS|EdgA/i.test(userAgent);
}
function canUseMobileAnkiHandoff(settings) {
return settings.ankiMobileHandoff && isMobileAnkiHandoffEnvironment();
}
function isIpadOSDesktopUserAgent() {
if (typeof navigator === "undefined") return false;
const maxTouchPoints = navigator.maxTouchPoints ?? 0;
const platform = navigator.platform ?? "";
return maxTouchPoints > 1 && /Mac/i.test(platform) && /Macintosh/i.test(navigator.userAgent ?? "");
}
function openMobileAnkiHandoff(note) {
const handoff = mobileAnkiHandoffTarget(note);
if (!window.confirm(mobileAnkiHandoffPrompt(note, handoff.appName))) return false;
location.href = handoff.url;
return true;
}
function mobileAnkiHandoffTarget(note) {
if (isAndroidUserAgent()) return { appName: "AnkiDroid", url: androidAnkiDroidIntentUrl(note) };
return { appName: "AnkiMobile", url: iosAnkiMobileUrl(note) };
}
function isAndroidUserAgent() {
return /Android/i.test(typeof navigator === "undefined" ? "" : navigator.userAgent);
}
function mobileAnkiHandoffPrompt(note, appName) {
const title = stripForMobileHandoff(note.fields.Expression || note.fields.Sentence || "this note");
return `Open ${appName} to add "${title}"?`;
}
function iosAnkiMobileUrl(note) {
const params = new URLSearchParams();
params.set("type", note.modelName);
params.set("deck", note.deckName);
params.set("dupes", "1");
if (note.tags?.length) params.set("tags", note.tags.join(" "));
Object.entries(note.fields).forEach(([field, value]) => {
const handoffValue = iosAnkiMobileFieldValue(field, value);
if (handoffValue !== null) params.set(`fld${field}`, handoffValue);
});
return `anki://x-callback-url/addnote?${params.toString()}`;
}
function iosAnkiMobileFieldValue(field, value) {
if (field !== "Image") return value;
const trimmed = value.trim();
if (!trimmed || /^data:/i.test(trimmed)) return null;
return trimmed;
}
function androidAnkiDroidIntentUrl(note) {
const front = stripForMobileHandoff(note.fields.Expression || note.fields.Sentence || "");
const back = stripForMobileHandoff([
note.fields.Reading,
note.fields.Meaning,
note.fields.DictionaryDefinitions,
note.fields.Source
].filter(Boolean).join("\n\n"));
return [
"intent:#Intent",
"action=android.intent.action.SEND",
"type=text/plain",
"package=com.ichi2.anki",
`S.android.intent.extra.SUBJECT=${encodeURIComponent(front)}`,
`S.android.intent.extra.TEXT=${encodeURIComponent(back)}`,
`S.browser_fallback_url=${encodeURIComponent("https://play.google.com/store/apps/details?id=com.ichi2.anki")}`,
"end"
].join(";");
}
function stripForMobileHandoff(value) {
return stripHtml$1(value).replace(/\s+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
}
function visibleArea(element2) {
const rect = element2.getBoundingClientRect();
const width = Math.max(0, Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0));
const height = Math.max(0, Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0));
return width * height;
}
function quoteAnkiSearch(term) {
return `"${term.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
function unique$2(items) {
return [...new Set(items)];
}
function flattenNoteFields(fields) {
const out = {};
Object.entries(fields ?? {}).forEach(([name, value]) => {
out[name] = stripHtml$1(String(value?.value ?? ""));
});
return out;
}
function emptyAnkiLookupResult() {
return { state: "not-in-deck", notes: [], primary: null };
}
function cardsByNoteId(cards) {
const cardsByNote = /* @__PURE__ */ new Map();
for (const cardInfo of cards) addCardInfoByNoteId(cardsByNote, cardInfo);
return cardsByNote;
}
function addCardInfoByNoteId(cardsByNote, cardInfo) {
const noteId = Number(cardInfo.note);
if (!Number.isFinite(noteId)) return;
const list = cardsByNote.get(noteId) ?? [];
list.push(cardInfo);
cardsByNote.set(noteId, list);
}
function ankiExistingNoteFromInfo(note, noteCards) {
const state = stateFromAnkiCards(noteCards);
return {
noteId: note.noteId,
modelName: note.modelName,
deckNames: ankiNoteDeckNames(noteCards),
cardIds: note.cards ?? [],
primaryCardId: ankiNotePrimaryCardId(note, noteCards),
state,
fields: flattenNoteFields(note.fields),
renderedCards: ankiRenderedCards(noteCards),
tags: note.tags ?? [],
...ankiNoteReviewMetrics(noteCards)
};
}
function ankiRenderedCards(noteCards) {
return noteCards.filter((card) => card.question || card.answer).map((card) => ({
cardId: card.cardId,
deckName: card.deckName,
question: String(card.question ?? ""),
answer: String(card.answer ?? "")
}));
}
function ankiNoteDeckNames(noteCards) {
return unique$2(noteCards.map((item) => item.deckName).filter(Boolean));
}
function ankiNotePrimaryCardId(note, noteCards) {
return pickPrimaryCard(noteCards)?.cardId ?? note.cards?.[0] ?? null;
}
function ankiNoteReviewMetrics(noteCards) {
return {
reps: sumAnkiCardMetric(noteCards, "reps"),
lapses: sumAnkiCardMetric(noteCards, "lapses")
};
}
function sumAnkiCardMetric(cards, metric) {
return cards.reduce((sum, item) => sum + Number(item[metric] || 0), 0);
}
function noteLooksLikeCard(note, card) {
const fields = flattenNoteFields(note.fields);
const exactTargets = noteCardExactTargets(card);
return noteHasExactTarget(fields, exactTargets) || noteExpressionContainsTarget(fields, exactTargets) || noteReadingContainsTarget(fields, card);
}
function noteCardExactTargets(card) {
return unique$2([card.spelling, card.reading].filter(Boolean));
}
function noteFieldValues(fields) {
return Object.values(fields).map((value) => value.replace(/\s+/g, " ").trim()).filter(Boolean);
}
function noteHasExactTarget(fields, exactTargets) {
const values = noteFieldValues(fields);
return exactTargets.some((target) => values.some((value) => value === target));
}
function noteExpressionContainsTarget(fields, exactTargets) {
const expression = firstNoteField(fields, ["Expression", "Front", "Word", "Vocab", "Term", "Expression Reading"]);
return Boolean(expression && exactTargets.some((target) => expression.includes(target)));
}
function firstNoteField(fields, names) {
return names.map((name) => fields[name]).find(Boolean) ?? "";
}
function noteReadingContainsTarget(fields, card) {
const reading = firstNoteReading(fields);
return Boolean(reading && card.reading && reading.includes(card.reading));
}
function firstNoteReading(fields) {
return firstNoteField(fields, ["Reading", "Kana", "Yomi"]);
}
function stripHtml$1(value) {
return value.replace(/ /gi, "\n").replace(/<[^>]+>/g, "").replace(/ /g, " ").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").trim();
}
function stateFromAnkiCards(cards) {
if (!cards.length) return "known";
return ANKI_CARD_STATE_RULES.find((rule) => cards.some(rule.matches))?.state ?? "known";
}
const ANKI_CARD_STATE_RULES = [
{ state: "suspended", matches: (card) => card.queue === -1 },
{ state: "failed", matches: (card) => card.type === 3 || card.queue === 3 },
{ state: "learning", matches: (card) => card.queue === 1 || card.type === 1 },
{ state: "new", matches: (card) => card.queue === 0 || card.type === 0 },
{ state: "due", matches: (card) => card.queue === 2 && Number(card.due ?? 0) <= 0 }
];
function stateFromExistingNotes(notes) {
const order = ["failed", "due", "learning", "new", "known", "suspended"];
return order.find((state) => notes.some((note) => note.state === state)) ?? (notes.length ? "known" : "not-in-deck");
}
function pickPrimaryCard(cards) {
const order = (card) => {
if (card.type === 3 || card.queue === 3) return 0;
if (card.queue === 2 && Number(card.due ?? 0) <= 0) return 1;
if (card.queue === 1 || card.type === 1) return 2;
if (card.queue === 0 || card.type === 0) return 3;
return 4;
};
return [...cards].sort((a, b) => order(a) - order(b))[0] ?? null;
}
function pickPrimaryExistingNote(notes) {
const order = (note) => {
if (note.state === "failed") return 0;
if (note.state === "due") return 1;
if (note.state === "learning") return 2;
if (note.state === "new") return 3;
if (note.state === "known") return 4;
return 5;
};
return [...notes].sort((a, b) => order(a) - order(b))[0] ?? null;
}
function ankiEaseFromGrade(grade) {
return ANKI_EASE_BY_GRADE[grade] ?? 3;
}
function yomuCardTemplates(settings) {
const language = settings.interfaceLanguage;
const recognitionFront = `
{{Expression}}
${settings.ankiFrontReading ? '{{#Reading}}{{Reading}}
{{/Reading}}' : ""}
${settings.ankiFrontSentence ? '{{#Sentence}}{{Sentence}}
{{/Sentence}}' : ""}
${settings.ankiFrontImage ? '{{#Image}}{{Image}}
{{/Image}}' : ""}
`;
const contextFront = `
{{#Sentence}}{{Sentence}}
{{/Sentence}}
${settings.ankiFrontImage ? '{{#Image}}{{Image}}
{{/Image}}' : ""}
${escapeHtml$1(uiText(language, "ankiPromptRecallWord"))}
`;
const back = `
{{FrontSide}}
{{Expression}}
{{#Reading}}{{Reading}}
{{/Reading}}
{{#Audio}}{{Audio}}
{{/Audio}}
{{#Meaning}}${escapeHtml$1(uiText(language, "ankiMeaningHeading"))} {{Meaning}}
{{/Meaning}}
{{#DictionaryDefinitions}}${escapeHtml$1(uiText(language, "dictionaries"))} {{DictionaryDefinitions}} {{/DictionaryDefinitions}}
{{#Kanji}}${escapeHtml$1(uiText(language, "kanji"))} {{Kanji}} {{/Kanji}}
`;
return {
[settings.ankiTemplateMode === "context" ? uiText(language, "ankiTemplateContext") : uiText(language, "ankiTemplateRecognition")]: {
Front: settings.ankiTemplateMode === "context" ? contextFront : recognitionFront,
Back: back
}
};
}
function yomuCardCss() {
return `
.card {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Yu Gothic", sans-serif;
font-size: 20px;
line-height: 1.45;
text-align: left;
color: #f4f7fb;
background: #15181e;
}
.yomu-card { max-width: 760px; margin: 0 auto; padding: 22px; }
.yomu-expression { font-size: 44px; font-weight: 850; letter-spacing: 0; line-height: 1.1; }
.yomu-reading { margin-top: 6px; color: #bac3d0; font-size: 24px; }
.yomu-prompt { margin-top: 14px; color: #bac3d0; font-size: 16px; }
.yomu-sentence {
margin-top: 18px;
padding: 14px 16px;
border: 1px solid #323843;
border-radius: 12px;
background: #1e232b;
color: #d8dee8;
}
.yomu-highlight { color: #7ad119; font-weight: 800; }
.yomu-sentence-front { font-size: 28px; }
.yomu-image img, .yomu-image { max-width: 100%; border-radius: 10px; margin-top: 16px; }
.yomu-section {
margin-top: 16px;
padding: 14px 16px;
border: 1px solid #303641;
border-radius: 12px;
background: #1b2028;
}
.yomu-section h2 {
margin: 0 0 10px;
color: #c2cad7;
font-size: 14px;
font-weight: 800;
letter-spacing: .08em;
text-transform: uppercase;
}
.yomu-definition, .yomu-dict-entry, .yomu-kanji-entry { margin-top: 12px; }
.yomu-definition:first-child, .yomu-dict-entry:first-child, .yomu-kanji-entry:first-child { margin-top: 0; }
.yomu-pos, .yomu-dict-label, .yomu-tags {
display: inline-block;
margin: 0 8px 6px 0;
color: #92a0b3;
font-size: 14px;
font-style: italic;
}
.yomu-glossary div { margin-top: 4px; }
.yomu-dict-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; margin-bottom: 4px; }
.yomu-dict-expression, .yomu-kanji-char { color: #fff; font-size: 24px; font-weight: 800; }
.yomu-dict-reading, .yomu-kanji-reading { color: #aab4c2; }
.yomu-kanji-char { font-size: 34px; }
.yomu-chip {
display: inline-block;
margin: 2px 6px 2px 0;
padding: 2px 8px;
border: 1px solid #4b5565;
border-radius: 999px;
color: #cdd5e1;
font-size: 14px;
}
.yomu-meta > div { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.yomu-meta > div:first-child { margin-top: 0; }
.yomu-meta strong { min-width: 112px; color: #8f9aaa; }
a { color: #7ad119; text-decoration: none; }
a:hover { text-decoration: underline; }
ul, ol { margin: 6px 0 0 22px; padding: 0; }
table { max-width: 100%; border-collapse: collapse; }
td, th { border: 1px solid #353c47; padding: 4px 6px; }
`;
}
function renderJpdbMeanings(card) {
return card.meanings.slice(0, 8).map((meaning) => {
const pos = formatPartOfSpeech(meaning.partOfSpeech);
return `
${pos ? `
${escapeHtml$1(pos)} ` : ""}
${escapeHtml$1(meaning.glosses.join("; "))}
`;
}).join("");
}
function renderSentence(sentence, expression) {
if (!sentence) return "";
if (!expression || !sentence.includes(expression)) return escapeHtml$1(sentence);
return sentence.split(expression).map((part) => escapeHtml$1(part)).join(`${escapeHtml$1(expression)} `);
}
function renderDictionaryDefinitions(entries, preferences) {
const groups = groupTermEntriesByDictionary$1(entries).slice(0, 6);
return groups.map(([dictionary, items]) => `
${escapeHtml$1(dictionaryLabel(dictionary, preferences))}
${items.slice(0, 6).map((entry) => `
${escapeHtml$1(entry.expression)}
${entry.reading && entry.reading !== entry.expression ? `${escapeHtml$1(entry.reading)} ` : ""}
${entry.definitionTags || entry.rules || entry.termTags ? `${escapeHtml$1([entry.definitionTags, entry.rules, entry.termTags].filter(Boolean).join(" · "))} ` : ""}
${entry.glossary.slice(0, 5).map((item) => `
${safeGlossaryHtml(item, entry.dictionary)}
`).join("")}
`).join("")}
`).join("");
}
function renderKanjiDefinitions$1(entries, preferences, language) {
const byCharacter = /* @__PURE__ */ new Map();
for (const entry of entries) {
const group = byCharacter.get(entry.character) ?? [];
group.push(entry);
byCharacter.set(entry.character, group);
}
return Array.from(byCharacter.entries()).slice(0, 8).map(([character, items]) => `
${escapeHtml$1(character)}
${escapeHtml$1(items.map((item) => dictionaryLabel(item.dictionary, preferences)).filter(uniqueValue).slice(0, 3).join(" · "))}
${items.slice(0, 3).map((item) => `
${item.onyomi.length ? `
${escapeHtml$1(uiText(language, "onReading"))} ${escapeHtml$1(item.onyomi.join("、"))} ` : ""}
${item.kunyomi.length ? `
${escapeHtml$1(uiText(language, "kunReading"))} ${escapeHtml$1(item.kunyomi.join("、"))} ` : ""}
${item.meanings.slice(0, 8).map((meaning) => escapeHtml$1(meaning)).join("; ")}
${item.tags.length ? `
${escapeHtml$1(item.tags.join(" · "))} ` : ""}
`).join("")}
`).join("");
}
function renderFrequency(card, entries, preferences) {
const chips = [];
if (card.frequencyRank) chips.push(`JPDB #${card.frequencyRank} `);
for (const entry of entries) {
appendFrequencyChip(chips, entry, preferences);
if (chips.length >= 8) break;
}
return chips.filter(uniqueValue).join(" ");
}
function appendFrequencyChip(chips, entry, preferences) {
if (entry.mode !== "freq") return;
const value = formatMetaFrequency$1(entry.data);
if (value) chips.push(`${escapeHtml$1(dictionaryLabel(entry.dictionary, preferences))} ${escapeHtml$1(value)} `);
}
function renderPitchField(card, entries, preferences) {
const chips = firstJpdbPitchChip(card);
for (const entry of entries) {
appendPitchChip(chips, entry, preferences);
if (chips.length >= 4) break;
}
return chips.filter(uniqueValue).join(" ");
}
function firstJpdbPitchChip(card) {
const pitch = card.pitchAccent.find(Boolean);
if (!pitch) return [];
const reading = card.reading && card.reading !== card.spelling ? `${card.reading} ` : "";
return [`JPDB ${escapeHtml$1(reading)}${escapeHtml$1(pitch)} `];
}
function appendPitchChip(chips, entry, preferences) {
if (entry.mode !== "pitch") return;
const value = formatMetaPitch(entry.data);
if (value) chips.push(`${escapeHtml$1(dictionaryLabel(entry.dictionary, preferences))} ${escapeHtml$1(value)} `);
}
function renderSource(sourceUrl, sourceTitle) {
const source = ankiSourceLink(sourceUrl, sourceTitle);
if (!source.label) return "";
return source.href ? `${escapeHtml$1(source.label)} ` : escapeHtml$1(source.label);
}
function ankiSourceLink(sourceUrl, sourceTitle) {
return { href: sourceUrl, label: sourceTitle || sourceUrl };
}
function groupTermEntriesByDictionary$1(entries) {
const grouped = /* @__PURE__ */ new Map();
for (const entry of entries) {
const group = grouped.get(entry.dictionary) ?? [];
group.push(entry);
grouped.set(entry.dictionary, group);
}
return Array.from(grouped.entries());
}
function dictionaryLabel(name, preferences) {
return preferences.find((item) => item.name === name)?.alias || name;
}
function uniqueValue(value, index, array) {
return array.indexOf(value) === index;
}
function safeGlossaryHtml(value, dictionary) {
const html = glossaryToHtml(value, dictionary);
return html || escapeHtml$1(glossaryToText(value));
}
function formatMetaFrequency$1(value) {
const display = metaFrequencyDisplayValue$1(value);
return display == null ? "" : `#${display}`;
}
function metaFrequencyDisplayValue$1(value) {
if (typeof value === "number" || typeof value === "string") return String(value);
return scalarMetaValue$1(nestedMetaScalarValue(value));
}
function scalarMetaValue$1(value) {
if (typeof value === "number" || typeof value === "string") return String(value);
const nested = nestedMetaScalarValue(value);
return nested === void 0 ? null : scalarMetaValue$1(nested);
}
function nestedMetaScalarValue(value) {
const record = metaRecord(value);
return record ? record.displayValue ?? record.frequency ?? record.value : void 0;
}
function formatMetaPitch(value) {
const record = metaRecord(value);
if (!record) return "";
const positions = metaPitchPositions(record);
return positions.length ? formatPitchPositions(positions) : formatPitchPosition(record.position);
}
function metaRecord(value) {
return value && typeof value === "object" ? value : null;
}
function metaPitchPositions(record) {
if (Array.isArray(record.pitches)) return record.pitches;
return Array.isArray(record.positions) ? record.positions : [];
}
function formatPitchPositions(positions) {
return positions.slice(0, 4).map(String).join(", ");
}
function formatPitchPosition(position) {
return typeof position === "number" ? String(position) : "";
}
function safeLocationHref() {
return typeof location === "undefined" ? "" : location.href;
}
function safeDocumentTitle() {
return typeof document === "undefined" ? "" : document.title;
}
function renderAnkiActionRow(ankiLookup, settings) {
if (!settings.ankiEnabled) return "";
if (ankiLookup.primary) return "";
return `${uiText(settings.interfaceLanguage, "addToAnki")}
`;
}
function renderAnkiExistingSection(ankiLookup, storedContext, language) {
const note = ankiLookup.primary;
if (!note) return "";
const preview = ankiExistingPreview(note, storedContext, language);
return `
Anki
${escapeHtml$1(preview.decks)}
${preview.renderedCard}
${preview.fields}
${preview.context}
${renderAnkiNoteActions(note, language)}
`;
}
function ankiExistingPreview(note, storedContext, language) {
return {
decks: note.deckNames.length ? note.deckNames.join(", ") : "Anki",
renderedCard: renderAnkiRenderedCard(note, language),
fields: renderAnkiFields(note, language),
context: storedContext ? renderLastMiningContext(storedContext, language) : ""
};
}
function renderAnkiRenderedCard(note, language) {
const card = primaryRenderedCard(note);
if (!card) return "";
const soundFilenames = ankiSoundFilenames(note);
const question = renderAnkiRenderedSide(uiText(language, "front"), card.question, soundFilenames, language);
const answer = renderAnkiRenderedSide(uiText(language, "back"), card.answer, soundFilenames, language);
if (!question && !answer) return "";
return `${question}${answer}
`;
}
function primaryRenderedCard(note) {
const cards = note.renderedCards ?? [];
if (!cards.length) return null;
return cards.find((card) => card.cardId === note.primaryCardId) ?? cards[0] ?? null;
}
function renderAnkiRenderedSide(label, value, soundFilenames, language) {
const html = sanitizeAnkiCardHtml(value, soundFilenames, language);
if (!html) return "";
return ``;
}
function renderAnkiFields(note, language) {
const fields = Object.entries(note.fields).map(([name, value]) => ({ name, value: value.trim() })).filter((field) => field.value).slice(0, 14);
if (!fields.length) return "";
return `
${fields.map((field) => previewField(field.name, field.value, language)).join("")}
`;
}
function previewField(label, value, language) {
return `${escapeHtml$1(label)} ${renderFieldText(value, language)}
`;
}
function renderFieldText(value, language) {
return escapeHtml$1(value).replace(
/\[sound:([^\]]+)]/gi,
(_, filename) => renderAnkiSoundChip(filename, language)
);
}
function renderAnkiSoundChip(filename, language) {
const label = ankiAudioLabel(filename, language);
return `${escapeHtml$1(label)} `;
}
function renderAnkiNoteActions(note, language) {
return `
${renderAnkiAudioMergeSelect(note, language)}
${escapeHtml$1(uiText(language, "mergeYomu"))}
${escapeHtml$1(uiText(language, "editInAnki"))}
`;
}
function renderAnkiAudioMergeSelect(note, language) {
if (!noteHasAudio(note)) return "";
return `
${escapeHtml$1(uiText(language, "audio"))}
${escapeHtml$1(uiText(language, "keepBothAudio"))}
${escapeHtml$1(uiText(language, "keepAnkiAudio"))}
${escapeHtml$1(uiText(language, "useYomuAudio"))}
`;
}
function noteHasAudio(note) {
return Object.entries(note.fields).some(
([name, value]) => /audio|sound|voice|pronunciation/i.test(name) || /\[sound:[^\]]+]/i.test(value)
);
}
function sanitizeAnkiCardHtml(value, soundFilenames, language) {
const trimmed = value.trim();
if (!trimmed) return "";
if (typeof document === "undefined") return escapeHtml$1(trimmed);
const template = document.createElement("template");
template.innerHTML = trimmed;
sanitizeAnkiCardFragment(template.content);
replaceAnkiPlaybackMarkers(template.content, soundFilenames, language);
return template.innerHTML.trim();
}
function sanitizeAnkiCardFragment(fragment) {
fragment.querySelectorAll("script, style, link, iframe, object, embed, base, meta").forEach((node) => node.remove());
fragment.querySelectorAll("*").forEach((node) => sanitizeAnkiCardElement(node));
}
function sanitizeAnkiCardElement(element2) {
for (const attr of Array.from(element2.attributes)) {
if (shouldRemoveAnkiCardAttribute(attr.name, attr.value)) element2.removeAttribute(attr.name);
}
}
function replaceAnkiPlaybackMarkers(root, soundFilenames, language) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
textNodes.forEach((node) => replaceAnkiPlaybackMarkerText(node, soundFilenames, language));
}
function replaceAnkiPlaybackMarkerText(node, soundFilenames, language) {
const parts = node.textContent?.split(/(\[anki:play:[^\]]+])/gi) ?? [];
if (parts.length < 2) return;
const fragment = document.createDocumentFragment();
for (const part of parts) {
if (!part) continue;
fragment.append(ankiPlaybackMarkerNode(part, soundFilenames, language) ?? document.createTextNode(part));
}
node.replaceWith(fragment);
}
function ankiPlaybackMarkerNode(value, soundFilenames, language) {
const match = /^\[anki:play:[^:\]]+:(\d+)]$/i.exec(value);
if (!match) return null;
const filename = soundFilenames[Number(match[1])] ?? soundFilenames[0] ?? "";
const chip = document.createElement("button");
chip.type = "button";
chip.className = "jpdb-reader-anki-sound jpdb-reader-anki-playback-marker";
chip.dataset.action = "anki-media-audio";
if (filename) chip.dataset.ankiMediaName = filename;
chip.title = filename ? ankiAudioLabel(filename, language) : uiText(language, "ankiAudioUnavailablePreview");
chip.disabled = !filename;
chip.textContent = uiText(language, "audio");
return chip;
}
function ankiSoundFilenames(note) {
const filenames = Object.values(note.fields).flatMap((value) => Array.from(value.matchAll(/\[sound:([^\]]+)]/gi), (match) => match[1]?.trim() ?? "")).filter(Boolean);
return [...new Set(filenames)];
}
function shouldRemoveAnkiCardAttribute(name, value) {
const lowerName = name.toLowerCase();
if (lowerName.startsWith("on") || lowerName === "srcdoc") return true;
if (!["href", "src", "poster", "xlink:href"].includes(lowerName)) return false;
return isUnsafeAnkiCardUrl(value);
}
function isUnsafeAnkiCardUrl(value) {
const trimmed = value.trim();
return /^(javascript|vbscript):/i.test(trimmed) || /^data:text\/html/i.test(trimmed);
}
function renderLastMiningContext(context, language) {
return `${escapeHtml$1(uiText(language, "lastSeen"))} ${escapeHtml$1(localizedContextLabel(context, language))} ${escapeHtml$1(context.sentence)}
`;
}
function localizedContextLabel(context, language) {
if (context.sourceKind === "immersion-kit" && context.immersionIndex !== void 0 && context.immersionTotal) {
return `${context.sourceTitle} ${context.immersionIndex + 1}/${context.immersionTotal}`;
}
if (context.sourceKind === "video" && context.sourceTitle) return `${uiText(language, "contextVideo")}: ${context.sourceTitle}`;
if (context.sourceKind === "image" && context.sourceTitle) return `${uiText(language, "contextImage")}: ${context.sourceTitle}`;
if (context.sourceKind === "jpdb" && context.sourceTitle) return `JPDB: ${context.sourceTitle}`;
return context.sourceTitle || context.sourceUrl || uiText(language, "contextCurrentPage");
}
function renderReviewButtons(settings, ankiNote = null, options = {}) {
const ankiAttrs = ankiNote?.primaryCardId ? ` data-anki-card-id="${ankiNote.primaryCardId}"` : "";
const disabledAttrs = reviewButtonDisabledAttrs(options, settings.interfaceLanguage);
const grades = reviewButtonGrades(settings);
return `
${grades.map(([grade, label]) => `${label} `).join("")}
`;
}
function reviewButtonDisabledAttrs(options, language) {
if (options.disabled) return ` disabled title="${escapeHtml$1(options.title || uiText(language, "unavailable"))}"`;
return options.title ? ` title="${escapeHtml$1(options.title)}"` : "";
}
function reviewButtonGrades(settings) {
const language = settings.interfaceLanguage;
return settings.twoButtonReviews ? [["fail", uiText(language, "gradeFailLabel")], ["pass", uiText(language, "gradePassLabel")]] : [["nothing", uiText(language, "gradeNothingLabel")], ["something", uiText(language, "gradeSomethingLabel")], ["hard", uiText(language, "gradeHardLabel")], ["okay", uiText(language, "gradeOkayLabel")], ["easy", uiText(language, "gradeEasyLabel")]];
}
function ankiAudioLabel(filename, language) {
const audio = uiText(language, "audio");
return filename ? `${audio} ${filename}` : audio;
}
const DEFAULT_POPOVER_WRITING_MODE = "horizontal-tb";
const SUPPORTED_POPOVER_WRITING_MODES = /* @__PURE__ */ new Set([
"horizontal-tb",
"vertical-rl",
"vertical-lr",
"sideways-rl",
"sideways-lr"
]);
function pauseActiveVideo() {
const videos = Array.from(document.querySelectorAll("video"));
const playable = videos.filter((video) => video.readyState > 0).sort((a, b) => {
const aArea = a.getBoundingClientRect().width * a.getBoundingClientRect().height;
const bArea = b.getBoundingClientRect().width * b.getBoundingClientRect().height;
return Number(a.paused) - Number(b.paused) || bArea - aArea;
});
const target = playable[0];
target?.pause();
}
function isEditableTarget(target) {
const element2 = target instanceof Element ? target : null;
return Boolean(element2?.closest('input, textarea, select, [contenteditable="true"]'));
}
async function copyText(text2) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text2);
return;
} catch {
}
}
const textarea = document.createElement("textarea");
textarea.value = text2;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.append(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
}
function normalizePressedKey(key) {
if (key === " ") return "space";
return key.toLowerCase();
}
function positionPopover(popover, anchor, fallbackRect, options = {}) {
const frame = preparePopoverPositionFrame(popover, anchor, fallbackRect, options);
if (!frame.sourceRects.length) {
positionPopoverWithoutAnchor(popover, frame);
return;
}
positionAnchoredPopover(popover, anchor, frame);
}
function preparePopoverPositionFrame(popover, anchor, fallbackRect, options) {
const viewport = getPopoverViewport();
const viewportHeight = viewport.bottom - viewport.top;
const viewportWidth = viewport.right - viewport.left;
popover.style.maxWidth = `${Math.max(0, viewportWidth)}px`;
popover.style.maxHeight = `${popoverMaxFrameHeight(viewportHeight, options)}px`;
return {
scrollTop: popover.scrollTop,
sourceRects: getPopoverSourceRects(anchor, fallbackRect, options),
viewport,
viewportWidth,
viewportHeight,
width: popover.offsetWidth,
height: popover.offsetHeight
};
}
function popoverMaxFrameHeight(viewportHeight, options) {
return Math.max(0, Math.min(viewportHeight, options.maxHeight ?? viewportHeight));
}
function positionPopoverWithoutAnchor(popover, frame) {
const margin = 8;
const fallbackLeft = (frame.viewport.left + frame.viewport.right - frame.width) / 2;
const fallbackTop = frame.viewportHeight * 0.18;
popover.style.left = `${Math.max(margin, Math.min(fallbackLeft, window.innerWidth - frame.width - margin))}px`;
popover.style.top = `${Math.max(margin, Math.min(fallbackTop, window.innerHeight - frame.height - margin))}px`;
restorePopoverScrollTop(popover, frame.scrollTop);
}
function positionAnchoredPopover(popover, anchor, frame) {
const writingMode = getPopoverWritingMode(anchor);
const position = getYomitanLikePopoverPosition(frame.sourceRects, writingMode, frame.viewport, frame.width, frame.height);
popover.style.maxWidth = `${Math.max(0, position.width)}px`;
popover.style.maxHeight = `${Math.max(0, position.height)}px`;
popover.dataset.jpdbReaderPlacementSide = getPlacementSide(writingMode, position);
popover.style.left = `${position.left}px`;
popover.style.top = `${position.top}px`;
restorePopoverScrollTop(popover, frame.scrollTop);
}
function restorePopoverScrollTop(popover, scrollTop) {
if (popover.scrollTop !== scrollTop) popover.scrollTop = scrollTop;
}
function getPopoverSourceRects(anchor, fallbackRect, options) {
if (options.followPoint) return pointPopoverRects(options.followPoint);
const anchorRects = anchorPopoverRects(anchor);
if (anchorRects.length) return anchorRects;
if (fallbackRect) return [domRectToPopoverRect(fallbackRect)];
return selectionPopoverRects();
}
function pointPopoverRects(point) {
const radius = 14;
return [{ left: point.x - radius, top: point.y - radius, right: point.x + radius, bottom: point.y + radius }];
}
function anchorPopoverRects(anchor) {
if (!anchor) return [];
const clientRects = rectListToPopoverRects(anchor.getClientRects());
if (clientRects.length) return clientRects;
const rect = domRectToPopoverRect(anchor.getBoundingClientRect());
return hasRectArea(rect) ? [rect] : [];
}
function selectionPopoverRects() {
const selection = window.getSelection();
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
if (!range) return [];
const clientRects = rangeClientPopoverRects(range);
return clientRects.length ? clientRects : rangeBoundingPopoverRects(range);
}
function rangeClientPopoverRects(range) {
return typeof range.getClientRects === "function" ? rectListToPopoverRects(range.getClientRects()) : [];
}
function rangeBoundingPopoverRects(range) {
if (typeof range.getBoundingClientRect !== "function") return [];
const rect = domRectToPopoverRect(range.getBoundingClientRect());
return hasRectArea(rect) ? [rect] : [];
}
function rectListToPopoverRects(rects) {
return Array.from(rects, domRectToPopoverRect).filter(hasRectArea);
}
function domRectToPopoverRect(rect) {
return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
}
function hasRectArea(rect) {
return rect.right > rect.left || rect.bottom > rect.top;
}
function getPopoverViewport() {
const { visualViewport } = window;
if (visualViewport) {
const left = visualViewport.offsetLeft;
const top = visualViewport.offsetTop;
return {
left,
top,
right: left + visualViewport.width,
bottom: top + visualViewport.height
};
}
return {
left: 0,
top: 0,
right: window.innerWidth,
bottom: window.innerHeight
};
}
function getPopoverWritingMode(anchor) {
return anchor ? normalizePopoverWritingMode(getComputedStyle(anchor).writingMode) : DEFAULT_POPOVER_WRITING_MODE;
}
function normalizePopoverWritingMode(writingMode) {
const normalized = writingMode;
return SUPPORTED_POPOVER_WRITING_MODES.has(normalized) ? normalized : DEFAULT_POPOVER_WRITING_MODE;
}
function getYomitanLikePopoverPosition(sourceRects, writingMode, viewport, frameWidth, frameHeight) {
const horizontal = isHorizontalPopoverMode(writingMode);
const layout = popoverWritingLayout(writingMode, horizontal);
return bestYomitanPopoverPosition(sourceRects, horizontal, viewport, frameWidth, frameHeight, layout) ?? fallbackPopoverPosition(viewport, frameWidth, frameHeight);
}
function bestYomitanPopoverPosition(sourceRects, horizontal, viewport, frameWidth, frameHeight, layout) {
let best = null;
for (const candidate of popoverSourceRectCandidates(sourceRects)) {
const result = getPositionForWritingMode(candidate.rect, horizontal, frameWidth, frameHeight, viewport, layout.horizontalOffset, layout.verticalOffset, layout.preferAfter);
if (!canUsePopoverPosition(candidate, result, sourceRects)) continue;
best = tallerPopoverPosition(best, result);
if (result.height >= frameHeight) break;
}
return best;
}
function isHorizontalPopoverMode(writingMode) {
return writingMode === DEFAULT_POPOVER_WRITING_MODE;
}
function fallbackPopoverPosition(viewport, frameWidth, frameHeight) {
return { left: viewport.left, top: viewport.top, width: frameWidth, height: frameHeight, after: true, below: true };
}
function popoverWritingLayout(writingMode, horizontal) {
return {
horizontalOffset: horizontal ? 0 : 10,
verticalOffset: horizontal ? 10 : 0,
preferAfter: horizontal ? true : isVerticalTextPopupOnRight(writingMode)
};
}
function tallerPopoverPosition(best, next) {
return best === null || next.height > best.height ? next : best;
}
function canUsePopoverPosition(candidate, position, sourceRects) {
return candidate.canOverlap || !isOverlapping(position, sourceRects, candidate.index);
}
function popoverSourceRectCandidates(sourceRects) {
const candidates = sourceRects.map((rect, index) => ({ rect, index, canOverlap: false }));
return sourceRects.length > 1 ? [...candidates, { rect: getBoundingSourceRect(sourceRects), index: sourceRects.length, canOverlap: true }] : candidates;
}
function getPositionForWritingMode(sourceRect, horizontal, frameWidth, frameHeight, viewport, horizontalOffset, verticalOffset, preferAfter) {
return horizontal ? getPositionForHorizontalText(sourceRect, frameWidth, frameHeight, viewport, horizontalOffset, verticalOffset, preferAfter) : getPositionForVerticalText(sourceRect, frameWidth, frameHeight, viewport, horizontalOffset, verticalOffset, preferAfter);
}
function getPositionForHorizontalText(sourceRect, frameWidth, frameHeight, viewport, horizontalOffset, verticalOffset, preferBelow) {
const [left, width, after] = getConstrainedPosition(
sourceRect.right - horizontalOffset,
sourceRect.left + horizontalOffset,
frameWidth,
viewport.left,
viewport.right,
true
);
const [top, height, below] = getConstrainedPositionBinary(
sourceRect.top - verticalOffset,
sourceRect.bottom + verticalOffset,
frameHeight,
viewport.top,
viewport.bottom,
preferBelow
);
return { left, top, width, height, after, below };
}
function getPositionForVerticalText(sourceRect, frameWidth, frameHeight, viewport, horizontalOffset, verticalOffset, preferRight) {
const [left, width, after] = getConstrainedPositionBinary(
sourceRect.left - horizontalOffset,
sourceRect.right + horizontalOffset,
frameWidth,
viewport.left,
viewport.right,
preferRight
);
const [top, height, below] = getConstrainedPosition(
sourceRect.bottom - verticalOffset,
sourceRect.top + verticalOffset,
frameHeight,
viewport.top,
viewport.bottom,
true
);
return { left, top, width, height, after, below };
}
function isVerticalTextPopupOnRight(writingMode) {
return !(writingMode === "vertical-lr" || writingMode === "sideways-lr");
}
function getConstrainedPosition(positionBefore, positionAfter, size, minLimit, maxLimit, after) {
size = Math.min(size, maxLimit - minLimit);
let position;
{
position = Math.max(minLimit, positionAfter);
position = position - Math.max(0, position + size - maxLimit);
}
return [position, size, after];
}
function getConstrainedPositionBinary(positionBefore, positionAfter, size, minLimit, maxLimit, after) {
const overflowBefore = minLimit - (positionBefore - size);
const overflowAfter = positionAfter + size - maxLimit;
if (overflowAfter > 0 || overflowBefore > 0) {
after = overflowAfter < overflowBefore;
}
let position;
if (after) {
size -= Math.max(0, overflowAfter);
position = Math.max(minLimit, positionAfter);
} else {
size -= Math.max(0, overflowBefore);
position = Math.min(maxLimit, positionBefore) - size;
}
return [position, size, after];
}
function getBoundingSourceRect(sourceRects) {
switch (sourceRects.length) {
case 0:
return { left: 0, top: 0, right: 0, bottom: 0 };
case 1:
return sourceRects[0];
}
let { left, top, right, bottom } = sourceRects[0];
for (let i = 1, ii = sourceRects.length; i < ii; ++i) {
const sourceRect = sourceRects[i];
left = Math.min(left, sourceRect.left);
top = Math.min(top, sourceRect.top);
right = Math.max(right, sourceRect.right);
bottom = Math.max(bottom, sourceRect.bottom);
}
return { left, top, right, bottom };
}
function isOverlapping(sizeRect, sourceRects, ignoreIndex) {
const { left, top } = sizeRect;
const right = left + sizeRect.width;
const bottom = top + sizeRect.height;
for (let i = 0, ii = sourceRects.length; i < ii; ++i) {
if (i === ignoreIndex) continue;
const sourceRect = sourceRects[i];
if (rectsOverlap({ left, top, right, bottom }, sourceRect)) return true;
}
return false;
}
function rectsOverlap(a, b) {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
function getPlacementSide(writingMode, position) {
if (writingMode === "horizontal-tb") return position.below ? "below" : "above";
return position.after ? "right" : "left";
}
const log$q = Logger.scope("StudyTools");
const PARTICLE_CHUNK = String.raw`[^はがをにへとでもやのて、。!?!?\s]{1,24}`;
const FORM_CHUNK = String.raw`[^はがをにへとでもやのてで、。!?!?\s]{0,24}`;
const GRAMMAR_PREFERENCES_KEY = "yomu.grammarPreferences.v1";
const MAX_LOCAL_GRAMMAR_HINTS = 12;
const ENGLISH_TEXT_RE = /[A-Za-z]{3,}/u;
const JAPANESE_TEXT_RE = /[\u3040-\u30ff\u3400-\u9fff]/u;
function gp(ruleId, level, name, source, kind, short, detail, url, examples, confidence = "medium", priority = 30) {
return { ruleId, level, pattern: new RegExp(source, "u"), name, kind, short, detail, url, confidence, priority, examples };
}
function ex(japanese, english, note) {
return note ? [{ japanese, english, note }] : [{ japanese, english }];
}
const GRAMMAR_PATTERNS = [
gp("potential-koto-ga-dekiru", "N4", "ことができる", `${FORM_CHUNK}ことができ(?:る|ます|ない|ません|た|ました|なかった|ませんでした)?`, "Potential expression", "can do something", 'Turns the action before こと into an ability or possibility: "can do..."', "https://www.tofugu.com/japanese-grammar/koto-ga-dekiru/", ex("日本語を話すことができます。", "I can speak Japanese.", "Verb + ことができる marks ability."), "high", 5),
gp("obligation-nakereba-naranai", "N4", "なければならない", `${FORM_CHUNK}(?:なければならない|なければなりません|なくてはならない|なくてはなりません|なくてはいけない|なくてはいけません|なければいけない|なければいけません|なきゃ(?:いけない|だめ)?|なくちゃ(?:いけない|だめ)?|ないといけない|ねばならない)`, "Obligation", "must or have to do", "Says an action is necessary or required. Casual forms like なきゃ and なくちゃ carry the same basic idea.", "https://www.tofugu.com/japanese-grammar/nakereba-naranai/", ex("明日までに払わなければならない。", "I have to pay by tomorrow.", "The first clause is required."), "high", 4),
gp("permission-not-required-nakutemo-ii", "N5", "なくてもいい", `${FORM_CHUNK}なくても(?:いい|よい|大丈夫)(?:です)?`, "Permission / no obligation", "do not have to", "Says the action is not required, or that not doing it is okay.", "", ex("今日は来なくてもいいです。", "You do not have to come today.", "Negative て-form + もいい removes obligation."), "high", 4),
gp("prohibition-tewa-ikenai", "N4", "てはいけない", `${FORM_CHUNK}(?:(?:[てで]は|ちゃ|じゃ)いけ(?:ない|ません|なかった|ませんでした)|(?:[てで]は|ちゃ|じゃ)だめ(?:だ|です)?)`, "Prohibition", "must not do", "Marks an action as not allowed or unacceptable. Casual ちゃだめ and じゃだめ are included.", "https://www.tofugu.com/japanese-grammar/tewa-ikenai/", ex("ここで写真を撮ってはいけません。", "You must not take photos here.", "てはいけない is a direct prohibition."), "high", 5),
gp("permission-temo-ii", "N5", "てもいい", `${FORM_CHUNK}[てで]も(?:いい|よい|よかった|よくない|よくありません)(?:です)?`, "Permission", "permission or approval", "Means it is okay to do the action before てもいい. Negative variants can ask or say whether it is not okay.", "https://www.tofugu.com/japanese-grammar/temoii/", ex("水を飲んでもいいです。", "It is okay to drink water.", "て-form + もいい grants permission."), "high", 5),
gp("request-te-kudasai", "N5", "てください", `${FORM_CHUNK}[てで]ください(?:ませんか)?`, "Request", "please do", "Makes a direct but polite request. くださいませんか is softer.", "", ex("ゆっくり話してください。", "Please speak slowly.", "て-form + ください requests an action."), "high", 6),
gp("polite-request-te-itadakemasen-ka", "N4", "ていただけませんか", `${FORM_CHUNK}[てで](?:いただけませんか|くださいませんか)`, "Polite request", "could you please do", "A softer request that asks someone to do something for you politely.", "", ex("もう一度説明していただけませんか。", "Could you please explain it one more time?", "ていただけませんか is a polite request form."), "high", 6),
gp("request-naide-kudasai", "N5", "ないでください", `${FORM_CHUNK}ないでください`, "Negative request", "please do not do", "Politely asks someone not to do an action.", "", ex("ここで走らないでください。", "Please do not run here.", "ないでください is the negative request form."), "high", 5),
gp("advice-hou-ga-ii", "N4", "方がいい", `${FORM_CHUNK}ほうが(?:いい|よい)(?:です)?`, "Advice", "better to do", "Gives advice or says one option is better.", "", ex("早く寝たほうがいいです。", "It is better to go to bed early.", "Often follows past tense for advice."), "high", 6),
gp("command-nasai", "N4", "なさい", `${FORM_CHUNK}なさい`, "Command", "do this", "A command form often used by adults toward children or in instructions.", "", ex("宿題をしなさい。", "Do your homework.", "Stem + なさい gives an instruction."), "high", 6),
gp("experience-ta-koto-ga-aru", "N4", "たことがある", `${FORM_CHUNK}たことが(?:あ(?:る|ります|った|りました|りません|りませんでした)|ない|なかった|ありません|ありませんでした)`, "Experience", "has done before", "Uses a past verb plus ことがある to talk about having had an experience.", "https://www.tofugu.com/japanese-grammar/ta-koto-ga-aru/", ex("京都に行ったことがあります。", "I have been to Kyoto.", "Past verb + ことがある marks experience."), "high", 6),
gp("completion-te-shimau", "N4", "てしまう", `(?:${FORM_CHUNK}[てで]しま(?:う|います|った|いました|わない|いません)|${FORM_CHUNK}(?:ちゃう|ちゃいます|ちゃった|ちゃいました|じゃう|じゃいます|じゃった|じゃいました))`, "Completion / regret", "do completely or unfortunately", "Can show that an action is completed, often with a feeling of regret, surprise, or accident.", "https://www.tofugu.com/japanese-grammar/te-shimau/", ex("財布を忘れてしまいました。", "I unfortunately forgot my wallet.", "てしまう can add regret or completion."), "high", 6),
gp("attempt-te-miru", "N4", "てみる", `${FORM_CHUNK}[てで]み(?:る|ます|た|ました|たい|ない|ません)`, "Attempt", "try doing", "Means to try an action and see what happens.", "https://www.tofugu.com/japanese-grammar/te-miru/", ex("新しい店で食べてみます。", "I will try eating at the new shop.", "て-form + みる is experimental trying."), "high", 6),
gp("preparation-te-oku", "N4", "ておく", `(?:${FORM_CHUNK}[てで]お(?:く|きます|いた|きました|かない|きません)|${FORM_CHUNK}(?:とく|ときます|といた|ときました|どく|どきます|どいた|どきました))`, "Preparation", "do in advance or leave as is", "Often marks an action done ahead of time, or a state intentionally left alone. Casual とく and どく are included.", "https://www.tofugu.com/japanese-grammar/teoku/", ex("旅行の前に予約しておきます。", "I will make a reservation before the trip.", "ておく prepares for later."), "high", 6),
gp("desire-other-te-hoshii", "N4", "てほしい", `${FORM_CHUNK}[てで]ほしい`, "Desire / request", "want someone to do", "Says the speaker wants someone else to do the action.", "", ex("もう少し待ってほしいです。", "I want you to wait a little longer.", "てほしい points desire at someone else's action."), "high", 7),
gp("benefactive-te-kureru-morau", "N4", "てくれる / てもらう", `${FORM_CHUNK}[てで](?:くれ(?:る|ます|た|ました|ない|ません)|くださ(?:る|います|った|いました)|あげ(?:る|ます|た|ました)|や(?:る|ります|った|りました)|もら(?:う|います|った|いました)|いただ(?:く|きます|いた|きました))`, "Giving and receiving", "favor done for someone", "Combines て-form with giving or receiving verbs to show who benefits from an action.", "https://www.tofugu.com/japanese-grammar/te-kureru/", ex("先生が説明してくださいました。", "The teacher kindly explained it.", "The helper verb shows benefit and direction."), "medium", 8),
gp("change-you-ni-naru", "N4", "ようになる", `${FORM_CHUNK}ようにな(?:る|ります|った|りました|らない|りません)`, "Change over time", "come to do or become so that", "Shows a new ability, habit, or state developing over time.", "https://www.tofugu.com/japanese-grammar/you-ni-naru/", ex("漢字が読めるようになりました。", "I became able to read kanji.", "Often describes gradual change."), "high", 8),
gp("habit-you-ni-suru", "N4", "ようにする", `${FORM_CHUNK}ように(?:す(?:る|ます|た|ました)|し(?:ている|ています|た|ました))`, "Effort / habit", "make sure to do", "Shows an intentional effort to make an action happen regularly or reliably.", "https://www.tofugu.com/japanese-grammar/you-ni-suru/", ex("毎日復習するようにしています。", "I try to review every day.", "ようにする describes deliberate effort."), "high", 8),
gp("voice-causative-passive", "N3", "させられる", `${FORM_CHUNK}(?:させられ(?:る|ます|た|ました)|[かがさざただなばまらわ]せられ(?:る|ます|た|ました)|[かがさざただなばまらわ]され(?:る|ます|た|ました))`, "Causative-passive", "be made to do", "Combines causative and passive meaning: someone is made to do an action, often unwillingly.", "https://www.tofugu.com/japanese-grammar/verb-causative-form-saseru/", ex("子どものころ、野菜を食べさせられました。", "When I was a child, I was made to eat vegetables.", "Regex can only flag the form; context decides the exact verb."), "medium", 8),
gp("voice-causative", "N4", "させる", `${FORM_CHUNK}(?:させ(?:る|ます|た|ました)|[かがさざただなばまらわ]せ(?:る|ます|た|ました))`, "Causative", "make or let someone do", "Adds a causer: someone makes, lets, or has someone else do the action.", "https://www.tofugu.com/japanese-grammar/verb-causative-form-saseru/", ex("母は子どもを遊ばせた。", "The mother let the child play.", "Causative can mean make or let."), "medium", 9),
gp("voice-passive-potential", "N4", "れる / られる", `${FORM_CHUNK}(?:られる|られます|[かがさざただなばまわ]れる|[かがさざただなばまわ]れます)`, "Passive / potential", "passive, potential, or honorific form", "This ending can mark passive voice, ability, or respectful speech; context decides which reading fits.", "https://www.tofugu.com/japanese-grammar/verb-passive-form-rareru/", ex("この漢字はよく見られます。", "This kanji is often seen.", "Surface regex cannot fully disambiguate passive, potential, and honorific."), "medium", 9),
gp("evidence-rashii-mitai", "N4", "らしい / みたい", `(?:${FORM_CHUNK}らしい|${FORM_CHUNK}みたい(?:だ|です|に|な))`, "Hearsay / likeness", "seems like or apparently", "Expresses appearance, hearsay, tendency, or resemblance depending on the form and context.", "https://www.tofugu.com/japanese-grammar/rashii/", ex("明日は雨らしいです。", "Apparently it will rain tomorrow.", "らしい often reports what one has heard."), "medium", 9),
gp("modality-kamoshirenai", "N4", "かもしれない", "(?:かもしれない|かもしれません|かも)", "Possibility", "might or maybe", "Softens a statement into a possibility rather than a firm claim.", "https://www.tofugu.com/japanese-grammar/kamoshirenai/", ex("彼は来ないかもしれません。", "He might not come.", "かも is a casual short form."), "high", 9),
gp("modality-deshou-darou", "N5", "でしょう / だろう", "(?:でしょう|でしょうか|だろう|だろうか)", "Probability", "probably or right?", "Adds probability, expectation, or a confirmation-seeking tone.", "https://www.tofugu.com/japanese-grammar/deshou/", ex("明日は晴れるでしょう。", "It will probably be sunny tomorrow.", "でしょう is polite; だろう is plainer."), "high", 10),
gp("quotation-to-omou", "N4", "と思う", `${FORM_CHUNK}と思(?:う|います|った|いました|っている|っています)`, "Quotation / thought", "think that...", "Marks the content of a thought or statement before 思う.", "https://www.tofugu.com/japanese-grammar/to-omou/", ex("これは便利だと思います。", "I think this is convenient.", "The phrase before と is the thought content."), "high", 10),
gp("attempt-you-to-suru", "N3", "ようとする", `${FORM_CHUNK}ようと(?:す(?:る|ます|た|ました|ている|ています)|し(?:た|ました|ている|ています))`, "Attempt / about to", "try to or be about to", "Uses the volitional form plus とする for an attempted action or something about to happen.", "https://www.tofugu.com/japanese-grammar/verb-volitional-form-you/", ex("出かけようとした時、電話が鳴った。", "Just as I was about to go out, the phone rang.", "Volitional + とする marks trying or being about to act."), "medium", 11),
gp("plan-tsumori-yotei", "N4", "つもり / 予定", `${FORM_CHUNK}(?:つもり|予定)(?:だ|です|だった|でした)?`, "Plan / intention", "intend or plan to do", "つもり points to intention, while 予定 points to a plan or schedule.", "https://www.tofugu.com/japanese-grammar/tsumori/", ex("来年日本へ行くつもりです。", "I intend to go to Japan next year.", "つもり is intention; 予定 is a plan."), "medium", 12),
gp("expectation-hazu", "N4", "はず", `${FORM_CHUNK}はず(?:だ|です|だった|でした|がない|はない)?`, "Expectation", "should be or expected to", "Marks a strong expectation based on what the speaker knows.", "https://www.tofugu.com/japanese-grammar/hazu/", ex("彼はもう着いたはずです。", "He should have arrived already.", "はず signals a reasoned expectation."), "high", 12),
gp("reasoning-wake", "N3", "わけ", `${FORM_CHUNK}わけ(?:ではない|じゃない|がない|にはいかない|だ|です)?`, "Reasoning", "reason, conclusion, or not necessarily", "Points to a logical reason or conclusion, with negative forms often meaning not necessarily or cannot reasonably.", "https://www.tofugu.com/japanese-grammar/wake/", ex("高いわけではありません。", "It is not necessarily expensive.", "Specific わけ forms may be more precise if also detected."), "medium", 24),
gp("reasoning-wake-dewa-nai", "N3", "わけではない", `${FORM_CHUNK}わけ(?:では|じゃ)(?:ない|ありません)`, "Qualification", "not necessarily", "Says a statement is not fully or necessarily true.", "", ex("嫌いなわけではない。", "It is not that I dislike it.", "Often softens or qualifies a previous implication."), "high", 11),
gp("impossibility-wake-ga-nai", "N3", "わけがない", `${FORM_CHUNK}わけが(?:ない|ありません)`, "Impossibility", "there is no way", "Strongly denies possibility or reasonableness.", "", ex("彼が知らないわけがない。", "There is no way he does not know.", "わけがない rejects the possibility."), "high", 11),
gp("constraint-wake-ni-wa-ikanai", "N3", "わけにはいかない", `${FORM_CHUNK}わけにはい(?:かない|きません)`, "Social constraint", "cannot reasonably do", "Says one cannot do something because of duty, social pressure, or circumstances.", "", ex("約束を破るわけにはいかない。", "I cannot break the promise.", "The barrier is often social or practical."), "high", 11),
gp("purpose-tame-ni", "N4", "ために", `${FORM_CHUNK}ために`, "Purpose / benefit", "for the sake of or in order to", "Links an action or noun to a purpose, goal, or beneficiary.", "https://www.tofugu.com/japanese-grammar/tame-ni/", ex("家族のために働いています。", "I work for my family.", "ために marks purpose or benefit."), "high", 12),
gp("purpose-you-ni", "N4", "ように", `${FORM_CHUNK}ように`, "Purpose / manner", "so that or in the way that", "Can mark a goal, desired result, or manner of doing something.", "https://www.tofugu.com/japanese-grammar/you-ni/", ex("忘れないようにメモします。", "I will write a note so I do not forget.", "This is broader than ようになる and ようにする."), "medium", 28),
gp("timing-tokoro", "N4", "ところ", `${FORM_CHUNK}ところ(?:だ|です|だった|でした|で|に)?`, "Timing / situation", "point in time or situation", "Frames an action as about to happen, happening now, just happened, or as a situation.", "https://www.tofugu.com/japanese/tokoro-bakari/", ex("今、出かけるところです。", "I am just about to go out.", "ところ focuses on the moment or situation."), "medium", 14),
gp("simultaneous-nagara", "N4", "ながら", `${FORM_CHUNK}ながら`, "Simultaneous action", "while doing", "Connects two actions done at the same time by the same subject.", "https://www.tofugu.com/japanese-grammar/nagara/", ex("音楽を聞きながら勉強します。", "I study while listening to music.", "ながら joins simultaneous actions."), "high", 14),
gp("state-mama", "N3", "まま", `${FORM_CHUNK}まま`, "Unchanged state", "as is or while still", "Keeps a state unchanged while another action or situation continues.", "https://www.tofugu.com/japanese-grammar/mama/", ex("電気をつけたまま寝てしまった。", "I fell asleep with the light still on.", "まま preserves the previous state."), "medium", 15),
gp("list-tari", "N5", "たり", `${FORM_CHUNK}たり`, "Representative list", "doing things like...", "Lists example actions without claiming the list is complete.", "https://www.tofugu.com/japanese-grammar/tari/", ex("週末は映画を見たり本を読んだりします。", "On weekends I do things like watch movies and read books.", "たり usually appears in pairs but can be single."), "medium", 16),
gp("limitation-bakari", "N4", "ばかり", `${FORM_CHUNK}ばかり`, "Limitation / recent action", "only, just did, or nothing but", "Can mark a recent completed action or a sense of only or nothing but depending on context.", "https://www.tofugu.com/japanese/tokoro-bakari/", ex("彼はゲームばかりしています。", "He does nothing but play games.", "ばかり can also mean just did after past tense."), "medium", 16),
gp("limitation-dake-shika", "N5", "だけ / しか", `${FORM_CHUNK}(?:だけ|しか)`, "Limitation", "only or nothing but", "だけ means only; しか usually pairs with a negative ending to mean nothing but.", "https://www.tofugu.com/japanese-grammar/dake/", ex("百円しかありません。", "I have only 100 yen.", "しか expects a negative predicate."), "medium", 18),
gp("degree-hodo-kurai", "N4", "ほど / くらい", `${FORM_CHUNK}(?:ほど|くらい|ぐらい)`, "Degree / approximation", "extent or about", "Marks approximate amount or the degree to which something is true.", "https://www.tofugu.com/japanese-grammar/hodo/", ex("一時間ぐらい待ちました。", "I waited about an hour.", "ほど often emphasizes degree; くらい can be approximate."), "medium", 18),
gp("role-toshite", "N3", "として", `${FORM_CHUNK}として`, "Role / standpoint", "as or in the role of", "Marks the role, capacity, or standpoint from which something is true.", "", ex("医者として働いています。", "I work as a doctor.", "として marks role or capacity."), "high", 18),
gp("relation-ni-yotte", "N3", "によって", `${FORM_CHUNK}によって`, "Means / cause / by", "by, depending on, or because of", "Can mark means, agent in passive sentences, cause, or variation depending on context.", "", ex("国によって習慣が違います。", "Customs differ depending on the country.", "によって is highly context-dependent."), "medium", 18),
gp("topic-ni-tsuite", "N3", "について", `${FORM_CHUNK}について`, "Topic", "about or concerning", "Marks the topic being discussed, considered, or investigated.", "", ex("日本の歴史について調べています。", "I am researching Japanese history.", "について is a topic marker."), "high", 18),
gp("target-ni-taishite", "N3", "に対して", `${FORM_CHUNK}に対して`, "Target / contrast", "toward, against, or in contrast to", "Marks the target of an attitude or action, or sets up a contrast.", "", ex("子どもに対して優しい。", "She is kind toward children.", "に対して points at the target."), "medium", 18),
gp("concession-ni-mo-kakawarazu", "N2", "にもかかわらず", `${FORM_CHUNK}にもかかわらず`, "Concession", "despite or even though", "Connects two facts when the second happens despite the first.", "", ex("雨にもかかわらず試合は行われた。", "The game was held despite the rain.", "Formal concessive connector."), "high", 18),
gp("concession-kuse-ni", "N3", "くせに", `${FORM_CHUNK}くせに`, "Blame / contradiction", "even though, with criticism", "Marks a contradiction with a blaming or critical tone.", "", ex("知らないくせに文句を言う。", "He complains even though he does not know.", "くせに often sounds critical."), "medium", 18),
gp("suffix-tachi", "N5", "たち / 達", `${PARTICLE_CHUNK}(?:たち|(? grammarMatches(item, normalized)).sort(compareRankedGrammarHints);
for (const item of ranked) {
const key = `${item.ruleId}:${item.match}:${item.index}`;
if (seenMatches.has(key)) continue;
const count = seenNames.get(item.ruleId) ?? 0;
if (count >= 2) continue;
if (selected.some((existing) => shouldSuppressOverlappingGrammarHint(existing, item))) continue;
seenMatches.add(key);
seenNames.set(item.ruleId, count + 1);
selected.push(item);
if (selected.length >= MAX_LOCAL_GRAMMAR_HINTS) break;
}
return selected.sort(compareGrammarHints).map(({ priority: _priority, ...hint }) => hint);
}
function compareRankedGrammarHints(a, b) {
return a.priority - b.priority || a.index - b.index || b.match.length - a.match.length || a.name.localeCompare(b.name);
}
function compareGrammarHints(a, b) {
return a.index - b.index || a.name.localeCompare(b.name);
}
function shouldSuppressOverlappingGrammarHint(existing, next) {
if (!grammarHintRangesOverlap(existing, next)) return false;
if (sameGrammarHintLocation(existing, next)) return true;
if (existing.priority < 40 && next.priority < 40) return false;
const nextIsLooseEndingOrParticle = next.priority >= 40;
if (nextIsLooseEndingOrParticle && existing.priority < next.priority) return true;
const nextEnd = next.index + next.match.length;
const existingEnd = existing.index + existing.match.length;
const nextInsideExisting = next.index >= existing.index && nextEnd <= existingEnd;
return nextInsideExisting && existing.priority <= next.priority && existing.match.length > next.match.length;
}
function sameGrammarHintLocation(existing, next) {
return existing.match === next.match && existing.index === next.index;
}
function grammarHintRangesOverlap(a, b) {
const aEnd = a.index + a.match.length;
const bEnd = b.index + b.match.length;
return a.index < bEnd && b.index < aEnd;
}
function readGrammarPreferences() {
const fallback = { knownRuleIds: [], showKnown: false };
try {
const raw = globalThis.localStorage?.getItem(GRAMMAR_PREFERENCES_KEY);
if (!raw) return fallback;
const parsed = JSON.parse(raw);
return {
knownRuleIds: Array.isArray(parsed.knownRuleIds) ? parsed.knownRuleIds.filter((id) => typeof id === "string" && id.length > 0) : [],
showKnown: parsed.showKnown === true
};
} catch (error) {
log$q.warn("Grammar preference read failed", { error });
return fallback;
}
}
function writeGrammarPreferences(preferences) {
try {
const uniqueKnownRuleIds = Array.from(new Set(preferences.knownRuleIds)).sort();
globalThis.localStorage?.setItem(GRAMMAR_PREFERENCES_KEY, JSON.stringify({
knownRuleIds: uniqueKnownRuleIds,
showKnown: preferences.showKnown
}));
} catch (error) {
log$q.warn("Grammar preference write failed", { error });
}
}
function setGrammarRuleKnown(ruleId, known) {
const preferences = readGrammarPreferences();
const knownRuleIds = new Set(preferences.knownRuleIds);
if (known) knownRuleIds.add(ruleId);
else knownRuleIds.delete(ruleId);
const next = { ...preferences, knownRuleIds: Array.from(knownRuleIds) };
writeGrammarPreferences(next);
return next;
}
function setKnownGrammarVisible(showKnown) {
const next = { ...readGrammarPreferences(), showKnown };
writeGrammarPreferences(next);
return next;
}
async function translateJapaneseSentence(sentence, language = "en") {
const trimmed = sentence.trim();
if (!trimmed) return "";
const targetLanguage = translationTargetLanguage(language);
const cacheKey = `${targetLanguage}:${trimmed}`;
const cached = translationCache.get(cacheKey);
if (cached) {
return cached;
}
const inFlight = translationInFlight.get(cacheKey);
if (inFlight) {
return inFlight;
}
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=ja&tl=${targetLanguage}&dt=t&dt=bd&dj=1&q=${encodeURIComponent(trimmed)}`;
const promise = (async () => {
const done = log$q.time("Translate sentence", { sentenceLength: trimmed.length });
try {
const json = await requestJson$2(url);
const translated = (json.sentences ?? []).map((item) => item.trans ?? "").join("").trim();
if (!translated) throw new Error("No translation returned.");
translationCache.set(cacheKey, translated);
log$q.info("Translation completed", { sentenceLength: trimmed.length, translationLength: translated.length });
return translated;
} catch (error) {
log$q.warn("Translation failed", { sentenceLength: trimmed.length, error });
throw error;
} finally {
done();
}
})();
translationInFlight.set(cacheKey, promise);
void promise.then(() => {
if (translationInFlight.get(cacheKey) === promise) translationInFlight.delete(cacheKey);
}, () => {
if (translationInFlight.get(cacheKey) === promise) translationInFlight.delete(cacheKey);
});
return promise;
}
function translationTargetLanguage(language) {
return language === "ja" ? "ja" : "en";
}
async function renderGrammarHints(hints, sentence, preferences = readGrammarPreferences(), language = "en") {
if (!hints.length) return "";
const knownRuleIds = new Set(preferences.knownRuleIds);
const visibleHints = visibleGrammarHints(hints, knownRuleIds, preferences.showKnown);
const knownCount = countKnownGrammarHints(hints, knownRuleIds);
return `
${renderGrammarSentence(sentence)}
${renderGrammarToolbar(visibleHints.length, knownCount, preferences.showKnown, language)}
${await renderGrammarHintList(visibleHints, knownRuleIds, language)}`;
}
function visibleGrammarHints(hints, knownRuleIds, showKnown) {
return showKnown ? hints : hints.filter((hint) => !knownRuleIds.has(hint.ruleId));
}
function countKnownGrammarHints(hints, knownRuleIds) {
return hints.filter((hint) => knownRuleIds.has(hint.ruleId)).length;
}
function renderGrammarSentence(sentence) {
return `
${escapeHtml$1(sentence)}
`;
}
function renderGrammarToolbar(visibleCount, knownCount, showKnown, language) {
const hiddenKnownCount = showKnown ? 0 : knownCount;
return `
`;
}
function renderGrammarKnownVisibilityButton(knownCount, showKnown, language) {
if (!knownCount) return "";
const label = showKnown ? uiText(language, "grammarHideKnown") : uiText(language, "grammarShowKnown");
return `${label} `;
}
async function renderGrammarHintList(visibleHints, knownRuleIds, language) {
if (!visibleHints.length) return `${escapeHtml$1(uiText(language, "allDetectedGrammarKnown"))}
`;
const items = await Promise.all(visibleHints.map((hint) => renderGrammarHintItem(hint, knownRuleIds.has(hint.ruleId), language)));
return `
${items.join("")}
`;
}
async function renderGrammarHintItem(hint, known, language) {
const copy = await grammarHintCopy(hint, language);
const displayName = grammarDisplayName(hint, language);
return `
${escapeHtml$1(displayName)}
${escapeHtml$1(grammarLevelText(hint.level, language))}
${escapeHtml$1(copy.kind)}
${known ? uiText(language, "grammarReview") : uiText(language, "grammarKnown")}
${escapeHtml$1(copy.short)}
${escapeHtml$1(uiText(language, "grammarDetails"))}
${escapeHtml$1(copy.detail)}
${escapeHtml$1(uiText(language, "grammarFoundIn"))} ${escapeHtml$1(hint.match)}
${renderGrammarHintExamples(hint, language)}
${renderGrammarHintGuide(hint, language)}
`;
}
async function grammarHintCopy(hint, language) {
const fallback = { kind: hint.kind, short: hint.short, detail: hint.detail };
if (language !== "ja") return fallback;
const ruleCopy = await grammarRuleText(language, hint.ruleId);
if (ruleCopy) return ruleCopy;
const name = grammarDisplayName(hint, language);
return {
kind: hint.kind === "Hanabira grammar" ? uiText(language, "grammarKindHanabira") : uiText(language, "grammar"),
short: interpolateUiText(language, "grammarGenericShort", { name, match: hint.match }),
detail: interpolateUiText(language, "grammarGenericDetail", { name, match: hint.match })
};
}
function grammarLevelText(level, language) {
return language === "ja" && level === "Core" ? uiText(language, "grammarLevelCore") : level;
}
function grammarDisplayName(hint, language) {
if (language !== "ja" || !ENGLISH_TEXT_RE.test(hint.name)) return hint.name;
if (JAPANESE_TEXT_RE.test(hint.match)) return hint.match;
return japaneseGrammarText(hint.name) || hint.name;
}
function japaneseGrammarText(value) {
return (value.match(/[ぁ-んァ-ヶ一-龯々〆ヵヶー〜]+/gu) ?? []).join(" / ");
}
function interpolateUiText(language, key, values) {
return uiText(language, key).replace(/\{(\w+)}/g, (_, name) => values[name] ?? "");
}
function renderGrammarHintExamples(hint, language) {
const examples = (hint.examples ?? []).slice(0, 2);
if (!examples.length) return "";
return `${escapeHtml$1(uiText(language, "grammarExample"))} ${examples.map((example) => renderGrammarExample(example, language)).join("")}
`;
}
function renderGrammarExample(example, language) {
const english = language === "ja" || !example.english ? "" : `${escapeHtml$1(example.english)}
`;
const note = language === "ja" || !example.note || ENGLISH_TEXT_RE.test(example.note) ? "" : `${escapeHtml$1(example.note)}
`;
return `${escapeHtml$1(example.japanese)}
${english}${note}
`;
}
function renderGrammarHintGuide(hint, language) {
return hint.url ? `${escapeHtml$1(uiText(language, "grammarGuide"))} ` : "";
}
function grammarMatches(item, sentence) {
const flags = item.pattern.flags.includes("g") ? item.pattern.flags : `${item.pattern.flags}g`;
const pattern = new RegExp(item.pattern.source, flags);
return Array.from(sentence.matchAll(pattern)).map((match) => {
const rawMatch = match[0];
const learnerFacingMatch = learnerMatch(item.name, rawMatch);
const learnerOffset = rawMatch.lastIndexOf(learnerFacingMatch);
const indexOffset = learnerOffset > 0 ? learnerOffset : 0;
return {
ruleId: item.ruleId,
name: item.name,
level: item.level,
kind: item.kind,
short: item.short,
detail: item.detail,
url: item.url,
match: learnerFacingMatch,
confidence: item.confidence,
index: (match.index ?? 0) + indexOffset,
priority: item.priority,
examples: item.examples
};
}).filter((hint) => hint.match.length > 0);
}
function grammarSummary(visibleCount, hiddenKnownCount, language) {
const shown = `${visibleCount} ${uiText(language, "grammarShown")}`;
if (hiddenKnownCount) return `${shown} · ${hiddenKnownCount} ${uiText(language, "grammarKnownHidden")}`;
return shown;
}
const LEARNER_MATCH_ENDING_NAMES = /* @__PURE__ */ new Set([
"たい",
"ない",
"ました",
"ます",
"た",
"よう",
"そう",
"方",
"やすい / にくい",
"すぎる",
"れる / られる",
"させる",
"させられる",
"がち",
"気味",
"げ",
"っぽい",
"めく"
]);
const LEARNER_MATCH_HELPER_NAMES = /* @__PURE__ */ new Set([
"てください",
"ていただけませんか",
"ないでください",
"させてください",
"てほしい",
"てくれる / てもらう",
"てしまう",
"てみる",
"ておく",
"ている",
"てある",
"てくる",
"ていく",
"てから"
]);
function learnerMatch(name, rawMatch) {
let match = rawMatch.replace(/^(?:そして|それで|でも|また|しかし|それに|つまり|ただし|だから)/u, "");
if (LEARNER_MATCH_HELPER_NAMES.has(name)) {
const afterClauseBoundary = match.replace(/^.*(?:[、。!?!?]|たら|なら|ので|から)/u, "");
if (afterClauseBoundary) match = afterClauseBoundary;
}
if (!LEARNER_MATCH_ENDING_NAMES.has(name)) return match;
const afterLastParticle = match.replace(/^.*[はがをにへともやの]/u, "");
return afterLastParticle || match;
}
function requestJson$2(url) {
const userscriptRequest = getUserscriptHttpRequest();
if (userscriptRequest) {
return new Promise((resolve, reject) => {
userscriptRequest({
method: "GET",
url,
responseType: "json",
timeout: 8e3,
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
resolve(response.response ?? JSON.parse(String(response.responseText ?? "{}")));
} else {
log$q.warn("Translation request returned HTTP error", { status: response.status });
reject(new Error(`Translation request failed (${response.status}).`));
}
},
onerror: (error) => {
log$q.warn("Translation request failed", { error });
reject(error);
},
ontimeout: () => {
log$q.warn("Translation request timed out");
reject(new Error("Translation timed out."));
}
});
});
}
return fetch(url).then(async (response) => {
if (!response.ok) {
log$q.warn("Translation request returned HTTP error", { status: response.status });
throw new Error(`Translation request failed (${response.status}).`);
}
return response.json();
});
}
const log$p = Logger.scope("StudyRender");
async function renderStudyToolResult(button2, action, sentence, grammarHints, language = "en") {
const panel = button2.closest(".jpdb-reader-study-tools")?.querySelector("[data-study-panel]");
if (!panel || !sentence) return;
panel.hidden = false;
panel.textContent = studyToolPendingText(action, language);
const done = log$p.time("studyTool", { action, sentenceLength: sentence.length });
if (action === "study-translate") {
try {
const translated = await translateJapaneseSentence(sentence, language);
setInnerHtml(panel, `${escapeHtml$1(uiText(language, "meaning"))}
${escapeHtml$1(translated)}
`);
return;
} finally {
done();
}
}
const hints = resolvedGrammarHints(sentence, grammarHints);
if (!hints.length) {
panel.hidden = true;
panel.textContent = "";
done();
return;
}
setInnerHtml(panel, await renderGrammarHints(hints, sentence, void 0, language));
done();
}
function studyToolPendingText(action, language) {
return action === "study-translate" ? uiText(language, "translating") : uiText(language, "findingGrammar");
}
function resolvedGrammarHints(sentence, grammarHints) {
return grammarHints ?? detectGrammarHints(sentence);
}
function handleStudyGrammarAction(button2, sentence, language = "en") {
if (!sentence) return false;
if (button2.dataset.action === "study-grammar-toggle-known") {
const ruleId = button2.dataset.grammarRuleId;
if (!ruleId) return false;
setGrammarRuleKnown(ruleId, button2.dataset.grammarKnown !== "true");
void rerenderGrammarPanel(button2, sentence, language);
return true;
}
if (button2.dataset.action === "study-grammar-toggle-known-visibility") {
setKnownGrammarVisible(button2.getAttribute("aria-pressed") !== "true");
void rerenderGrammarPanel(button2, sentence, language);
return true;
}
return false;
}
async function rerenderGrammarPanel(button2, sentence, language) {
const panel = button2.closest(".jpdb-reader-study-panel");
if (!panel) return;
const hints = detectGrammarHints(sentence);
setInnerHtml(panel, await renderGrammarHints(hints, sentence, void 0, language));
}
function assertReviewableJpdbCardState(states, settings) {
if (states.includes("blacklisted")) throw new Error(uiText(settings.interfaceLanguage, "reviewBlockedBlacklisted"));
if (states.includes("never-forget")) throw new Error(uiText(settings.interfaceLanguage, "reviewBlockedNeverForget"));
}
class CardActionController {
constructor(options) {
this.options = options;
}
async perform(action, button2, card, sentence) {
const studyAction = this.performStudyAction(action, button2, sentence);
if (studyAction !== void 0) return await studyAction;
const readerAction = this.performReaderAction(action, card);
if (readerAction !== void 0) return await readerAction;
const miningAction = await this.performMiningAction(action, button2, card, sentence);
if (miningAction !== void 0) return miningAction;
return Boolean(action);
}
performStudyAction(action, button2, sentence) {
if (!action) return void 0;
return this.studyActionHandler(action, button2, sentence)?.();
}
studyActionHandler(action, button2, sentence) {
const handlers = {
"study-grammar-toggle-known": () => this.performStudyGrammarToggle(button2, sentence),
"study-grammar-toggle-known-visibility": () => this.performStudyGrammarToggle(button2, sentence),
"study-translate": () => this.performStudyTool(button2, action, sentence),
"study-grammar": () => this.performStudyGrammarTool(button2, sentence),
"study-read-sentence": () => this.performStudyReadSentence(sentence),
"jpdb-example-audio": () => this.performJpdbExampleAudio(button2),
"anki-media-audio": () => this.performAnkiMediaAudio(button2)
};
return handlers[action];
}
performStudyGrammarToggle(button2, sentence) {
handleStudyGrammarAction(button2, sentence, this.options.getSettings().interfaceLanguage);
void this.reparsePopoverJapanese(button2);
return false;
}
async performStudyTool(button2, action, sentence) {
await renderStudyToolResult(button2, action, sentence, void 0, this.options.getSettings().interfaceLanguage);
void this.reparsePopoverJapanese(button2);
return false;
}
async performStudyGrammarTool(button2, sentence) {
await renderStudyToolResult(button2, "study-grammar", sentence, sentence ? await this.options.detectGrammarHints(sentence) : void 0, this.options.getSettings().interfaceLanguage);
void this.reparsePopoverJapanese(button2);
return false;
}
async performStudyReadSentence(sentence) {
await this.options.playSentenceAudio(sentence);
return false;
}
async performJpdbExampleAudio(button2) {
const audioIds = button2.dataset.jpdbAudio ?? "";
const fallbackSentence = button2.dataset.jpdbExampleSentence ?? "";
if (!this.options.playJpdbExampleAudio) await this.options.playSentenceAudio(fallbackSentence);
else await this.options.playJpdbExampleAudio(audioIds, fallbackSentence);
return false;
}
async performAnkiMediaAudio(button2) {
await this.playAnkiMediaAudio(button2);
return false;
}
performReaderAction(action, card) {
if (!action) return void 0;
const handlers = {
"copy-word": () => this.copyWord(card),
audio: () => this.playCardAudio(card),
"setup-dictionaries": () => this.openSettingsPanel("dictionaries"),
"setup-jpdb": () => this.openSettingsPanel("basics")
};
return handlers[action]?.();
}
async copyWord(card) {
await copyText(card.spelling);
this.options.toast(uiText(this.options.getSettings().interfaceLanguage, "copiedWord"));
return false;
}
async playCardAudio(card) {
await this.options.playAudio(card, { userGesture: true });
return false;
}
async openSettingsPanel(panel) {
this.options.showSettings(panel);
return false;
}
async performMiningAction(action, button2, card, sentence) {
if (!action) return void 0;
const handler = this.miningActionHandler(action, button2, card, sentence);
if (handler) return this.finishMiningAction(handler());
return this.performJpdbDeckMiningAction(action, card);
}
miningActionHandler(action, button2, card, sentence) {
const handlers = {
add: () => this.addToSelectedDeck(button2, card, sentence),
anki: () => this.addToAnki(card, sentence),
"anki-edit": () => this.openAnkiNote(button2),
"anki-merge": () => this.mergeExistingAnkiCard(button2, card, sentence),
grade: () => this.gradeCard(button2, card, sentence)
};
return handlers[action];
}
async performJpdbDeckMiningAction(action, card) {
if (action === "neverforget") {
const settings = this.options.getSettings();
return this.finishMiningAction(this.changeJpdbDeckState(card, "never-forget", settings.neverForgetDeck, uiText(settings.interfaceLanguage, "jpdbDeckStateApiKeyRequired")));
}
if (action === "blacklist") {
const settings = this.options.getSettings();
return this.finishMiningAction(this.changeJpdbDeckState(card, "blacklisted", settings.blacklistDeck, uiText(settings.interfaceLanguage, "jpdbDeckStateApiKeyRequired")));
}
return void 0;
}
async finishMiningAction(action) {
await action;
return true;
}
async reparsePopoverJapanese(button2) {
const popover = button2.closest(".jpdb-reader-popover");
if (!popover) return;
delete popover.dataset.jpdbReaderParseKey;
delete popover.dataset.jpdbReaderParseLoadingKey;
await this.options.parsePopoverJapanese(popover);
}
assertJpdbActionAllowed(card, message) {
const settings = this.options.getSettings();
if (!settings.jpdbMiningEnabled) throw new Error(uiText(settings.interfaceLanguage, "jpdbActionsDisabled"));
if (!settings.apiKey.trim()) throw new Error(message);
if (!this.options.isJpdbBackedCard(card)) throw new Error(message);
}
assertJpdbReviewAllowed(card, message) {
const settings = this.options.getSettings();
if (!settings.enableReviews) throw new Error(uiText(settings.interfaceLanguage, "reviewActionsDisabled"));
if (!settings.jpdbMiningEnabled) throw new Error(uiText(settings.interfaceLanguage, "jpdbActionsDisabled"));
if (!settings.apiKey.trim()) throw new Error(message);
if (!this.options.isJpdbBackedCard(card)) throw new Error(message);
}
async addToSelectedDeck(button2, card, sentence) {
const settings = this.options.getSettings();
const deck = selectedDeckChoice(button2, settings);
if (deck.source === "anki") {
await this.addToAnki(card, sentence, deck.id);
return;
}
this.assertJpdbActionAllowed(card, uiText(settings.interfaceLanguage, "jpdbAddApiKeyRequired"));
await this.addToSelectedJpdbDeck(card, sentence, deck.id);
if (shouldMineAnkiAlongsideJpdb(settings)) await this.addToAnki(card, sentence, settings.ankiDeck);
this.options.toast(uiText(settings.interfaceLanguage, "addedToJpdb"));
}
async openAnkiNote(button2) {
const settings = this.options.getSettings();
const noteId = Number(button2.dataset.noteId);
if (!Number.isFinite(noteId)) throw new Error(uiText(settings.interfaceLanguage, "ankiNoteNotFound"));
await this.options.anki.browseNote(noteId);
this.options.toast(uiText(settings.interfaceLanguage, "openedInAnki"));
}
async playAnkiMediaAudio(button2) {
const settings = this.options.getSettings();
const filename = button2.dataset.ankiMediaName?.trim();
if (!filename) throw new Error(uiText(settings.interfaceLanguage, "ankiAudioFileNotFound"));
if (!this.options.playMediaUrl) throw new Error(uiText(settings.interfaceLanguage, "ankiAudioPlaybackUnavailable"));
await this.options.playMediaUrl(await this.options.anki.mediaFileDataUrl(filename));
}
async mergeExistingAnkiCard(button2, card, sentence) {
const settings = this.options.getSettings();
if (canUseMobileAnkiHandoff(settings)) throw new Error(uiText(settings.interfaceLanguage, "ankiMergeNeedsDesktop"));
const noteId = Number(button2.dataset.noteId);
if (!Number.isFinite(noteId)) throw new Error(uiText(settings.interfaceLanguage, "ankiNoteNotFound"));
const [dictionaryContext, context, wordAudio] = await Promise.all([
this.loadAnkiDictionaryContext(card, settings),
this.options.resolveMiningContext(card, sentence),
resolveAnkiWordAudio(card, settings).catch(() => null)
]);
const result = await this.options.anki.mergeYomuData(noteId, card, miningSentenceForAnki(context.sentence, sentence), {
imageDataUrl: context.imageDataUrl,
audioDataUrl: context.audioDataUrl,
wordAudioDataUrl: wordAudio?.dataUrl,
wordAudioUrl: wordAudio?.url,
audioMergeMode: selectedAnkiAudioMergeMode(button2),
...dictionaryContext,
dictionaryPreferences: settings.dictionaryPreferences,
sourceTitle: ankiSourceTitle(context.sourceTitle),
sourceUrl: ankiSourceUrl(context.sourceUrl)
});
this.options.invalidateCardData?.();
this.options.toast(ankiMergeToast(result, settings));
await this.options.showCard(card, sentence, this.options.getActivePopoverAnchor(), {
autoPlay: false,
trigger: this.options.getActivePopoverMode() === "hover" ? "hover" : "modal",
navigation: "preserve",
preservePosition: true
});
}
async changeJpdbDeckState(card, state, deck, message) {
this.assertJpdbActionAllowed(card, message);
await this.toggleDeck(card, state, deck);
}
async gradeCard(button2, card, sentence) {
const grade = button2.dataset.grade;
const ankiCardId = Number(button2.dataset.ankiCardId);
await this.reviewGrade(grade, card, sentence, {
ankiCardId: Number.isFinite(ankiCardId) && ankiCardId > 0 ? ankiCardId : void 0,
deckId: defaultJpdbDeckId(this.options.getSettings())
});
}
async reviewGrade(grade, card, sentence, options = {}) {
const settings = this.options.getSettings();
if (!settings.enableReviews) throw new Error(uiText(settings.interfaceLanguage, "reviewActionsDisabled"));
if (options.ankiCardId) return this.options.anki.answerCard(options.ankiCardId, grade);
this.assertJpdbReviewAllowed(card, uiText(settings.interfaceLanguage, "addJpdbApiKeyReview"));
const states = normalizeCardStates(card.cardState);
assertReviewableJpdbCardState(states, settings);
const wasNotInDeck = states.includes("not-in-deck");
if (wasNotInDeck) await this.addToSelectedJpdbDeck(card, sentence, this.reviewDeckId(options));
await this.options.jpdb.reviewCard(card, grade);
if (wasNotInDeck) this.options.toast(uiText(settings.interfaceLanguage, "addedToDeckAndReviewed"));
}
reviewDeckId(options) {
return options.deckId || this.options.getSettings().miningDeck || "forq";
}
async addToAnki(card, sentence, deckName) {
const settings = this.options.getSettings();
if (canUseMobileAnkiHandoff(settings)) {
await this.options.anki.addCard(card, sentence || card.sentence || "", {
deckName,
dictionaryPreferences: settings.dictionaryPreferences
});
this.options.toast(uiText(settings.interfaceLanguage, "sentToAnki"));
return;
}
const existing = await this.options.anki.findExistingCards(card);
if (existing.primary) return this.showExistingAnkiCard(card, sentence);
const [dictionaryContext, context, wordAudio] = await Promise.all([
this.loadAnkiDictionaryContext(card, settings),
this.options.resolveMiningContext(card, sentence),
resolveAnkiWordAudio(card, settings).catch(() => null)
]);
await this.options.anki.addCard(card, miningSentenceForAnki(context.sentence, sentence), {
deckName,
imageDataUrl: context.imageDataUrl,
audioDataUrl: context.audioDataUrl,
wordAudioDataUrl: wordAudio?.dataUrl,
wordAudioUrl: wordAudio?.url,
...dictionaryContext,
dictionaryPreferences: settings.dictionaryPreferences,
sourceTitle: ankiSourceTitle(context.sourceTitle),
sourceUrl: ankiSourceUrl(context.sourceUrl)
});
this.options.toast(ankiSentToast(context, settings, Boolean(wordAudio?.dataUrl || wordAudio?.url)));
}
async showExistingAnkiCard(card, sentence) {
const settings = this.options.getSettings();
this.options.toast(uiText(settings.interfaceLanguage, "alreadyInAnki"));
await this.options.showCard(card, sentence, this.options.getActivePopoverAnchor(), {
autoPlay: false,
trigger: this.options.getActivePopoverMode() === "hover" ? "hover" : "modal",
navigation: "preserve",
preservePosition: true
});
}
async loadAnkiDictionaryContext(card, settings) {
const [localEntries, kanjiEntries, metaEntries] = await Promise.all([
this.lookupAnkiLocalTerms(card, settings),
this.lookupAnkiLocalKanji(card, settings),
this.lookupAnkiLocalMeta(card, settings)
]);
return { localEntries, kanjiEntries, metaEntries };
}
lookupAnkiLocalTerms(card, settings) {
return settings.localDictionariesEnabled ? this.options.dictionaries.lookup(card.spelling, card.reading, settings.localDictionaryMaxResults, settings.dictionaryPreferences).catch(() => []) : Promise.resolve([]);
}
lookupAnkiLocalKanji(card, settings) {
return settings.localDictionariesEnabled && settings.localDictionaryShowKanji ? this.options.dictionaries.lookupKanji(card.spelling, settings.localDictionaryMaxResults, settings.dictionaryPreferences).catch(() => []) : Promise.resolve([]);
}
lookupAnkiLocalMeta(card, settings) {
return settings.localDictionariesEnabled ? this.options.dictionaries.lookupTermMeta(card.spelling, 12, settings.dictionaryPreferences).catch(() => []) : Promise.resolve([]);
}
async toggleDeck(card, state, deck) {
if (normalizeCardStates(card.cardState).includes(state)) {
await this.options.jpdb.removeFromDeck(deck, card);
this.options.toast(uiText(this.options.getSettings().interfaceLanguage, "removedFromDeck"));
} else {
await this.options.jpdb.addToDeck(deck, card);
this.options.toast(uiText(this.options.getSettings().interfaceLanguage, "addedToDeckToast"));
}
}
async addToSelectedJpdbDeck(card, sentence, deckId) {
const settings = this.options.getSettings();
const targetDeck = selectedJpdbDeckId(deckId, settings);
await this.options.jpdb.addToDeck(targetDeck, card, sentence);
if (shouldAlsoAddToForq(settings, targetDeck)) await this.options.jpdb.addToDeck("forq", card, sentence);
}
}
function miningSentenceForAnki(contextSentence, fallbackSentence) {
return contextSentence || fallbackSentence;
}
function ankiSentToast(context, settings, hasWordAudio = false) {
const language = settings.interfaceLanguage;
const hasAudio = Boolean(context.audioDataUrl || hasWordAudio);
if (context.imageDataUrl && hasAudio) return uiText(language, "sentToAnkiWithContextImageAndAudio");
if (context.imageDataUrl) return uiText(language, "sentToAnkiWithContextImage");
if (hasAudio) return uiText(language, "sentToAnkiWithAudio");
return uiText(language, "sentToAnki");
}
function ankiMergeToast(result, settings) {
const language = settings.interfaceLanguage;
if (!result.updatedFields.length && !result.audioAdded && !result.imageAdded) return uiText(language, "ankiMergeNoNewData");
const parts = [
result.updatedFields.length ? `${result.updatedFields.length} ${uiText(language, result.updatedFields.length === 1 ? "ankiMergeFieldSingular" : "ankiMergeFieldPlural")}` : "",
result.audioAdded ? uiText(language, "ankiMergeAudio") : "",
result.imageAdded ? uiText(language, "ankiMergeImage") : ""
].filter(Boolean);
return uiText(language, "ankiMergeComplete").replace("{parts}", parts.join(", "));
}
function selectedAnkiAudioMergeMode(button2) {
const value = button2.closest(".jpdb-reader-anki-card-preview")?.querySelector("[data-anki-audio-merge]")?.value;
return value === "theirs" || value === "ours" ? value : "both";
}
function ankiSourceTitle(sourceTitle) {
return sourceTitle || document.title;
}
function ankiSourceUrl(sourceUrl) {
return sourceUrl || location.href;
}
function selectedDeckChoice(button2, settings) {
const source = selectedDeckSource(button2);
return {
source,
id: selectedDeckId(button2, settings, source)
};
}
function selectedDeckSource(button2) {
if (button2.dataset.deckSource === "anki") return "anki";
return "jpdb";
}
function selectedDeckId(button2, settings, source) {
const id = button2.dataset.deckId?.trim();
if (id) return id;
return defaultDeckIdForSource(source, settings);
}
function defaultDeckIdForSource(source, settings) {
if (source === "anki") return defaultAnkiDeckName(settings);
return defaultJpdbDeckId(settings);
}
function defaultAnkiDeckName(settings) {
return settings.ankiDeck || "よむ";
}
function defaultJpdbDeckId(settings) {
return settings.miningDeck.trim() || "forq";
}
function selectedJpdbDeckId(deckId, settings) {
const selectedDeckId2 = deckId.trim();
if (selectedDeckId2) return selectedDeckId2;
return defaultJpdbDeckId(settings);
}
function shouldAlsoAddToForq(settings, targetDeck) {
if (!settings.addToForq) return false;
return targetDeck !== "forq";
}
function shouldMineAnkiAlongsideJpdb(settings) {
if (!settings.ankiEnabled) return false;
return settings.ankiMineWithJpdb;
}
function renderDeckChoiceOptions(settings, jpdbDecks, ankiDecks, includeJpdb) {
const options = [];
addJpdbDeckChoiceOptions(settings, options, jpdbDecks);
if (settings.ankiEnabled) addAnkiDeckChoiceOptions(settings, options, ankiDecks);
if (!options.length) return "";
return deckChoicePlaceholderOption(settings) + options.map(renderDeckChoiceOption).join("");
}
function jpdbDeckLabel(settings, deckId, decks) {
if (deckId === "forq") return "FORQ";
const deck = decks.find((candidate) => candidate.id === deckId);
return deck?.name || deckId;
}
function addJpdbDeckChoiceOptions(settings, options, jpdbDecks) {
const selected = settings.miningDeck.trim() || "forq";
addDeckChoiceOption(options, "jpdb", "forq", "JPDB: FORQ");
addDeckChoiceOption(options, "jpdb", selected, `JPDB: ${jpdbDeckLabel(settings, selected, jpdbDecks)}`);
for (const deck of jpdbDecks) {
if (!isSpecialJpdbDeck(settings, deck)) addDeckChoiceOption(options, "jpdb", deck.id, `JPDB: ${deck.name}`);
}
}
function addAnkiDeckChoiceOptions(settings, options, ankiDecks) {
const configuredDeck = settings.ankiDeck || "よむ";
addDeckChoiceOption(options, "anki", configuredDeck, `Anki: ${configuredDeck}`);
for (const deck of ankiDecks) addDeckChoiceOption(options, "anki", deck, `Anki: ${deck}`);
}
function addDeckChoiceOption(options, source, value, label) {
const normalizedValue = value.trim();
const key = `${source}:${normalizedValue}`;
if (!normalizedValue || options.some(([existing]) => existing === key)) return;
options.push([key, label]);
}
function renderDeckChoiceOption([value, label]) {
const [source, ...idParts] = value.split(":");
const deckId = idParts.join(":");
return `${escapeHtml$1(label)} `;
}
function deckChoicePlaceholderOption(settings) {
return `${escapeHtml$1(uiText(settings.interfaceLanguage, "deck"))} `;
}
function isSpecialJpdbDeck(settings, deck) {
const neverForgetDeck = settings.neverForgetDeck.trim();
const blacklistDeck = settings.blacklistDeck.trim();
if (deck.id === neverForgetDeck || deck.id === blacklistDeck) return true;
return /never\s*-?\s*forget|blacklist|suspend/i.test(`${deck.id} ${deck.name}`);
}
const KANJI_STROKE_SOURCE_ID = "__kanji_stroke__";
const KANJI_JPDB_SOURCE_ID = "__kanji_jpdb__";
const KANJI_RTK_SOURCE_ID = "__kanji_rtk__";
const KANJI_UCHISEN_SOURCE_ID = "__kanji_uchisen__";
const KANJI_DICTIONARIES_SOURCE_ID = "__kanji_dictionaries__";
const KANJI_SIMILAR_WORDS_SOURCE_ID = "__kanji_similar_words__";
const KANJI_ORIGINS_SOURCE_ID = "__kanji_origins__";
const KANJI_DICTIONARY_SOURCE_PREFIX = "__kanji_dictionary__:";
function definitionSourceRows(settings) {
const builtInRows = [
{
id: JPDB_DEFINITION_SOURCE_ID,
name: "JPDB",
alias: "JPDB",
enabled: settings.jpdbDefinitionsEnabled,
priority: settings.jpdbDefinitionsPriority,
prefix: "jpdbDefinitions",
readonly: true,
help: "JPDB meanings shown directly from the current card."
},
{
id: STUDY_TRANSLATION_SOURCE_ID,
name: "Translation",
alias: "Translation",
enabled: settings.studyTranslationEnabled,
priority: settings.studyTranslationPriority,
prefix: "studyTranslation",
readonly: true,
help: "Automatic sentence translation for the current lookup context."
},
{
id: STUDY_GRAMMAR_SOURCE_ID,
name: "Grammar",
alias: "Grammar",
enabled: settings.studyGrammarEnabled,
priority: settings.studyGrammarPriority,
prefix: "studyGrammar",
readonly: true,
help: "Automatic local grammar hints for the current lookup context."
},
{
id: IMMERSION_KIT_SOURCE_ID,
name: "Immersion Kit",
alias: "Immersion Kit",
enabled: settings.immersionKitEnabled,
priority: settings.immersionKitPriority,
prefix: "immersionKit",
readonly: true,
help: "Example sentences, images, and audio for the looked-up word."
}
];
return [
...builtInRows,
...settings.dictionaryPreferences.filter((preference) => {
const type = preference.type ?? "terms";
return type === "terms" || type === "kanji";
}).map((preference) => ({
id: preference.name,
name: preference.name,
alias: preference.alias,
enabled: preference.enabled,
priority: preference.priority,
prefix: `dictionaryPreferences.${settings.dictionaryPreferences.indexOf(preference)}`,
readonly: false,
removable: true,
dictionaryType: preference.type === "kanji" ? "kanji" : "terms",
help: ""
}))
].filter((row) => row.id !== IMMERSION_KIT_SOURCE_ID || settings.immersionKitEnabled).sort(compareSourceRows);
}
function kanjiSourceRows(settings) {
const kanjiDictionaryRows = settings.dictionaryPreferences.filter((preference) => preference.type === "kanji").map((preference) => ({
id: kanjiDictionarySourceId(preference.name),
name: preference.name,
alias: preference.alias,
enabled: settings.localDictionaryShowKanji && preference.enabled,
priority: preference.priority,
prefix: `dictionaryPreferences.${settings.dictionaryPreferences.indexOf(preference)}`,
readonly: false,
removable: true,
dictionaryType: "kanji",
help: "Imported Yomitan kanji dictionary."
}));
return [
{
id: KANJI_STROKE_SOURCE_ID,
name: "Stroke practice",
alias: "Stroke practice",
enabled: settings.kanjivgEnabled,
priority: settings.kanjivgPriority,
prefix: "kanjivg",
readonly: true,
help: "Stroke order preview and drawing pad."
},
{
id: KANJI_JPDB_SOURCE_ID,
name: "Readings and components",
alias: "Readings and components",
enabled: settings.jpdbKanjiEnabled,
priority: settings.jpdbKanjiPriority,
prefix: "jpdbKanji",
readonly: true,
help: "JPDB readings, components, and mnemonic when available."
},
{
id: KANJI_RTK_SOURCE_ID,
name: "RTK",
alias: "RTK",
enabled: settings.rtkEnabled,
priority: settings.rtkPriority,
prefix: "rtk",
readonly: true,
help: "Remembering the Kanji keywords, elements, and stories."
},
{
id: KANJI_UCHISEN_SOURCE_ID,
name: "Uchisen",
alias: "Uchisen",
enabled: settings.uchisenEnabled,
priority: settings.uchisenPriority,
prefix: "uchisen",
readonly: true,
help: "Uchisen mnemonic image carousel."
},
...kanjiDictionaryRows.length ? [] : [{
id: KANJI_DICTIONARIES_SOURCE_ID,
name: "Imported kanji dictionaries",
alias: "Imported kanji dictionaries",
enabled: settings.localDictionaryShowKanji,
priority: settings.kanjiDictionariesPriority,
prefix: "kanjiDictionaries",
readonly: true,
help: "Kanji entries from imported Yomitan dictionaries."
}],
...kanjiDictionaryRows,
{
id: KANJI_SIMILAR_WORDS_SOURCE_ID,
name: "Words using this kanji",
alias: "Words using this kanji",
enabled: settings.similarKanjiWords,
priority: settings.similarKanjiWordsPriority,
prefix: "similarKanjiWords",
readonly: true,
help: "Related JPDB and imported-dictionary vocabulary."
},
{
id: KANJI_ORIGINS_SOURCE_ID,
name: "Component graph",
alias: "Component graph",
enabled: settings.kanjiOriginsEnabled,
priority: settings.kanjiOriginsPriority,
prefix: "kanjiOrigins",
readonly: true,
help: "Compact facts, component graph, and radical images."
}
].sort(compareSourceRows);
}
function orderedDefinitionSourceIds(settings, dictionaryNames) {
const preferences = new Map(settings.dictionaryPreferences.map((item) => [item.name, item]));
const sources = [
{
id: JPDB_DEFINITION_SOURCE_ID,
enabled: settings.jpdbDefinitionsEnabled,
priority: settings.jpdbDefinitionsPriority,
name: "JPDB"
},
{
id: STUDY_TRANSLATION_SOURCE_ID,
enabled: settings.studyTranslationEnabled,
priority: settings.studyTranslationPriority,
name: "Translation"
},
{
id: STUDY_GRAMMAR_SOURCE_ID,
enabled: settings.studyGrammarEnabled,
priority: settings.studyGrammarPriority,
name: "Grammar"
},
{
id: IMMERSION_KIT_SOURCE_ID,
enabled: settings.immersionKitEnabled,
priority: settings.immersionKitPriority,
name: "Immersion Kit"
},
...dictionaryNames.filter((name) => (preferences.get(name)?.type ?? "terms") === "terms").map((name, index) => {
const preference = preferences.get(name);
return {
id: name,
enabled: preference?.enabled ?? true,
priority: preference?.priority ?? 1e3 + index,
name
};
})
];
return sources.filter((source) => source.enabled).sort(compareSourceOrder).map((source) => source.id);
}
function orderedKanjiSourceIds(settings) {
return kanjiSourceRows(settings).filter((row) => row.enabled).filter((row) => row.id !== KANJI_DICTIONARIES_SOURCE_ID || !settings.dictionaryPreferences.some((preference) => preference.type === "kanji")).map((row) => row.id);
}
function kanjiSourceLabel(settings, sourceId, fallback = "") {
const row = kanjiSourceRows(settings).find((candidate) => candidate.id === sourceId);
return row?.alias || row?.name || fallback;
}
function kanjiDictionarySourceId(name) {
return `${KANJI_DICTIONARY_SOURCE_PREFIX}${name}`;
}
function kanjiDictionaryNameFromSourceId(sourceId) {
return sourceId.startsWith(KANJI_DICTIONARY_SOURCE_PREFIX) ? sourceId.slice(KANJI_DICTIONARY_SOURCE_PREFIX.length) : null;
}
function compareSourceRows(a, b) {
return a.priority - b.priority || a.name.localeCompare(b.name);
}
function compareSourceOrder(a, b) {
return a.priority - b.priority || a.name.localeCompare(b.name);
}
const LOCAL_TAG_SPLIT_RE = /[\s,;|/]+/;
const HIDDEN_LOCAL_TERM_TAGS = /* @__PURE__ */ new Set(["0", "1", "2", "3", "4", "5"]);
const LOCAL_TERM_TAG_LABELS = /* @__PURE__ */ new Map([
["n", "noun"],
["pn", "pronoun"],
["r", "rare"],
["uk", "usually kana"],
["adj-i", "i-adjective"],
["adj-na", "na-adjective"],
["adv", "adverb"],
["exp", "expression"],
["int", "interjection"],
["prt", "particle"],
["suf", "suffix"],
["pref", "prefix"],
["vs", "suru verb"],
["vi", "intransitive"],
["vt", "transitive"]
]);
const LOCAL_TERM_TAG_LABELS_JA = /* @__PURE__ */ new Map([
["n", "名詞"],
["pn", "代名詞"],
["r", "まれ"],
["uk", "かな表記が多い"],
["adj-i", "い形容詞"],
["adj-na", "な形容詞"],
["adv", "副詞"],
["exp", "表現"],
["int", "感動詞"],
["prt", "助詞"],
["suf", "接尾辞"],
["pref", "接頭辞"],
["vs", "する動詞"],
["vi", "自動詞"],
["vt", "他動詞"]
]);
function localTermTags(entries, language = "en") {
const tags = entries.flatMap((entry) => [entry.definitionTags, entry.termTags, entry.rules]).flatMap((value) => typeof value === "string" ? value.split(LOCAL_TAG_SPLIT_RE) : []).map((value) => value.trim()).map((tag) => localTermTagLabel(tag, language)).filter(Boolean);
return [...new Set(tags)].slice(0, 8);
}
function localTermTagLabel(tag, language) {
const normalized = tag.toLowerCase();
if (!normalized || HIDDEN_LOCAL_TERM_TAGS.has(normalized)) return "";
if (language === "ja") return LOCAL_TERM_TAG_LABELS_JA.get(normalized) ?? tag;
return LOCAL_TERM_TAG_LABELS.get(normalized) ?? tag;
}
function hasRichStructuredGlossary(value) {
if (!value || typeof value !== "object") return false;
if (Array.isArray(value)) return value.some(hasRichStructuredGlossary);
const record = value;
return isRichStructuredGlossaryRecord(record) || hasRichStructuredGlossary(record.content);
}
function isRichStructuredGlossaryRecord(record) {
const tag = typeof record.tag === "string" ? record.tag.toLowerCase() : "";
return record.type === "image" || "path" in record || tag === "img" || tag === "table";
}
function pillStyle(key) {
const hue = stableHue(key);
return `--chip-bg:hsl(${hue} 70% 36%);--chip-border:hsl(${hue} 72% 50%);--chip-text:#fff;`;
}
function bestFrequencyEntries(entries) {
const bestByDictionary = /* @__PURE__ */ new Map();
const others = [];
for (const entry of entries) {
if (entry.mode !== "freq") {
others.push(entry);
continue;
}
const current = bestByDictionary.get(entry.dictionary);
if (!current || metaFrequencyRank(entry.data) < metaFrequencyRank(current.data)) bestByDictionary.set(entry.dictionary, entry);
}
return [...bestByDictionary.values(), ...others];
}
function dictionaryPreferencePriority(settings, dictionary) {
const preference = settings.dictionaryPreferences.find((item) => item.name === dictionary);
return preference?.priority ?? Number.MAX_SAFE_INTEGER;
}
function metaFrequencyRank(value) {
if (typeof value === "number") return value;
if (typeof value === "string") return numericFrequencyRank(value);
const nested = nestedMetaFrequencyValue(value);
return nested === void 0 ? Number.POSITIVE_INFINITY : metaFrequencyRank(nested);
}
function nestedMetaFrequencyValue(value) {
if (!value || typeof value !== "object") return void 0;
const record = value;
return record.frequency ?? record.value ?? record.displayValue;
}
function numericFrequencyRank(value) {
return Number(value.replace(/[^\d.]/g, "")) || Number.POSITIVE_INFINITY;
}
function normalizeFrequencyChipValue(label, value) {
const marker = label.match(/[㋕㋐]$/u)?.[0];
return marker && value.endsWith(marker) ? value.slice(0, -marker.length) : value;
}
function stableHue(value) {
let hash = 0;
for (let index = 0; index < value.length; index++) hash = (hash << 5) - hash + value.charCodeAt(index) | 0;
return Math.abs(hash) % 360;
}
function formatLookupUrl(template, values) {
const replacements = {
query: values.query,
word: values.word,
term: values.word,
reading: values.reading,
vid: values.vid,
sid: values.sid
};
const url = template.replace(/\{([a-z]+)\}/gi, (_, key) => encodeURIComponent(replacements[key.toLowerCase()] ?? values.query));
try {
const parsed = new URL(url);
return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.toString() : "";
} catch {
return "";
}
}
const JPDB_KANJI_BASE_URL = "https://jpdb.io/kanji";
const JAPANESE_RE$2 = /[\u3040-\u30ff\u3400-\u9fff]/u;
const log$o = Logger.scope("JpdbKanji");
class JpdbKanjiClient {
constructor(getCorsProxyUrl = () => "") {
this.getCorsProxyUrl = getCorsProxyUrl;
}
cache = /* @__PURE__ */ new Map();
actions = /* @__PURE__ */ new Map();
lookup(kanji) {
const key = Array.from(kanji)[0] ?? kanji;
if (!key) return Promise.resolve(null);
let promise = this.cache.get(key);
if (!promise) {
promise = this.fetchInfo(key);
this.cache.set(key, promise);
}
return promise;
}
async performAction(actionId) {
const action = this.actions.get(actionId);
if (!action) throw new Error("JPDB kanji action is no longer available.");
if (!action.enabled) throw new Error("JPDB kanji action is disabled.");
log$o.info("Performing JPDB kanji action", { kanji: action.kanji, role: action.role, kind: action.kind });
await requestText$6(action.url, "", {
method: action.method,
payload: action.payload,
allowProxyFallback: false,
allowConfiguredProxy: false,
credentials: "same-origin"
});
this.cache.delete(action.kanji);
return this.lookup(action.kanji);
}
async fetchInfo(kanji) {
const html = await requestText$6(`${JPDB_KANJI_BASE_URL}/${encodeURIComponent(kanji)}`, this.getCorsProxyUrl()).catch((error) => {
log$o.warn("Kanji page request failed", { kanji }, error);
return "";
});
const info = html ? parseJpdbKanjiHtml(html, kanji) : null;
if (info) {
visibleJpdbKanjiActions(info).forEach((action) => this.actions.set(action.id, action));
}
return info;
}
}
function parseJpdbKanjiHtml(html, kanji) {
const doc = parseHtmlDocument(html);
const keyword = sectionText$1(doc, "Keyword") || metaKeyword(doc, kanji);
if (!keyword) return null;
const parsed = parsedJpdbKanjiPage(doc);
const actions = kanjiActions(doc, kanji);
const visibleActions = actions.filter(isVisibleKanjiAction);
return {
kanji,
keyword,
...parsed,
mnemonic: sectionText$1(doc, "Mnemonic"),
actions,
loggedIn: isLoggedIn(doc),
kanjiReviewsEnabled: visibleActions.length > 0
};
}
function parsedJpdbKanjiPage(doc) {
const infoRows = infoTableRows(doc);
return {
frequency: infoRows.get("Frequency") ?? "",
type: infoRows.get("Type") ?? "",
kanken: infoRows.get("Kanken") ?? "",
heisig: infoRows.get("Heisig") ?? "",
oldForms: oldForms(doc),
readings: readings(doc),
components: components(doc),
usedInKanji: usedInKanji(doc),
vocabulary: vocabulary(doc).slice(0, 8)
};
}
function visibleJpdbKanjiActions(info) {
if (!info?.kanjiReviewsEnabled) return [];
return info.actions.filter(isVisibleKanjiAction).slice(0, 3);
}
function jpdbKanjiActionClass(action) {
return KANJI_ACTION_CLASS_BY_ROLE[action.role] ?? "";
}
const KANJI_ACTION_CLASS_BY_ROLE = {
mine: "add",
review: "add",
known: "nf",
neverforget: "nf",
blacklist: "blacklist",
forget: "nf danger",
other: ""
};
function isVisibleKanjiAction(action) {
return action.enabled && action.role !== "other";
}
function isLoggedIn(doc) {
return !doc.querySelector('a[href="/login"], a[href^="/login?"], form[action="/login"], form[action^="/login?"]');
}
function kanjiActions(doc, kanji) {
const menu = doc.querySelector(".result.kanji .menu, .kanji .menu, .menu");
if (!menu) return [];
const actions = [];
const push = (action) => {
const id = `jpdb-kanji:${encodeURIComponent(kanji)}:${actions.length}`;
actions.push({ ...action, id });
};
menu.querySelectorAll("form").forEach((form) => {
const method = (form.getAttribute("method") || "GET").toUpperCase() === "POST" ? "POST" : "GET";
const url = absoluteJpdbUrl$1(form.getAttribute("action") || `/kanji/${encodeURIComponent(kanji)}`);
const submitters = Array.from(form.querySelectorAll('button, input[type="submit"], input[type="button"]')).filter((submitter) => cleanText$5(labelForControl(submitter)) && submitter.getAttribute("type")?.toLowerCase() !== "button");
const controls = submitters.length ? submitters : [form];
controls.forEach((control) => {
const label = cleanText$5(control instanceof HTMLFormElement ? form.textContent ?? "" : labelForControl(control));
if (!label) return;
push({
kanji,
label,
role: classifyKanjiAction(label, `${url} ${control instanceof HTMLFormElement ? form.textContent ?? "" : control.getAttribute("value") ?? ""}`),
kind: "form",
method,
url,
payload: formPayload(form, control instanceof HTMLFormElement ? null : control),
enabled: !(control instanceof HTMLFormElement) && isDisabled(control) ? false : !isDisabled(form)
});
});
});
menu.querySelectorAll("a[href]").forEach((link) => {
if (link.closest("form")) return;
const label = cleanText$5(labelForControl(link));
if (!label) return;
const url = absoluteJpdbUrl$1(link.getAttribute("href") ?? "");
push({
kanji,
label,
role: classifyKanjiAction(label, url),
kind: "link",
method: "GET",
url,
payload: {},
enabled: !isDisabled(link)
});
});
return actions.filter((action) => action.role !== "other" || /kanji|review|deck|blacklist|known|forget/i.test(action.label));
}
function labelForControl(element2) {
if (element2 instanceof HTMLInputElement) return inputControlLabel(element2);
return element2.getAttribute("aria-label") || element2.title || element2.textContent || "";
}
function classifyKanjiAction(label, context) {
const labelText = label.toLowerCase();
const text2 = `${label} ${context}`.toLowerCase();
if (KANJI_ACTION_OTHER_RE.test(labelText)) return "other";
return KANJI_ACTION_PATTERNS.find(({ pattern }) => pattern.test(text2))?.role ?? "other";
}
function inputControlLabel(element2) {
return element2.getAttribute("aria-label") || element2.title || element2.value || element2.name;
}
const KANJI_ACTION_OTHER_RE = /\b(enable|settings?|configure|preferences?|history|stats?|open|view)\b/;
const KANJI_ACTION_PATTERNS = [
{ role: "blacklist", pattern: /\b(blacklist|unblacklist|block|ignore|suspend)\b/ },
{ role: "neverforget", pattern: /\b(never[-\s]?forget|always\s+remember)\b/ },
{ role: "forget", pattern: /\b(forget|remove|delete|unlearn)\b/ },
{ role: "known", pattern: /\b(known|know|learned|mark\s+known|remember)\b/ },
{ role: "review", pattern: /\b(review|due|study)\b/ },
{ role: "mine", pattern: /\b(add|mine|mining|deck|prioriti[sz]e|learn)\b/ }
];
function formPayload(form, submitter) {
const payload = {};
form.querySelectorAll("input, select, textarea").forEach((control) => {
if (control instanceof HTMLInputElement) {
const type = control.type.toLowerCase();
if (!control.name || type === "submit" || type === "button" || type === "image" || type === "reset" || type === "file") return;
if ((type === "checkbox" || type === "radio") && !control.checked) return;
payload[control.name] = control.value;
return;
}
if (!control.name) return;
payload[control.name] = control.value;
});
if (submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement) {
const name = submitter.name;
if (name) payload[name] = submitter.value;
}
return payload;
}
function isDisabled(element2) {
return element2.hasAttribute("disabled") || element2.getAttribute("aria-disabled") === "true" || element2.classList.contains("disabled") || element2.classList.contains("is-disabled");
}
function absoluteJpdbUrl$1(value) {
try {
return new URL(value || "/", "https://jpdb.io").toString();
} catch {
return "https://jpdb.io/";
}
}
function sectionText$1(doc, label) {
const heading = Array.from(doc.querySelectorAll(".subsection-label")).find((element2) => cleanText$5(element2.textContent ?? "") === label);
const section = heading?.parentElement?.querySelector(".subsection") ?? null;
const value = cleanText$5(section?.textContent ?? "");
return isMissingSectionValue(value, section) ? "" : value;
}
function infoTableRows(doc) {
const rows = /* @__PURE__ */ new Map();
doc.querySelectorAll(".cross-table tr").forEach((row) => {
const cells = Array.from(row.querySelectorAll("td"));
if (cells.length < 2) return;
const key = cleanText$5(cells[0].textContent ?? "");
const value = cleanInfoTableValue(cells[1]);
if (value) rows.set(key, value);
});
return rows;
}
function oldForms(doc) {
const row = Array.from(doc.querySelectorAll(".cross-table tr")).find((item) => cleanText$5(item.querySelector("td")?.textContent ?? "") === "Old form");
return Array.from(row?.querySelectorAll('a[href^="/kanji/"]') ?? []).map((link) => cleanText$5(link.textContent ?? "")).filter(Boolean);
}
function readings(doc) {
const seen = /* @__PURE__ */ new Set();
const entries = [];
doc.querySelectorAll(".kanji-reading-list-common > div, .kanji-reading-list > div").forEach((row) => {
const link = row.querySelector("a");
const reading = cleanText$5(link?.textContent ?? "");
if (!reading || seen.has(reading)) return;
seen.add(reading);
entries.push({
reading,
share: cleanText$5(row.textContent ?? "").replace(reading, "").trim(),
common: row.closest(".kanji-reading-list-common") !== null
});
});
return entries;
}
function components(doc) {
return kanjiSectionEntries(doc, (label) => label.startsWith("Composed of"));
}
function usedInKanji(doc) {
return kanjiSectionEntries(doc, (label) => label.startsWith("Used in kanji"));
}
function kanjiSectionEntries(doc, matchesLabel) {
return Array.from(doc.querySelectorAll(".subsection-composed-of-kanji")).filter((section) => matchesLabel(cleanText$5(section.querySelector(".subsection-label")?.textContent ?? ""))).flatMap((section) => Array.from(section.querySelectorAll(".subsection > div"))).map((element2) => ({
kanji: cleanText$5(element2.querySelector(".spelling")?.textContent ?? ""),
keyword: cleanText$5(element2.querySelector(".description")?.textContent ?? "")
})).filter((component) => component.kanji && component.keyword);
}
function vocabulary(doc) {
const entries = [];
doc.querySelectorAll(".subsection-used-in .used-in").forEach((element2) => {
const link = element2.querySelector('.jp a[href^="/vocabulary/"]');
if (!link) return;
const { expression, reading } = vocabularyFromHref(link.getAttribute("href") ?? "");
const fallbackExpression = expression || textWithoutRuby(link);
const meaning = cleanText$5(element2.querySelector(".en")?.textContent ?? "");
if (!JAPANESE_RE$2.test(fallbackExpression) || !meaning) return;
entries.push({
expression: fallbackExpression,
reading,
meaning,
url: new URL(link.getAttribute("href") ?? "", "https://jpdb.io").toString()
});
});
return entries;
}
function vocabularyFromHref(href) {
const path = href.split("#")[0] ?? href;
const parts = path.split("/").filter(Boolean);
if (parts[0] !== "vocabulary") return { expression: "", reading: "" };
return {
expression: decodePathPart$2(parts[2] ?? ""),
reading: decodePathPart$2(parts[3] ?? "")
};
}
function decodePathPart$2(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function textWithoutRuby(element2) {
const clone = element2.cloneNode(true);
clone.querySelectorAll("rt, rp").forEach((node) => node.remove());
return cleanText$5(clone.textContent ?? "");
}
function metaKeyword(doc, kanji) {
const description = doc.querySelector('meta[name="description"]')?.content ?? "";
const match = new RegExp(`${escapeRegExp(kanji)}[^—-]*[—-]\\s*([^\\n]+)`).exec(description);
return cleanText$5(match?.[1] ?? "");
}
function cleanText$5(value) {
return value.replace(/\s+/g, " ").trim();
}
function cleanInfoTableValue(cell) {
return cleanText$5(cell.textContent ?? "").replace(/\s+\?$/, "");
}
function isMissingSectionValue(value, section) {
const normalized = value.trim().toLowerCase();
return normalized === "" || normalized === "missing" || section?.querySelector(".keyword-missing") !== null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function requestText$6(url, proxyUrl = "", options = {}) {
const method = options.method ?? "GET";
const body = requestTextBody(options.payload);
const requestUrl2 = requestTextUrl(url, method, body);
const headers = requestTextHeaders(method);
return requestText$7(requestUrl2, {
method,
headers,
data: method === "POST" ? body : void 0,
proxyUrl,
credentials: options.credentials ?? "omit",
redirect: "follow",
timeoutMs: 8e3,
allowPublicProxies: options.allowProxyFallback ?? method === "GET",
allowConfiguredProxy: options.allowConfiguredProxy,
failureLabel: "JPDB kanji request",
timeoutLabel: "JPDB kanji request timed out."
});
}
function requestTextBody(payload) {
return payload && Object.keys(payload).length ? new URLSearchParams(payload).toString() : "";
}
function requestTextUrl(url, method, body) {
return method === "GET" && body ? `${url}${url.includes("?") ? "&" : "?"}${body}` : url;
}
function requestTextHeaders(method) {
return method === "POST" ? { "Content-Type": "application/x-www-form-urlencoded" } : void 0;
}
function graphEllipseOffset(dx, dy, rx, ry) {
const denominator = Math.sqrt(dx * dx / (rx * rx) + dy * dy / (ry * ry));
return denominator > 0 ? Math.min(0.48, 1 / denominator) : 0;
}
function formatGraphCoordinate(value) {
return Number(value.toFixed(2)).toString();
}
function graphEdgePath(from, to, targetZone = "auto") {
const normalizedTargetZone = normalizeGraphAnchorZone(targetZone);
if (normalizedTargetZone === "auto" || normalizedTargetZone === "center") {
return graphAutoEdgePath(from, to);
}
const target = graphFixedAnchorPoint(to, normalizedTargetZone);
const source = graphAutoBoundaryPoint(from, target);
return {
d: `M${formatGraphCoordinate(source.x)} ${formatGraphCoordinate(source.y)} L${formatGraphCoordinate(target.x)} ${formatGraphCoordinate(target.y)}`,
points: [
graphLinePoint(source.x, source.y, target.x, target.y, 0.38),
graphLinePoint(source.x, source.y, target.x, target.y, 0.66)
]
};
}
function graphAutoEdgePath(from, to) {
const dx = to.x - from.x;
const dy = to.y - from.y;
const sourceOffset = graphEllipseOffset(dx, dy, from.rx, from.ry);
const targetOffset = graphEllipseOffset(dx, dy, to.rx, to.ry);
const x1 = from.x + dx * sourceOffset;
const y1 = from.y + dy * sourceOffset;
const x2 = to.x - dx * targetOffset;
const y2 = to.y - dy * targetOffset;
return {
d: `M${formatGraphCoordinate(x1)} ${formatGraphCoordinate(y1)} L${formatGraphCoordinate(x2)} ${formatGraphCoordinate(y2)}`,
points: [
graphLinePoint(x1, y1, x2, y2, 0.38),
graphLinePoint(x1, y1, x2, y2, 0.66)
]
};
}
function graphAutoBoundaryPoint(from, to) {
const dx = to.x - from.x;
const dy = to.y - from.y;
const offset = graphEllipseOffset(dx, dy, from.rx, from.ry);
return {
x: from.x + dx * offset,
y: from.y + dy * offset
};
}
function graphFixedAnchorPoint(node, zone) {
switch (zone) {
case "top":
return { x: node.x, y: node.y - node.ry };
case "left":
return { x: node.x - node.rx, y: node.y };
case "right":
return { x: node.x + node.rx, y: node.y };
case "bottom":
return { x: node.x, y: node.y + node.ry };
}
return { x: node.x, y: node.y };
}
function normalizeGraphAnchorZone(zone) {
if (zone === "upper") return "top";
if (zone === "lower") return "bottom";
return zone;
}
function graphLinePoint(x1, y1, x2, y2, t) {
return {
x: x1 + (x2 - x1) * t,
y: y1 + (y2 - y1) * t
};
}
function splitRtkElements$1(value) {
const seen = /* @__PURE__ */ new Set();
const elements = [];
value.split(/[、,;++]/).map(cleanRtkElementKeyword).filter(Boolean).forEach((keyword) => {
const key = rtkElementKey(keyword);
if (seen.has(key)) return;
seen.add(key);
elements.push(keyword);
});
return elements.slice(0, 16);
}
function cleanRtkElementKeyword(value) {
return value.replace(/\s+/g, " ").trim().replace(/\d+$/u, "").trim();
}
function rtkElementKey(value) {
return cleanRtkElementKeyword(value).toLowerCase().replace(/[’']/g, "");
}
const RTK_ELEMENT_GLYPH_FALLBACKS = new Map(
"heart=心=心|fishhook=乙=乙|fishguts=乙=乙|fish guts=乙=乙|stick=丨|walking stick=丨|drop=丶|drops=丶|a drop of=丶|hook right=⺃|hook (right)=⺃|state of mind=⺖|valentine=⺗|animal legs=ハ|human legs=儿|wind=几|bound up=勹|bound up small=⺈|bound up (small)=⺈|horns=丷|saber=⺉|little=⺌|cliff=厂|water=⺡|fire=⺣|hood=冂|house=宀|flower=艹|pack of wild dogs=⺨|cow left=牜|cow top=⺧|umbrella=𠆢|road=⻌|walking legs=夂|crown=冖|top hat=亠|taskmaster=攵|fiesta=戈|stretch=廴|zoo=疋|zoo left=⺪|cloak=⻂|ice left=冫|ice bottom=⺀|reclining=𠂉|wings=羽=羽|feathers=羽=羽|person=⺅|finger=扌|two hands bottom=廾|elbow=厶|going=彳|altar=⺭|broom=彐|broom old=⺔|rake=⺺|shovel=凵|old man=耂|cocoon=幺|stamp=卩|chop seal=ㄗ|chop seal small=マ|silver=艮|sheaf=㐅|cornucopia=丩|key=ユ|sickness=疒|box=匚|shape=彡|row=业|city walls right=⻏".split("|").map((value) => {
const [key, glyph, kanji] = value.split("=");
return [key, kanji ? { glyph, kanji } : { glyph }];
})
);
function rtkElementFallbackGlyph(keyword) {
return RTK_ELEMENT_GLYPH_FALLBACKS.get(rtkElementKey(keyword));
}
function pickTokenForSelection(tokens = [], selected) {
const exact = tokens.find((token) => token.card.spelling === selected || token.card.reading === selected);
if (exact) {
return exact;
}
const fuzzy = tokens.find((token) => selected.includes(token.card.spelling) || token.card.spelling.includes(selected));
return fuzzy;
}
function formatMetaFrequency(value) {
const display = metaFrequencyDisplayValue(value);
if (display == null) return "";
return `#${display}`;
}
function metaFrequencyDisplayValue(value) {
const primitive = primitiveMetaValue(value);
if (primitive !== null) return primitive;
const record = objectRecord(value);
return record ? scalarMetaValue(nestedMetaValue(record)) : null;
}
function scalarMetaValue(value) {
const primitive = primitiveMetaValue(value);
if (primitive !== null) return primitive;
const record = objectRecord(value);
return record ? scalarMetaValue(nestedMetaValue(record)) : null;
}
function primitiveMetaValue(value) {
return typeof value === "number" || typeof value === "string" ? String(value) : null;
}
function objectRecord(value) {
return value && typeof value === "object" ? value : null;
}
function nestedMetaValue(record) {
return record.displayValue ?? record.frequency ?? record.value;
}
function groupTermEntriesByDictionary(entries) {
const grouped = /* @__PURE__ */ new Map();
for (const entry of entries) {
const group = grouped.get(entry.dictionary) ?? [];
group.push(entry);
grouped.set(entry.dictionary, group);
}
return grouped;
}
function groupTermEntriesByHeadword(entries) {
const grouped = /* @__PURE__ */ new Map();
const meaningKeys = /* @__PURE__ */ new Map();
for (const entry of entries) {
const key = termHeadwordKey(entry);
const group = grouped.get(key) ?? createLearnerTermGroup(entry);
group.entries.push(entry);
updateLearnerTermFrequency(group, entry);
addLearnerTermMeaning(group, entry, key, meaningKeys);
grouped.set(key, group);
}
return [...grouped.values()];
}
function termHeadwordKey(entry) {
return `${entry.expression || entry.reading}
${entry.reading || ""}`;
}
function createLearnerTermGroup(entry) {
return { expression: entry.expression || entry.reading, reading: entry.reading || "", entries: [], meanings: [] };
}
function updateLearnerTermFrequency(group, entry) {
if (entry.jpdbFrequency !== void 0 && (group.frequency === void 0 || entry.jpdbFrequency < group.frequency)) {
group.frequency = entry.jpdbFrequency;
}
}
function addLearnerTermMeaning(group, entry, key, meaningKeys) {
const meaning = summarizeLearnerGlossary(entry);
if (!meaning) return;
const seen = meaningKeys.get(key) ?? /* @__PURE__ */ new Set();
const meaningKey = meaning.toLocaleLowerCase();
if (!seen.has(meaningKey)) {
seen.add(meaningKey);
group.meanings.push(meaning);
}
meaningKeys.set(key, seen);
}
function buildRtkComponentSummaries(rtkInfo, jpdbInfo, entries) {
const elementKeywords = splitRtkElements$1(rtkInfo?.elements ?? "").filter((keyword) => rtkElementKey(keyword) !== rtkElementKey(rtkInfo?.keyword ?? ""));
const jpdbByKanji = new Map((jpdbInfo?.components ?? []).map((component) => [component.kanji, component.keyword]));
const localByKanji = new Map(entries.map((entry) => [entry.character, entry.meanings.slice(0, 3).join(", ")]));
const summaries = [.../* @__PURE__ */ new Set([...rtkInfo?.componentKanji ?? [], ...jpdbInfo?.components.map((component) => component.kanji) ?? []])].filter(isKanjiCharacter$1).map((kanji, index) => ({
kanji,
keyword: jpdbByKanji.get(kanji) || elementKeywords[index] || "",
meaning: localByKanji.get(kanji) || ""
}));
return summaries;
}
function mergeSimilarKanjiWords(localEntries, jpdbVocabulary, currentCard, dictionaryLabel2) {
const currentKeys = /* @__PURE__ */ new Set([`${currentCard.spelling}
${currentCard.reading}`, `${currentCard.spelling}
`]);
const words = /* @__PURE__ */ new Map();
const add = (entry) => {
const key = `${entry.expression}
${entry.reading}`;
if (currentKeys.has(key) || entry.expression === currentCard.spelling) return;
const existing = words.get(key);
if (existing) {
existing.meaning ||= entry.meaning;
existing.frequency ??= entry.frequency;
if (!existing.source.includes(entry.source)) existing.source = `${existing.source} · ${entry.source}`;
return;
}
words.set(key, entry);
};
jpdbVocabulary.forEach((entry) => add({
expression: entry.expression,
reading: entry.reading,
meaning: entry.meaning,
source: "JPDB"
}));
localEntries.forEach((entry) => add({
expression: entry.expression,
reading: entry.reading,
meaning: summarizeLearnerGlossary(entry),
frequency: entry.jpdbFrequency,
source: dictionaryLabel2(entry.dictionary)
}));
const result = Array.from(words.values()).sort(
(a, b) => compareOptionalNumber(a.frequency, b.frequency) || a.expression.length - b.expression.length || a.expression.localeCompare(b.expression)
);
return result;
}
function summarizeLearnerGlossary(entry) {
const candidates = entry.glossary.flatMap((item) => splitLearnerGlossaryText(glossaryToText(item))).map(cleanLearnerGlossaryText).filter(Boolean);
return Array.from(new Set(candidates)).slice(0, 3).join(", ");
}
const LEARNER_GLOSSARY_SOURCE_RE = /\b(?:JMdict|JMDict|Tatoeba)\b.*$/i;
const LEARNER_GLOSSARY_TAG_RE = /^(?:\[[^\]]+\]\s*)?(?:(?:adj-(?:i|ix|ku|na|no|pn|t|f)|na-adj|adv(?:-to)?|aux(?:-[a-z]+)?|conj|ctr|exp|int|n(?:-[a-z]+)?|noun|pn|pref|prt|suf|suffix|vs(?:-[a-z]+)?|v[0-9a-z-]+|vi|vk|vn|vr|vs|vt|suru|transitive|intransitive|adjective|adverb|kana|usually|uk|arch|abbr|hon|hum|pol|sl|col|obs|obscure|rare|relative)\s+)+/i;
function splitLearnerGlossaryText(text2) {
const normalized = text2.replace(/\s+/g, " ").trim();
if (!normalized) return [];
const withoutExamples = cutBeforeExampleText(normalized).replace(LEARNER_GLOSSARY_SOURCE_RE, "").trim();
return withoutExamples.split(/\s*(?:;|,|\/|\||\u3001|\u30fb)\s*/).map((item) => item.trim()).filter(Boolean);
}
function cleanLearnerGlossaryText(text2) {
let clean = text2.replace(/^\[[^\]]+\]\s*/, "").replace(LEARNER_GLOSSARY_TAG_RE, "").replace(/^\((?:relative|usually|kana|uk|arch|abbr|hon|hum|pol|sl|col|obs|obscure|rare)\)\s*/i, "").replace(/\s+/g, " ").trim();
clean = humanizeTerseGlosses(trimLearnerMeaning(clean));
if (!clean || HAS_JAPANESE$1.test(clean) || looksLikeGrammarTag(clean)) return "";
return clean;
}
function cutBeforeExampleText(text2) {
const japaneseIndex = text2.search(HAS_JAPANESE$1);
const sentenceIndex = text2.search(/\s+[A-Z][^.;!?]*(?:[.;!?]|$)/);
const indexes = [japaneseIndex, sentenceIndex].filter((index) => index >= 0);
const cutoff = indexes.length ? Math.min(...indexes) : -1;
return cutoff >= 0 ? text2.slice(0, cutoff) : text2;
}
function trimLearnerMeaning(text2, maxLength = 56) {
if (text2.length <= maxLength) return text2;
const truncated = text2.slice(0, maxLength).replace(/\s+\S*$/, "").trim();
return truncated || text2.slice(0, maxLength).trim();
}
function humanizeTerseGlosses(text2) {
const words = text2.split(/\s+/).filter(Boolean);
if (words.length < 2 || words.length > 4) return text2;
if (words.some((word) => /^(?:a|an|and|as|for|in|of|on|or|the|to|with)$/i.test(word))) return text2;
if (words.every((word) => /^[a-z][a-z'-]*$/i.test(word))) return words.join(", ");
return text2;
}
function looksLikeGrammarTag(text2) {
return /^(?:adj|adv|aux|conj|ctr|exp|int|n|noun|pn|pref|prt|suf|suffix|v[0-9a-z-]+|vi|vt|vs|vk|vn|vr|suru|transitive|intransitive|adjective|adverb|kana|uk)(?:\s|$)/i.test(text2);
}
function renderKanjiKeywordLine(jpdbInfo, rtkInfo, entries, language = "en") {
const keywords = /* @__PURE__ */ new Map();
const addKeyword = (text2, source) => {
const normalized = text2?.trim();
if (!normalized) return;
const key = normalized.toLocaleLowerCase();
const existing = keywords.get(key) ?? { text: normalized, sources: [] };
if (!existing.sources.includes(source)) existing.sources.push(source);
keywords.set(key, existing);
};
addKeyword(jpdbInfo?.keyword, "JPDB");
addKeyword(rtkInfo?.keyword, "RTK");
entries.flatMap((entry) => entry.meanings).filter(Boolean).slice(0, 3).forEach((keyword) => addKeyword(keyword, uiText(language, "dict")));
const chips = Array.from(keywords.values()).slice(0, 6).map((keyword) => `${escapeHtml$1(keyword.sources.join("/"))} ${escapeHtml$1(keyword.text)} `).join("");
return chips ? `${chips}
` : `${escapeHtml$1(uiText(language, "kanjiDetailsUnavailable"))}
`;
}
function parseRtkElementChip(value) {
const match = value.match(/^([^\sA-Za-z0-9])\s*(.+)$/u);
if (!match) return { keyword: value, glyph: "", kanji: "" };
const glyph = match[1] ?? "";
return { glyph, kanji: isKanjiCharacter$1(glyph) ? glyph : "", keyword: match[2]?.trim() ?? "" };
}
function buildRtkElementChips(info, components2) {
const componentKanji = new Set(components2.map((component) => component.kanji).filter(Boolean));
const componentByKeyword = /* @__PURE__ */ new Map();
components2.forEach((component) => {
if (component.keyword) componentByKeyword.set(rtkElementKey(component.keyword), { glyph: component.kanji, kanji: component.kanji });
});
const chips = splitRtkElements$1(info.elements).map(parseRtkElementChip).filter((chip) => chip.keyword && rtkElementKey(chip.keyword) !== rtkElementKey(info.keyword)).map((chip) => {
const inlineGlyph = chip.glyph && (!componentKanji.size || componentKanji.has(chip.kanji)) ? { glyph: chip.glyph, kanji: chip.kanji } : void 0;
const inferred = inlineGlyph ?? componentByKeyword.get(rtkElementKey(chip.keyword)) ?? info.elementGlyphs?.[rtkElementKey(chip.keyword)] ?? rtkElementFallbackGlyph(chip.keyword);
return {
keyword: chip.keyword,
glyph: inferred?.glyph ?? "",
kanji: inferred?.kanji ?? ""
};
});
const anchoredKanji = new Set(chips.map((chip) => chip.kanji).filter(Boolean));
const allKnownComponentsAnchored = componentKanji.size > 0 && [...componentKanji].every((kanji) => anchoredKanji.has(kanji));
return chips.map((chip, index) => {
if (chip.glyph) return chip;
const previous = lastAnchoredRtkChip(chips, index);
if (!previous) return chip;
const next = nextAnchoredRtkChip(chips, index);
return next || allKnownComponentsAnchored ? { ...chip, glyph: previous.glyph, kanji: previous.kanji } : chip;
});
}
function lastAnchoredRtkChip(chips, beforeIndex) {
for (let index = beforeIndex - 1; index >= 0; index -= 1) {
if (chips[index]?.kanji) return chips[index] ?? null;
}
return null;
}
function nextAnchoredRtkChip(chips, afterIndex) {
for (let index = afterIndex + 1; index < chips.length; index += 1) {
if (chips[index]?.kanji) return chips[index] ?? null;
}
return null;
}
function compareOptionalNumber(a, b) {
if (a === void 0 && b === void 0) return 0;
if (a === void 0) return 1;
if (b === void 0) return -1;
return a - b;
}
function sourceStateAttribute(sourceStateKey, initiallyExpanded) {
return sourceStateKey ? `data-source-state-key="${escapeHtml$1(sourceStateKey)}" data-source-initial-open="${String(initiallyExpanded)}"` : "";
}
function renderKanjiPractice(info, kanji, language, initiallyExpanded = true, sourceStateKey, title = uiText(language, "strokePractice")) {
const ghost = `${escapeHtml$1(kanji)}
`;
return `
${escapeHtml$1(title)}
${uiText(language, "textTrace")}
${uiText(language, "hideTrace")}
${uiText(language, "clear")}
`;
}
function renderKanjiOrigins(facts, graph, sourceInfo, settings, language, initiallyExpanded = settings.dictionarySourcesInitiallyExpanded, sourceStateKey, excludeFactLabels, title = uiText(language, "originStructure")) {
if (!hasKanjiOriginContent(facts, graph, sourceInfo)) {
return "";
}
const map = sourceInfo?.kanjiMap;
return `
${escapeHtml$1(title)}
${renderKanjiOriginDetail(map, settings, language)}
${settings.kanjiOriginGraphEnabled ? renderKanjiOriginGraph(graph, language) : ""}
${renderKanjiFactPills(facts, language, excludeFactLabels)}
`;
}
function hasKanjiOriginContent(facts, graph, sourceInfo) {
return Boolean(facts.length || graph && graph.nodes.length > 1 || sourceInfo?.kanjiMap);
}
function renderKanjiFactPills(facts, language, excludeFactLabels) {
if (!facts.length) return "";
const excludedFacts = excludeFactLabels ? new Set(excludeFactLabels) : null;
const visibleFacts = excludedFacts ? facts.filter((fact) => !excludedFacts.has(fact.label)) : facts;
if (!visibleFacts.length) return "";
return `
${visibleFacts.map((fact) => `${escapeHtml$1(kanjiFactLabel(fact.label, language))} ${escapeHtml$1(fact.value)} `).join("")}
`;
}
function kanjiFactLabel(label, language) {
switch (label) {
case "Meaning":
return uiText(language, "factMeaning");
case "Type":
return uiText(language, "factType");
case "Frequency":
return uiText(language, "factFrequency");
case "Grade":
return uiText(language, "factGrade");
case "Strokes":
return uiText(language, "strokes");
case "Radical":
return uiText(language, "radical");
default:
return label;
}
}
function renderKanjiOriginDetail(map, settings, language) {
if (!map) return "";
const radicalCard = renderKanjiRadicalCard(map, settings, language);
return radicalCard ? `${radicalCard}
` : '
';
}
function renderKanjiRadicalCard(map, settings, language) {
const radical = map.radical;
if (!radical && !map.hint) return "";
return `
${renderKanjiRadicalGlyph(radical, language)}
${renderKanjiRadicalSummary(radical, language)}
${map.hint ? `${escapeHtml$1(map.hint)} ` : ""}
${renderKanjiRadicalFrames(radicalFrameUrls(radical, settings))}
`;
}
function renderKanjiRadicalGlyph(radical, language) {
return radical ? `${escapeHtml$1(radical.symbol || uiText(language, "radical"))} ` : "";
}
function renderKanjiRadicalSummary(radical, language) {
if (!radical) return "";
const values = [radical.reading, radical.meaning, radical.strokes ? `${radical.strokes} ${uiText(language, "strokes")}` : ""];
return `${escapeHtml$1(values.filter(Boolean).join(" · "))} `;
}
function radicalFrameUrls(radical, settings) {
return settings.kanjiOriginRadicalImagesEnabled && radical ? [radical.image, ...radical.animation].filter(Boolean).slice(0, 4) : [];
}
function renderKanjiRadicalFrames(radicalFrames) {
if (!radicalFrames.length) return "";
return `
${radicalFrames.map((url, index) => `
`).join("")}
`;
}
function renderKanjiOriginGraph(graph, language) {
const model = buildKanjiOriginGraphRenderModel(graph);
if (!model) return "";
const { hasSubcomponentEdges, markerId, outboundMarkerId, subcomponentMarkerId } = model;
const lines = renderOriginGraphLines(model);
const nodeButtons = renderOriginGraphNodeButtons(model);
return `
${lines}
${renderOriginGraphToggles(model, language)}
${nodeButtons}
`;
}
function renderOriginGraphToggles(model, language) {
const toggles = [
model.hasSubcomponentEdges ? renderOriginGraphToggle(uiText(language, "originShowSubcomponents"), "data-origin-subcomponent-toggle") : "",
model.hasOutboundEdges ? renderOriginGraphToggle(uiText(language, "originShowOutbound"), "data-origin-outbound-toggle") : ""
].filter(Boolean);
return toggles.length ? `${toggles.join("")}
` : "";
}
function renderOriginGraphToggle(label, attribute) {
return `
${escapeHtml$1(label)}
`;
}
const SIMPLIFIED_ONLY_COMPONENTS = /* @__PURE__ */ new Set(["讠", "钅", "饣", "纟", "门", "车", "贝", "见", "长", "马", "鸟", "鱼"]);
const TOP_COMPONENTS = /* @__PURE__ */ new Set(["亠", "宀", "冖", "艹", "⺾", "竹", "⺮", "雨", "穴", "覀", "西", "爫", "𠂉"]);
const BOTTOM_COMPONENTS = /* @__PURE__ */ new Set(["心", "忄", "灬", "儿", "皿", "貝", "贝", "日", "寸", "廾"]);
const LEFT_COMPONENTS = /* @__PURE__ */ new Set(["亻", "人", "彳", "氵", "忄", "扌", "木", "言", "訁", "口", "女", "糸", "纟", "土", "王", "犭", "礻", "衤", "月", "火", "禾", "虫", "足", "車", "车"]);
const RIGHT_COMPONENTS = /* @__PURE__ */ new Set(["阝", "刂", "卩", "頁", "页", "隹", "攵", "殳", "欠", "鳥", "鸟"]);
const WHOLE_COMPONENTS = /* @__PURE__ */ new Set(["大", "夫", "天", "失", "央", "本", "末", "未"]);
const KNOWN_COMPONENT_ZONES = [
["top", TOP_COMPONENTS],
["bottom", BOTTOM_COMPONENTS],
["center", WHOLE_COMPONENTS],
["left", LEFT_COMPONENTS],
["right", RIGHT_COMPONENTS]
];
const COMPONENT_POSITION_ZONES = [
[/へん|left/, "left"],
[/つくり|right/, "right"],
[/かんむり|top|upper/, "top"],
[/あし|した|bottom|lower/, "bottom"],
[/かまえ|enclosure|surround/, "center"]
];
const OUTBOUND_COMPONENT_PLACEMENT_OVERRIDES = /* @__PURE__ */ new Map([
["夫\0失", "upper"],
["夫\0替", "top"],
["夫\0難", "left"],
["夫\0僕", "lower"]
]);
const INBOUND_COMPONENT_PLACEMENT_OVERRIDES = /* @__PURE__ */ new Map([
["友\0ナ", { zone: "upper", x: 33, y: 39 }],
["友\0又", { zone: "bottom", x: 58, y: 72 }]
]);
function buildKanjiOriginGraphRenderModel(graph) {
const base = originGraphBase(graph);
if (!base) return null;
const selectedEdges = selectedOriginGraphEdges(base);
const visible = visibleOriginGraph(base, selectedEdges);
if (!visible) return null;
const roles = originGraphNodeRoles(visible.edgeGroups, base.current.id);
const positioned = forceLayoutOriginGraph(visible.nodes, visible.edgeGroups, base.current.id);
const markerId = originGraphMarkerId(positioned);
return {
current: base.current,
nodeById: base.nodeById,
edgeGroups: visible.edgeGroups,
positioned,
...roles,
markerId,
outboundMarkerId: `${markerId}-outbound`,
subcomponentMarkerId: `${markerId}-subcomponent`,
hasOutboundEdges: visible.edgeGroups.some((edge) => isOriginOutboundEdge(edge, base.current.id)),
hasSubcomponentEdges: visible.edgeGroups.some(isOriginSubcomponentEdge)
};
}
function originGraphBase(graph) {
const nodes = originGraphRenderableNodes(graph);
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const edges = originGraphRenderableEdges(graph, nodeById);
if (shouldSkipOriginGraph(nodes, edges)) return null;
return {
nodes,
nodeById,
edges,
current: originGraphCurrentNode(nodes)
};
}
function originGraphRenderableNodes(graph) {
return graph?.nodes.filter((node) => !node.id.startsWith("rtk:")) ?? [];
}
function originGraphRenderableEdges(graph, nodeById) {
const nodeIds = new Set(nodeById.keys());
return graph?.edges.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to)) ?? [];
}
function shouldSkipOriginGraph(nodes, edges) {
return nodes.length <= 1 || !edges.length;
}
function originGraphCurrentNode(nodes) {
return nodes.find((node) => node.kind === "current") ?? nodes[0];
}
function selectedOriginGraphEdges(base) {
const groupedEdges = groupOriginEdges(base.edges);
const primaryEdges = selectOriginEdgeGroups(
groupedEdges.filter((edge) => !isOriginOutboundEdge(edge, base.current.id) && !isOriginSubcomponentEdge(edge)),
base.nodeById
);
return [
...primaryEdges,
...selectOriginSubcomponentEdgeGroups(groupedEdges, base.nodeById),
...selectOriginOutboundEdgeGroups(groupedEdges, base.nodeById, base.current.id)
];
}
function visibleOriginGraph(base, selectedEdges) {
if (!selectedEdges.length) {
return null;
}
const connectedIds = connectedOriginNodeIds(base.current.id, selectedEdges);
const graphNodes = base.nodes.filter((node) => connectedIds.has(node.id) && !isNoisyOriginNode(node));
const visibleNodes = chooseOriginGraphNodes(graphNodes, selectedEdges, base.current.id);
const visibleIds = new Set(visibleNodes.map((node) => node.id));
const edgeGroups = selectedEdges.filter((edge) => visibleIds.has(edge.from) && visibleIds.has(edge.to));
if (visibleNodes.length <= 1 || !edgeGroups.length) {
return null;
}
return { nodes: visibleNodes, edgeGroups };
}
function connectedOriginNodeIds(currentId, edges) {
const ids = /* @__PURE__ */ new Set([currentId]);
edges.forEach((edge) => {
ids.add(edge.from);
ids.add(edge.to);
});
return ids;
}
function originGraphNodeRoles(edgeGroups, currentId) {
const primaryIds = /* @__PURE__ */ new Set([currentId]);
const outboundIds = /* @__PURE__ */ new Set();
const subcomponentIds = /* @__PURE__ */ new Set();
edgeGroups.forEach((edge) => addOriginGraphNodeRole(edge, currentId, primaryIds, outboundIds, subcomponentIds));
return { primaryIds, outboundIds, subcomponentIds };
}
function addOriginGraphNodeRole(edge, currentId, primaryIds, outboundIds, subcomponentIds) {
if (isOriginOutboundEdge(edge, currentId)) {
outboundIds.add(edge.to);
return;
}
if (isOriginSubcomponentEdge(edge)) {
subcomponentIds.add(edge.from);
if (edge.to !== currentId) subcomponentIds.add(edge.to);
return;
}
primaryIds.add(edge.from);
primaryIds.add(edge.to);
}
function originGraphMarkerId(positioned) {
return `jpdb-reader-origin-target-${hashOriginGraphId(positioned.map((item) => item.node.id).join("|"))}`;
}
function renderOriginGraphLines(model) {
const coords = new Map(model.positioned.map((item) => [item.node.id, item]));
return model.edgeGroups.map((edge) => renderOriginGraphEdgeGroup(edge, coords, model)).join("");
}
function renderOriginGraphEdgeGroup(edge, coords, model) {
const from = coords.get(edge.from);
const to = coords.get(edge.to);
if (!from || !to) return "";
const targetZone = originEdgeTargetZone(edge, model.current.id, model.nodeById);
const edgePath = clippedOriginEdgePath(from, to, targetZone);
const label = edge.labels.join(" / ");
const outbound = isOriginOutboundEdge(edge, model.current.id);
const subcomponent = isOriginSubcomponentEdge(edge);
const outboundAttrs = outbound ? ' data-origin-outbound="true"' : "";
const subcomponentAttrs = subcomponent ? ' data-origin-subcomponent="true"' : "";
const markerId = outbound ? model.outboundMarkerId : subcomponent ? model.subcomponentMarkerId : model.markerId;
return `
${escapeHtml$1(label)}
`;
}
function renderOriginGraphNodeButtons(model) {
return model.positioned.map((node) => renderOriginGraphNodeButton(node, model)).join("");
}
function renderOriginGraphNodeButton(positioned, model) {
const { node, x, y, rx, ry } = positioned;
const style = `left:${formatGraphNumber(x)}%;top:${formatGraphNumber(y)}%`;
const outboundOnly = node.id !== model.current.id && model.outboundIds.has(node.id) && !model.primaryIds.has(node.id);
const subcomponentOnly = node.id !== model.current.id && model.subcomponentIds.has(node.id) && !model.primaryIds.has(node.id) && !model.outboundIds.has(node.id);
const attrs = `data-graph-node="${escapeHtml$1(node.id)}" data-label-length="${originGraphLabelLengthAttribute(node.label)}" data-x="${formatGraphNumber(x)}" data-y="${formatGraphNumber(y)}" data-rx="${formatGraphNumber(rx)}" data-ry="${formatGraphNumber(ry)}"${outboundOnly ? ' data-origin-outbound="true"' : ""}${subcomponentOnly ? ' data-origin-subcomponent="true"' : ""} style="${style}"`;
if (node.kind === "related") return renderRelatedOriginGraphNode(node, attrs);
return renderKanjiOriginGraphNode(node, attrs);
}
function originGraphLabelLengthAttribute(label) {
const length = Array.from(label).length;
return length > 2 ? "many" : String(length || 1);
}
function renderRelatedOriginGraphNode(node, attrs) {
return `${escapeHtml$1(node.label)} `;
}
function renderKanjiOriginGraphNode(node, attrs) {
const title = [node.detail, node.source].filter(Boolean).join(" · ");
return `${escapeHtml$1(node.label)} `;
}
function chooseOriginGraphNodes(nodes, edges, currentId) {
const current = nodes.find((node) => node.id === currentId) ?? nodes[0];
const degree = /* @__PURE__ */ new Map();
edges.forEach((edge) => {
degree.set(edge.from, (degree.get(edge.from) ?? 0) + 1);
degree.set(edge.to, (degree.get(edge.to) ?? 0) + 1);
});
const ranked = nodes.filter((node) => node.id !== current.id).sort((a, b) => {
const priority = originNodePriority(a.id, edges, current.id) - originNodePriority(b.id, edges, current.id);
if (priority) return priority;
const degreeDelta = (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0);
if (degreeDelta) return degreeDelta;
return a.label.localeCompare(b.label, "ja");
});
return [current, ...ranked.slice(0, 18)];
}
function originNodePriority(id, edges, currentId) {
if (edges.some((edge) => edge.from === id && edge.to === currentId)) return 0;
if (edges.some((edge) => edge.from === currentId && edge.to === id)) return 1;
if (edges.some((edge) => edge.from === id || edge.to === id)) return 2;
return 3;
}
function selectOriginEdgeGroups(groups, nodeById) {
const useful = groups.filter((edge) => {
const from = nodeById.get(edge.from);
const to = nodeById.get(edge.to);
return from && to && !isNoisyOriginNode(from) && !isNoisyOriginNode(to);
});
const structural = useful.filter((edge) => edge.labels.some((label) => label === "radical" || label === "structural part"));
if (structural.length) return structural;
const jpdb = useful.filter((edge) => edge.labels.includes("JPDB component"));
if (jpdb.length) return jpdb;
return useful.filter((edge) => !edge.labels.includes("memory cue"));
}
function selectOriginOutboundEdgeGroups(groups, nodeById, currentId) {
return groups.filter((edge) => {
if (!isOriginOutboundEdge(edge, currentId)) return false;
const to = nodeById.get(edge.to);
return to && !isNoisyOriginNode(to);
});
}
function selectOriginSubcomponentEdgeGroups(groups, nodeById) {
return groups.filter((edge) => {
if (!isOriginSubcomponentEdge(edge)) return false;
const from = nodeById.get(edge.from);
const to = nodeById.get(edge.to);
return from && to && !isNoisyOriginNode(from) && !isNoisyOriginNode(to);
});
}
function isOriginOutboundEdge(edge, currentId) {
return edge.from === currentId && edge.to !== currentId;
}
function isOriginSubcomponentEdge(edge) {
return edge.labels.includes("subcomponent");
}
function originEdgeTargetZone(edge, currentId, nodeById) {
if (edge.to === currentId) {
const source = nodeById.get(edge.from);
return source ? inferInboundComponentZone(source, currentId) : "auto";
}
if (edge.from === currentId) {
const target = nodeById.get(edge.to);
return target ? inferOutboundComponentZone(currentId, target) : "auto";
}
if (isOriginSubcomponentEdge(edge)) {
const source = nodeById.get(edge.from);
return source ? inferInboundComponentZone(source, edge.to) : "auto";
}
return "auto";
}
function isNoisyOriginNode(node) {
return SIMPLIFIED_ONLY_COMPONENTS.has(node.id) || SIMPLIFIED_ONLY_COMPONENTS.has(node.label);
}
function groupOriginEdges(edges) {
const groups = /* @__PURE__ */ new Map();
for (const edge of edges) {
const key = `${edge.from}\0${edge.to}`;
const group = groups.get(key) ?? { from: edge.from, to: edge.to, labels: [] };
if (edge.label && !group.labels.includes(edge.label)) group.labels.push(edge.label);
groups.set(key, group);
}
return Array.from(groups.values());
}
function forceLayoutOriginGraph(nodes, edges, currentId) {
const anchors = originGraphAnchors(nodes, edges, currentId);
const states = createOriginNodeStates(nodes, anchors);
const byId = new Map(states.map((state) => [state.node.id, state]));
for (let iteration = 0; iteration < 240; iteration++) {
const alpha = Math.pow(1 - iteration / 240, 1.45);
applyOriginNodeRepulsion(states, alpha);
applyOriginEdgePulls(byId, edges, currentId, alpha);
applyOriginEdgeNodeAvoidance(states, byId, edges, alpha);
applyOriginAnchorPulls(states, currentId, alpha);
integrateOriginNodeStates(states, currentId);
}
return positionOriginNodes(states);
}
function createOriginNodeStates(nodes, anchors) {
return nodes.map((node, index) => {
const { rx, ry } = originNodeRadii(node);
const anchor = anchors.get(node.id) ?? { x: 50, y: 50 };
const jitter = index === 0 ? 0 : (index % 2 === 0 ? 1 : -1) * (1.2 + index % 3 * 0.45);
return {
node,
x: anchor.x + jitter,
y: anchor.y - jitter * 0.6,
rx,
ry,
vx: 0,
vy: 0,
anchorX: anchor.x,
anchorY: anchor.y,
anchorPinned: anchor.pinned === true,
collision: Math.max(rx * 1.35, ry) + 5.2
};
});
}
function applyOriginNodeRepulsion(states, alpha) {
for (let aIndex = 0; aIndex < states.length; aIndex++) {
for (let bIndex = aIndex + 1; bIndex < states.length; bIndex++) {
repelOriginNodePair(states[aIndex], states[bIndex], aIndex, bIndex, alpha);
}
}
}
function repelOriginNodePair(a, b, aIndex, bIndex, alpha) {
const delta = originNodeDelta(a, b, aIndex, bIndex);
const distanceSquared = Math.max(8, delta.dx * delta.dx + delta.dy * delta.dy);
const distance = Math.sqrt(distanceSquared);
const repel = Math.min(0.68, 17 * alpha / distanceSquared);
a.vx -= delta.dx * repel;
a.vy -= delta.dy * repel;
b.vx += delta.dx * repel;
b.vy += delta.dy * repel;
const minimumDistance = a.collision + b.collision;
if (distance >= minimumDistance) return;
const push = (minimumDistance - distance) / distance * 0.14 * alpha;
a.vx -= delta.dx * push;
a.vy -= delta.dy * push;
b.vx += delta.dx * push;
b.vy += delta.dy * push;
}
function originNodeDelta(a, b, aIndex, bIndex) {
const dx = b.x - a.x;
const dy = b.y - a.y;
return Math.abs(dx) + Math.abs(dy) < 0.01 ? { dx: (bIndex - aIndex) * 0.13, dy: (aIndex + bIndex) * 0.11 } : { dx, dy };
}
function applyOriginEdgePulls(byId, edges, currentId, alpha) {
for (const edge of edges) {
const source = byId.get(edge.from);
const target = byId.get(edge.to);
if (source && target) pullOriginEdge(source, target, edge, currentId, alpha);
}
}
function pullOriginEdge(source, target, edge, currentId, alpha) {
const dx = target.x - source.x;
const dy = target.y - source.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
const targetDistance = isOriginSubcomponentEdge(edge) ? 21 : source.node.id === currentId || target.node.id === currentId ? 36 : 24;
const pull = (distance - targetDistance) / distance * 0.06 * alpha;
source.vx += dx * pull;
source.vy += dy * pull;
target.vx -= dx * pull;
target.vy -= dy * pull;
}
function applyOriginEdgeNodeAvoidance(states, byId, edges, alpha) {
for (const edge of edges) {
const source = byId.get(edge.from);
const target = byId.get(edge.to);
if (!source || !target) continue;
for (const state of states) {
if (state === source || state === target) continue;
pushOriginNodeAwayFromEdge(state, source, target, alpha);
}
}
}
function pushOriginNodeAwayFromEdge(node, source, target, alpha) {
const closest = closestOriginEdgePoint(node.x, node.y, source.x, source.y, target.x, target.y);
const dx = node.x - closest.x;
const dy = node.y - closest.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1e-3;
const clearance = Math.max(node.rx, node.ry) * 0.72 + 2.2;
if (distance >= clearance) return;
const fallback = originEdgeNormal(source, target);
const ux = distance > 0.01 ? dx / distance : fallback.x;
const uy = distance > 0.01 ? dy / distance : fallback.y;
const push = (clearance - distance) * 0.045 * alpha;
node.vx += ux * push;
node.vy += uy * push;
}
function closestOriginEdgePoint(px, py, x1, y1, x2, y2) {
const dx = x2 - x1;
const dy = y2 - y1;
const lengthSquared = dx * dx + dy * dy;
if (lengthSquared <= 1e-3) return { x: x1, y: y1 };
const t = clampGraphValue(((px - x1) * dx + (py - y1) * dy) / lengthSquared, 0, 1);
return { x: x1 + dx * t, y: y1 + dy * t };
}
function originEdgeNormal(source, target) {
const dx = target.x - source.x;
const dy = target.y - source.y;
const length = Math.sqrt(dx * dx + dy * dy) || 1;
return { x: -dy / length, y: dx / length };
}
function applyOriginAnchorPulls(states, currentId, alpha) {
for (const state of states) {
const anchorStrength = state.node.id === currentId ? 0.32 : state.anchorPinned ? 0.38 : 0.16;
state.vx += (state.anchorX - state.x) * anchorStrength * alpha;
state.vy += (state.anchorY - state.y) * anchorStrength * alpha;
}
}
function integrateOriginNodeStates(states, currentId) {
for (const state of states) {
integrateOriginNodeState(state, currentId);
state.x = clampGraphValue(state.x, 9 + state.rx, 91 - state.rx);
state.y = clampGraphValue(state.y, 7 + state.ry, 93 - state.ry);
}
}
function integrateOriginNodeState(state, currentId) {
if (state.node.id === currentId) {
state.x += (state.anchorX - state.x) * 0.4;
state.y += (state.anchorY - state.y) * 0.4;
state.vx = 0;
state.vy = 0;
return;
}
state.x += state.vx;
state.y += state.vy;
state.vx *= 0.58;
state.vy *= 0.58;
}
function positionOriginNodes(states) {
return states.map(({ node, x, y, rx, ry }) => ({
node,
x: Number(x.toFixed(2)),
y: Number(y.toFixed(2)),
rx,
ry
}));
}
function originGraphAnchors(nodes, edges, currentId) {
const anchors = /* @__PURE__ */ new Map();
const current = nodes.find((node) => node.id === currentId);
if (current) anchors.set(current.id, { x: 50, y: 50 });
const currentReference = current ? originNodeGeometryReference(current, { x: 50, y: 50 }) : void 0;
const incoming = nodes.filter((node) => node.id !== currentId && edges.some((edge) => edge.from === node.id && edge.to === currentId));
const outgoing = nodes.filter((node) => node.id !== currentId && edges.some((edge) => edge.from === currentId && edge.to === node.id));
const attached = new Set([...incoming, ...outgoing].map((node) => node.id));
const others = nodes.filter((node) => node.id !== currentId && !attached.has(node.id));
if (outgoing.length) {
spreadInboundComponents(incoming, currentId, currentReference).forEach(({ node, x, y, pinned }) => anchors.set(node.id, { x, y, pinned }));
spreadOutboundComponents(outgoing, currentId).forEach(({ node, x, y }) => anchors.set(node.id, { x, y }));
} else {
spreadInboundComponents(incoming, currentId, currentReference).forEach(({ node, x, y, pinned }) => anchors.set(node.id, { x, y, pinned }));
}
spreadNestedComponents(nodes, edges, anchors);
const anchored = new Set(anchors.keys());
const remainingOthers = others.filter((node) => !anchored.has(node.id));
remainingOthers.forEach((node, index) => {
const t = (index + 1) / (remainingOthers.length + 1);
anchors.set(node.id, { x: 26 + t * 48, y: 78 + index % 2 * 3 });
});
return anchors;
}
function spreadNestedComponents(nodes, edges, anchors) {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const nestedByParent = nestedEdgesByParent(edges, nodeById);
for (let pass = 0; pass < nodes.length; pass++) {
let placed = false;
nestedByParent.forEach((parentEdges, parentId) => {
const parentAnchor = anchors.get(parentId);
if (!parentAnchor) return;
const sorted = [...parentEdges].sort((a, b) => {
const aNode = nodeById.get(a.from);
const bNode = nodeById.get(b.from);
return componentZoneSort(aNode ? inferInboundComponentZone(aNode, parentId) : "center") - componentZoneSort(bNode ? inferInboundComponentZone(bNode, parentId) : "center") || (aNode?.label ?? a.from).localeCompare(bNode?.label ?? b.from, "ja");
});
sorted.forEach((edge, index) => {
if (anchors.has(edge.from)) return;
const node = nodeById.get(edge.from);
if (!node) return;
const parentNode = nodeById.get(parentId);
const geometryAnchor = originNodeGeometryAnchor(node, parentNode ? originNodeGeometryReference(parentNode, parentAnchor) : void 0);
if (geometryAnchor) {
anchors.set(edge.from, { ...geometryAnchor, pinned: true });
placed = true;
return;
}
const zone = inferInboundComponentZone(node, parentId);
anchors.set(edge.from, { ...nestedZoneAnchor(parentAnchor, zone, index, sorted.length), pinned: true });
placed = true;
});
});
if (!placed) return;
}
}
function nestedEdgesByParent(edges, nodeById) {
const result = /* @__PURE__ */ new Map();
edges.filter(isOriginSubcomponentEdge).forEach((edge) => {
if (!nodeById.has(edge.from) || !nodeById.has(edge.to)) return;
const list = result.get(edge.to) ?? [];
list.push(edge);
result.set(edge.to, list);
});
return result;
}
function nestedZoneAnchor(parent, zone, index, total) {
const xStep = 18;
const yStep = 20;
const side = nestedExpansionSide(parent.x);
const offset = (index - (total - 1) / 2) * 14;
const base = nestedZoneAnchorBase(parent, zone, side, xStep, yStep);
const withOffset = zone === "top" || zone === "upper" || zone === "bottom" || zone === "lower" ? { x: base.x + offset, y: base.y } : { x: base.x, y: base.y + offset };
return {
x: clampGraphValue(withOffset.x, 11, 89),
y: clampGraphValue(withOffset.y, 12, 88)
};
}
function nestedExpansionSide(parentX) {
if (parentX <= 34) return 1;
if (parentX >= 66) return -1;
return parentX < 50 ? -1 : 1;
}
function nestedZoneAnchorBase(parent, zone, side, xStep, yStep) {
switch (zone) {
case "top":
return { x: parent.x, y: parent.y - yStep };
case "upper":
return { x: parent.x + side * (xStep * 0.45), y: parent.y - yStep * 0.72 };
case "left":
return { x: parent.x - xStep, y: parent.y };
case "right":
return { x: parent.x + xStep, y: parent.y };
case "lower":
return { x: parent.x + side * (xStep * 0.45), y: parent.y + yStep * 0.72 };
case "bottom":
return { x: parent.x, y: parent.y + yStep };
case "center":
return { x: parent.x + side * xStep, y: parent.y };
}
}
function spreadInboundComponents(nodes, currentId = "", currentReference) {
if (!nodes.length) return [];
const ordered = [...nodes].sort((a, b) => componentZoneSort(inferInboundComponentZone(a, currentId)) - componentZoneSort(inferInboundComponentZone(b, currentId)) || a.label.localeCompare(b.label, "ja"));
const usedByZone = /* @__PURE__ */ new Map();
return ordered.map((node, index) => {
const geometryAnchor = originNodeGeometryAnchor(node, currentReference);
if (geometryAnchor) return { node, ...geometryAnchor, pinned: true };
const override = inboundPlacementOverride(currentId, node);
if (override) return { node, x: override.x, y: override.y, pinned: true };
const zone = inferInboundComponentZone(node, currentId);
const used = usedByZone.get(zone) ?? 0;
usedByZone.set(zone, used + 1);
const anchor = inboundZoneAnchor(zone, used, ordered.filter((item) => inferInboundComponentZone(item, currentId) === zone).length);
const fallback = spreadConstellation(ordered)[index] ?? { x: 30, y: 50 };
return { node, x: anchor?.x ?? fallback.x, y: anchor?.y ?? fallback.y, pinned: zone !== "center" };
});
}
function spreadOutboundComponents(nodes, currentId) {
if (!nodes.length) return [];
const ordered = [...nodes].sort((a, b) => componentZoneSort(inferOutboundComponentZone(currentId, a)) - componentZoneSort(inferOutboundComponentZone(currentId, b)) || a.label.localeCompare(b.label, "ja"));
const usedByZone = /* @__PURE__ */ new Map();
return ordered.map((node) => {
const zone = inferOutboundComponentZone(currentId, node);
const used = usedByZone.get(zone) ?? 0;
usedByZone.set(zone, used + 1);
const anchor = outboundZoneAnchor(zone, used, ordered.filter((item) => inferOutboundComponentZone(currentId, item) === zone).length);
return { node, x: anchor.x, y: anchor.y };
});
}
function originNodeGeometryAnchor(node, reference) {
const geometry = node.geometry;
if (!geometry || !Number.isFinite(geometry.x) || !Number.isFinite(geometry.y)) return void 0;
const x = 14 + clampGraphValue(geometry.x, 0, 1) * 72;
const y = 14 + clampGraphValue(geometry.y, 0, 1) * 72;
const anchor = {
x: clampGraphValue(x, 12, 88),
y: clampGraphValue(y, 12, 88)
};
return reference ? separateOriginGeometryAnchor(node, anchor, reference) : anchor;
}
function separateOriginGeometryAnchor(node, anchor, reference) {
const radii = originNodeRadii(node);
const dx = anchor.x - reference.x;
const dy = anchor.y - reference.y;
const distance = Math.hypot(dx, dy);
const direction = distance > 0.01 ? { x: dx / distance, y: dy / distance } : originComponentZoneDirection(inferInboundComponentZone(node));
const requiredDistance = originEllipseRadius(reference.rx, reference.ry, direction) + originEllipseRadius(radii.rx, radii.ry, direction) + 4.5;
if (distance >= requiredDistance) return anchor;
return {
x: clampGraphValue(reference.x + direction.x * requiredDistance, 9 + radii.rx, 91 - radii.rx),
y: clampGraphValue(reference.y + direction.y * requiredDistance, 7 + radii.ry, 93 - radii.ry)
};
}
function originNodeGeometryReference(node, point) {
const radii = originNodeRadii(node);
return { ...point, rx: radii.rx, ry: radii.ry };
}
function originEllipseRadius(rx, ry, direction) {
const denominator = Math.sqrt(direction.x * direction.x / (rx * rx) + direction.y * direction.y / (ry * ry));
return denominator > 0 ? 1 / denominator : Math.max(rx, ry);
}
function originComponentZoneDirection(zone) {
switch (zone) {
case "top":
return { x: 0, y: -1 };
case "upper":
return { x: 0.447, y: -0.894 };
case "left":
return { x: -1, y: 0 };
case "right":
return { x: 1, y: 0 };
case "lower":
return { x: 0.447, y: 0.894 };
case "bottom":
return { x: 0, y: 1 };
case "center":
return { x: -1, y: 0 };
}
}
function inferInboundComponentZone(node, currentId = "") {
const override = inboundPlacementOverride(currentId, node);
if (override) return override.zone;
const position = (node.position ?? "").toLowerCase();
return zoneFromComponentPosition(position) ?? zoneFromKnownComponent(node) ?? "center";
}
function inboundPlacementOverride(currentId, node) {
return INBOUND_COMPONENT_PLACEMENT_OVERRIDES.get(`${currentId}\0${node.id}`) ?? INBOUND_COMPONENT_PLACEMENT_OVERRIDES.get(`${currentId}\0${node.label}`);
}
function zoneFromComponentPosition(position) {
return COMPONENT_POSITION_ZONES.find(([pattern]) => pattern.test(position))?.[1] ?? null;
}
function zoneFromKnownComponent(node) {
return KNOWN_COMPONENT_ZONES.find(([, components2]) => components2.has(node.id) || components2.has(node.label))?.[0] ?? null;
}
function inferOutboundComponentZone(currentId, node) {
return OUTBOUND_COMPONENT_PLACEMENT_OVERRIDES.get(`${currentId}\0${node.id}`) ?? "center";
}
function componentZoneSort(zone) {
return { top: 0, upper: 1, left: 2, center: 3, right: 4, lower: 5, bottom: 6 }[zone];
}
function inboundZoneAnchor(zone, index, total) {
return zoneAnchor(INBOUND_ZONE_ANCHORS, zone, index, total, 10);
}
function outboundZoneAnchor(zone, index, total) {
if (zone === "center" && total > 2) {
const offset = (index - (total - 1) / 2) * 19;
return {
x: index % 2 === 0 ? 72 : 86,
y: 50 + offset
};
}
return zoneAnchor(OUTBOUND_ZONE_ANCHORS, zone, index, total, total > 2 ? 20 : 14);
}
const INBOUND_ZONE_ANCHORS = {
top: { x: 50, y: 19, offsetAxis: "x" },
upper: { x: 58, y: 35, offsetAxis: "x" },
left: { x: 21, y: 50, offsetAxis: "y" },
right: { x: 79, y: 50, offsetAxis: "y" },
lower: { x: 58, y: 65, offsetAxis: "x" },
bottom: { x: 50, y: 82, offsetAxis: "x" },
center: { x: 24, y: 50, offsetAxis: "y" }
};
const OUTBOUND_ZONE_ANCHORS = {
top: { x: 72, y: 23, offsetAxis: "x" },
upper: { x: 79, y: 34, offsetAxis: "x" },
left: { x: 84, y: 47, offsetAxis: "y" },
right: { x: 72, y: 47, offsetAxis: "y" },
lower: { x: 79, y: 66, offsetAxis: "x" },
bottom: { x: 72, y: 77, offsetAxis: "x" },
center: { x: 82, y: 50, offsetAxis: "y" }
};
function zoneAnchor(anchors, zone, index, total, step) {
const spec = anchors[zone] ?? anchors.center;
const offset = (index - (total - 1) / 2) * step;
return spec.offsetAxis === "x" ? { x: spec.x + offset, y: spec.y } : { x: spec.x, y: spec.y + offset };
}
function spreadConstellation(nodes) {
const presets = [
[],
[{ x: 26, y: 50 }],
[{ x: 28, y: 50 }, { x: 72, y: 50 }],
[{ x: 50, y: 24 }, { x: 27, y: 65 }, { x: 73, y: 65 }],
[{ x: 28, y: 34 }, { x: 72, y: 34 }, { x: 28, y: 66 }, { x: 72, y: 66 }],
[{ x: 50, y: 22 }, { x: 25, y: 40 }, { x: 75, y: 40 }, { x: 32, y: 74 }, { x: 68, y: 74 }]
];
const preset = presets[nodes.length];
if (preset) return nodes.map((node, index) => ({ node, ...preset[index] }));
return nodes.map((node, index) => {
const angle = (-90 + index * (360 / nodes.length)) * Math.PI / 180;
return {
node,
x: 50 + Math.cos(angle) * 30,
y: 50 + Math.sin(angle) * 28
};
});
}
function originNodeRadii(node) {
const length = Array.from(node.label).length;
if (node.kind === "current") return { rx: 7.6, ry: 12.9 };
if (node.kind === "related") return { rx: Math.min(13.6, 8.3 + length * 1.2), ry: 14.2 };
return { rx: Math.min(11.5, 8.4 + Math.max(0, length - 1) * 1.15), ry: 14.2 };
}
function clippedOriginEdgePath(from, to, targetZone) {
return graphEdgePath(from, to, targetZone);
}
function clampGraphValue(value, min, max2) {
return Math.max(min, Math.min(max2, value));
}
function formatGraphNumber(value) {
return Number(value.toFixed(2)).toString();
}
function hashOriginGraphId(value) {
let hash = 0;
for (const character of value) {
hash = (hash << 5) - hash + character.charCodeAt(0) | 0;
}
return Math.abs(hash).toString(36);
}
function renderJpdbKanjiInfo(info, language, initiallyExpanded = true, sourceStateKey, title = uiText(language, "readingsComponents")) {
if (!info) return "";
const facts = [
[uiText(language, "factKeyword"), info.keyword],
[uiText(language, "factType"), info.type],
[uiText(language, "factFrequency"), info.frequency],
[language === "ja" ? "漢検" : "Kanken", info.kanken],
["Heisig", info.heisig],
[uiText(language, "factOldForms"), info.oldForms.join(", ")]
].filter(([, value]) => Boolean(value?.trim()));
const factSection = renderJpdbKanjiFactSection(facts);
const readingsSection = renderJpdbKanjiReadings(info);
const componentSection = renderJpdbKanjiComponents(info, language);
const mnemonicSection = renderJpdbKanjiMnemonic(info, language);
return `
${escapeHtml$1(title)}
${factSection}
${readingsSection}
${componentSection}
${mnemonicSection}
`;
}
function expandedAttribute(initiallyExpanded) {
return initiallyExpanded ? "open" : "";
}
function renderJpdbKanjiFactSection(facts) {
if (!facts.length) return "";
return `
${facts.map(([label, value]) => `${escapeHtml$1(label)} ${escapeHtml$1(value)} `).join("")}
`;
}
function renderJpdbKanjiReadings(info) {
if (!info.readings.length) return "";
return `
${info.readings.slice(0, 8).map((reading) => `${escapeHtml$1(reading.reading)}${reading.share ? ` ${escapeHtml$1(reading.share)}` : ""} `).join("")}
`;
}
function renderJpdbKanjiComponents(info, language) {
if (!info.components.length) return "";
return `
${info.components.map((component) => `
${escapeHtml$1(component.kanji)}
${escapeHtml$1(component.keyword)}
`).join("")}
`;
}
function renderJpdbKanjiMnemonic(info, language) {
return info.mnemonic ? `${uiText(language, "jpdbMnemonic")} ${escapeHtml$1(info.mnemonic)}
` : "";
}
function renderJpdbKanjiMiningControls(info, language) {
const actions = visibleJpdbKanjiActions(info);
if (!actions.length) return "";
return `
${actions.map((action) => `${escapeHtml$1(jpdbKanjiActionLabel(action, language))} `).join("")}
`;
}
function jpdbKanjiActionLabel(action, language) {
switch (action.role) {
case "mine":
return uiText(language, "jpdbKanjiActionMine");
case "known":
return uiText(language, "jpdbKanjiActionKnown");
case "neverforget":
return uiText(language, "jpdbKanjiActionNeverForget");
case "forget":
return uiText(language, "jpdbKanjiActionForget");
case "blacklist":
return uiText(language, "jpdbKanjiActionBlacklist");
case "review":
return uiText(language, "jpdbKanjiActionReview");
default:
return action.label;
}
}
function renderRtkInfo(info, components2, language, initiallyExpanded = true, sourceStateKey) {
if (!info) return "";
const elementChips = buildRtkElementChips(info, components2);
const readings2 = renderRtkReadings(info, language);
const elementSection = renderRtkElementSection(elementChips, language);
const stories = renderRtkStories(info, language);
return `
RTK
${escapeHtml$1(info.keyword)}
${info.frameNumber ? `${escapeHtml$1(info.frameNumber)} ` : ""}
${readings2}
${elementSection}
${stories}
`;
}
function renderRtkReadings(info, language) {
if (!info.onYomi && !info.kunYomi) return "";
return `
${info.onYomi ? `${uiText(language, "onReading")} ${escapeHtml$1(info.onYomi)} ` : ""}
${info.kunYomi ? `${uiText(language, "kunReading")} ${escapeHtml$1(info.kunYomi)} ` : ""}
`;
}
function renderRtkElementSection(elementChips, language) {
return elementChips.length ? `${elementChips.map((chip) => renderRtkElementChip(chip, language)).join("")}
` : "";
}
function renderRtkElementChip(chip, language) {
const content = `${chip.glyph ? `${escapeHtml$1(chip.glyph)} ` : ""}${escapeHtml$1(chip.keyword)} `;
return chip.kanji ? `${content} ` : `${content} `;
}
function renderRtkStories(info, language) {
return [
info.heisigStory ? `${uiText(language, "heisigStory")} ${escapeHtml$1(info.heisigStory)}
` : "",
info.heisigComment ? `${uiText(language, "heisigComment")} ${escapeHtml$1(info.heisigComment)}
` : "",
info.koohiiStories.length ? `${uiText(language, "koohiiStories")} ${info.koohiiStories.map((story) => `${escapeHtml$1(story)}
`).join("")} ` : ""
].join("");
}
function renderPitch(card, metaEntries = []) {
const pitch = card.pitchAccent[0] || localPitchPatternFromMeta(card.reading, metaEntries);
if (!pitch) return "";
const morae = splitMorae$1(card.reading);
const highs = Array.from(pitch).filter((ch) => ch === "H" || ch === "L").slice(0, morae.length);
if (highs.length < 2) return "";
const width = morae.length * 24 + 18;
const points = highs.map((level, index) => `${9 + index * 24},${level === "H" ? 10 : 29}`).join(" ");
const cls = getPitchClassName(pitch, morae.length);
return `
${highs.map((level, index) => ` `).join("")}
${morae.map((mora, index) => `${escapeHtml$1(mora)} `).join("")}
`;
}
function localPitchPatternFromMeta(reading, entries) {
for (const entry of entries) {
if (entry.mode !== "pitch") continue;
const position = readPitchPosition(entry.data, reading);
const pattern = position == null ? "" : pitchPatternFromPosition(reading, position);
if (pattern) return pattern;
}
return "";
}
function readPitchPosition(value, reading) {
const record = objectRecord(value);
if (!record) return pitchPositionFromValue(value);
if (!pitchMetadataReadingMatches(record, reading)) return null;
return pitchPositionFromMetadataRecord(record);
}
function pitchPositionFromMetadataRecord(record) {
const direct = pitchPositionFromValue(record.position);
if (direct != null) return direct;
return firstPitchPositionCandidate(record);
}
function firstPitchPositionCandidate(record) {
return pitchPositionCandidates(record).map((candidate) => pitchPositionFromValue(candidate)).find((position) => position != null) ?? null;
}
function pitchMetadataReadingMatches(record, reading) {
const metadataReading = typeof record.reading === "string" ? record.reading : "";
return !metadataReading || !reading || metadataReading === reading;
}
function pitchPositionCandidates(record) {
if (Array.isArray(record.pitches)) return record.pitches;
return Array.isArray(record.positions) ? record.positions : [];
}
function pitchPositionFromValue(value) {
const direct = directPitchPositionValue(value);
if (direct !== null) return direct;
if (!value || typeof value !== "object") return null;
const record = value;
return pitchPositionFromValue(record.position);
}
function directPitchPositionValue(value) {
if (typeof value === "number") return validPitchPosition(value);
if (typeof value === "string" && value.trim()) return validPitchPosition(Number(value));
return null;
}
function validPitchPosition(value) {
return Number.isInteger(value) && value >= 0 ? value : null;
}
function pitchPatternFromPosition(reading, position) {
const morae = splitMorae$1(reading);
if (!morae.length || position > morae.length) return "";
if (position === 0) return `L${"H".repeat(morae.length)}`;
const levels = morae.map((_, index) => {
const moraPosition = index + 1;
if (position === 1) return moraPosition === 1 ? "H" : "L";
return moraPosition === 1 ? "L" : moraPosition <= position ? "H" : "L";
});
return `${levels.join("")}L`;
}
function externalLinkIcon() {
return `
`;
}
function copyIcon() {
return `
`;
}
function speakerIcon() {
return `
`;
}
function uniqueKanji(value) {
return [...new Set(Array.from(value).filter(isKanjiCharacter$1))];
}
function isKanjiCharacter$1(value) {
const code = value.codePointAt(0) ?? 0;
return code >= 13312 && code <= 40959;
}
function splitMorae$1(reading) {
const small = new Set("ゃゅょャュョァィゥェォ");
const morae = [];
for (const char of Array.from(reading)) {
if (morae.length && small.has(char)) morae[morae.length - 1] += char;
else morae.push(char);
}
return morae;
}
function getPitchClassName(pitch, moraCount = 0) {
const levels = Array.from(pitch).filter((ch) => ch === "H" || ch === "L");
return classifyPopupPitch({
pitch,
dropAt: levels.findIndex((level, index) => index > 0 && levels[index - 1] === "H" && level === "L"),
drops: (pitch.match(/HL/g) ?? []).length,
rises: (pitch.match(/LH/g) ?? []).length,
moraCount
});
}
function classifyPopupPitch(profile) {
return POPUP_PITCH_CLASSIFIERS.find(([, matches]) => matches(profile))?.[0] ?? "odaka";
}
const POPUP_PITCH_CLASSIFIERS = [
["atamadaka", isPopupAtamadaka],
["odaka", isPopupOdaka],
["heiban", isPopupHeiban],
["nakadaka", isPopupNakadaka],
["kifuku", isPopupKifuku]
];
function isPopupAtamadaka(profile) {
return profile.pitch.startsWith("H") && profile.drops === 1;
}
function isPopupOdaka(profile) {
return Boolean(profile.moraCount && profile.pitch.startsWith("L") && profile.dropAt === profile.moraCount);
}
function isPopupHeiban(profile) {
return profile.pitch.startsWith("L") && profile.rises === 1 && !profile.pitch.endsWith("L");
}
function isPopupNakadaka(profile) {
return profile.pitch.startsWith("L") && profile.rises === 1 && profile.pitch.endsWith("L");
}
function isPopupKifuku(profile) {
return profile.rises > 1 || profile.drops > 1;
}
function renderJpdbDefinitionSource(card, sourceAttributes, info = null, language = "en") {
const meanings = jpdbDefinitionMeanings(card, info).map((meaning) => `${escapeHtml$1(meaning)}
`).join("");
const extras = renderJpdbVocabularyExtras(info, sourceAttributes, language);
if (!meanings && !extras) return "";
return `
JPDB
${meanings ? `${meanings}
` : ""}
${extras}
`;
}
function jpdbDefinitionMeanings(card, info) {
if (shouldPreferCardMeanings(card)) return cardDefinitionMeanings(card, info);
return (info?.meanings ?? []).slice(0, 6);
}
function renderJpdbVocabularyExtras(info, sourceAttributes, language) {
if (!hasJpdbVocabularyExtras(info)) return "";
return ``;
}
function shouldPreferCardMeanings(card) {
return card.source !== "local" && card.source !== "anki" && card.source !== "fallback";
}
function cardDefinitionMeanings(card, info) {
const cardMeanings = card.meanings.slice(0, 6).map((meaning) => meaning.glosses.join("; ").trim()).filter(Boolean);
return cardMeanings.length ? cardMeanings : (info?.meanings ?? []).slice(0, 6);
}
function hasJpdbVocabularyExtras(info) {
return Boolean(info && (info.compounds.length || (info.usedInVocabulary?.length ?? 0) || info.examples.length));
}
function renderJpdbCompounds(info) {
return info.compounds.length ? `
` : "";
}
function renderJpdbUsedInVocabulary(info, sourceAttributes, language) {
const entries = info.usedInVocabulary ?? [];
return entries.length ? `
${escapeHtml$1(uiText(language, "usedInVocabulary"))}
${entries.length}
` : "";
}
function renderJpdbExamples(info, sourceAttributes, language) {
return info.examples.length ? `
${escapeHtml$1(uiText(language, "exampleSentences"))}
${info.examples.length}
${info.examples.map((example) => `
${renderJpdbExampleAudioButton(example.audioIds, example.sentence, language)}
${escapeHtml$1(example.sentence)}
${example.translation ? `
${escapeHtml$1(example.translation)}
` : ""}
`).join("")}
` : "";
}
function renderJpdbExampleAudioButton(audioIds, sentence, language) {
const audio = audioIds?.join(",") ?? "";
const label = uiText(language, "playJpdbExampleAudio");
return audio ? `
${speakerIcon()}
` : "";
}
function renderLocalDefinitionSourcesSection(dictionaries, grouped, settings, sourceAttributes, dictionaryLabel2, reference) {
const groupsByDictionary = dictionaries.map((dictionary) => ({ dictionary, groups: groupTermEntriesByHeadword(grouped.get(dictionary) ?? []) })).filter((source) => source.groups.length);
const dictionarySections = groupsByDictionary.map((source) => renderLocalDictionaryGroup(source.dictionary, source.groups, sourceAttributes, dictionaryLabel2, settings.interfaceLanguage, reference)).filter(Boolean);
if (!dictionarySections.length) return "";
const sourceCount = groupsByDictionary.length;
const termCount = groupsByDictionary.reduce((count, source) => count + source.groups.length, 0);
const status = [
`${sourceCount} ${uiText(settings.interfaceLanguage, sourceCount === 1 ? "sourceSingular" : "sourcePlural")}`,
`${termCount} ${uiText(settings.interfaceLanguage, termCount === 1 ? "localWordSingular" : "localWordPlural")}`
].join(" · ");
return `
${uiText(settings.interfaceLanguage, "dictionaries")}
${escapeHtml$1(status)}
${dictionarySections.join("")}
`;
}
function renderKanjiDefinitions(entries, sourceAttributes, dictionaryLabel2, sourceId = KANJI_DICTIONARIES_SOURCE_ID, title = void 0, language = "en") {
if (!entries.length) return "";
const heading = title ?? uiText(language, "kanjiDictionaries");
return `
${escapeHtml$1(heading)}
${entries.map((entry) => `
${escapeHtml$1(entry.character)}
${escapeHtml$1(dictionaryLabel2(entry.dictionary))}
${entry.onyomi.length ? `${escapeHtml$1(uiText(language, "onReading"))} ${escapeHtml$1(entry.onyomi.join("、"))} ` : ""}
${entry.kunyomi.length ? `${escapeHtml$1(uiText(language, "kunReading"))} ${escapeHtml$1(entry.kunyomi.join("、"))} ` : ""}
${entry.meanings.slice(0, 6).map((meaning) => `
${escapeHtml$1(meaning)}
`).join("")}
`).join("")}
`;
}
function renderSimilarKanjiWordsShell(kanji, language, sourceStateKey, sourceOpen, sourceAttributes, title = uiText(language, "wordsUsingKanji").replace("{kanji}", kanji)) {
const help = uiText(language, sourceOpen ? "loadingSimilarWords" : "openToLoadSimilarWords");
return `
${escapeHtml$1(title)}
`;
}
function renderSimilarKanjiWordsContent(entries, jpdbVocabulary, currentCard, settings, dictionaryLabel2) {
const words = mergeSimilarKanjiWords(entries, jpdbVocabulary, currentCard, dictionaryLabel2).slice(0, settings.similarKanjiWordLimit);
if (!words.length) return "";
return `
${words.map((entry) => `
${escapeHtml$1(entry.expression)}
${entry.frequency ? `#${entry.frequency} ` : ""}
${entry.reading && entry.reading !== entry.expression ? `${escapeHtml$1(entry.reading)} ` : ""}
${entry.meaning ? `${escapeHtml$1(entry.meaning)} ` : ""}
`).join("")}
`;
}
function renderFrequencyPills(metaEntries, settings, dictionaryLabel2) {
return bestFrequencyEntries(metaEntries).filter((entry) => entry.mode === "freq").sort((a, b) => {
const priority = dictionaryPreferencePriority(settings, a.dictionary) - dictionaryPreferencePriority(settings, b.dictionary);
if (priority) return priority;
return dictionaryLabel2(a.dictionary).localeCompare(dictionaryLabel2(b.dictionary), "ja");
}).map((entry) => renderFrequencyPill(entry, dictionaryLabel2)).filter(Boolean).slice(0, 8);
}
function definitionSourceStateKey(sourceId) {
return `definition-source:${sourceId}`;
}
function localDictionaryStateKey(dictionary) {
return `definition-dictionary:${dictionary}`;
}
function kanjiSourceStateKey(sourceId) {
return `kanji:${sourceId}`;
}
function renderLocalDictionaryGroup(dictionary, groups, sourceAttributes, dictionaryLabel2, language, reference) {
const entryCount = groups.length;
return `
${escapeHtml$1(dictionaryLabel2(dictionary))}
${entryCount} ${escapeHtml$1(uiText(language, entryCount === 1 ? "localWordSingular" : "localWordPlural"))}
${groups.map((group) => renderLocalTermGroup(dictionary, group, dictionaryLabel2, language, reference, { showDictionaryTag: false })).join("")}
`;
}
function renderLocalTermGroup(dictionary, group, dictionaryLabel2, language, reference, options = {}) {
return `
${renderLocalTermHead(group, reference)}
${renderLocalTermTags(dictionary, group, dictionaryLabel2, options.showDictionaryTag ?? true, language)}
${renderLocalTermMeaning(dictionary, group)}
`;
}
function renderLocalTermHead(group, reference) {
if (repeatsLookupHeadword(group, reference)) return "";
return `
${escapeHtml$1(group.expression)}
${renderLocalTermReading(group)}
${renderLocalTermFrequency(group)}
`;
}
function repeatsLookupHeadword(group, reference) {
if (!matchesLookupExpression(group, reference)) return false;
return matchesLookupReading(group, reference);
}
function matchesLookupExpression(group, reference) {
if (!reference) return false;
return group.expression === reference.spelling;
}
function matchesLookupReading(group, reference) {
if (!reference.reading) return true;
if (group.reading === reference.reading) return true;
return group.reading === group.expression;
}
function renderLocalTermReading(group) {
return group.reading && group.reading !== group.expression ? `${escapeHtml$1(group.reading)} ` : "";
}
function renderLocalTermTags(dictionary, group, dictionaryLabel2, showDictionaryTag, language) {
const tagItems = [
showDictionaryTag ? `${escapeHtml$1(dictionaryLabel2(dictionary))} ` : "",
...localTermTags(group.entries, language).map((tag) => `${escapeHtml$1(tag)} `)
].filter(Boolean);
return tagItems.length ? `${tagItems.join("")}
` : "";
}
function renderLocalTermMeaning(dictionary, group) {
if (group.entries.some(hasAdditionalLocalDictionaryText)) return renderLocalGlossaryEntries(dictionary, group.entries, { showIndex: false });
if (!group.meanings.length) return renderLocalGlossaryEntries(dictionary, group.entries);
return `
${group.meanings.slice(0, 8).map((meaning, index) => `
${group.meanings.length > 1 ? `${index + 1} ` : ""}
${escapeHtml$1(meaning)}
`).join("")}
`;
}
function renderLocalTermFrequency(group) {
return group.frequency !== void 0 ? `#${escapeHtml$1(String(group.frequency))} ` : "";
}
function renderLocalGlossaryEntries(dictionary, entries, options = {}) {
const showIndex = options.showIndex ?? entries.length > 1;
const entryHtml = entries.map((entry, index) => {
const content = localGlossaryItemsForRender(entry.glossary).map((item) => glossaryToHtml(item, entry.dictionary, { internalSearchLinks: true })).filter((html) => html.replace(/<[^>]+>/g, "").trim() || /<(?:img|table|ruby|a|ul|ol|li)\b/i.test(html)).map((html) => `${html}
`).join("");
if (!content) return "";
return `
${showIndex ? `
${index + 1} ` : ""}
${content}
`;
}).filter(Boolean).join("");
if (!entryHtml) return "";
return `
${entryHtml}
`;
}
function localGlossaryItemsForRender(glossary) {
const items = /* @__PURE__ */ new Set();
glossary.slice(0, 4).forEach((item) => items.add(item));
glossary.filter((item) => hasRichStructuredGlossary(item) || HAS_JAPANESE$1.test(glossaryToText(item))).forEach((item) => items.add(item));
return Array.from(items);
}
function hasAdditionalLocalDictionaryText(entry) {
const summary = summarizeLearnerGlossary(entry);
return entry.glossary.some((item) => {
if (hasRichStructuredGlossary(item)) return true;
const text2 = glossaryToText(item).replace(/\s+/g, " ").trim();
if (!text2 || text2 === summary) return false;
return HAS_JAPANESE$1.test(text2);
});
}
function renderFrequencyPill(entry, dictionaryLabel2) {
const label = dictionaryLabel2(entry.dictionary);
const value = normalizeFrequencyChipValue(label, formatMetaFrequency(entry.data));
return value ? `${escapeHtml$1(label)} ${escapeHtml$1(value)} ` : "";
}
const CONTEXT_PREFIX = "yomu-mining-context:";
const CONTEXT_MAX_AGE_MS = 1e3 * 60 * 60 * 24 * 21;
const MINING_SOURCE_KINDS = ["page", "video", "image", "immersion-kit", "jpdb"];
const log$n = Logger.scope("MiningContext");
function normalizeMiningSentence(sentence) {
return (sentence ?? "").replace(/\s+/g, " ").trim();
}
function inferMiningSourceKind({ isImageSource, hasVideo, hostname = location.hostname } = {}) {
if (isImageSource) return "image";
if (hasVideo) return "video";
if (hostname === "jpdb.io") return "jpdb";
return "page";
}
function createStoredMiningContext(term, context, updatedAt = Date.now()) {
const normalizedTerm = term.trim();
const sentence = context.sentence.trim();
if (!normalizedTerm || !sentence) return null;
return {
...context,
term: normalizedTerm,
sentence,
sourceTitle: context.sourceTitle.trim(),
sourceUrl: context.sourceUrl.trim(),
imageUrl: optionalText(context.imageUrl),
audioUrls: optionalTextArray(context.audioUrls),
immersionIndex: optionalNumber(context.immersionIndex),
immersionTotal: optionalNumber(context.immersionTotal),
updatedAt
};
}
function createFallbackMiningContext(term, context, updatedAt = Date.now()) {
return createStoredMiningContext(term, context, updatedAt) ?? {
...context,
term: term.trim(),
sentence: context.sentence.trim() || term.trim(),
sourceTitle: context.sourceTitle.trim(),
sourceUrl: context.sourceUrl.trim(),
imageUrl: optionalText(context.imageUrl),
audioUrls: optionalTextArray(context.audioUrls),
immersionIndex: optionalNumber(context.immersionIndex),
immersionTotal: optionalNumber(context.immersionTotal),
updatedAt
};
}
async function resolveMiningContext({
term,
sentence,
settings,
activeContext,
storedContext,
sourceKind,
imageDataUrl,
videoImageDataUrl,
fetchImageDataUrl,
fetchAudioDataUrl: fetchAudioDataUrl2
}) {
const done = log$n.time("Resolve mining context", {
term,
hasSentence: Boolean(sentence?.trim()),
activeKind: activeContext?.sourceKind,
storedKind: storedContext?.sourceKind,
sourceKind,
hasImage: Boolean(imageDataUrl),
hasVideoImage: Boolean(videoImageDataUrl)
});
const cleanSentence = normalizeMiningSentence(sentence);
try {
const direct = resolveDirectImageMiningContext(term, cleanSentence, imageDataUrl, videoImageDataUrl);
if (direct) return direct;
const immersion = await resolveStoredImmersionMiningContext({
term,
settings,
activeContext,
storedContext,
fetchImageDataUrl,
fetchAudioDataUrl: fetchAudioDataUrl2
});
return immersion ?? resolvePageMiningContext(term, cleanSentence, sourceKind);
} finally {
done();
}
}
function resolveDirectImageMiningContext(term, sentence, imageDataUrl, videoImageDataUrl) {
if (imageDataUrl && sentence) {
return miningContextWithImage(term, sentence, "image", imageDataUrl);
}
if (videoImageDataUrl && sentence) {
return miningContextWithImage(term, sentence, "video", videoImageDataUrl);
}
return null;
}
async function resolveStoredImmersionMiningContext(options) {
const { term, settings, activeContext, storedContext, fetchImageDataUrl, fetchAudioDataUrl: fetchAudioDataUrl2 } = options;
const chosen = activeContext?.term === term ? activeContext : storedContext ?? void 0;
if (!chosen || !shouldUseImmersionContext(settings, chosen)) return null;
const [fetchedImageDataUrl, fetchedAudioDataUrl] = await Promise.all([
fetchMiningContextImage(chosen, settings, fetchImageDataUrl),
fetchMiningContextAudio(chosen, settings, fetchAudioDataUrl2)
]);
return { ...chosen, imageDataUrl: fetchedImageDataUrl, audioDataUrl: fetchedAudioDataUrl };
}
function fetchMiningContextImage(context, settings, fetchImageDataUrl) {
if (!context.imageUrl || !settings.immersionKitShowImages || !fetchImageDataUrl) return Promise.resolve(void 0);
return fetchImageDataUrl(context.imageUrl, settings.audioTimeoutMs).catch(() => {
return void 0;
});
}
function fetchMiningContextAudio(context, settings, fetchAudioDataUrl2) {
if (!context.audioUrls?.length || !fetchAudioDataUrl2) return Promise.resolve(void 0);
return fetchAudioDataUrl2(context.audioUrls, settings.audioTimeoutMs).catch(() => {
return void 0;
});
}
function resolvePageMiningContext(term, sentence, sourceKind) {
const context = pageMiningContext(sentence || term, sourceKind ?? inferMiningSourceKind());
const result = saveMiningContext(term, context) ?? createFallbackMiningContext(term, context);
return result;
}
function miningContextWithImage(term, sentence, sourceKind, imageDataUrl) {
const context = pageMiningContext(sentence, sourceKind);
return {
...saveMiningContext(term, context) ?? createFallbackMiningContext(term, context),
imageDataUrl
};
}
function saveMiningContext(term, context) {
const stored = createStoredMiningContext(term, context);
if (!stored) {
return null;
}
try {
gmStorageSetSync(contextStorageKey(stored.term), stored);
} catch (error) {
log$n.warn("Mining context save failed", { term: stored.term, sourceKind: stored.sourceKind, error });
}
return stored;
}
function loadMiningContext(term) {
const normalized = term.trim();
if (!normalized) return null;
try {
const stored = gmStorageGetSync(contextStorageKey(normalized), null);
if (!stored) {
return null;
}
const context = parseStoredMiningContext(stored, normalized);
return context;
} catch (error) {
log$n.warn("Mining context load failed", { term: normalized, error });
return null;
}
}
function immersionContextFromExample(term, example, index, total, imageUrl, audioUrls = []) {
return {
sentence: example.sentence,
sourceKind: "immersion-kit",
sourceTitle: example.sourceTitle || "Immersion Kit",
sourceUrl: immersionKitUrl(term, index),
imageUrl: imageUrl || void 0,
audioUrls: optionalTextArray(audioUrls),
immersionIndex: index,
immersionTotal: total
};
}
function immersionContextFromElement(sentence, element2, sourceUrl = location.href) {
return {
sentence,
sourceKind: "immersion-kit",
sourceTitle: element2.dataset.immersionSourceTitle || "Immersion Kit",
sourceUrl,
imageUrl: optionalText(element2.dataset.immersionImageUrl),
audioUrls: immersionAudioUrlsFromElement(element2),
immersionIndex: optionalNumber(Number(element2.dataset.immersionIndex ?? 0)),
immersionTotal: optionalNumber(Number(element2.dataset.immersionTotal ?? 0))
};
}
function pageMiningContext(sentence, sourceKind = "page") {
return {
sentence,
sourceKind,
sourceTitle: document.title || location.hostname,
sourceUrl: location.href
};
}
function shouldUseImmersionContext(settings, context) {
return Boolean(settings.immersionKitEnabled && context?.sourceKind === "immersion-kit" && context.sentence.trim());
}
function contextStorageKey(term) {
return `${CONTEXT_PREFIX}${term}`;
}
function parseStoredMiningContext(value, expectedTerm, now = Date.now()) {
const record = storedMiningContextRecord(value, expectedTerm);
if (!record) return null;
const sourceKind = storedMiningSourceKind(record.sourceKind);
if (!sourceKind) return null;
const updatedAt = storedMiningContextUpdatedAt(record.updatedAt, now);
if (updatedAt === null) return null;
const context = createStoredMiningContext(expectedTerm, {
sentence: text$1(record.sentence),
sourceKind,
sourceTitle: text$1(record.sourceTitle),
sourceUrl: text$1(record.sourceUrl),
imageUrl: optionalText(record.imageUrl),
audioUrls: optionalTextArray(record.audioUrls),
immersionIndex: optionalNumber(record.immersionIndex),
immersionTotal: optionalNumber(record.immersionTotal)
}, updatedAt);
return context;
}
function storedMiningContextRecord(value, expectedTerm) {
if (!isRecord$1(value)) return null;
return text$1(value.term) === expectedTerm ? value : null;
}
function storedMiningSourceKind(value) {
return isMiningSourceKind(value) ? value : null;
}
function storedMiningContextUpdatedAt(value, now) {
const updatedAt = Number(value);
if (!isStoredMiningContextFresh(updatedAt, now)) {
return null;
}
return updatedAt;
}
function isStoredMiningContextFresh(updatedAt, now) {
return Number.isFinite(updatedAt) && now - updatedAt <= CONTEXT_MAX_AGE_MS;
}
function isMiningSourceKind(value) {
return typeof value === "string" && MINING_SOURCE_KINDS.includes(value);
}
function isRecord$1(value) {
return Boolean(value && typeof value === "object");
}
function optionalText(value) {
const normalized = text$1(value);
return normalized || void 0;
}
function optionalTextArray(value) {
if (!Array.isArray(value)) return void 0;
const values = uniqueTexts(value);
return values.length ? values : void 0;
}
function immersionAudioUrlsFromElement(element2) {
const parsed = parseTextArray(element2.dataset.immersionAudioUrls);
return optionalTextArray(parsed ?? [element2.dataset.immersionAudioUrl]);
}
function parseTextArray(value) {
if (typeof value !== "string" || !value.trim()) return null;
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
function uniqueTexts(values) {
return Array.from(new Set(values.map(text$1).filter(Boolean)));
}
function optionalNumber(value) {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : void 0;
}
function text$1(value) {
return typeof value === "string" ? value.trim() : "";
}
function immersionKitUrl(term, index) {
const url = new URL("https://www.immersionkit.com/dictionary");
url.searchParams.set("keyword", term);
url.searchParams.set("sort", "sentence_length:asc");
url.searchParams.set("page", String(index + 1));
return url.toString();
}
class CardPopoverRenderer {
constructor(dependencies) {
this.dependencies = dependencies;
}
render(card, sentence, trigger, data) {
const view = this.renderView(card, data);
return `
${this.dependencies.renderWordHistory(view.language, trigger)}
${this.renderHeader(card, data, view)}
${this.renderPartOfSpeech(view)}
${this.dependencies.renderDefinitionSources(card, data.localEntries, sentence, data.jpdbVocabularyInfo)}
${view.loadingDetails}
${this.renderAnkiExistingSection(data, view)}
${renderKanjiDefinitions(data.kanjiEntries, (key, initiallyExpanded) => this.dependencies.dictionarySourceAttributes(key, initiallyExpanded), (name) => this.dependencies.dictionaryLabel(name), void 0, uiText(view.language, "kanjiDictionaries"), view.language)}
${this.renderActions(view)}
`;
}
renderView(card, data) {
const cardStates = normalizeCardStates(card.cardState);
const state = primaryCardState(cardStates);
const settings = this.settings();
const language = settings.interfaceLanguage;
const hasJpdb = this.dependencies.isJpdbBackedCard(card);
const selectedDeckLabel = jpdbDeckLabel(settings, settings.miningDeck.trim() || "forq", data.jpdbDecks);
const reviewBlockReason = !data.ankiLookup.primary?.primaryCardId ? this.reviewBlockReason(cardStates, language) : "";
return {
cardStates,
state,
storedContext: data.loading ? null : loadMiningContext(card.spelling),
jpdbUrl: `https://jpdb.io/vocabulary/${card.vid}/${encodeURIComponent(card.spelling)}/${encodeURIComponent(card.reading)}`,
cardPos: formatPartOfSpeech(card.partOfSpeech),
cardPosDetails: formatPartOfSpeechDetails(card.partOfSpeech),
language,
hasJpdb,
miningActions: this.renderJpdbMiningActions(cardStates, language, data, hasJpdb),
ankiActions: data.loading ? "" : renderAnkiActionRow(data.ankiLookup, settings),
reviewButtons: this.renderReviewButtons(card, cardStates, data, hasJpdb, selectedDeckLabel, reviewBlockReason, language),
metaItems: this.renderMetaItems(card, hasJpdb, state, data),
loadingDetails: this.renderLoadingDetails(data.loading, language),
audioButtonDisabled: !settings.audioEnabled,
audioButtonTitle: uiText(language, settings.audioEnabled ? "playAudio" : "audioPlaybackDisabled")
};
}
renderHeader(card, data, view) {
return ``;
}
renderTitleRow(card, view) {
return `
${escapeHtml$1(card.spelling)}
${renderReading(card)}
${renderMeta(view.metaItems)}
`;
}
renderPitch(card, data) {
return this.settings().showPitchAccent ? renderPitch(card, data.metaEntries) : "";
}
renderPartOfSpeech(view) {
return view.cardPos ? `${escapeHtml$1(view.cardPos)}
` : "";
}
renderAnkiExistingSection(data, view) {
return data.loading ? "" : renderAnkiExistingSection(data.ankiLookup, view.storedContext, view.language);
}
renderActions(view) {
const hasMiningPanel = Boolean(view.miningActions);
const miningPanel = hasMiningPanel ? this.renderMiningPanel(view) : "";
return `
${renderMiningGutter(miningPanel, view.language)}
${miningPanel}
${hasMiningPanel ? "" : view.ankiActions}
${view.reviewButtons}
`;
}
renderMiningPanel(view) {
return `
${view.miningActions}
${view.ankiActions}
`;
}
renderJpdbMiningActions(cardStates, language, data, hasJpdb) {
if (!this.canRenderJpdbMiningActions(hasJpdb)) return "";
const state = miningActionState(cardStates, language);
const addDeckSelect = this.renderAddDeckSelect(data, language);
return this.renderJpdbMiningActionDetails(language, state, addDeckSelect);
}
canRenderJpdbMiningActions(hasJpdb) {
const settings = this.settings();
return hasJpdb && Boolean(settings.apiKey.trim()) && settings.jpdbMiningEnabled;
}
renderAddDeckSelect(data, language) {
const deckOptions = renderDeckChoiceOptions(this.settings(), data.jpdbDecks, data.ankiDecks);
if (!deckOptions) return "";
return `${deckOptions} `;
}
renderJpdbMiningActionDetails(language, state, addDeckSelect) {
const addToDeckLabel = `${uiText(language, "addToDeck")} +`;
return `
${escapeHtml$1(addToDeckLabel)}
${state.neverForgetLabel}
${state.blacklistLabel}
${addDeckSelect}
`;
}
renderReviewButtons(card, cardStates, data, hasJpdb, selectedDeckLabel, reviewBlockReason, language) {
if (!this.shouldRenderReviewButtons(data, hasJpdb, reviewBlockReason)) {
return this.dependencies.renderReviewButtonsFallback?.(card, data) ?? "";
}
return renderReviewButtons(this.settings(), data.ankiLookup.primary, {
title: cardStates.includes("not-in-deck") ? `${uiText(language, "reviewAddsToDeck")} ${selectedDeckLabel}` : ""
});
}
shouldRenderReviewButtons(data, hasJpdb, reviewBlockReason) {
if (reviewBlockReason || data.loading || !this.settings().enableReviews) return false;
return this.canReviewWithJpdb(hasJpdb) || Boolean(data.ankiLookup.primary?.primaryCardId);
}
canReviewWithJpdb(hasJpdb) {
const settings = this.settings();
return hasJpdb && Boolean(settings.apiKey.trim()) && settings.jpdbMiningEnabled;
}
renderMetaItems(card, hasJpdb, state, data) {
return [
card.frequencyRank ? `#${card.frequencyRank} ` : "",
hasJpdb ? ` ${escapeHtml$1(cardStateLabel$1(state, this.settings().interfaceLanguage))} ` : "",
data.ankiLookup.primary ? ` Anki ${escapeHtml$1(cardStateLabel$1(data.ankiLookup.state, this.settings().interfaceLanguage))} ` : ""
].filter(Boolean);
}
renderLoadingDetails(loading, language) {
return loading ? `${escapeHtml$1(uiText(language, "loadingDictionaryDetails"))}
` : "";
}
reviewBlockReason(cardStates, language) {
if (cardStates.includes("blacklisted")) return uiText(language, "reviewBlockedBlacklisted");
if (cardStates.includes("never-forget")) return uiText(language, "reviewBlockedNeverForget");
return "";
}
settings() {
return this.dependencies.getSettings();
}
}
function miningActionState(cardStates, language) {
const isNeverForget = cardStates.includes("never-forget");
const isBlacklisted = cardStates.includes("blacklisted");
return {
isNeverForget,
isBlacklisted,
neverForgetTitle: isNeverForget ? uiText(language, "forgetHint") : uiText(language, "neverHint"),
blacklistTitle: isBlacklisted ? uiText(language, "unlistHint") : uiText(language, "blacklistHint"),
neverForgetLabel: isNeverForget ? uiText(language, "forget") : uiText(language, "never"),
blacklistLabel: isBlacklisted ? uiText(language, "unlist") : uiText(language, "blacklist")
};
}
function renderReading(card) {
return card.reading !== card.spelling ? `${escapeHtml$1(card.reading)}
` : "";
}
function renderMeta(metaItems) {
return metaItems.length ? `${metaItems.join("")}
` : "";
}
function renderMiningGutter(miningActions, language) {
return miningActions ? `
` : "";
}
function cardStateLabel$1(state, language) {
const key = CARD_STATE_LABEL_KEYS$1[state];
return key ? uiText(language, key) : state;
}
const CARD_STATE_LABEL_KEYS$1 = {
new: "stateNew",
learning: "stateLearning",
known: "stateKnown",
due: "stateDue",
failed: "stateFailed",
locked: "stateLocked",
"never-forget": "stateNeverForget",
blacklisted: "stateBlacklisted",
suspended: "stateSuspended",
"not-in-deck": "stateNotInDeck",
redundant: "stateRedundant"
};
function cardKey$1(card) {
return `${card.vid}:${card.sid}:${card.spelling}:${card.reading}`;
}
function createAudioPreviewCard() {
return {
vid: 0,
sid: 0,
rid: 0,
spelling: "読む",
reading: "よむ",
frequencyRank: null,
partOfSpeech: [],
meanings: [],
cardState: [],
pitchAccent: [],
wordWithReading: null,
source: "fallback"
};
}
const log$m = Logger.scope("CardRenderData");
const CARD_RENDER_DATA_CACHE_TTL_MS = 3e4;
const CARD_RENDER_DETAIL_TIMEOUT_MS = 4500;
function loadingCardRenderData(localEntries, ankiLookup) {
return {
localEntries,
kanjiEntries: [],
metaEntries: [],
ankiLookup,
jpdbDecks: [],
ankiDecks: [],
jpdbVocabularyInfo: null,
loading: true
};
}
class CardRenderDataLoader {
constructor(dependencies) {
this.dependencies = dependencies;
}
cache = /* @__PURE__ */ new Map();
clear() {
this.cache.clear();
}
load(card) {
const key = this.cacheKey(card);
const now = Date.now();
const cached = this.cache.get(key);
if (cached && cached.expiresAt > now) return cached.load;
const load = this.fetch(card);
void load.all.catch(() => {
if (this.cache.get(key)?.load === load) this.cache.delete(key);
});
this.cache.set(key, { expiresAt: now + CARD_RENDER_DATA_CACHE_TTL_MS, load });
return load;
}
fetch(card) {
const timeoutMs = this.detailTimeoutMs();
const localEntries = this.loadLocalTermEntries(card, timeoutMs);
const all = this.loadAll(card, timeoutMs, localEntries);
return { localEntries, all };
}
detailTimeoutMs() {
return CARD_RENDER_DETAIL_TIMEOUT_MS;
}
withFallback(card, timeoutMs, detail, promise, fallback) {
return cardRenderDetailWithFallback(detail, card, promise, fallback, timeoutMs);
}
loadLocalTermEntries(card, timeoutMs) {
const settings = this.settings();
if (!settings.localDictionariesEnabled) return Promise.resolve([]);
return this.withFallback(card, timeoutMs, "local term dictionary", this.dependencies.dictionaries.lookup(card.spelling, card.reading, settings.localDictionaryMaxResults, settings.dictionaryPreferences).catch((error) => {
log$m.warn("Local term lookup failed while rendering card", { term: card.spelling }, error);
return [];
}), []);
}
loadLocalKanjiEntries(card, timeoutMs) {
const settings = this.settings();
if (!settings.localDictionariesEnabled || !settings.localDictionaryShowKanji) return Promise.resolve([]);
return this.withFallback(card, timeoutMs, "local kanji dictionary", this.dependencies.dictionaries.lookupKanji(card.spelling, settings.localDictionaryMaxResults, settings.dictionaryPreferences).catch((error) => {
log$m.warn("Local kanji lookup failed while rendering card", { term: card.spelling }, error);
return [];
}), []);
}
loadLocalMetaEntries(card, timeoutMs) {
const settings = this.settings();
if (!settings.localDictionariesEnabled) return Promise.resolve([]);
return this.withFallback(card, timeoutMs, "local metadata dictionary", this.dependencies.dictionaries.lookupTermMeta(card.spelling, 12, settings.dictionaryPreferences).catch((error) => {
log$m.warn("Local metadata lookup failed while rendering card", { term: card.spelling }, error);
return [];
}), []);
}
loadPublicPitch(card, timeoutMs) {
const settings = this.settings();
if (!settings.showPitchAccent || card.pitchAccent.length) return Promise.resolve([]);
return this.withFallback(card, timeoutMs, "JPDB public pitch", this.dependencies.jpdbPublicPitch.lookup(card.spelling, card.reading).catch((error) => {
log$m.warn("Public JPDB pitch lookup failed while rendering card", { term: card.spelling }, error);
return [];
}), []);
}
loadJpdbVocabularyInfo(card, timeoutMs) {
const settings = this.settings();
if (!settings.jpdbDefinitionsEnabled) return Promise.resolve(null);
return this.withFallback(card, timeoutMs, "JPDB vocabulary details", this.dependencies.jpdbVocabulary.lookup(card.vid, card.spelling, card.reading).catch((error) => {
log$m.warn("JPDB vocabulary page lookup failed while rendering card", { term: card.spelling }, error);
return null;
}), null);
}
loadAnkiLookup(card, timeoutMs) {
const fallback = { state: "not-in-deck", notes: [], primary: null };
if (!this.settings().ankiEnabled || canUseMobileAnkiHandoff(this.settings())) return Promise.resolve(fallback);
return this.withFallback(card, timeoutMs, "Anki existing cards", this.dependencies.anki.findExistingCards(card).catch((error) => {
log$m.warn("Anki lookup failed while rendering card", { term: card.spelling }, error);
return fallback;
}), fallback);
}
loadJpdbDecks(card, timeoutMs) {
const settings = this.settings();
if (!settings.jpdbMiningEnabled || !settings.apiKey.trim() || !this.dependencies.isJpdbBackedCard(card)) return Promise.resolve([]);
return this.withFallback(card, timeoutMs, "JPDB deck list", this.dependencies.jpdb.listDecks().catch((error) => {
log$m.warn("JPDB deck list failed while rendering card", { term: card.spelling }, error);
return [];
}), []);
}
loadAnkiDecks(card, timeoutMs) {
if (!this.settings().ankiEnabled || canUseMobileAnkiHandoff(this.settings())) return Promise.resolve([]);
return this.withFallback(card, timeoutMs, "Anki deck list", this.dependencies.anki.deckNames().catch((error) => {
log$m.warn("Anki deck list failed while rendering card", { term: card.spelling }, error);
return [];
}), []);
}
loadAll(card, timeoutMs, localEntries) {
return Promise.all([
localEntries,
this.loadLocalKanjiEntries(card, timeoutMs),
this.loadLocalMetaEntries(card, timeoutMs),
this.loadPublicPitch(card, timeoutMs),
this.loadAnkiLookup(card, timeoutMs),
this.loadJpdbDecks(card, timeoutMs),
this.loadAnkiDecks(card, timeoutMs),
this.loadJpdbVocabularyInfo(card, timeoutMs)
]).then(([localEntriesValue, kanjiEntries, metaEntries, jpdbPublicPitch, ankiLookup, jpdbDecks, ankiDecks, jpdbVocabularyInfo]) => {
if (!card.pitchAccent.length && jpdbPublicPitch.length) card.pitchAccent = jpdbPublicPitch;
return { localEntries: localEntriesValue, kanjiEntries, metaEntries, ankiLookup, jpdbDecks, ankiDecks, jpdbVocabularyInfo };
});
}
cacheKey(card) {
const settings = this.settings();
return JSON.stringify({
card: cardKey$1(card),
local: settings.localDictionariesEnabled,
kanji: settings.localDictionaryShowKanji,
max: settings.localDictionaryMaxResults,
pitch: settings.showPitchAccent,
anki: settings.ankiEnabled,
dictionaries: settings.dictionaryPreferences.map((preference) => ({
name: preference.name,
enabled: preference.enabled,
priority: preference.priority
}))
});
}
settings() {
return this.dependencies.getSettings();
}
}
function cardRenderDetailWithFallback(detail, card, promise, fallback, timeoutMs) {
return Promise.race([
promise,
delay$1(timeoutMs).then(() => {
log$m.debug(`${detail} timed out while rendering card`, { term: card.spelling, timeoutMs });
return fallback;
})
]);
}
function delay$1(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
const STORAGE_KEY = "jpdb-reader-source-open-state";
class DictionarySourceStateController {
constructor(dependencies) {
this.dependencies = dependencies;
}
openOverrides = loadOpenOverrides();
clear() {
this.openOverrides.clear();
gmStorageDeleteSync(STORAGE_KEY);
}
isOpen(sourceStateKey, initiallyExpanded = this.dependencies.getSettings().dictionarySourcesInitiallyExpanded) {
return this.openOverrides.get(sourceStateKey) ?? initiallyExpanded;
}
attributes(sourceStateKey, initiallyExpanded = this.dependencies.getSettings().dictionarySourcesInitiallyExpanded) {
const isOpen = this.isOpen(sourceStateKey, initiallyExpanded);
return `data-source-state-key="${escapeHtml$1(sourceStateKey)}" data-source-initial-open="${String(isOpen)}"${isOpen ? " open" : ""}`;
}
installTracking(popover) {
if (popover.dataset.jpdbReaderSourceTrackingInstalled === "true") return;
popover.dataset.jpdbReaderSourceTrackingInstalled = "true";
popover.addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target : null;
const summary = target?.closest("summary.jpdb-reader-local-title");
const details = summary?.parentElement instanceof HTMLDetailsElement ? summary.parentElement : null;
if (!summary || !details || !popover.contains(summary) || !details.dataset.sourceStateKey) return;
if (details.dataset.immersionEmpty !== "true") return;
event.preventDefault();
event.stopPropagation();
});
popover.addEventListener("toggle", (event) => {
const details = event.target instanceof HTMLDetailsElement ? event.target : null;
if (!details?.dataset.sourceStateKey) return;
if (details.dataset.immersionEmpty === "true") {
if (details.open) details.open = false;
return;
}
this.remember(details);
}, true);
}
remember(details) {
const sourceStateKey = details.dataset.sourceStateKey;
if (!sourceStateKey) return;
const rememberedOpen = this.openOverrides.get(sourceStateKey);
if (rememberedOpen === details.open) return;
const initialOpen = details.dataset.sourceInitialOpen === "true";
if (rememberedOpen === void 0 && details.open === initialOpen) return;
this.openOverrides.set(sourceStateKey, details.open);
saveOpenOverrides(this.openOverrides);
this.dependencies.onStateChange();
}
}
function loadOpenOverrides() {
const stored = gmStorageGetSync(STORAGE_KEY, {});
if (!stored || typeof stored !== "object" || Array.isArray(stored)) return /* @__PURE__ */ new Map();
return new Map(Object.entries(stored).filter((entry) => typeof entry[1] === "boolean"));
}
function saveOpenOverrides(openOverrides) {
gmStorageSetSync(STORAGE_KEY, Object.fromEntries(openOverrides));
}
const DICTIONARY_STYLE_ID = "jpdb-reader-yomitan-dictionary-styles";
class DictionaryStyleController {
constructor(options) {
this.options = options;
}
styleElement;
async refresh() {
this.apply(await this.loadCss());
}
remove() {
this.styleElement?.remove();
this.styleElement = void 0;
}
async loadCss() {
try {
return await this.options.loadCss();
} catch (error) {
this.options.onUnavailable?.(error);
return "";
}
}
apply(css) {
const existing = this.styleElement ?? document.getElementById(DICTIONARY_STYLE_ID);
if (!css.trim()) {
existing?.remove();
this.styleElement = void 0;
this.options.onCleared?.();
return;
}
const style = existing ?? document.createElement("style");
style.id = DICTIONARY_STYLE_ID;
style.textContent = css;
if (!style.isConnected) appendToDocumentHead(style);
this.styleElement = style;
this.options.onRefreshed?.(css.length);
}
}
const log$l = Logger.scope("FactoryReset");
const FACTORY_RESET_PREPARE_DELAY_MS = 80;
const FACTORY_RESET_REMOTE_GUARD_TIMEOUT_MS = 3e4;
const FACTORY_RESET_DICTIONARY_DELETE_TIMEOUT_MS = 750;
class FactoryResetCoordinator {
constructor(dependencies) {
this.dependencies = dependencies;
}
unsubscribe;
activeResetId = "";
handledSignals = /* @__PURE__ */ new Set();
remoteGuardReleaseTimer;
bind() {
if (this.unsubscribe) return;
this.unsubscribe = subscribeToFactoryResetSignals((signal, source) => {
void this.handleSignal(signal, source);
});
}
destroy() {
this.unsubscribe?.();
this.unsubscribe = void 0;
this.clearRemoteGuardReleaseTimer();
}
async resetAllData() {
const confirmed = window.confirm([
`Reset all ${APP_NAME} data?`,
"",
"This deletes settings, API keys, preferences, cached cards, local dictionaries, and all other local/GM storage for the userscript."
].join("\n"));
if (!confirmed) return;
const resetSignal = createFactoryResetSignal("prepare");
this.activeResetId = resetSignal.id;
beginSettingsResetGuard();
try {
await publishFactoryResetSignal(resetSignal);
await this.dependencies.invalidateRuntimeStores();
await delay(FACTORY_RESET_PREPARE_DELAY_MS);
const deletedStorageValues = await clearManagedStoredValues();
await deleteSettingsStorage();
await this.assertSettingsStorageDeleted();
const dictionaryReset = await this.resetDictionaryDatabaseBestEffort();
await publishFactoryResetSignal(createFactoryResetSignal("complete", resetSignal.id));
await clearFactoryResetSignal();
log$l.info("All local data reset; reloading page", { deletedStorageValues, dictionaryReset });
this.dependencies.reload();
} catch (error) {
this.activeResetId = "";
endSettingsResetGuard();
log$l.warn("All-data reset failed", error);
this.dependencies.toast(error instanceof Error ? error.message : "Reset failed.");
}
}
async resetDictionaryDatabaseBestEffort() {
try {
return await this.dependencies.resetDictionaryDatabase();
} catch (error) {
log$l.warn("Dictionary database reset failed after settings storage was cleared", error);
this.dependencies.toast("Settings were reset. Local dictionaries may need clearing after closing other よむ tabs.");
return { cleared: false, deleted: false, error: error instanceof Error ? error.message : String(error) };
}
}
async handleSignal(signal, source) {
if (this.dependencies.isDestroyed() || signal.id === this.activeResetId) return;
const handledKey = `${signal.id}:${signal.phase}`;
if (this.handledSignals.has(handledKey)) return;
this.handledSignals.add(handledKey);
beginSettingsResetGuard();
log$l.info("Factory reset signal received", {
phase: signal.phase,
href: signal.href,
remote: source.remote,
transport: source.transport
});
await this.dependencies.invalidateRuntimeStores();
if (signal.phase === "complete") {
this.clearRemoteGuardReleaseTimer();
this.dependencies.toast("よむ was reset in another tab. Reloading...");
window.setTimeout(() => this.dependencies.reload(), 50);
} else {
this.scheduleRemoteGuardRelease();
}
}
async assertSettingsStorageDeleted() {
const settingsKeysStillPresent = await settingsStorageKeysStillPresent();
if (!settingsKeysStillPresent.length) return;
log$l.warn("Settings storage keys still present after factory reset deletion", { settingsKeysStillPresent });
throw new Error("Factory reset could not delete saved settings. Please close other よむ tabs and try again.");
}
scheduleRemoteGuardRelease() {
this.clearRemoteGuardReleaseTimer();
this.remoteGuardReleaseTimer = window.setTimeout(() => {
this.remoteGuardReleaseTimer = void 0;
endSettingsResetGuard();
}, FACTORY_RESET_REMOTE_GUARD_TIMEOUT_MS);
}
clearRemoteGuardReleaseTimer() {
if (this.remoteGuardReleaseTimer === void 0) return;
window.clearTimeout(this.remoteGuardReleaseTimer);
this.remoteGuardReleaseTimer = void 0;
}
}
function delay(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
const API_BASE$1 = "https://apiv2express.immersionkit.com";
const LEGACY_API_BASE = "https://apiv2.immersionkit.com";
const API_BASES = [API_BASE$1, LEGACY_API_BASE];
const NADESHIKO_API_BASE = "https://api.nadeshiko.co/v1";
const OBJECT_STORE_BASE = "https://us-southeast-1.linodeobjects.com/immersionkit";
const MEDIA_BLOB_CACHE_TTL_MS = 10 * 60 * 1e3;
const MEDIA_CANDIDATE_LIMIT = 4;
const SEARCH_EXAMPLE_LIMIT = 250;
const NADESHIKO_SEARCH_LIMIT = 25;
const MIN_LEARNING_SENTENCE_LENGTH = 8;
const DEFAULT_EXAMPLE_SORT = "sentence_length:asc";
const log$k = Logger.scope("ImmersionKit");
const IMMERSION_KIT_TITLES = {
your_lie_in_april: "Your Lie in April",
princess_mononoke: "Princess Mononoke",
girls_band_cry: "Girls Band Cry",
only_yesterday: "Only Yesterday",
chobits: "Chobits",
k_on_: "K-On!",
weathering_with_you: "Weathering with You",
from_the_new_world: "From the New World",
grave_of_the_fireflies: "Grave of the Fireflies",
steins_gate: "Steins Gate",
sword_art_online: "Sword Art Online",
nisekoi: "Nisekoi",
death_note: "Death Note",
wolf_children: "Wolf Children",
demon_slayer___kimetsu_no_yaiba: "Demon Slayer - Kimetsu no Yaiba",
your_name: "Your Name",
alya_sometimes_hides_her_feelings_in_russian: "Alya Sometimes Hides Her Feelings in Russian",
cardcaptor_sakura: "Cardcaptor Sakura",
kill_la_kill: "Kill la Kill",
howl_s_moving_castle: "Howl's Moving Castle",
whisper_of_the_heart: "Whisper of the Heart",
bunny_drop: "Bunny Drop",
fermat_kitchen: "Fermat Kitchen",
haruhi_suzumiya: "Haruhi Suzumiya",
hunter_x_hunter: "Hunter × Hunter",
god_s_blessing_on_this_wonderful_world_: "God's Blessing on this Wonderful World!",
assassination_classroom_season_1: "Assassination Classroom Season 1",
durarara__: "Durarara!!",
bakemonogatari: "Bakemonogatari",
hyouka: "Hyouka",
relife: "ReLIFE",
from_up_on_poppy_hill: "From Up on Poppy Hill",
sound__euphonium: "Sound! Euphonium",
lucky_star: "Lucky Star",
kokoro_connect: "Kokoro Connect",
my_little_sister_can_t_be_this_cute: "My Little Sister Can't Be This Cute",
is_the_order_a_rabbit: "Is The Order a Rabbit",
clannad: "Clannad",
angel_beats_: "Angel Beats!",
daily_lives_of_high_school_boys: "Daily Lives of High School Boys",
new_game_: "New Game!",
the_wind_rises: "The Wind Rises",
fate_zero: "Fate Zero",
toradora_: "Toradora!",
anohana_the_flower_we_saw_that_day: "Anohana the flower we saw that day",
wandering_witch_the_journey_of_elaina: "Wandering Witch The Journey of Elaina",
kino_s_journey: "Kino's Journey",
boku_no_hero_academia_season_1: "Boku no Hero Academia Season 1",
fullmetal_alchemist_brotherhood: "Fullmetal Alchemist Brotherhood",
one_week_friends: "One Week Friends",
erased: "Erased",
mononoke: "Mononoke",
little_witch_academia: "Little Witch Academia",
re_zero___starting_life_in_another_world: "Re Zero − Starting Life in Another World",
fruits_basket_season_1: "Fruits Basket Season 1",
mahou_shoujo_madoka_magica: "Mahou Shoujo Madoka Magica",
the_irregular_at_magic_high_school: "The Irregular at Magic High School",
clannad_after_story: "Clannad After Story",
frieren_beyond_journey_s_end: "Frieren Beyond Journey's End",
kakegurui: "Kakegurui",
the_garden_of_words: "The Garden of Words",
when_marnie_was_there: "When Marnie Was There",
castle_in_the_sky: "Castle in the sky",
shirokuma_cafe: "Shirokuma Cafe",
my_neighbor_totoro: "My Neighbor Totoro",
kiki_s_delivery_service: "Kiki's Delivery Service",
the_girl_who_leapt_through_time: "The Girl Who Leapt Through Time",
fate_stay_night_unlimited_blade_works: "Fate Stay Night Unlimited Blade Works",
code_geass_season_1: "Code Geass Season 1",
the_world_god_only_knows: "The World God Only Knows",
the_pet_girl_of_sakurasou: "The Pet Girl of Sakurasou",
no_game_no_life: "No Game No Life",
kanon__2006_: "Kanon (2006)",
psycho_pass: "Psycho Pass",
the_cat_returns: "The Cat Returns",
the_secret_world_of_arrietty: "The Secret World of Arrietty",
spirited_away: "Spirited Away",
noragami: "Noragami",
fairy_tail: "Fairy Tail",
i_m_taking_the_day_off: "I'm Taking the Day Off",
border: "Border",
weakest_beast: "Weakest Beast",
mob_psycho_100: "Mob Psycho 100",
the_journalist: "The Journalist",
sailor_suit_and_machine_gun__2006_: "Sailor Suit and Machine Gun (2006)",
smoking: "Smoking",
i_am_mita__your_housekeeper: "I am Mita, Your Housekeeper",
good_morning_call: "Good Morning Call",
overprotected_kahoko: "Overprotected Kahoko",
quartet: "Quartet",
million_yen_woman: "Million Yen Woman",
legal_high_season_1: "Legal High Season 1",
witcher_3: "Witcher 3",
cyberpunk_2077: "Cyberpunk 2077",
skyrim: "Skyrim"
};
class ImmersionKitClient {
cache = /* @__PURE__ */ new Map();
inflight = /* @__PURE__ */ new Map();
preloadKeys = /* @__PURE__ */ new Set();
mediaBlobUrlCache = new ObjectUrlCache(MEDIA_BLOB_CACHE_TTL_MS);
async search(term, settings) {
const query = term.trim();
if (!canSearchImmersionExamples(query, settings)) return [];
const cacheKey = this.searchCacheKey(query, settings);
const cached = this.cache.get(cacheKey);
if (cached) return cached;
const inflight = this.inflight.get(cacheKey);
if (inflight) return inflight;
const done = log$k.time("search", { query, source: settings.immersionKitExampleSource, category: settings.immersionKitCategory, exact: settings.immersionKitExactMatch });
const promise = this.searchEnabledSources(query, settings).then((examples) => {
const result = applySearchExampleLimit(examples, settings);
this.cache.set(cacheKey, result);
return result;
}).finally(() => {
this.inflight.delete(cacheKey);
done();
});
this.inflight.set(cacheKey, promise);
return promise;
}
async searchEnabledSources(query, settings) {
const sources = enabledImmersionExampleSources(settings);
const resultSets = await Promise.all(sources.map(
(source) => source === "nadeshiko" ? this.searchNadeshiko(query, settings).catch((error) => {
log$k.warn("Nadeshiko examples failed", { query }, error);
return [];
}) : this.searchImmersionKit(query, settings).catch((error) => {
log$k.warn("Immersion Kit examples failed", { query }, error);
return [];
})
));
return settings.immersionKitExampleSource === "combined" ? deterministicMergedExamples(sources, resultSets, this.combinedShuffleSeed(query, settings)) : resultSets.flat();
}
searchImmersionKit(query, settings) {
return requestJson$1(apiUrls(`/search?${this.searchParams(query, settings)}`), settings.audioTimeoutMs, settings.corsProxyUrl).then((data) => filterSearchExamples(data, query, settings, this.minimumSentenceLength(settings), "immersion-kit"));
}
searchNadeshiko(query, settings) {
const apiKey = settings.nadeshikoApiKey.trim();
if (!apiKey) return Promise.resolve([]);
return requestJson$3(`${NADESHIKO_API_BASE}/search`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
data: JSON.stringify(nadeshikoSearchPayload(query, settings, this.minimumSentenceLength(settings))),
timeoutMs: settings.audioTimeoutMs,
allowDirectCrossOrigin: true,
allowPublicProxies: false,
allowConfiguredProxy: false,
preferFetch: shouldPreferFetchForImmersionKitRequests(),
failureLabel: "Nadeshiko request",
timeoutLabel: "Nadeshiko request timed out."
}).then((data) => filterNadeshikoExamples(data, query, settings, this.minimumSentenceLength(settings)));
}
searchCacheKey(query, settings) {
return JSON.stringify({
query,
source: settings.immersionKitExampleSource,
nadeshikoKey: sensitiveFingerprint(settings.nadeshikoApiKey),
limit: SEARCH_EXAMPLE_LIMIT,
userLimit: settings.immersionKitLimitEnabled ? settings.immersionKitLimit : 0,
min: this.minimumSentenceLength(settings),
max: settings.immersionKitMaxLength,
category: settings.immersionKitCategory,
sort: this.effectiveSort(settings),
exact: settings.immersionKitExactMatch
});
}
combinedShuffleSeed(query, settings) {
return JSON.stringify({
query,
source: settings.immersionKitExampleSource,
key: sensitiveFingerprint(settings.nadeshikoApiKey),
min: this.minimumSentenceLength(settings),
max: settings.immersionKitMaxLength,
category: settings.immersionKitCategory,
exact: settings.immersionKitExactMatch
});
}
searchParams(query, settings) {
const params = new URLSearchParams({
q: query,
limit: String(SEARCH_EXAMPLE_LIMIT),
sort: this.apiSort(settings)
});
if (settings.immersionKitExactMatch) params.set("exactMatch", "true");
if (settings.immersionKitCategory !== "all") params.set("category", settings.immersionKitCategory);
return params;
}
effectiveSort(settings) {
return settings.immersionKitSort === "random" ? DEFAULT_EXAMPLE_SORT : settings.immersionKitSort;
}
apiSort(settings) {
const sort = this.effectiveSort(settings);
return sort;
}
minimumSentenceLength(settings) {
return Math.max(settings.immersionKitMinLength, MIN_LEARNING_SENTENCE_LENGTH);
}
mediaUrl(example, kind) {
return this.mediaUrls(example, kind)[0] ?? "";
}
mediaUrls(example, kind) {
const direct = directMediaUrl(example, kind);
if (direct) return [direct];
const file = mediaFileName(example, kind);
if (!file) return [];
return mediaFileUrls(example, file).slice(0, MEDIA_CANDIDATE_LIMIT);
}
preload(term, settings) {
const query = term.trim();
if (!canSearchImmersionExamples(query, settings) || this.preloadKeys.has(query)) return;
this.preloadKeys.add(query);
void this.search(query, settings).then((examples) => {
for (const example of examples.slice(0, 1)) {
const imageUrls = settings.immersionKitShowImages ? this.mediaUrls(example, "image") : [];
if (imageUrls.length) {
void this.fetchBlobUrl(imageUrls, settings.audioTimeoutMs, settings.corsProxyUrl).then((url) => {
const image = new Image();
image.decoding = "async";
image.loading = "eager";
image.src = url;
}).catch(() => void 0);
}
const soundUrls = this.mediaUrls(example, "sound");
if (soundUrls.length) {
void this.fetchBlobUrl(soundUrls, settings.audioTimeoutMs, settings.corsProxyUrl).then(() => void 0).catch(() => void 0);
}
}
}).catch(() => void 0);
}
async fetchBlobUrl(url, timeoutMs, proxyUrl = "") {
const urls = urlCandidates(url);
const key = urls.join("");
return this.mediaBlobUrlCache.getOrCreate(key, async () => {
const blob = await requestFirstBlob(url, timeoutMs, proxyUrl);
const blobUrl = await createPageMediaUrl(blob);
return blobUrl;
});
}
async fetchDataUrl(url, timeoutMs, proxyUrl = "") {
const blob = await requestFirstBlob(url, timeoutMs, proxyUrl);
return blobToDataUrl$1(blob);
}
}
function canSearchImmersionExamples(query, settings) {
return Boolean(query && settings.immersionKitEnabled);
}
function enabledImmersionExampleSources(settings) {
if (settings.immersionKitExampleSource === "nadeshiko") return ["nadeshiko"];
if (settings.immersionKitExampleSource === "combined") return ["immersion-kit", "nadeshiko"];
return ["immersion-kit"];
}
function collectExamples(value) {
if (Array.isArray(value)) return value;
if (!value || typeof value !== "object") return [];
const record = value;
return firstArrayField(record, ["examples", "results", "data"]);
}
function firstArrayField(record, keys) {
return keys.map((key) => record[key]).find(Array.isArray) ?? [];
}
function filterSearchExamples(data, query, settings, minLength, provider = "immersion-kit") {
return collectExamples(data).map((value) => normalizeExample(value, provider)).filter((example) => Boolean(example)).filter((example) => isSearchExampleInRange(example, settings, minLength)).filter((example) => isSearchExampleSurfaceMatch(example, query));
}
function filterNadeshikoExamples(data, query, settings, minLength) {
const response = nadeshikoResponseRecord(data);
if (!response) return [];
const media = nadeshikoMediaMap(response);
return nadeshikoSegments(response).map((value) => normalizeNadeshikoExample(value, media)).filter((example) => Boolean(example)).filter((example) => isSearchExampleInRange(example, settings, minLength)).filter((example) => isSearchExampleSurfaceMatch(example, query));
}
function applySearchExampleLimit(examples, settings) {
return settings.immersionKitLimitEnabled ? examples.slice(0, Math.max(1, settings.immersionKitLimit)) : examples;
}
function isSearchExampleInRange(example, settings, minLength) {
const length = sentenceLength(example.sentence);
return length >= minLength && (!settings.immersionKitMaxLength || length <= settings.immersionKitMaxLength);
}
function isSearchExampleSurfaceMatch(example, query) {
return !requiresSurfaceMatch(query) || sentenceContainsQuery(example.sentence, query);
}
function normalizeExample(value, provider = "immersion-kit") {
return isRecord(value) ? normalizeExampleRecord(value, provider) : null;
}
function normalizeExampleRecord(record, provider = "immersion-kit") {
const id = text(record.id);
const sentence = firstText(record, ["sentence", "text"]);
if (!sentence) return null;
const titleSlug = exampleTitleSlug(record, id);
const sourceTitle = exampleSourceTitle(record, titleSlug);
const category = exampleCategory(record, id);
const soundFile = firstText(record, ["sound", "audio", "audio_file", "audioFile"]);
const imageFile = firstText(record, ["image", "image_file", "imageFile"]);
return {
id,
provider,
sentence,
sentenceWithFurigana: firstText(record, ["sentence_with_furigana", "sentenceWithFurigana"]),
translation: firstText(record, ["translation", "translation_en", "english"]),
sourceTitle,
titleSlug,
category,
soundFile,
imageFile,
soundUrl: absoluteMediaUrl(firstText(record, ["sound_url", "audio_url", "soundUrl", "audioUrl"])),
imageUrl: absoluteMediaUrl(firstText(record, ["image_url", "imageUrl"]))
};
}
function nadeshikoSearchPayload(query, settings, minLength) {
const maxLength = settings.immersionKitMaxLength || 1e3;
return {
query: { search: query },
take: NADESHIKO_SEARCH_LIMIT,
filters: {
segmentLengthChars: {
min: minLength,
max: Math.max(minLength, maxLength)
}
}
};
}
function nadeshikoResponseRecord(data) {
if (Array.isArray(data)) return { segments: data };
return isRecord(data) ? data : null;
}
function nadeshikoSegments(response) {
return firstArrayField(response, ["segments", "examples", "results", "data"]);
}
function nadeshikoMediaMap(response) {
const includes = response.includes;
const media = isRecord(includes) ? includes.media : void 0;
return isRecord(media) ? media : {};
}
function normalizeNadeshikoExample(value, mediaById) {
if (!isRecord(value)) return null;
const sentence = nestedText(value, "textJa", ["content", "text"]) || firstText(value, ["sentence", "text", "textJa"]);
if (!sentence) return null;
const publicId = firstText(value, ["publicId", "public_id", "id"]);
const mediaPublicId = firstText(value, ["mediaPublicId", "media_public_id", "mediaId"]);
const media = isRecord(mediaById[mediaPublicId]) ? mediaById[mediaPublicId] : {};
const urls = isRecord(value.urls) ? value.urls : {};
const sourceTitle = firstText(media, ["nameRomaji", "name_romaji", "titleRomaji", "title_romaji", "name", "title", "nameJa"]) || firstText(value, ["mediaName", "sourceTitle", "source", "title"]) || "Nadeshiko";
return {
id: `nadeshiko_${publicId || mediaPublicId || hashString(sentence).toString(36)}`,
provider: "nadeshiko",
sentence,
sentenceWithFurigana: firstText(value, ["furi_sentence", "sentenceWithFurigana", "sentence_with_furigana"]),
translation: nestedText(value, "textEn", ["content", "text"]) || firstText(value, ["translation", "translation_en", "english"]),
sourceTitle,
titleSlug: slugFromTitle(sourceTitle),
category: firstText(media, ["type", "category"]) || firstText(value, ["category"]) || "anime",
soundFile: "",
imageFile: "",
soundUrl: absoluteMediaUrl(firstText(urls, ["audioUrl", "soundUrl", "audio_url", "sound_url"]) || firstText(value, ["audioUrl", "soundUrl", "audio_url", "sound_url"])),
imageUrl: absoluteMediaUrl(firstText(urls, ["imageUrl", "image_url"]) || firstText(value, ["imageUrl", "image_url"])),
publicId,
mediaPublicId
};
}
function nestedText(record, key, fields) {
const value = record[key];
return isRecord(value) ? firstText(value, fields) : "";
}
function directMediaUrl(example, kind) {
return kind === "image" ? example.imageUrl : example.soundUrl;
}
function mediaFileName(example, kind) {
return kind === "image" ? example.imageFile : example.soundFile;
}
function mediaFileUrls(example, file) {
const category = example.category || categoryFromId(example.id);
return uniqueStrings$2(mediaTitleCandidates(example, file).flatMap((title) => mediaFileTitleUrls(category, title, file)));
}
function mediaFileTitleUrls(category, title, file) {
const path = `media/${category}/${title}/media/${file}`;
return [
...apiUrls(`/download_media?${new URLSearchParams({ path })}`),
`${OBJECT_STORE_BASE}/${path.split("/").map(encodeURIComponent).join("/")}`
];
}
function exampleTitleSlug(record, id) {
return firstText(record, ["title", "deck", "source"]) || titleSlugFromId(id);
}
function exampleSourceTitle(record, titleSlug) {
return firstText(record, ["sourceTitle", "display_title", "displayTitle"]) || titleFromSlug(titleSlug);
}
function exampleCategory(record, id) {
return text(record.category) || categoryFromId(id);
}
function firstText(record, keys) {
for (const key of keys) {
const value = text(record[key]);
if (value) return value;
}
return "";
}
function text(value) {
return typeof value === "string" ? value.trim() : "";
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function titleSlugFromId(id) {
const parts = id.split("_");
if (parts.length < 3) return "";
return parts.slice(1, -1).join("_");
}
function categoryFromId(id) {
const [category] = id.split("_");
return category || "anime";
}
function titleFromSlug(slug) {
if (!slug) return "Unknown";
const override = IMMERSION_KIT_TITLES[slug];
if (override) return override;
return slug.replace(/_+$/g, "").split("_").filter(Boolean).map((part) => part.length <= 3 ? part.toUpperCase() : part[0].toUpperCase() + part.slice(1)).join(" ");
}
function slugFromTitle(title) {
return title.trim().toLowerCase().replace(/[^a-z0-9ぁ-んァ-ン一-龯]+/gi, "_").replace(/^_+|_+$/g, "");
}
function mediaTitleCandidates(example, file) {
const slug = example.titleSlug || titleSlugFromId(example.id);
return uniqueStrings$2([
titleFromSlug(slug),
example.sourceTitle,
titleFromMediaFile(file),
slug
].filter(Boolean));
}
function titleFromMediaFile(file) {
const stem = file.replace(/\.[^.]+$/u, "");
const episodeMatch = /^(.+?)(?:_S\d|_\d|_E\d|-\s*\d)/i.exec(stem);
const title = (episodeMatch?.[1] || stem).replace(/^A[_-]/, "").replace(/_/g, " ").trim();
if (!title) return "";
return title.replace(/\bKOn\b/u, "K-On!").replace(/\bDurarara\b/u, "Durarara!!").replace(/\bAngel Beats!?\b/u, "Angel Beats!");
}
function uniqueStrings$2(values) {
const seen = /* @__PURE__ */ new Set();
const result = [];
for (const value of values.map((item) => item.trim()).filter(Boolean)) {
if (seen.has(value)) continue;
seen.add(value);
result.push(value);
}
return result;
}
function deterministicShuffle(values, seed) {
const result = [...values];
let state = hashString(seed) || 1;
for (let index = result.length - 1; index > 0; index--) {
state = nextRandomState(state);
const swapIndex = state % (index + 1);
[result[index], result[swapIndex]] = [result[swapIndex], result[index]];
}
return result;
}
function deterministicMergedExamples(sources, resultSets, seed) {
const groups = deterministicShuffle(sources.map((source, index) => ({
source,
examples: deterministicShuffle(resultSets[index] ?? [], `${seed}:${source}`)
})), `${seed}:providers`).filter((group) => group.examples.length);
const result = [];
while (groups.some((group) => group.examples.length)) {
for (const group of groups) {
const example = group.examples.shift();
if (example) result.push(example);
}
}
return result;
}
function nextRandomState(value) {
return Math.imul(value, 1664525) + 1013904223 >>> 0;
}
function sensitiveFingerprint(value) {
const trimmed = value.trim();
return trimmed ? hashString(trimmed).toString(36) : "";
}
function hashString(value) {
let hash = 2166136261;
for (let index = 0; index < value.length; index++) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function absoluteMediaUrl(value) {
if (!value) return "";
if (isAbsoluteMediaUrl(value)) return value;
if (value.startsWith("media/")) return `${OBJECT_STORE_BASE}/${value.split("/").map(encodeURIComponent).join("/")}`;
return "";
}
function isAbsoluteMediaUrl(value) {
return /^https?:\/\//i.test(value) || value.startsWith("data:");
}
function sentenceLength(sentence) {
return Array.from(sentence.replace(/\s+/g, "")).length;
}
function requiresSurfaceMatch(query) {
return /[0-90-9]/u.test(query);
}
function sentenceContainsQuery(sentence, query) {
const normalizedSentence = normalizeForSurfaceMatch(sentence);
const normalizedQuery = normalizeForSurfaceMatch(query);
return Boolean(normalizedQuery) && normalizedSentence.includes(normalizedQuery);
}
function normalizeForSurfaceMatch(value) {
return value.normalize("NFKC").replace(/\s+/g, "").toLowerCase();
}
async function requestJson$1(url, timeoutMs, proxyUrl = "") {
let lastError;
for (const candidate of urlCandidates(url)) {
try {
return await requestJsonCandidate(candidate, timeoutMs, proxyUrl);
} catch (error) {
lastError = error;
}
}
throw requestError(lastError, "Immersion Kit request failed.");
}
function urlCandidates(url) {
return Array.isArray(url) ? url : [url];
}
function requestError(error, fallback) {
return error instanceof Error ? error : new Error(fallback);
}
function requestJsonCandidate(url, timeoutMs, proxyUrl = "") {
return requestJson$3(url, {
proxyUrl,
timeoutMs,
allowDirectCrossOrigin: true,
preferFetch: shouldPreferFetchForImmersionKitRequests(),
failureLabel: "Immersion Kit request",
timeoutLabel: "Immersion Kit request timed out."
}).catch((error) => {
if (error instanceof Error && /blocked|cross-origin|cors/i.test(error.message)) {
throw new Error("Immersion Kit search is blocked in this browser. Configure browser/CORS or use the built-in fallback settings.");
}
throw requestError(error, "Immersion Kit request failed.");
});
}
function requestBlob$2(url, timeoutMs, proxyUrl = "") {
return requestBlob$4(url, {
proxyUrl,
timeoutMs,
allowDirectCrossOrigin: true,
preferFetch: shouldPreferFetchForImmersionKitRequests(),
failureLabel: "Media request",
timeoutLabel: "Media request timed out."
}).then((blob) => {
if (isErrorDocumentBlob(blob)) throw new Error("Media request returned an error document instead of audio or image.");
return blob;
});
}
async function requestFirstBlob(urls, timeoutMs, proxyUrl = "") {
const candidates = prioritizeMediaCandidates(urlCandidates(urls)).slice(0, MEDIA_CANDIDATE_LIMIT);
let lastError;
for (const url of candidates) {
try {
return await requestBlob$2(url, timeoutMs, proxyUrl);
} catch (error) {
lastError = error;
}
}
throw requestError(lastError, "No Immersion Kit media candidate could be loaded.");
}
function prioritizeMediaCandidates(urls) {
return [...urls].sort((a, b) => Number(isObjectStoreMediaUrl(a)) - Number(isObjectStoreMediaUrl(b)));
}
function isObjectStoreMediaUrl(url) {
try {
return new URL(url, location.href).origin === new URL(OBJECT_STORE_BASE).origin;
} catch {
return false;
}
}
function isErrorDocumentBlob(blob) {
const type = blob.type.toLowerCase();
if (isMediaBlobType(type)) return false;
return ERROR_DOCUMENT_TYPE_MARKERS.some((marker) => type.includes(marker)) || type.startsWith("text/");
}
const ERROR_DOCUMENT_TYPE_MARKERS = ["xml", "html", "json"];
function isMediaBlobType(type) {
return ["image/", "audio/", "video/"].some((prefix) => type.startsWith(prefix));
}
function shouldPreferFetchForImmersionKitRequests() {
return typeof window !== "undefined" && window.__YOMU_READER_RUNTIME__ === "newtab";
}
function blobToDataUrl$1(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error ?? new Error("Could not read media."));
reader.readAsDataURL(blob);
});
}
function apiUrls(path) {
const cleanPath = path.startsWith("/") ? path : `/${path}`;
return API_BASES.map((base) => `${base}${cleanPath}`);
}
const JAPANESE_QUERY_RUN_RE = /[\u3040-\u30ff\u3400-\u9fff々〆ヵヶー]+/gu;
const JAPANESE_SCRIPT_GROUP_RE$1 = /[\u3400-\u9fff々〆ヵヶ]+|[\u3040-\u309fー]+|[\u30a0-\u30ffー]+/gu;
const COMMON_PARTICLES = /* @__PURE__ */ new Set(["は", "が", "を", "に", "へ", "で", "と", "も", "の", "や", "か", "ね", "よ", "ぞ", "ぜ", "な", "わ", "から", "まで", "だけ", "しか", "より"]);
const IMMERSION_FALLBACK_QUERY_LIMIT = 5;
function normalizeImmersionSearchQuery(value) {
return value.replace(/\s+/g, " ").trim();
}
function queryKey(value) {
return normalizeImmersionSearchQuery(value).replace(/\s+/g, "").toLowerCase();
}
function queryLength(value) {
return Array.from(queryKey(value)).length;
}
function queryHasKanji(value) {
return /[\u3400-\u9fff々〆]/u.test(value);
}
function shouldRequireOriginalSurfaceMatch(value) {
return queryHasKanji(value) && queryLength(value) >= 3;
}
function immersionSentenceContainsQuery(sentence, query) {
const normalizedSentence = normalizeImmersionSurface(sentence);
const normalizedQuery = normalizeImmersionSurface(query);
return Boolean(normalizedQuery) && normalizedSentence.includes(normalizedQuery);
}
function isUsefulImmersionFallbackQuery(query, exactQuery) {
if (isSameImmersionQuery(query, exactQuery)) return false;
return isUsefulStandaloneImmersionQuery(query);
}
function isUsefulImmersionPreloadQuery(query) {
return isUsefulStandaloneImmersionQuery(query);
}
function uniqueImmersionQueries(values) {
const seen = /* @__PURE__ */ new Set();
const result = [];
for (const value of values) {
const query = normalizeImmersionSearchQuery(value);
const key = queryKey(query);
if (!query || seen.has(key)) continue;
seen.add(key);
result.push(query);
}
return result;
}
function immersionFallbackFragments(value) {
const fragments = [];
const runs = normalizeImmersionSearchQuery(value).match(JAPANESE_QUERY_RUN_RE) ?? [];
for (const run of runs) {
fragments.push(...scriptGroupFallbackFragments(run));
}
return uniqueImmersionQueries(fragments).sort(compareImmersionFallbackFragments);
}
function normalizeImmersionSurface(value) {
return value.normalize("NFKC").replace(/\s+/g, "").toLowerCase();
}
function isSameImmersionQuery(query, exactQuery) {
return queryKey(query) === queryKey(exactQuery);
}
function isUsefulStandaloneImmersionQuery(query) {
if (!query || !HAS_JAPANESE$1.test(query)) return false;
if (COMMON_PARTICLES.has(queryKey(query))) return false;
return queryLength(query) >= 2;
}
function scriptGroupFallbackFragments(run) {
const scriptGroups = run.match(JAPANESE_SCRIPT_GROUP_RE$1) ?? [];
if (scriptGroups.length <= 1) return scriptGroups;
return [...scriptGroups, ...scriptGroups.filter(queryHasKanji)];
}
function compareImmersionFallbackFragments(a, b) {
const kanjiOrder = Number(queryHasKanji(b)) - Number(queryHasKanji(a));
if (kanjiOrder) return kanjiOrder;
return queryLength(b) - queryLength(a);
}
const IMMERSION_SEARCH_CACHE_TTL_MS = 3e4;
const log$j = Logger.scope("ImmersionPopover");
class ImmersionPopoverController {
constructor(options) {
this.options = options;
}
audioElement;
audioKey = "";
audioLoadingKey = "";
audioRequestId = 0;
preloadedTerms = /* @__PURE__ */ new Set();
hoverAudioPlayedKeys = /* @__PURE__ */ new Set();
activeMiningContext;
contextByCardKey = /* @__PURE__ */ new Map();
searchResultCache = /* @__PURE__ */ new Map();
hasActiveContext(card, sentence) {
return this.activeMiningContext?.term === card.spelling && this.activeMiningContext.sentence === (sentence || "").replace(/\s+/g, " ").trim();
}
activeContextFor(card) {
return this.activeMiningContext?.term === card.spelling ? this.activeMiningContext : void 0;
}
storedContextFor(card) {
return this.contextByCardKey.get(cardKey$1(card)) ?? loadMiningContext(card.spelling);
}
rememberPageMiningContext(card, sentence, anchor) {
const cleanSentence = normalizeMiningSentence(sentence);
if (!isPageMiningSentence(cleanSentence, card)) return;
this.rememberStoredMiningContext(saveMiningContext(card.spelling, this.pageMiningContextDraft(cleanSentence, anchor)));
}
pageMiningContextDraft(sentence, anchor) {
const immersionCard = anchor?.closest(".jpdb-reader-example-card") ?? null;
if (immersionCard) return immersionContextFromElement(sentence, immersionCard);
const sourceKind = pageMiningSourceKind(anchor);
return pageMiningContext(sentence, sourceKind);
}
rememberStoredMiningContext(stored) {
if (!stored) return;
this.activeMiningContext = stored;
}
async loadExamples(popover, card, searchPromise = this.searchExamples(card)) {
const container = popover.querySelector("[data-immersion-kit]");
if (!container) return;
try {
const result = await searchPromise;
if (!isConnectedImmersionSurface(popover, container)) return;
this.renderLoadedExamples(container, card, result);
} catch (error) {
log$j.warn("Immersion Kit examples failed", { term: card.spelling }, error);
this.renderEmptyIfConnected(popover, container);
}
}
renderLoadedExamples(container, card, result) {
const { examples } = result;
if (!examples.length) {
this.renderEmpty(container);
return;
}
this.bindExampleCarousel(container, card, result);
}
bindExampleCarousel(container, card, result) {
const { examples } = result;
let index = this.startIndex(card, examples);
let renderRequest = 0;
let hoverAudioCanPlay = false;
let hoverAudioActive = false;
requestAnimationFrame(() => {
hoverAudioCanPlay = !container.matches(":hover");
});
const render = (nextIndex, playAudio, promoteMiningContext = false) => {
const requestId = ++renderRequest;
index = (nextIndex + examples.length) % examples.length;
this.renderExample(container, card, examples, index, playAudio, result.query, () => requestId === renderRequest, promoteMiningContext);
bindHoverMedia();
};
container.addEventListener("click", (event) => {
const button2 = event.target.closest("[data-immersion-action]");
const media = event.target.closest(".jpdb-reader-example-media");
const translation = event.target.closest(".jpdb-reader-example-translation");
if (translation) {
event.preventDefault();
event.stopPropagation();
this.toggleTranslationBlur(container);
return;
}
if (!button2 && (!media || !this.options.getSettings().immersionKitPlayOnImageClick)) return;
event.preventDefault();
event.stopPropagation();
if (!button2) {
void this.playExampleAudio(examples[index]);
return;
}
const action = button2.dataset.immersionAction;
const shouldAutoPlay = this.options.getSettings().immersionKitAutoPlayAudio;
if (action === "previous") render(index - 1, shouldAutoPlay, true);
if (action === "next") render(index + 1, shouldAutoPlay, true);
if (action === "audio") void this.playExampleAudio(examples[index]);
});
container.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const translation = event.target.closest(".jpdb-reader-example-translation");
if (!translation) return;
event.preventDefault();
this.toggleTranslationBlur(container);
});
const handleImmersionHover = (event) => {
const media = event.target.closest?.(".jpdb-reader-example-media");
if (!media || !this.options.getSettings().immersionKitPlayOnHover) return;
const pointerType = "pointerType" in event ? event.pointerType : "mouse";
const cannotHover = pointerType !== "mouse" && (window.matchMedia?.("(hover: none)").matches ?? false);
if (pointerType === "touch" || cannotHover) return;
if (media.contains(event.relatedTarget)) return;
if (!hoverAudioCanPlay) return;
const audioKey = hoverAudioExampleKey(examples[index]);
if (this.hoverAudioPlayedKeys.has(audioKey)) return;
this.hoverAudioPlayedKeys.add(audioKey);
hoverAudioCanPlay = false;
hoverAudioActive = true;
void this.playExampleAudio(examples[index], true, () => hoverAudioActive && container.isConnected && media.isConnected && media.matches(":hover"));
};
const bindHoverMedia = () => {
container.querySelectorAll(".jpdb-reader-example-media").forEach((media) => {
if (media.dataset.immersionHoverBound === "true") return;
media.dataset.immersionHoverBound = "true";
media.addEventListener("pointerover", handleImmersionHover);
media.addEventListener("mouseover", handleImmersionHover);
});
};
container.addEventListener("pointerleave", () => {
hoverAudioCanPlay = true;
hoverAudioActive = false;
});
container.addEventListener("mouseleave", () => {
hoverAudioCanPlay = true;
hoverAudioActive = false;
});
render(index, false);
}
renderEmptyIfConnected(popover, container) {
if (!isConnectedImmersionSurface(popover, container)) return;
this.renderEmpty(container);
}
async searchExamples(card, options = {}) {
const key = this.searchCacheKey(card, options);
const now = Date.now();
const cached = this.searchResultCache.get(key);
if (cached && cached.expiresAt > now) return cached.promise;
const promise = this.fetchExamples(card, options).catch((error) => {
if (this.searchResultCache.get(key)?.promise === promise) this.searchResultCache.delete(key);
throw error;
});
this.searchResultCache.set(key, { expiresAt: now + IMMERSION_SEARCH_CACHE_TTL_MS, promise });
return promise;
}
preloadForTokens(tokens) {
const settings = this.options.getSettings();
if (!settings.immersionKitEnabled) return;
this.queuePreloads(tokens, settings);
}
queuePreloads(tokens, settings) {
let queued = 0;
for (const token of tokens) {
const term = this.nextPreloadTerm(token);
if (!term) continue;
this.options.client.preload(term, settings);
queued++;
if (queued >= 2) break;
}
}
nextPreloadTerm(token) {
const term = token.card.spelling.trim();
if (!isUsefulImmersionPreloadQuery(term) || this.preloadedTerms.has(term)) return "";
this.preloadedTerms.add(term);
return term;
}
stopAudio() {
this.audioRequestId++;
this.clearAudio();
}
async fetchExamples(card, options) {
const exactQuery = normalizeImmersionSearchQuery(card.spelling);
const queries = await this.immersionSearchQueries(card, options, exactQuery);
const triedQueries = [];
for (const query of queries) {
const result = await this.fetchExamplesForQuery(query, exactQuery, triedQueries);
if (result) return result;
}
return { examples: [], query: exactQuery, usedFallback: false, triedQueries };
}
async immersionSearchQueries(card, options, exactQuery) {
const relatedQueries = uniqueImmersionQueries(options.relatedQueries ?? []).map(normalizeImmersionSearchQuery).filter((query) => isUsefulImmersionFallbackQuery(query, exactQuery));
const fallbackQueries = await this.fallbackQueries(card, exactQuery);
return uniqueImmersionQueries([exactQuery, ...relatedQueries, ...fallbackQueries]).slice(0, 1 + IMMERSION_FALLBACK_QUERY_LIMIT);
}
async fetchExamplesForQuery(query, exactQuery, triedQueries) {
if (!query) return null;
triedQueries.push(query);
try {
const examples = await this.options.client.search(query, this.options.getSettings());
return immersionSearchResultForQuery(query, exactQuery, triedQueries, examples);
} catch {
return null;
}
}
searchCacheKey(card, options) {
const settings = this.options.getSettings();
return JSON.stringify({
spelling: card.spelling,
reading: card.reading,
enabled: settings.immersionKitEnabled,
source: settings.immersionKitExampleSource,
nadeshikoKey: Boolean(settings.nadeshikoApiKey.trim()),
limit: settings.immersionKitLimit,
limitEnabled: settings.immersionKitLimitEnabled,
min: settings.immersionKitMinLength,
max: settings.immersionKitMaxLength,
category: settings.immersionKitCategory,
sort: settings.immersionKitSort,
exact: settings.immersionKitExactMatch,
parse: this.options.canParseJapanese(),
relatedQueries: uniqueImmersionQueries(options.relatedQueries ?? []).map(normalizeImmersionSearchQuery)
});
}
async fallbackQueries(card, exactQuery) {
const candidates = [];
addImmersionFallbackQuery(candidates, card.reading !== card.spelling ? card.reading : "", exactQuery);
await this.addParsedFallbackQueries(candidates, card, exactQuery);
addImmersionFallbackQueries(candidates, immersionFallbackFragments(card.spelling), exactQuery);
return uniqueImmersionQueries(candidates).slice(0, IMMERSION_FALLBACK_QUERY_LIMIT);
}
async addParsedFallbackQueries(candidates, card, exactQuery) {
if (!this.options.canParseJapanese()) return;
const tokens = await this.fallbackParseTokens(card);
addImmersionFallbackQueries(candidates, fallbackTokenQueries(card, tokens), exactQuery);
}
async fallbackParseTokens(card) {
const [tokens] = await this.options.parseJapanese([card.spelling]).catch(() => {
return [[]];
});
return tokens ?? [];
}
renderEmpty(container) {
const settings = this.options.getSettings();
container.removeAttribute("open");
container.dataset.immersionEmpty = "true";
setInnerHtml(container, `
${uiText(settings.interfaceLanguage, "immersionKit")}
${uiText(settings.interfaceLanguage, "noImmersionExamplesCompact")}
`);
this.options.repositionPopover();
}
startIndex(card, examples) {
const context = this.miningContextForStartIndex(card);
if (!context || context.sourceKind !== "immersion-kit") return 0;
const sentenceIndex = examples.findIndex((example) => example.sentence === context.sentence);
if (sentenceIndex >= 0) return sentenceIndex;
return validImmersionExampleIndex(Number(context.immersionIndex), examples.length);
}
miningContextForStartIndex(card) {
return this.activeMiningContext?.term === card.spelling ? this.activeMiningContext : this.contextByCardKey.get(cardKey$1(card)) ?? loadMiningContext(card.spelling);
}
renderExample(container, card, examples, index, playAudio, searchQuery, isCurrent = () => true, promoteMiningContext = false) {
const example = examples[index];
const settings = this.options.getSettings();
const imageUrls = settings.immersionKitShowImages ? this.mediaUrls(example, "image") : [];
const audioUrls = this.mediaUrls(example, "sound");
const hasAudio = audioUrls.length > 0;
const imageUrl = imageUrls[0] ?? "";
this.rememberExampleMiningContext(card, example, index, examples.length, imageUrl, audioUrls, promoteMiningContext);
delete container.dataset.immersionEmpty;
setInnerHtml(container, this.renderExampleHtml(container, card, example, examples.length, index, searchQuery, settings, imageUrl, audioUrls, hasAudio));
this.loadRenderedExampleImages(container, imageUrls, isCurrent);
this.options.repositionPopover();
if (playAudio) void this.playExampleAudio(example, true);
this.parseRenderedExampleSentence(container, card, example, searchQuery, isCurrent);
}
rememberExampleMiningContext(card, example, index, total, imageUrl, audioUrls, promoteMiningContext) {
const storedContext = saveMiningContext(card.spelling, immersionContextFromExample(card.spelling, example, index, total, imageUrl, audioUrls));
if (storedContext) {
this.contextByCardKey.set(cardKey$1(card), storedContext);
this.promoteExampleMiningContext(card, storedContext, promoteMiningContext);
}
}
promoteExampleMiningContext(card, storedContext, promoteMiningContext) {
if (shouldPromoteExampleMiningContext(this.activeMiningContext, card, promoteMiningContext)) this.activeMiningContext = storedContext;
}
renderExampleHtml(container, card, example, total, index, searchQuery, settings, imageUrl, audioUrls, hasAudio) {
const language = settings.interfaceLanguage;
const sentenceHtml = renderHighlightedTextHtml(example.sentence, [card.spelling, card.reading, searchQuery], "jpdb-reader-example-target");
const translation = renderExampleTranslation(example.translation, settings);
const sourceLabel2 = immersionExampleSourceLabel(card, example, searchQuery);
const sentence = renderExampleSentenceHtml(sentenceHtml);
const image = renderExampleImageHtml(container, imageUrl, sentence);
return `
${escapeHtml$1(immersionExampleProviderLabel(example))}
${image}
${image ? "" : sentence}
${translation}
`;
}
loadRenderedExampleImages(container, imageUrls, isCurrent) {
container.querySelectorAll("[data-immersion-image]").forEach((imageElement) => {
let imageCandidateIndex = 0;
let imageRequestId = 0;
const holdUntilReady = imageElement.dataset.immersionHoldUntilReady === "true";
let pendingImage = null;
const showImageCandidate = (sourceUrl, displayUrl) => {
imageElement.dataset.immersionImageSrc = sourceUrl;
imageElement.src = displayUrl;
imageElement.removeAttribute("data-immersion-hold-until-ready");
};
const loadNextImageCandidate = () => {
if (!isCurrent() || !imageElement.isConnected) return;
const fallbackUrl = imageUrls[imageCandidateIndex++];
if (!fallbackUrl) {
if (imageElement.complete && imageElement.naturalWidth > 0) return;
this.hideBrokenExampleImage(container, imageElement);
return;
}
const currentSrc = imageElement.currentSrc || imageElement.src;
const requestId = ++imageRequestId;
this.options.client.fetchBlobUrl(fallbackUrl, this.options.getSettings().audioTimeoutMs, this.options.getSettings().corsProxyUrl).then((displayUrl) => {
if (requestId !== imageRequestId || !isCurrent() || !imageElement.isConnected) return;
if (!holdUntilReady || currentSrc === displayUrl) {
showImageCandidate(fallbackUrl, displayUrl);
return;
}
const preload = new Image();
pendingImage = preload;
preload.decoding = "async";
preload.onload = () => {
if (pendingImage !== preload || requestId !== imageRequestId || !isCurrent() || !imageElement.isConnected) return;
pendingImage = null;
showImageCandidate(fallbackUrl, displayUrl);
this.options.repositionPopover();
};
preload.onerror = () => {
if (pendingImage !== preload || requestId !== imageRequestId) return;
pendingImage = null;
loadNextImageCandidate();
};
preload.src = displayUrl;
}).catch(() => {
if (requestId !== imageRequestId) return;
loadNextImageCandidate();
});
};
imageElement.addEventListener("error", loadNextImageCandidate);
imageElement.addEventListener("load", () => this.options.repositionPopover(), { once: true });
if (!imageElement.dataset.immersionImageSrc) {
this.hideBrokenExampleImage(container, imageElement);
return;
}
loadNextImageCandidate();
});
}
hideBrokenExampleImage(container, imageElement) {
if (!imageElement.isConnected) return;
const media = imageElement.closest(".jpdb-reader-example-media");
const sentence = media?.querySelector(".jpdb-reader-example-sentence");
if (sentence) {
sentence.classList.remove("jpdb-subtitle-primary");
media?.after(sentence);
}
media?.remove();
if (imageElement.isConnected) imageElement.remove();
container.querySelector(".jpdb-reader-example-card")?.classList.remove("has-image");
this.options.repositionPopover();
}
parseRenderedExampleSentence(container, card, example, searchQuery, isCurrent) {
void this.options.parseJapanese([example.sentence]).then(([tokens]) => {
if (!isCurrent() || !container.isConnected) return;
const sentence = container.querySelector("[data-immersion-sentence-render]");
if (!sentence) return;
setInnerHtml(sentence, renderTokensToHtml(example.sentence, tokens ?? [], this.options.getSettings()));
this.highlightTarget(sentence, card, searchQuery);
void this.options.parsePopoverJapanese(container);
void this.options.enrichAnkiWords(tokens ?? []);
this.options.repositionPopover();
}).catch(() => void 0);
}
highlightTarget(sentence, card, searchQuery = "") {
const cardVid = String(card.vid);
const cardSid = String(card.sid);
const targets = [card.spelling, card.reading, searchQuery].map((value) => value.trim()).filter(Boolean);
sentence.querySelectorAll(".jpdb-reader-word").forEach((word) => {
const surface = word.textContent?.replace(/\s+/g, "") ?? "";
if (word.dataset.vid === cardVid && word.dataset.sid === cardSid || targets.some((target) => surface.includes(target))) {
word.classList.add("jpdb-reader-example-target");
}
});
}
toggleTranslationBlur(container) {
const shouldBlur = !this.options.getSettings().immersionKitRevealTranslationOnClick;
this.options.setImmersionTranslationBlurred(shouldBlur);
container.querySelectorAll(".jpdb-reader-example-translation").forEach((translation) => {
setTranslationBlurAttributes(translation, shouldBlur, "immersionTranslationBlurred");
});
this.options.repositionPopover();
}
async playExampleAudio(example, quiet = false, isCurrent = () => true) {
const source = this.exampleAudioSource(example, quiet);
if (!source) return;
let requestId = 0;
try {
requestId = this.startExampleAudioRequest(source.key);
if (!requestId) return;
await this.playFetchedExampleAudio(source, requestId, isCurrent);
} catch (error) {
this.handleExampleAudioError(example, quiet, requestId, error);
}
}
async playFetchedExampleAudio(source, requestId, isCurrent) {
if (await this.playDirectExampleAudio(source, requestId, isCurrent)) return;
const src = await this.options.client.fetchBlobUrl(source.urls, this.options.getSettings().audioTimeoutMs, this.options.getSettings().corsProxyUrl);
if (!this.isExampleAudioRequestCurrent(requestId, source.key, isCurrent)) {
this.clearAudioRequestIfCurrent(requestId, source.key);
return;
}
const audio = this.attachExampleAudio(src);
await this.playAttachedExampleAudio(audio, isCurrent);
}
async playDirectExampleAudio(source, requestId, isCurrent) {
const src = source.urls[0];
if (!src) return false;
try {
const audio = this.attachExampleAudio(src);
await this.playAttachedExampleAudio(audio, isCurrent);
return this.isExampleAudioRequestCurrent(requestId, source.key, isCurrent);
} catch {
if (this.isExampleAudioRequestCurrent(requestId, source.key, isCurrent)) this.clearAudio();
return false;
}
}
async playAttachedExampleAudio(audio, isCurrent) {
if (!isCurrent()) {
this.clearAudio();
return;
}
await audio.play();
if (!isCurrent()) this.clearAudio();
}
handleExampleAudioError(example, quiet, requestId, error) {
if (this.shouldClearAudioAfterExampleError(requestId)) this.clearAudio();
log$j.warn("Immersion example audio failed", { provider: immersionExampleProviderLabel(example), sourceTitle: example.sourceTitle, quiet }, error);
if (!quiet) this.options.toast(error instanceof Error ? error.message : "Example audio failed.");
}
shouldClearAudioAfterExampleError(requestId) {
return !requestId || requestId === this.audioRequestId;
}
exampleAudioSource(example, quiet) {
const urls = this.mediaUrls(example, "sound");
const key = urls[0] ?? "";
if (key) return { urls, key };
if (!quiet) this.options.toast(`No ${immersionExampleProviderLabel(example)} audio for this example.`);
return null;
}
startExampleAudioRequest(key) {
if (this.isAudioBusy(key)) return 0;
const requestId = ++this.audioRequestId;
this.clearAudio();
this.audioKey = key;
this.audioLoadingKey = key;
this.options.audio.stop();
return requestId;
}
isExampleAudioRequestCurrent(requestId, key, isCurrent) {
return requestId === this.audioRequestId && this.audioKey === key && isCurrent();
}
clearAudioRequestIfCurrent(requestId, key) {
if (requestId === this.audioRequestId && this.audioKey === key) this.clearAudio();
}
attachExampleAudio(src) {
const audio = new Audio(src);
audio.preload = "auto";
audio.playbackRate = this.options.getSettings().immersionKitPlaybackRate;
this.audioElement = audio;
this.audioLoadingKey = "";
const cleanup = () => {
if (this.audioElement !== audio) return;
this.clearAudio();
};
audio.addEventListener("ended", cleanup, { once: true });
audio.addEventListener("error", cleanup, { once: true });
return audio;
}
mediaUrls(example, kind) {
const client = this.options.client;
return client.mediaUrls?.(example, kind) ?? [client.mediaUrl(example, kind)].filter(Boolean);
}
clearAudio() {
this.audioElement?.pause();
this.audioElement = void 0;
this.audioKey = "";
this.audioLoadingKey = "";
}
isAudioBusy(key) {
if (this.audioLoadingKey === key) return true;
return Boolean(this.audioElement && this.audioKey === key && !this.audioElement.ended);
}
}
function immersionExampleSourceLabel(card, example, searchQuery) {
return queryKey(searchQuery) !== queryKey(card.spelling) ? `${searchQuery} · ${example.sourceTitle}` : example.sourceTitle;
}
function immersionExampleProviderLabel(example) {
return example.provider === "nadeshiko" ? "Nadeshiko" : "Immersion Kit";
}
function accurateImmersionExamples(query, examples) {
return shouldFilterImmersionExamplesBySurface(query) ? examples.filter((example) => immersionSentenceContainsQuery(example.sentence, query)) : examples;
}
function immersionSearchResultForQuery(query, exactQuery, triedQueries, examples) {
const accurateExamples = accurateImmersionExamples(query, examples);
if (!accurateExamples.length) return null;
return {
examples: accurateExamples,
query,
usedFallback: queryKey(query) !== queryKey(exactQuery),
triedQueries
};
}
function shouldFilterImmersionExamplesBySurface(query) {
return queryHasKanji(query) || shouldRequireOriginalSurfaceMatch(query);
}
function isPageMiningSentence(sentence, card) {
return Boolean(sentence && sentence !== card.spelling);
}
function shouldPromoteExampleMiningContext(activeContext, card, promoteMiningContext) {
return promoteMiningContext || !activeContext || activeContext.term !== card.spelling;
}
function pageMiningSourceKind(anchor) {
return inferMiningSourceKind({
isImageSource: Boolean(anchor?.closest(".jpdb-ocr-line")),
hasVideo: Boolean(anchor?.closest(".jpdb-subtitle-player")) || Boolean(document.querySelector("video")),
hostname: location.hostname
});
}
function isConnectedImmersionSurface(popover, container) {
return popover.isConnected && container.isConnected;
}
function addImmersionFallbackQuery(candidates, value, exactQuery) {
const query = normalizeImmersionSearchQuery(value);
if (isUsefulImmersionFallbackQuery(query, exactQuery)) candidates.push(query);
}
function addImmersionFallbackQueries(candidates, values, exactQuery) {
for (const value of values) addImmersionFallbackQuery(candidates, value, exactQuery);
}
function fallbackTokenQueries(card, tokens) {
return sortedFallbackTokenCandidates(card, tokens).flatMap((item) => [
item.token.card.spelling,
item.surface,
item.token.card.reading !== item.token.card.spelling ? item.token.card.reading : ""
].filter(Boolean));
}
function sortedFallbackTokenCandidates(card, tokens) {
return tokens.map((token) => ({
token,
surface: card.spelling.slice(token.start, token.end),
length: queryLength(token.card.spelling)
})).sort(compareFallbackTokenCandidates);
}
function compareFallbackTokenCandidates(a, b) {
return Number(queryHasKanji(b.token.card.spelling)) - Number(queryHasKanji(a.token.card.spelling)) || b.length - a.length;
}
function validImmersionExampleIndex(index, length) {
return Number.isFinite(index) && index >= 0 && index < length ? index : 0;
}
function renderExampleImageHtml(container, imageUrl, overlay = "") {
if (!imageUrl) return "";
const heldImage = heldExampleImage(container);
return ``;
}
function renderExampleSentenceHtml(sentenceHtml) {
return `${sentenceHtml}
`;
}
function renderExampleActionsHtml(hasAudio, language) {
return `
‹
${hasAudio ? `${speakerIcon()} ` : ""}
›
`;
}
function heldExampleMediaStyle(image) {
return image.minHeight > 0 ? ` style="min-height:${image.minHeight}px"` : "";
}
function heldExampleImageAttributes(image) {
return `${heldExampleHoldAttribute(image)}${heldExampleSourceAttribute(image)}`;
}
function heldExampleHoldAttribute(image) {
return image.holdUntilReady ? ' data-immersion-hold-until-ready="true"' : "";
}
function heldExampleSourceAttribute(image) {
return image.src ? ` src="${escapeHtml$1(image.src)}"` : "";
}
function heldExampleImage(container) {
const currentImage = container.querySelector("[data-immersion-image]");
const src = heldExampleImageSource(currentImage);
const holdUntilReady = Boolean(src && currentImage?.isConnected);
return {
src: holdUntilReady ? src : "",
minHeight: holdUntilReady ? heldExampleImageHeight(currentImage) : 0,
holdUntilReady
};
}
function heldExampleImageSource(image) {
return image?.currentSrc || image?.src || "";
}
function heldExampleImageHeight(image) {
const media = image?.closest(".jpdb-reader-example-media") ?? null;
return Math.ceil(media?.getBoundingClientRect().height || image?.getBoundingClientRect().height || 0);
}
function hoverAudioExampleKey(example) {
if (!example) return "";
return example.id || `${example.provider ?? "immersion-kit"}:${example.sourceTitle}:${example.sentence}:${example.soundFile || example.soundUrl}`;
}
function renderExampleTranslation(translation, settings) {
if (!settings.immersionKitShowTranslation || !translation) return "";
const escaped = escapeHtml$1(translation);
if (!settings.immersionKitRevealTranslationOnClick) {
return `${escaped}
`;
}
return `${escaped}
`;
}
function setTranslationBlurAttributes(element2, blurred, key) {
if (blurred) {
element2.dataset[key] = "true";
element2.setAttribute("role", "button");
element2.setAttribute("tabindex", "0");
element2.setAttribute("aria-label", "Reveal translation");
return;
}
delete element2.dataset[key];
element2.removeAttribute("tabindex");
element2.removeAttribute("role");
element2.removeAttribute("aria-label");
}
class FloatingButtonController {
button;
abortController;
install(settings, saveSettings2, openSettings) {
this.destroy();
document.querySelectorAll("[data-jpdb-reader-root].jpdb-reader-fab").forEach((element2) => element2.remove());
if (!settings.showFloatingButton) return;
const button2 = document.createElement("button");
button2.className = "jpdb-reader-fab";
button2.type = "button";
button2.textContent = APP_PUCK;
button2.title = APP_NAME;
button2.dataset.jpdbReaderRoot = "true";
restoreButtonPosition(button2, settings);
this.installDragHandlers(button2, settings, saveSettings2);
button2.addEventListener("click", (event) => {
if (button2.dataset.jpdbReaderMoved === "true") {
event.preventDefault();
event.stopPropagation();
button2.dataset.jpdbReaderMoved = "false";
return;
}
openSettings();
});
document.body.appendChild(button2);
this.button = button2;
clampRestoredButtonPosition(button2, settings);
this.installVideoAvoidance(button2, settings, saveSettings2);
}
destroy() {
this.abortController?.abort();
this.abortController = void 0;
this.button?.remove();
this.button = void 0;
}
installVideoAvoidance(button2, settings, saveSettings2) {
this.abortController?.abort();
const controller = new AbortController();
this.abortController = controller;
const schedule = () => requestAnimationFrame(() => avoidVideoOverlap(button2, settings, saveSettings2));
window.addEventListener("resize", schedule, { passive: true, signal: controller.signal });
window.addEventListener("scroll", schedule, { passive: true, signal: controller.signal });
document.addEventListener("fullscreenchange", schedule, { signal: controller.signal });
schedule();
}
installDragHandlers(button2, settings, saveSettings2) {
let dragging = false;
let moved = false;
let startX = 0;
let startY = 0;
let originX = 0;
let originY = 0;
button2.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
dragging = true;
moved = false;
button2.dataset.jpdbReaderMoved = "false";
startX = event.clientX;
startY = event.clientY;
const rect = button2.getBoundingClientRect();
originX = rect.left;
originY = rect.top;
button2.setPointerCapture?.(event.pointerId);
});
button2.addEventListener("pointermove", (event) => {
if (!dragging) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (Math.hypot(dx, dy) > 4) moved = true;
if (!moved) return;
event.preventDefault();
button2.dataset.jpdbReaderMoved = "true";
const position = clampPuck(button2, originX + dx, originY + dy);
if (!position) return;
button2.style.left = `${position.x}px`;
button2.style.top = `${position.y}px`;
button2.style.right = "auto";
button2.style.bottom = "auto";
}, { passive: false });
const finishDrag = (event) => {
if (!dragging) return;
dragging = false;
button2.releasePointerCapture?.(event.pointerId);
if (!moved) return;
const rect = button2.getBoundingClientRect();
const position = clampPuck(button2, rect.left, rect.top);
if (!position) return;
settings.puckPositionX = Math.round(position.x);
settings.puckPositionY = Math.round(position.y);
saveSettings2();
};
button2.addEventListener("pointerup", finishDrag);
button2.addEventListener("pointercancel", finishDrag);
}
}
function avoidVideoOverlap(button2, settings, saveSettings2) {
if (!canAvoidVideoOverlap(button2)) return;
const rect = button2.getBoundingClientRect();
const video = overlappingVideo(rect);
button2.classList.toggle("jpdb-reader-fab-over-video", Boolean(video));
if (!shouldMoveAwayFromVideo(button2, video)) return;
for (const position of nonOverlappingPuckPositions(button2, rect, video.getBoundingClientRect())) {
movePuck(button2, position, settings, saveSettings2);
button2.classList.remove("jpdb-reader-fab-over-video");
return;
}
}
function canAvoidVideoOverlap(button2) {
return button2.isConnected && !document.fullscreenElement;
}
function shouldMoveAwayFromVideo(button2, video) {
return Boolean(video && !button2.matches(":hover, :focus, :focus-visible"));
}
function overlappingVideo(rect) {
return visibleVideos().find((candidate) => intersects(rect, candidate.getBoundingClientRect()));
}
function nonOverlappingPuckPositions(button2, rect, videoRect) {
const candidates = [
{ x: videoRect.right + 10, y: rect.top },
{ x: videoRect.left - rect.width - 10, y: rect.top },
{ x: rect.left, y: videoRect.bottom + 10 },
{ x: rect.left, y: videoRect.top - rect.height - 10 }
];
return candidates.map((candidate) => clampPuck(button2, candidate.x, candidate.y)).filter((position) => Boolean(position)).filter((position) => !intersects(new DOMRect(position.x, position.y, rect.width, rect.height), videoRect));
}
function movePuck(button2, position, settings, saveSettings2) {
button2.style.left = `${position.x}px`;
button2.style.top = `${position.y}px`;
button2.style.right = "auto";
button2.style.bottom = "auto";
settings.puckPositionX = Math.round(position.x);
settings.puckPositionY = Math.round(position.y);
saveSettings2();
}
function restoreButtonPosition(button2, settings) {
if (settings.puckPositionX === void 0 || settings.puckPositionY === void 0) return;
button2.style.left = `${settings.puckPositionX}px`;
button2.style.top = `${settings.puckPositionY}px`;
button2.style.right = "auto";
button2.style.bottom = "auto";
}
function clampRestoredButtonPosition(button2, settings) {
if (settings.puckPositionX === void 0 || settings.puckPositionY === void 0) return;
requestAnimationFrame(() => {
if (!button2.isConnected) return;
const rect = button2.getBoundingClientRect();
const position = clampPuck(button2, rect.left, rect.top);
if (!position) return;
if (Math.round(rect.left) === Math.round(position.x) && Math.round(rect.top) === Math.round(position.y)) return;
button2.style.left = `${position.x}px`;
button2.style.top = `${position.y}px`;
button2.style.right = "auto";
button2.style.bottom = "auto";
});
}
function clampPuck(button2, x, y) {
const rect = button2.getBoundingClientRect();
const margin = 8;
if (!canClampPuck(rect, x, y, margin)) return null;
return {
x: Math.max(margin, Math.min(window.innerWidth - rect.width - margin, x)),
y: Math.max(margin, Math.min(window.innerHeight - rect.height - margin, y))
};
}
function canClampPuck(rect, x, y, margin) {
if (!finitePuckPosition(x, y)) return false;
if (!finiteViewport()) return false;
if (!hasViewportRoom(margin)) return false;
return hasVisiblePuckRect(rect);
}
function finitePuckPosition(x, y) {
return Number.isFinite(x) && Number.isFinite(y);
}
function finiteViewport() {
return Number.isFinite(window.innerWidth) && Number.isFinite(window.innerHeight);
}
function hasViewportRoom(margin) {
return window.innerWidth > margin * 2 && window.innerHeight > margin * 2;
}
function hasVisiblePuckRect(rect) {
return rect.width > 0 && rect.height > 0;
}
function visibleVideos() {
return Array.from(document.querySelectorAll("video")).filter((video) => video instanceof HTMLVideoElement).filter((video) => {
const rect = video.getBoundingClientRect();
return rect.width > 120 && rect.height > 90;
});
}
function intersects(a, b) {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
const API_BASE = "https://jpdb.io/api/v1";
const RATE_LIMIT_BACKOFF_MS = 3e4;
const REQUEST_TIMEOUT_MS$1 = 3e4;
const log$i = Logger.scope("JpdbApi");
class JpdbApiClient {
constructor(getApiKey, getProxyUrl = () => "") {
this.getApiKey = getApiKey;
this.getProxyUrl = getProxyUrl;
}
retryAfter = 0;
request(endpoint, body) {
return this.requestByUrl(`${API_BASE}/${endpoint}`, body);
}
async requestByUrl(url, body, options = {}) {
const token = this.getApiKey();
const endpoint = endpointLabel(url);
this.assertCanRequest(token, endpoint);
const done = log$i.time("request", { endpoint, hasBody: Boolean(body) });
const response = await postJson(url, token, body, this.getProxyUrl());
done();
this.assertSuccessfulResponse(response, endpoint);
return parseJpdbApiResponse(response, endpoint, options.response);
}
assertCanRequest(token, endpoint) {
if (!token) {
log$i.warn("Request blocked; JPDB API key is missing", { endpoint });
throw new Error("JPDB API key is not set.");
}
if (Date.now() < this.retryAfter) {
log$i.warn("Request blocked by JPDB rate-limit backoff", { endpoint, retryAfterMs: this.retryAfter - Date.now() });
throw new Error("JPDB is rate limited. Try again in a moment.");
}
}
assertSuccessfulResponse(response, endpoint) {
if (response.status === 429) {
this.retryAfter = Date.now() + RATE_LIMIT_BACKOFF_MS;
log$i.warn("JPDB rate limit reached", { endpoint, backoffMs: RATE_LIMIT_BACKOFF_MS });
throw new Error("JPDB rate limit reached.");
}
if (response.status === 403) {
log$i.warn("JPDB rejected API key", { endpoint });
throw new Error("JPDB rejected the API key.");
}
if (!response.ok) {
log$i.warn("JPDB request failed", { endpoint, status: response.status });
throw new Error(`JPDB request failed (${response.status}).`);
}
}
}
function parseJpdbApiResponse(response, endpoint, responseMode) {
if (responseMode === "none" || !response.text) return void 0;
const json = JSON.parse(response.text);
const errorMessage2 = jpdbApplicationErrorMessage(json);
if (errorMessage2) {
log$i.warn("JPDB returned application error", { endpoint, message: errorMessage2 });
throw new Error(errorMessage2);
}
return json;
}
function jpdbApplicationErrorMessage(value) {
if (!isJsonRecord(value)) return void 0;
const message = value.error_message;
return typeof message === "string" && message ? message : void 0;
}
function isJsonRecord(value) {
return Boolean(value && typeof value === "object");
}
function postJson(url, token, body, proxyUrl = "") {
const data = body ? JSON.stringify(body) : void 0;
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
Accept: "application/json"
};
const userscriptRequest = getUserscriptHttpRequest();
if (userscriptRequest) return postJsonWithUserscriptRequest(userscriptRequest, url, headers, data);
return postJsonWithFetch(url, headers, data, proxyUrl);
}
async function postJsonWithFetch(url, headers, data, proxyUrl) {
let lastError;
for (const candidate of jpdbApiFetchCandidates(url, proxyUrl)) {
try {
const response = await fetchWithTimeout(candidate, {
method: "POST",
headers,
body: data
}, REQUEST_TIMEOUT_MS$1);
if (!response.ok && candidate !== url) {
lastError = new Error(`JPDB proxy request failed (${response.status}).`);
continue;
}
return {
status: response.status,
ok: response.ok,
text: await response.text()
};
} catch (error) {
lastError = error;
}
}
throw lastError instanceof Error ? lastError : new Error("JPDB request failed.");
}
async function fetchWithTimeout(url, init, timeoutMs) {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} catch (error) {
if (isAbortError(error)) throw new Error("JPDB request timed out.");
throw error;
} finally {
window.clearTimeout(timeoutId);
}
}
function isAbortError(error) {
return error instanceof DOMException && error.name === "AbortError";
}
function jpdbApiFetchCandidates(url, proxyUrl) {
const configuredProxy = configuredProxyFetchUrl(url, proxyUrl);
const shouldPreferProxy = Boolean(configuredProxy) && shouldPreferConfiguredProxyForJpdbApi(url);
const candidates = shouldPreferProxy ? [configuredProxy, url] : [url, configuredProxy];
return [...new Set(candidates.filter((candidate) => Boolean(candidate)))];
}
function configuredProxyFetchUrl(targetUrl, configuredProxyUrl) {
const proxy = configuredProxyUrl.trim();
if (!proxy) return null;
try {
const url = new URL(proxy);
url.searchParams.set("url", targetUrl);
return url.href;
} catch {
return null;
}
}
function shouldPreferConfiguredProxyForJpdbApi(url) {
if (!isJpdbApiUrl(url)) return false;
return isHostedGithubPagesApp() || isAppleTouchBrowser();
}
function isJpdbApiUrl(url) {
try {
const target = new URL(url);
return target.hostname === "jpdb.io" && target.pathname.startsWith("/api/v1/");
} catch {
return false;
}
}
function isHostedGithubPagesApp() {
if (typeof location === "undefined") return false;
try {
const current = new URL(location.href);
return current.origin === "https://hrussellzfac023.github.io" && current.pathname.replace(/\/index\.html$/, "/").startsWith("/yomu-reader/");
} catch {
return false;
}
}
function isAppleTouchBrowser() {
if (typeof navigator === "undefined") return false;
const userAgent = navigator.userAgent ?? "";
const platform = navigator.platform ?? "";
return /iPad|iPhone|iPod/i.test(userAgent) || /Macintosh/i.test(userAgent) && /Mac/i.test(platform) && (navigator.maxTouchPoints ?? 0) > 1;
}
function postJsonWithUserscriptRequest(request, url, headers, data) {
return new Promise((resolve, reject) => {
const handleLoad = (response) => resolve({
status: response.status,
ok: response.status >= 200 && response.status < 300,
text: String(response.responseText ?? response.response ?? "")
});
const result = request({
method: "POST",
url,
headers,
data,
responseType: "text",
timeout: REQUEST_TIMEOUT_MS$1,
onload: handleLoad,
onerror: reject,
ontimeout: () => reject(new Error("JPDB request timed out."))
});
if (result && typeof result.then === "function") {
result.then(handleLoad, reject);
}
});
}
function endpointLabel(url) {
try {
const parsed = new URL(url);
return parsed.hostname === "jpdb.io" ? parsed.pathname.replace(/^\/api\/v1\//, "") : parsed.hostname + parsed.pathname;
} catch {
return url;
}
}
const COMBINING_KANA = new Set("ゃゅょぁぃぅぇぉャュョァィゥェォ");
function jpdbVocabularyToCards(vocabulary2) {
const cards = vocabulary2.map(([
vid,
sid,
rid,
spelling,
reading,
frequencyRank2,
partOfSpeech,
meaningsChunks,
meaningsPartOfSpeech,
cardState,
pitchAccent
]) => ({
vid,
sid,
rid,
spelling,
reading,
frequencyRank: frequencyRank2,
partOfSpeech,
meanings: meaningsChunks.map((glosses, index) => ({
glosses,
partOfSpeech: meaningsPartOfSpeech[index] ?? []
})),
cardState: normalizeCardStates(cardState),
pitchAccent: pitchAccent ?? [],
wordWithReading: null,
source: "jpdb"
}));
return cards;
}
function jpdbParseResultToTokens(paragraphs, rawTokens, cards) {
const tokens = rawTokens.map((innerTokens) => parseParagraphTokens(innerTokens, cards));
assignSentenceInfo(paragraphs, tokens);
return tokens;
}
function parseParagraphTokens(rawTokens, cards) {
let inheritedPitchClass = "";
return rawTokens.map((rawToken) => {
const token = parseToken(rawToken, cards, inheritedPitchClass);
inheritedPitchClass = token.pitchClass;
return token;
});
}
function parseToken([vocabularyIndex, position, length, furigana], cards, inheritedPitchClass) {
const card = cards[vocabularyIndex];
const token = {
card,
start: position,
end: position + length,
length,
rubies: parseRubies(furigana, position),
pitchClass: inheritedOrCurrentPitchClass(card, inheritedPitchClass)
};
assignWordWithReading(token);
return token;
}
function parseRubies(furigana, startOffset) {
if (furigana === null) return [];
let offset = startOffset;
return furigana.flatMap((part) => {
if (typeof part === "string") {
offset += part.length;
return [];
}
const [base, ruby] = part;
const start = offset;
const end = offset = start + base.length;
return [{ text: ruby, start, end, length: base.length }];
});
}
function inheritedOrCurrentPitchClass(card, inheritedPitchClass) {
if (card.partOfSpeech.includes("prt")) return inheritedPitchClass;
return getPitchClass(card.pitchAccent, card.reading) || inheritedPitchClass;
}
function assignSentenceInfo(paragraphs, tokens) {
paragraphs.forEach((paragraph, index) => {
const tokenData = tokens[index] ?? [];
const sentences = splitJapaneseSentences(paragraph);
if (sentences.length === 1) {
tokenData.forEach((token) => {
token.sentence = sentences[0];
});
return;
}
let offset = 0;
for (const sentence of sentences) {
const compare = sentence.replace(/(^[「『])|([。!?」』]$)/g, "");
const relativeStart = paragraph.slice(offset).indexOf(compare);
if (relativeStart === -1) {
offset += sentence.length;
continue;
}
const start = offset + relativeStart;
const end = start + sentence.length;
for (const token of tokenData) {
if (token.start >= start && token.end <= end) token.sentence = sentence;
}
offset += sentence.length;
}
});
}
function splitJapaneseSentences(text2) {
const sentences = [];
const state = { start: 0, quote: null };
for (let index = 0; index < text2.length; index++) {
index = advanceSentenceSplitter(sentences, text2, state, index);
}
const tail = text2.slice(state.start).trim();
if (tail) sentences.push(tail);
const nonEmptySentences = sentences.filter(Boolean);
const result = nonEmptySentences.length ? nonEmptySentences : [text2];
return result;
}
function advanceSentenceSplitter(sentences, text2, state, index) {
state.quote = closingQuoteFor(text2[index]) ?? state.quote;
if (state.quote) return advanceQuotedSentenceSplitter(sentences, text2, state, index);
return advancePunctuationSentenceSplitter(sentences, text2, state, index);
}
function advanceQuotedSentenceSplitter(sentences, text2, state, index) {
if (!state.quote) return index;
const boundary = quotedSentenceBoundary(text2, index, state.quote);
if (boundary) Object.assign(state, pushSentenceBoundary(sentences, text2, state.start, boundary.end));
return index;
}
function advancePunctuationSentenceSplitter(sentences, text2, state, index) {
const boundary = punctuationSentenceBoundary(text2, index);
if (!boundary) return index;
Object.assign(state, pushSentenceBoundary(sentences, text2, state.start, boundary.end));
return boundary.nextIndex;
}
function pushSentenceBoundary(sentences, text2, start, end) {
sentences.push(text2.slice(start, end).trim());
return { start: end, quote: null };
}
function closingQuoteFor(char) {
if (char === "「") return "」";
if (char === "『") return "』";
return null;
}
function quotedSentenceBoundary(text2, index, quote) {
if (text2[index] !== quote) return null;
const next = text2[index + 1];
return !next || /\s/.test(next) || !/[、,]/.test(next) ? { end: index + 1 } : null;
}
function punctuationSentenceBoundary(text2, index) {
if (!"。!?".includes(text2[index])) return null;
const next = text2[index + 1];
const includesClosingQuote = next === "」" || next === "』";
return {
end: includesClosingQuote ? index + 2 : index + 1,
nextIndex: includesClosingQuote ? index + 1 : index
};
}
function getPitchClass(pitchAccent, reading) {
const levels = pitchLevels(pitchAccent);
if (levels.length < 2) return "";
return classifyPitchProfile({
rises: countPitchTransitions(levels, "L", "H"),
drops: countPitchTransitions(levels, "H", "L"),
dropAt: levels.findIndex((level, index) => index > 0 && levels[index - 1] === "H" && level === "L"),
startsLow: levels[0] === "L",
startsHigh: levels[0] === "H",
endsLow: levels[levels.length - 1] === "L",
moraCount: countMorae(reading)
});
}
const PITCH_PROFILE_CLASSIFIERS = [
["atamadaka", isAtamadaka],
["odaka", isOdaka],
["heiban", isHeiban],
["nakadaka", isNakadaka],
["kifuku", isKifuku]
];
function pitchLevels(pitchAccent) {
return pitchAccent.length ? Array.from(pitchAccent[0]).filter((level) => level === "H" || level === "L") : [];
}
function classifyPitchProfile(profile) {
return PITCH_PROFILE_CLASSIFIERS.find(([, matches]) => matches(profile))?.[0] ?? "";
}
function isAtamadaka(profile) {
return profile.startsHigh && profile.drops === 1;
}
function isOdaka(profile) {
return Boolean(profile.moraCount && profile.startsLow && profile.dropAt === profile.moraCount);
}
function isHeiban(profile) {
return profile.startsLow && profile.rises === 1 && !profile.endsLow;
}
function isNakadaka(profile) {
return profile.startsLow && profile.rises === 1 && profile.endsLow;
}
function isKifuku(profile) {
return profile.rises > 1 || profile.drops > 1;
}
function countPitchTransitions(levels, from, to) {
let count = 0;
for (let index = 1; index < levels.length; index++) {
if (levels[index - 1] === from && levels[index] === to) count++;
}
return count;
}
function countMorae(reading) {
let count = 0;
for (const char of Array.from(reading)) {
if (count > 0 && COMBINING_KANA.has(char)) continue;
count++;
}
return count;
}
function assignWordWithReading(token) {
const { card, rubies, start: offset } = token;
if (!rubies.length) return;
const word = Array.from(card.spelling);
for (let i = rubies.length - 1; i >= 0; i--) {
const { text: text2, start, length } = rubies[i];
word.splice(start - offset + length, 0, `[${text2}]`);
}
card.wordWithReading = word.join("");
}
class LruCache {
constructor(maxSize) {
this.maxSize = maxSize;
}
map = /* @__PURE__ */ new Map();
get(key) {
const value = this.map.get(key);
if (value !== void 0) {
this.map.delete(key);
this.map.set(key, value);
}
return value;
}
set(key, value) {
this.map.delete(key);
this.map.set(key, value);
if (this.map.size > this.maxSize) {
const oldest = this.map.keys().next().value;
if (oldest !== void 0) {
this.map.delete(oldest);
}
}
}
clear() {
this.map.clear();
}
}
const TOKEN_FIELDS = ["vocabulary_index", "position", "length", "furigana"];
const VOCABULARY_FIELDS = [
"vid",
"sid",
"rid",
"spelling",
"reading",
"frequency_rank",
"part_of_speech",
"meanings_chunks",
"meanings_part_of_speech",
"card_state",
"pitch_accent"
];
const DECK_FIELDS = ["id", "name"];
const PARSE_CACHE_SIZE = 250;
const log$h = Logger.scope("JpdbClient");
class JpdbClient {
api;
cardCache = /* @__PURE__ */ new Map();
parseCache = new LruCache(PARSE_CACHE_SIZE);
parseInFlight = /* @__PURE__ */ new Map();
constructor(getApiKey, getProxyUrl = () => "") {
this.api = new JpdbApiClient(getApiKey, getProxyUrl);
}
async parse(paragraphs) {
const text2 = normalizeParagraphs(paragraphs);
if (!text2.length) return [];
const cacheKey = text2.join("\n");
const cached = this.parseCache.get(cacheKey);
if (cached) {
return cached;
}
const inFlight = this.parseInFlight.get(cacheKey);
if (inFlight) {
return inFlight;
}
const promise = this.fetchParse(text2, cacheKey);
this.parseInFlight.set(cacheKey, promise);
void promise.then(() => {
if (this.parseInFlight.get(cacheKey) === promise) this.parseInFlight.delete(cacheKey);
}, () => {
if (this.parseInFlight.get(cacheKey) === promise) this.parseInFlight.delete(cacheKey);
});
return promise;
}
async reviewCard(card, grade) {
log$h.info("Reviewing card", { term: card.spelling, grade });
await this.api.request("review", { vid: card.vid, sid: card.sid, grade });
await this.refreshCard(card);
}
async addToDeck(deckId, card, sentence) {
log$h.info("Adding card to deck", { term: card.spelling, deckId, hasSentence: Boolean(sentence) });
await this.addVocabularyToDeck(deckId, card);
if (sentence) await this.setCardSentence(card, sentence);
await this.refreshCard(card);
}
async listDecks() {
const response = await this.api.request("list-user-decks", { fields: DECK_FIELDS });
const decks = Array.isArray(response.decks) ? response.decks.map(normalizeDeck).filter((deck) => deck !== null) : [];
return decks;
}
async listDeckCards(deckId, limit = 80) {
const id = normalizeDeckRequestId(deckId);
const done = log$h.time("listDeckCards", { deckId, limit });
const response = await this.api.request("deck/list-vocabulary", {
id,
fetch_occurences: false
});
const pairs = normalizeVocabularyPairs(response.vocabulary).slice(0, Math.max(1, limit));
if (!pairs.length) {
done();
return [];
}
const lookup = await this.api.request("lookup-vocabulary", {
list: pairs,
fields: VOCABULARY_FIELDS
});
const cards = jpdbVocabularyToCards(lookup.vocabulary_info ?? []);
this.cacheCards(cards);
done();
return cards;
}
async removeFromDeck(deckId, card) {
log$h.info("Removing card from deck", { term: card.spelling, deckId });
await this.api.request("deck/remove-vocabulary", {
id: deckId,
vocabulary: [[card.vid, card.sid]]
});
await this.refreshCard(card);
}
getCard(vid, sid) {
return this.cardCache.get(cardKey(vid, sid));
}
clear() {
this.cardCache.clear();
this.parseCache.clear();
}
async addVocabularyToDeck(deckId, card) {
if (deckId === "forq") {
await this.api.requestByUrl("https://jpdb.io/prioritize", {
v: card.vid,
s: card.sid,
origin: "/"
}, { response: "none" });
return;
}
await this.api.request("deck/add-vocabulary", {
id: deckId,
vocabulary: [[card.vid, card.sid]]
});
}
async setCardSentence(card, sentence) {
await this.api.request("set-card-sentence", {
vid: card.vid,
sid: card.sid,
sentence
}).catch((error) => {
log$h.warn("Failed to set JPDB sentence", { term: card.spelling }, error);
});
}
async refreshCard(card) {
const lookup = await this.api.request("lookup-vocabulary", {
list: [[card.vid, card.sid]],
fields: VOCABULARY_FIELDS
});
const fresh = jpdbVocabularyToCards(lookup.vocabulary_info ?? [])[0];
if (!fresh) {
log$h.warn("Card refresh did not return updated card", { term: card.spelling, vid: card.vid, sid: card.sid });
return;
}
this.cardCache.set(cardKey(card.vid, card.sid), fresh);
Object.assign(card, fresh);
}
cacheCards(cards) {
for (const card of cards) {
this.cardCache.set(cardKey(card.vid, card.sid), card);
}
}
async fetchParse(text2, cacheKey) {
const done = log$h.time("parse request", { paragraphs: text2.length, chars: cacheKey.length });
try {
const raw = await this.api.request("parse", {
text: text2,
position_length_encoding: "utf16",
token_fields: TOKEN_FIELDS,
vocabulary_fields: VOCABULARY_FIELDS
});
const cards = jpdbVocabularyToCards(raw.vocabulary);
const tokens = jpdbParseResultToTokens(text2, raw.tokens, cards);
this.cacheCards(cards);
this.parseCache.set(cacheKey, tokens);
return tokens;
} finally {
done();
}
}
}
function normalizeParagraphs(paragraphs) {
return paragraphs.map((paragraph) => paragraph.trim()).filter(Boolean);
}
function cardKey(vid, sid) {
return `${vid}/${sid}`;
}
function normalizeDeckRequestId(value) {
const trimmed = value.trim();
const number = Number(trimmed);
return trimmed && Number.isInteger(number) && String(number) === trimmed ? number : trimmed;
}
function normalizeVocabularyPairs(value) {
if (!Array.isArray(value)) return [];
return value.map((item) => {
if (!Array.isArray(item)) return null;
const vid = Number(item[0]);
const sid = Number(item[1]);
return Number.isInteger(vid) && Number.isInteger(sid) ? [vid, sid] : null;
}).filter((item) => item !== null);
}
function normalizeDeck(value) {
if (Array.isArray(value)) return normalizeDeckTuple(value);
if (value && typeof value === "object") return normalizeDeckRecord(value);
return null;
}
function normalizeDeckTuple([id, name]) {
return isDeckId(id) && typeof name === "string" ? { id: String(id), name } : null;
}
function normalizeDeckRecord(record) {
const id = record.id;
const name = record.name ?? record.title;
return isDeckId(id) && typeof name === "string" ? { id: String(id), name } : null;
}
function isDeckId(value) {
return typeof value === "number" || typeof value === "string";
}
const JPDB_SEARCH_URL$1 = "https://jpdb.io/search";
const REQUEST_TIMEOUT_MS = 6e3;
const SMALL_KANA = new Set("ゃゅょャュョァィゥェォ");
const log$g = Logger.scope("JpdbPublicPitch");
class JpdbPublicPitchClient {
constructor(getCorsProxyUrl = () => "") {
this.getCorsProxyUrl = getCorsProxyUrl;
}
cache = /* @__PURE__ */ new Map();
lookup(spelling, reading) {
const normalizedSpelling = cleanText$4(spelling);
const normalizedReading = cleanText$4(reading);
if (!normalizedSpelling && !normalizedReading) return Promise.resolve([]);
const key = `${normalizedSpelling}
${normalizedReading}`;
let promise = this.cache.get(key);
if (!promise) {
promise = this.fetchPitch(normalizedSpelling, normalizedReading);
this.cache.set(key, promise);
}
return promise;
}
async fetchPitch(spelling, reading) {
for (const query of unique$1([spelling, reading].filter(Boolean))) {
const url = `${JPDB_SEARCH_URL$1}?q=${encodeURIComponent(query)}`;
const html = await requestText$5(url, this.getCorsProxyUrl()).catch((error) => {
log$g.warn("Public JPDB pitch request failed", { query }, error);
return "";
});
const pitch = html ? parseJpdbPublicPitchHtml(html, spelling, reading) : [];
if (pitch.length) {
return pitch;
}
}
return [];
}
}
function parseJpdbPublicPitchHtml(html, spelling = "", reading = "") {
const doc = parseHtmlDocument(html);
const roots = Array.from(doc.querySelectorAll(".result.vocabulary"));
const matchingRoots = roots.filter((root) => vocabularyRootMatches$1(root, spelling, reading));
const candidates = pitchCandidateRoots(doc, roots, matchingRoots, spelling, reading);
const patterns = candidates.flatMap(readPitchPatterns).filter(Boolean);
return unique$1(patterns);
}
function pitchCandidateRoots(doc, roots, matchingRoots, spelling, reading) {
if (matchingRoots.length) return matchingRoots;
return canUseGenericPitchRoot(doc, roots, spelling, reading) ? [roots[0] ?? doc] : [];
}
function canUseGenericPitchRoot(doc, roots, spelling, reading) {
return !hasRequestedVocabularyIdentity(spelling, reading) && roots.length === 1 || documentMatchesVocabulary$1(doc, spelling, reading);
}
function readPitchPatterns(root) {
const patterns = [];
root.querySelectorAll(".subsection-pitch-accent").forEach((section) => {
const stack = section.querySelector(".subsection > div") ?? section;
Array.from(stack.children).forEach((row) => {
const pattern = Array.from(row.querySelectorAll('div[style*="--pitch-low"], div[style*="--pitch-high"]')).map((segment) => pitchSegmentPattern(segment)).join("");
if (pattern.length >= 2) patterns.push(pattern);
});
});
return patterns;
}
function pitchSegmentPattern(segment) {
const level = pitchSegmentLevel(segment);
if (!level) return "";
return level.repeat(splitMorae(cleanText$4(segment.textContent ?? "")).length);
}
function pitchSegmentLevel(segment) {
const style = segment.getAttribute("style") ?? "";
if (style.includes("--pitch-high")) return "H";
return style.includes("--pitch-low") ? "L" : "";
}
function vocabularyRootMatches$1(root, spelling, reading) {
return vocabularyIdentities$1(root).some((identity) => vocabularyIdentityMatches$1(identity, spelling, reading));
}
function documentMatchesVocabulary$1(doc, spelling, reading) {
const canonical = doc.querySelector('link[rel="canonical"][href*="/vocabulary/"]')?.href ?? "";
const identity = vocabularyIdentityFromUrl$1(canonical);
return identity ? vocabularyIdentityMatches$1(identity, spelling, reading) : false;
}
function vocabularyIdentities$1(root) {
return Array.from(root.querySelectorAll('a[href^="/vocabulary/"], a[href*="jpdb.io/vocabulary/"]')).filter((link) => !link.closest(".subsection-used-in, .subsection-examples")).map((link) => vocabularyIdentityFromUrl$1(link.href || link.getAttribute("href") || "")).filter((identity) => identity !== null);
}
function vocabularyIdentityFromUrl$1(value) {
if (!value) return null;
try {
const parsed = new URL(value, "https://jpdb.io");
return vocabularyIdentityFromPath$1(parsed.pathname);
} catch {
return null;
}
}
function vocabularyIdentityFromPath$1(pathname) {
const parts = pathname.split("/").filter(Boolean);
if (parts[0] !== "vocabulary") return null;
return {
expression: decodePathPart$1(parts[2] ?? ""),
reading: decodePathPart$1(parts[3] ?? "")
};
}
function vocabularyIdentityMatches$1(identity, spelling, reading) {
const requestedSpelling = cleanText$4(spelling);
const requestedReading = cleanText$4(reading);
const expression = cleanText$4(identity.expression);
const canonicalReading = cleanText$4(identity.reading);
const requested = new Set([requestedSpelling, requestedReading].filter(Boolean));
if (!requested.size) return true;
if (!identityIntersectsRequest(requested, expression, canonicalReading)) return false;
if (!requestedReading) return true;
return identityMatchesRequestedReading(expression, canonicalReading, requestedSpelling, requestedReading);
}
function hasRequestedVocabularyIdentity(spelling, reading) {
return Boolean(cleanText$4(spelling) || cleanText$4(reading));
}
function identityIntersectsRequest(requested, expression, canonicalReading) {
return requested.has(expression) || requested.has(canonicalReading);
}
function identityMatchesRequestedReading(expression, canonicalReading, requestedSpelling, requestedReading) {
return canonicalReading === requestedReading || expression === requestedReading || expression === requestedSpelling;
}
function splitMorae(value) {
const morae = [];
for (const char of Array.from(value)) {
if (morae.length && SMALL_KANA.has(char)) morae[morae.length - 1] += char;
else morae.push(char);
}
return morae;
}
function decodePathPart$1(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function cleanText$4(value) {
return value.replace(/\s+/g, "").trim();
}
function unique$1(values) {
return [...new Set(values)];
}
function requestText$5(url, proxyUrl = "") {
return requestText$7(url, {
proxyUrl,
timeoutMs: REQUEST_TIMEOUT_MS,
failureLabel: "Public JPDB pitch request",
timeoutLabel: "Public JPDB pitch request timed out."
});
}
const KANJI_RE$1 = /[\p{Script=Han}\u2e80-\u2eff\u2f00-\u2fdf\u31c0-\u31ef\u3005\u3006\u3007々〆ヶ]/u;
function cleanText$3(value) {
return value.replace(/\s+/g, " ").trim();
}
function decodeEntities(value) {
const textarea = document.createElement("textarea");
textarea.innerHTML = value;
return textarea.value;
}
function canonicalUchisenUrl(value) {
let url = value.trim();
if (!/^https?:\/\//i.test(url)) {
if (url.startsWith("/")) url = `https://ik.imagekit.io/uchisen${url}`;
else if (url.startsWith("generated_")) url = `https://ik.imagekit.io/uchisen/generated/saved/${url}`;
else url = `https://ik.imagekit.io/uchisen/${url}`;
}
try {
const parsed = new URL(url);
parsed.pathname = parsed.pathname.replace(/\/{2,}/g, "/");
parsed.search = "";
parsed.hash = "";
return `${parsed.origin}${parsed.pathname}`;
} catch {
return url.replace(/\/{2,}/g, "/").split(/[?#]/)[0];
}
}
function firstReviewGlyph(text2) {
const direct = text2.match(KANJI_RE$1);
if (direct) return direct[0];
try {
return decodeURIComponent(text2).match(KANJI_RE$1)?.[0] ?? null;
} catch {
return null;
}
}
function parseJpdbReviewCardValue(value, response = null) {
const parts = (value ?? "").split(",");
const kind = (parts[0] ?? "").trim();
const kanji = firstReviewGlyph(parts.slice(1).join(",")) ?? "";
const isKanji2 = kind.startsWith("k") && Boolean(kanji);
const phase = reviewCardPhase(isKanji2, response);
return { kind, kanji, isKanji: isKanji2, phase };
}
function reviewCardPhase(isKanji2, response) {
if (!isKanji2) return "none";
return response === "1" ? "after" : "before";
}
const JPDB_REVIEW_BRIDGE_CHANNEL = "yomu-jpdb-review-bridge";
function initJpdbReviewPageBridge() {
if (typeof BroadcastChannel !== "function") return;
if (location.hostname !== "jpdb.io" || !location.pathname.startsWith("/review")) return;
const channel = new BroadcastChannel(JPDB_REVIEW_BRIDGE_CHANNEL);
const publish = () => {
channel.postMessage({
type: "status",
source: "jpdb",
status: parseJpdbReviewDocument(document, location.href)
});
};
const schedulePublish = debounce(publish, 160);
channel.onmessage = (event) => {
const message = event.data;
if (!message || message.source !== "newtab") return;
if (message.type === "request-current") {
publish();
return;
}
if (message.type !== "command") return;
if (message.command === "reveal") clickRevealControl();
if (message.command === "grade" && message.grade) clickGradeControl(message.grade);
window.setTimeout(publish, 300);
window.setTimeout(publish, 900);
};
new MutationObserver(schedulePublish).observe(document.body, { childList: true, subtree: true, attributes: true });
publish();
}
function parseJpdbReviewDocument(doc, href = "") {
if (reviewLoginRequired(doc)) {
return { connected: true, loginRequired: true, card: null, message: "Log in to JPDB, then open /review again." };
}
const parsed = parsedReviewDocument(doc, href);
if (!hasDetectedReviewCard(parsed.spelling, parsed.kanji, parsed.cardValue)) {
return { connected: true, loginRequired: false, card: null, message: "JPDB review is open but no review card was detected." };
}
return {
connected: true,
loginRequired: false,
message: "",
card: reviewBridgeCard(parsed, doc, href)
};
}
function parsedReviewDocument(doc, href) {
const url = safeUrl(href);
const { cardValue, response } = reviewRequestState(doc, url);
const cardState = parseJpdbReviewCardValue(cardValue, response);
const text2 = reviewDocumentText(doc);
const { kanji, isKanji: isKanji2 } = reviewKindInfo(doc, cardState, text2.kindLabel, text2.highlighted);
const phase = reviewPhase(doc, url, cardState.phase);
const fields = reviewCardTextFields(doc, isKanji2, text2.sentence, text2.highlighted, kanji, text2.keyword);
return {
cardValue,
phase,
kind: isKanji2 ? "kanji" : "vocabulary",
keyword: text2.keyword,
sentence: text2.sentence,
kanji,
...fields
};
}
function reviewDocumentText(doc) {
const sentenceElement = doc.querySelector(".card-sentence .sentence, .sentence, .plain");
return {
kindLabel: cleanText$2(doc.querySelector(".kind")?.textContent ?? ""),
sentence: cleanText$2(sentenceElement?.textContent ?? ""),
highlighted: cleanText$2(doc.querySelector(".highlight")?.textContent ?? ""),
keyword: reviewKeywordText(doc)
};
}
function reviewKeywordText(doc) {
return sectionText(doc, "Keyword") || cleanText$2(doc.querySelector(".keyword")?.textContent ?? "");
}
function reviewCardTextFields(doc, isKanji2, sentence, highlighted, kanji, keyword) {
const plain = cleanText$2(doc.querySelector(".plain")?.textContent ?? "");
const spelling = reviewCardSpelling(isKanji2, kanji, highlighted, sentence, plain);
const prompt = reviewCardPrompt(isKanji2, keyword, plain, kanji, sentence, spelling);
return {
prompt,
answer: isKanji2 ? kanji : spelling,
spelling,
reading: isKanji2 ? "" : readingFromDocument(doc)
};
}
function reviewCardSpelling(isKanji2, kanji, highlighted, sentence, plain) {
return isKanji2 ? kanji : highlighted || firstJapaneseRun(sentence) || plain;
}
function reviewCardPrompt(isKanji2, keyword, plain, kanji, sentence, spelling) {
return isKanji2 ? keyword || plain || kanji : sentence || spelling;
}
function reviewBridgeCard(parsed, doc, href) {
return {
id: parsed.cardValue || `${parsed.spelling}:${parsed.reading}`,
kind: parsed.kind,
phase: parsed.phase,
prompt: parsed.prompt,
answer: parsed.answer,
spelling: parsed.spelling || parsed.kanji,
reading: parsed.reading,
sentence: parsed.sentence,
kanji: parsed.kanji,
keyword: parsed.keyword,
itemsLeft: itemsLeft(doc),
href
};
}
function reviewRequestState(doc, url) {
return {
cardValue: url?.searchParams.get("c") ?? doc.querySelector('input[name="c"]')?.value ?? "",
response: url?.searchParams.get("r") ?? doc.querySelector('input[name="r"]')?.value ?? null
};
}
function reviewLoginRequired(doc) {
return Boolean(doc.querySelector('form[action*="/login"], input[name="password"], a[href^="/login"]'));
}
function reviewKindInfo(doc, cardState, kindLabel, highlighted) {
const kanji = cardState.kanji || firstKanji(doc.querySelector(".kanji, a.kanji.plain")?.textContent ?? "");
return {
kanji,
isKanji: cardState.isKanji || /kanji/i.test(kindLabel) || pageHasKanjiCard(doc, kanji, highlighted)
};
}
function pageHasKanjiCard(doc, kanji, highlighted) {
return Boolean(kanji && !highlighted && doc.querySelector(".kanji"));
}
function reviewPhase(doc, url, phase) {
return phase === "after" || url?.searchParams.has("r") || Boolean(doc.querySelector(".review-hidden, .answer-box")) ? "back" : "front";
}
function hasDetectedReviewCard(spelling, kanji, cardValue) {
return Boolean(spelling || kanji || cardValue);
}
function clickRevealControl() {
const direct = findControl(["reveal", "show answer", "answer"]);
if (direct) {
direct.click();
return;
}
const form = Array.from(document.querySelectorAll("form")).find((item) => item.innerHTML.includes('name="r"') || item.action.includes("/review"));
const submit = form?.querySelector('button, input[type="submit"]');
if (submit) submit.click();
else form?.requestSubmit?.();
}
function clickGradeControl(grade) {
const terms = gradeTerms(grade);
const control = findControl(terms);
if (control) {
control.click();
return;
}
const form = Array.from(document.querySelectorAll("form")).find((item) => terms.some((term) => formText(item).includes(term)));
const submit = form?.querySelector('button, input[type="submit"]');
if (submit) submit.click();
else form?.requestSubmit?.();
}
function findControl(terms) {
const controls = Array.from(document.querySelectorAll('button, input[type="submit"], a[href]'));
return controls.find((control) => {
if (control.closest("[data-jpdb-reader-root]")) return false;
const text2 = formText(control);
return terms.some((term) => text2.includes(term));
}) ?? null;
}
function gradeTerms(grade) {
return JPDB_GRADE_CONTROL_TERMS[grade] ?? [grade];
}
const JPDB_GRADE_CONTROL_TERMS = {
nothing: ["nothing", "again", "forgot"],
something: ["something"],
hard: ["hard"],
okay: ["okay", "ok", "good"],
easy: ["easy"],
fail: ["fail", "nothing", "again"],
pass: ["pass", "okay", "good", "easy"]
};
function formText(element2) {
const input2 = element2;
return cleanText$2([
element2.textContent,
input2.value,
input2.name,
element2.getAttribute("aria-label"),
element2.getAttribute("title"),
element2.className,
element2.getAttribute("data-grade")
].filter(Boolean).join(" ")).toLocaleLowerCase();
}
function sectionText(doc, label) {
const heading = Array.from(doc.querySelectorAll(".subsection-label")).find((element2) => cleanText$2(element2.textContent ?? "").toLocaleLowerCase() === label.toLocaleLowerCase());
return cleanText$2(heading?.parentElement?.querySelector(".subsection")?.textContent ?? "");
}
function readingFromDocument(doc) {
return cleanText$2(doc.querySelector(".plain ruby rt, rt, .reading")?.textContent ?? "");
}
function itemsLeft(doc) {
const text2 = cleanText$2(doc.body.textContent ?? "");
const match = /items?\s+left\s*\((\d+)\)|items?\s+left\s+(\d+)/i.exec(text2);
if (!match) return null;
const value = Number(match[1] ?? match[2]);
return Number.isFinite(value) ? value : null;
}
function firstJapaneseRun(value) {
return cleanText$2(value.match(/[\u3040-\u30ff\u3400-\u9fff々〆ー]+/u)?.[0] ?? "");
}
function firstKanji(value) {
return Array.from(value).find((character) => /[\u3400-\u9fff々〆]/u.test(character)) ?? "";
}
function cleanText$2(value) {
return value.replace(/\s+/g, " ").trim();
}
function safeUrl(value) {
try {
return value ? new URL(value, location.href) : new URL(location.href);
} catch {
return null;
}
}
function debounce(callback, delay2) {
let timer = 0;
return () => {
window.clearTimeout(timer);
timer = window.setTimeout(callback, delay2);
};
}
const log$f = Logger.scope("JpdbVocabulary");
const JPDB_VOCABULARY_BASE_URL = "https://jpdb.io/vocabulary";
const JPDB_SEARCH_URL = "https://jpdb.io/search";
const JPDB_COMPOUND_LIMIT = 8;
const JPDB_USED_IN_VOCABULARY_LIMIT = 3;
const JPDB_EXAMPLE_LIMIT = 3;
const JAPANESE_RE$1 = /[\u3040-\u30ff\u3400-\u9fff]/u;
const JPDB_AUDIO_ID_RE = /^(?:\/static\/user\/)?[A-Za-z0-9_./-]+$/;
class JpdbVocabularyClient {
constructor(getCorsProxyUrl = () => "") {
this.getCorsProxyUrl = getCorsProxyUrl;
}
cache = /* @__PURE__ */ new Map();
lookup(vid, spelling, reading) {
if (!spelling) return Promise.resolve(null);
const key = `${vid}:${spelling}:${reading}`;
let promise = this.cache.get(key);
if (!promise) {
promise = this.fetchInfo(vid, spelling, reading);
this.cache.set(key, promise);
}
return promise;
}
async fetchInfo(vid, spelling, reading) {
for (const url of vocabularyLookupUrls(vid, spelling, reading)) {
const html = await requestText$4(url, this.getCorsProxyUrl()).catch((error) => {
log$f.warn("Vocabulary page request failed", { vid, spelling, url }, error);
return "";
});
const info = html ? parseJpdbVocabularyHtml(html, spelling, reading) : null;
if (info) return await this.fetchSupplementaryInfo(info, html, url, vid, spelling, reading);
}
return null;
}
async fetchSupplementaryInfo(initialInfo, html, initialUrl, vid, spelling, reading) {
let info = initialInfo;
for (const supplement of vocabularySupplementUrls(html, spelling, reading, initialUrl)) {
if (!needsSupplement(info, supplement.kind)) continue;
const supplementHtml = await requestText$4(supplement.url, this.getCorsProxyUrl()).catch((error) => {
log$f.warn("Vocabulary supplement request failed", { vid, spelling, url: supplement.url }, error);
return "";
});
const supplementalInfo = supplementHtml ? parseJpdbVocabularyHtml(supplementHtml, spelling, reading) : null;
if (supplementalInfo) info = mergeVocabularyInfo(info, supplementalInfo);
}
return info;
}
}
function parseJpdbVocabularyHtml(html, spelling = "", reading = "") {
const doc = parseHtmlDocument(html);
const root = vocabularyRoot(doc, spelling, reading);
if (!root) return null;
const meanings = extractMeanings(root, doc, spelling, reading);
const compounds = extractCompounds(root);
const usedInVocabulary = extractUsedInVocabulary(root);
const examples = extractExamples(root);
return meanings.length || compounds.length || usedInVocabulary.length || examples.length ? { meanings, compounds, usedInVocabulary, examples } : null;
}
function vocabularyLookupUrls(vid, spelling, reading) {
const urls = [];
if (vid > 0) {
urls.push(`${JPDB_VOCABULARY_BASE_URL}/${vid}/${encodeURIComponent(spelling)}/${encodeURIComponent(reading || spelling)}`);
}
unique([spelling, reading].filter(Boolean)).forEach((query) => urls.push(`${JPDB_SEARCH_URL}?q=${encodeURIComponent(query)}`));
return unique(urls);
}
function vocabularySupplementUrls(html, spelling, reading, currentUrl = "") {
const doc = parseHtmlDocument(html);
const current = absoluteJpdbUrl(currentUrl);
return uniqueBy([
...vocabularyDetailUrls(doc, spelling, reading),
...vocabularyExpandUrls(doc)
], (supplement) => `${supplement.kind}:${supplement.url}`).filter((supplement) => !current || supplement.url !== current);
}
function vocabularyDetailUrls(doc, spelling, reading) {
if (!doc.querySelector(".results.search")) return [];
const root = vocabularyRoot(doc, spelling, reading);
if (!root) return [];
return Array.from(root.querySelectorAll('a.view-conjugations-link[href*="/vocabulary/"]')).filter((link) => /more details/i.test(cleanText$1(link.textContent ?? ""))).map((link) => absoluteJpdbUrl(link.getAttribute("href") ?? link.href)).filter(Boolean).map((url) => ({ url, kind: "details" }));
}
function vocabularyExpandUrls(doc) {
return Array.from(doc.querySelectorAll('a[href*="expand="]')).map((link) => vocabularyExpandSupplement(link.getAttribute("href") ?? link.href)).filter((supplement) => supplement !== null);
}
function vocabularyExpandSupplement(value) {
try {
const url = new URL(value, "https://jpdb.io");
const expand = url.searchParams.get("expand") ?? "";
if (expand.includes("e")) return { url: url.toString(), kind: "examples" };
if (expand.includes("v")) return { url: url.toString(), kind: "used-in-vocabulary" };
} catch {
return null;
}
return null;
}
function needsSupplement(info, kind) {
if (kind === "details") {
return info.examples.length < JPDB_EXAMPLE_LIMIT || (info.usedInVocabulary?.length ?? 0) < JPDB_USED_IN_VOCABULARY_LIMIT || info.compounds.length < JPDB_COMPOUND_LIMIT;
}
if (kind === "examples") return info.examples.length < JPDB_EXAMPLE_LIMIT;
return (info.usedInVocabulary?.length ?? 0) < JPDB_USED_IN_VOCABULARY_LIMIT;
}
function mergeVocabularyInfo(primary, supplemental) {
return {
meanings: unique([...primary.meanings, ...supplemental.meanings]).slice(0, 8),
compounds: mergeBy(primary.compounds, supplemental.compounds, (compound) => `${compound.term} ${compound.reading}`, JPDB_COMPOUND_LIMIT),
usedInVocabulary: mergeBy(
primary.usedInVocabulary ?? [],
supplemental.usedInVocabulary ?? [],
(entry) => `${entry.term} ${entry.reading}`,
JPDB_USED_IN_VOCABULARY_LIMIT
),
examples: mergeBy(primary.examples, supplemental.examples, (example) => example.sentence, JPDB_EXAMPLE_LIMIT)
};
}
function vocabularyRoot(doc, spelling, reading) {
const roots = Array.from(doc.querySelectorAll(".result.vocabulary"));
const matches = roots.filter((root) => vocabularyRootMatches(root, spelling, reading));
const matched = firstVocabularyRoot(matches);
if (matched) return matched;
if (canUseFallbackVocabularyRoot(doc, roots, spelling, reading)) return roots[0] ?? doc;
return null;
}
function firstVocabularyRoot(matches) {
return matches[0] ?? null;
}
function canUseGenericVocabularyRoot(roots, spelling, reading) {
const hasRequestedIdentity = Boolean(cleanText$1(spelling) || cleanText$1(reading));
return !hasRequestedIdentity && roots.length <= 1;
}
function canUseFallbackVocabularyRoot(doc, roots, spelling, reading) {
return canUseGenericVocabularyRoot(roots, spelling, reading) || documentMatchesVocabulary(doc, spelling, reading);
}
function vocabularyRootMatches(root, spelling, reading) {
return vocabularyIdentities(root).some((identity) => vocabularyIdentityMatches(identity, spelling, reading));
}
function documentMatchesVocabulary(doc, spelling, reading) {
const canonical = doc.querySelector('link[rel="canonical"][href*="/vocabulary/"]')?.href ?? "";
const identity = vocabularyIdentityFromUrl(canonical);
return identity ? vocabularyIdentityMatches(identity, spelling, reading) : false;
}
function vocabularyIdentities(root) {
return Array.from(root.querySelectorAll('a[href^="/vocabulary/"], a[href*="jpdb.io/vocabulary/"]')).filter((link) => !link.closest(".subsection-used-in, .subsection-examples")).map((link) => vocabularyIdentityFromUrl(link.href || link.getAttribute("href") || "")).filter((identity) => identity !== null);
}
function vocabularyIdentityFromUrl(value) {
if (!value) return null;
try {
const parsed = new URL(value, "https://jpdb.io");
return vocabularyIdentityFromPath(parsed.pathname);
} catch {
return null;
}
}
function vocabularyIdentityFromPath(pathname) {
const parts = pathname.split("/").filter(Boolean);
if (parts[0] !== "vocabulary") return null;
return {
expression: decodePathPart(parts[2] ?? ""),
reading: decodePathPart(parts[3] ?? "")
};
}
function vocabularyIdentityMatches(identity, spelling, reading) {
const requestedSpelling = cleanText$1(spelling);
const requestedReading = cleanText$1(reading);
const expression = cleanText$1(identity.expression);
const canonicalReading = cleanText$1(identity.reading);
const requested = new Set([requestedSpelling, requestedReading].filter(Boolean));
if (!requested.size) return true;
if (!vocabularyIdentityIntersectsRequest(requested, expression, canonicalReading)) return false;
if (!requestedReading) return true;
return vocabularyIdentityMatchesReading(expression, canonicalReading, requestedSpelling, requestedReading);
}
function vocabularyIdentityIntersectsRequest(requested, expression, canonicalReading) {
return requested.has(expression) || requested.has(canonicalReading);
}
function vocabularyIdentityMatchesReading(expression, canonicalReading, requestedSpelling, requestedReading) {
return canonicalReading === requestedReading || expression === requestedReading || expression === requestedSpelling;
}
function extractMeanings(root, doc, spelling, reading) {
const meanings = Array.from(root.querySelectorAll(".subsection-meanings .description")).map((element2) => cleanMeaning(element2.textContent ?? "")).filter(Boolean);
if (meanings.length) return unique(meanings).slice(0, 8);
return shouldReadMetaMeanings(spelling, reading) ? metaDescriptionMeanings(doc) : [];
}
function shouldReadMetaMeanings(spelling, reading) {
return Boolean(spelling || reading);
}
function metaDescriptionMeanings(doc) {
const description = doc.querySelector('meta[name="description"]')?.content ?? "";
const match = /\s[—-]\s(.+)$/.exec(description);
return match?.[1]?.split(/;\s+/).map(cleanMeaning).filter(Boolean).slice(0, 8) ?? [];
}
function extractCompounds(root) {
const entries = [];
root.querySelectorAll(".subsection-composed-of, .subsection-composed-of-vocabulary, .subsection-composed-of-kanji").forEach((section) => {
const label = cleanText$1(section.querySelector(".subsection-label")?.textContent ?? "").toLowerCase();
if (label && !label.startsWith("composed of")) return;
section.querySelectorAll(".subsection > div, .subsection .used-in").forEach((row) => addCompoundEntry(entries, row));
});
root.querySelectorAll(".subsection > .composed-of, .subsection .composed-of").forEach((row) => addCompoundEntry(entries, row));
return entries.slice(0, JPDB_COMPOUND_LIMIT);
}
function addCompoundEntry(entries, row) {
const link = row.querySelector('a[href^="/vocabulary/"], a[href^="/kanji/"]');
const spelling = row.querySelector('.spelling, .jp, .plain, a[href^="/vocabulary/"], a[href^="/kanji/"]') ?? link;
const term = cleanText$1(spelling ? baseText(spelling) : "") || cleanText$1(spelling?.textContent ?? "");
const reading = cleanText$1(spelling ? readingText(spelling) : "") || term;
if (!term || !JAPANESE_RE$1.test(term) || entries.some((entry) => entry.term === term)) return;
entries.push({
term,
reading,
meaning: cleanText$1(row.querySelector(".description, .en, .meaning")?.textContent ?? ""),
url: link?.getAttribute("href") ?? ""
});
}
function extractUsedInVocabulary(root) {
const entries = [];
root.querySelectorAll(".subsection-used-in, .subsection-used-in-vocabulary").forEach((section) => {
const label = cleanText$1(section.querySelector(".subsection-label")?.textContent ?? "").toLowerCase();
if (label && !label.startsWith("used in")) return;
usedInRows(section).forEach((row) => {
const link = vocabularyLink(row);
if (!link) return;
const identity = vocabularyIdentityFromUrl(link.href || link.getAttribute("href") || "");
const term = cleanText$1(identity?.expression ?? "") || cleanText$1(baseText(link)) || cleanText$1(link.textContent ?? "");
const reading = cleanText$1(identity?.reading ?? "") || cleanText$1(readingText(link)) || term;
if (!term || !JAPANESE_RE$1.test(term) || entries.some((entry) => entry.term === term && entry.reading === reading)) return;
entries.push({
term,
reading,
meaning: cleanText$1(row.querySelector(".description, .en, .english, .meaning")?.textContent ?? ""),
url: link.getAttribute("href") ?? ""
});
});
});
return entries.slice(0, JPDB_USED_IN_VOCABULARY_LIMIT);
}
function usedInRows(section) {
const rows = Array.from(section.querySelectorAll(".used-in, .subsection > div"));
const directLinks = Array.from(section.children).filter((child) => child instanceof HTMLElement && vocabularyLink(child) !== null);
return unique([...rows, ...directLinks]);
}
function vocabularyLink(root) {
if (root instanceof HTMLAnchorElement && isVocabularyLink(root)) return root;
return Array.from(root.querySelectorAll('a[href^="/vocabulary/"], a[href*="jpdb.io/vocabulary/"]')).find(isVocabularyLink) ?? null;
}
function isVocabularyLink(link) {
return vocabularyIdentityFromUrl(link.href || link.getAttribute("href") || "") !== null;
}
function extractExamples(root) {
const seen = /* @__PURE__ */ new Set();
const examples = [];
exampleSections(root).forEach((section) => {
section.querySelectorAll(".subsection > div, .example, li, p").forEach((row) => {
const sentenceNode = row.querySelector(".sentence, .jp, .japanese, .plain") ?? row;
const sentence = cleanText$1(baseText(sentenceNode)) || cleanText$1(sentenceNode.textContent ?? "");
if (!sentence || !JAPANESE_RE$1.test(sentence) || seen.has(sentence)) return;
seen.add(sentence);
examples.push({
sentence,
translation: cleanText$1(row.querySelector(".translation, .en, .english")?.textContent ?? ""),
audioIds: jpdbAudioIds(row)
});
});
});
return examples.slice(0, JPDB_EXAMPLE_LIMIT);
}
function exampleSections(root) {
const byClass = Array.from(root.querySelectorAll(".subsection-examples, .subsection-monolingual-examples"));
const byLabel = Array.from(root.querySelectorAll(".subsection-label")).filter((label) => cleanText$1(label.textContent ?? "").toLowerCase().includes("examples")).map(exampleSectionFromLabel).filter((section) => section !== null);
return unique([...byClass, ...byLabel]);
}
function exampleSectionFromLabel(label) {
let current = label.parentElement;
while (current) {
if (current.querySelector(".subsection")) return current;
current = current.parentElement;
}
return label.parentElement;
}
function jpdbAudioIds(root) {
return unique(Array.from(root.querySelectorAll("[data-audio]")).flatMap((element2) => parseJpdbAudioData(element2.dataset.audio ?? "")));
}
function parseJpdbAudioData(value) {
return value.split(/[,+]/).map((item) => item.trim()).filter(isValidJpdbAudioId);
}
function isValidJpdbAudioId(value) {
return Boolean(value && JPDB_AUDIO_ID_RE.test(value) && !value.includes("..") && !value.startsWith("//"));
}
function baseText(root) {
if (root.nodeType === Node.TEXT_NODE) return root.textContent ?? "";
if (root.nodeType !== Node.ELEMENT_NODE) return "";
return baseElementText(root);
}
function baseElementText(element2) {
if (isRubyAnnotation(element2)) return "";
return Array.from(element2.childNodes).map(baseText).join("");
}
function readingText(root) {
if (root.nodeType === Node.TEXT_NODE) return root.textContent ?? "";
if (root.nodeType !== Node.ELEMENT_NODE) return "";
return readingElementText(root);
}
function readingElementText(element2) {
if (isRubyAnnotation(element2)) return "";
if (element2.tagName === "RUBY") return rubyReadingText(element2);
return Array.from(element2.childNodes).map(readingText).join("");
}
function isRubyAnnotation(element2) {
return element2.tagName === "RT" || element2.tagName === "RP";
}
function rubyReadingText(element2) {
return Array.from(element2.children).find((child) => child.tagName === "RT")?.textContent || baseText(element2);
}
function cleanText$1(value) {
return value.replace(/\s+/g, " ").trim();
}
function cleanMeaning(value) {
return cleanText$1(value).replace(/^\d+\.\s*/, "");
}
function decodePathPart(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function absoluteJpdbUrl(value) {
try {
return new URL(value, "https://jpdb.io").toString();
} catch {
return "";
}
}
function unique(values) {
return [...new Set(values)];
}
function uniqueBy(values, key) {
const seen = /* @__PURE__ */ new Set();
return values.filter((value) => {
const current = key(value);
if (seen.has(current)) return false;
seen.add(current);
return true;
});
}
function mergeBy(primary, supplemental, key, limit) {
return uniqueBy([...primary, ...supplemental], key).slice(0, limit);
}
function requestText$4(url, proxyUrl = "") {
return requestText$7(url, {
proxyUrl,
timeoutMs: 8e3,
failureLabel: "JPDB vocabulary request",
timeoutLabel: "JPDB vocabulary request timed out."
});
}
const KANJI_MAP_KANJI_BASE = "https://raw.githubusercontent.com/gabor-kovacs/the-kanji-map/main/data/kanji";
const JAPANESE_RE = /[\u3040-\u30ff\u3400-\u9fff]/u;
const log$e = Logger.scope("KanjiOrigin");
class KanjiOriginClient {
cache = /* @__PURE__ */ new Map();
lookup(kanji, settings) {
const key = Array.from(kanji)[0] ?? kanji;
if (!key || !settings.kanjiOriginsEnabled) {
return Promise.resolve(null);
}
const cacheKey = kanjiOriginCacheKey(key, settings);
let promise = this.cache.get(cacheKey);
if (!promise) {
promise = this.fetchInfo(key, settings);
this.cache.set(cacheKey, promise);
}
return promise;
}
async fetchInfo(kanji, settings) {
const done = log$e.time("Kanji origin lookup", { kanji });
const kanjiMap = settings.kanjiOriginKanjiMapEnabled ? await fetchKanjiMapInfo(kanji).catch((error) => {
log$e.warn("Kanji Map origin lookup failed", { kanji, error });
return void 0;
}) : void 0;
const result = kanjiMap ? { kanjiMap } : null;
done();
return result;
}
}
function kanjiOriginCacheKey(kanji, settings) {
return [
kanji,
settings.kanjiOriginKanjiMapEnabled ? "map" : ""
].join(":");
}
async function fetchKanjiMapInfo(kanji) {
const done = log$e.time("Fetch Kanji Map info", { kanji });
const sourceUrl = `${KANJI_MAP_KANJI_BASE}/${encodeURIComponent(kanji)}.json`;
const raw = parseJson(await requestText$3(sourceUrl));
const info = raw ? parseKanjiMapInfo(raw, kanji, sourceUrl) : void 0;
done();
return info;
}
function parseKanjiMapInfo(raw, kanji, sourceUrl) {
const record = asRecord$1(raw);
if (!record) return void 0;
const kanjiAlive = asRecord$1(record.kanjialiveData);
const jisho = asRecord$1(record.jishoData);
const radical = readKanjiMapRadical(kanjiAlive, jisho);
const examples = readKanjiMapExamples(kanjiAlive, jisho);
const references = readKanjiMapReferences(kanjiAlive, jisho);
const metrics = readKanjiMapMetrics(kanjiAlive, jisho);
const readings2 = readKanjiMapReadings(kanjiAlive, jisho);
return {
kanji,
...metrics,
...readings2,
parts: readKanjiMapParts(jisho, kanji),
hint: stripHtml(stringValue(kanjiAlive?.mn_hint)),
radical,
examples,
references,
sourceUrl,
kanjiAliveUrl: `https://app.kanjialive.com/${encodeURIComponent(kanji)}`,
jishoUrl: stringValue(jisho?.uri)
};
}
function readKanjiMapMetrics(kanjiAlive, jisho) {
return {
meaning: kanjiMapMeaning(kanjiAlive, jisho),
grade: kanjiMapGrade(kanjiAlive, jisho),
jlpt: normalizeJlpt(stringValue(jisho?.jlptLevel)) ?? "",
strokeCount: kanjiMapStrokeCount(kanjiAlive, jisho),
frequencyRank: normalizeFrequency(stringValue(jisho?.newspaperFrequencyRank))
};
}
function kanjiMapMeaning(kanjiAlive, jisho) {
return stringValue(jisho?.meaning) || stringValue(kanjiAlive?.meaning);
}
function kanjiMapGrade(kanjiAlive, jisho) {
return normalizeGrade(stringValue(jisho?.taughtIn) || numberValue(kanjiAlive?.grade)) ?? "";
}
function kanjiMapStrokeCount(kanjiAlive, jisho) {
return numberValue(jisho?.strokeCount) ?? numberValue(kanjiAlive?.kstroke);
}
function readKanjiMapReadings(kanjiAlive, jisho) {
return {
kunyomi: stringArray(jisho?.kunyomi, stringValue(kanjiAlive?.kunyomi_ja) || stringValue(kanjiAlive?.kunyomi)),
onyomi: stringArray(jisho?.onyomi, stringValue(kanjiAlive?.onyomi_ja) || stringValue(kanjiAlive?.onyomi))
};
}
function readKanjiMapParts(jisho, kanji) {
return stringArray(jisho?.parts).filter((part) => part !== kanji && JAPANESE_RE.test(part)).slice(0, 10);
}
function stripHtml(value) {
return value.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
}
function buildKanjiFacts(kanji, jpdbInfo, rtkInfo, kanjiVGInfo, entries, sourceInfo = null) {
const facts = /* @__PURE__ */ new Map();
for (const candidate of kanjiFactCandidates(kanji, jpdbInfo, rtkInfo, kanjiVGInfo, entries, sourceInfo)) {
addKanjiFact(facts, candidate.label, candidate.value, candidate.source);
}
if (!facts.has("Character")) addKanjiFact(facts, "Character", kanji, "current lookup");
const result = Array.from(facts.values()).filter((fact) => fact.label !== "Character").slice(0, 6);
return result;
}
function kanjiFactCandidates(_kanji, jpdbInfo, rtkInfo, kanjiVGInfo, entries, sourceInfo) {
const local = extractLocalKanjiFacts(entries);
const map = sourceInfo?.kanjiMap;
return [
kanjiMeaningFact(map, jpdbInfo, rtkInfo, entries),
kanjiTypeFact(jpdbInfo, local, map),
kanjiJlptFact(local, map),
kanjiGradeFact(local, map),
kanjiStrokeFact(kanjiVGInfo, local, map),
kanjiFrequencyFact(jpdbInfo, local, map),
kanjiRadicalFact(map)
];
}
function kanjiMeaningFact(map, jpdbInfo, rtkInfo, entries) {
const meaning = kanjiMeaningCandidate(map, jpdbInfo, rtkInfo, entries);
return { label: "Meaning", value: meaning?.value ?? "", source: meaning?.source ?? "" };
}
function kanjiTypeFact(jpdbInfo, local, map) {
return {
label: "Type",
value: kanjiTypeValue(jpdbInfo, local, map),
source: kanjiTypeSource(jpdbInfo, local)
};
}
function kanjiTypeValue(jpdbInfo, local, map) {
return normalizeKanjiType(jpdbInfo?.type) ?? local.type ?? typeFromGrade(map?.grade) ?? "";
}
function kanjiTypeSource(jpdbInfo, local) {
return jpdbInfo?.type ? "JPDB" : local.typeSource ?? "Kanji Alive / Jisho";
}
function kanjiJlptFact(local, map) {
return { label: "JLPT", value: local.jlpt ?? map?.jlpt ?? "", source: local.jlptSource ?? "Jisho" };
}
function kanjiGradeFact(local, map) {
return { label: "Grade", value: local.grade ?? map?.grade ?? "", source: local.gradeSource ?? "Kanji Alive / Jisho" };
}
function kanjiStrokeFact(kanjiVGInfo, local, map) {
return { label: "Strokes", value: kanjiStrokeValue(kanjiVGInfo, local, map), source: kanjiStrokeSource(kanjiVGInfo, local) };
}
function kanjiFrequencyFact(jpdbInfo, local, map) {
return {
label: "Frequency",
value: kanjiFrequencyValue(jpdbInfo, local, map),
source: kanjiFrequencySource(jpdbInfo, local)
};
}
function kanjiFrequencyValue(jpdbInfo, local, map) {
return jpdbInfo?.frequency || local.frequency || map?.frequencyRank || "";
}
function kanjiFrequencySource(jpdbInfo, local) {
return jpdbInfo?.frequency ? "JPDB" : local.frequencySource ?? "Jisho";
}
function kanjiRadicalFact(map) {
return { label: "Radical", value: kanjiRadicalValue(map), source: "Kanji Alive / Jisho" };
}
function kanjiMeaningCandidate(map, jpdbInfo, rtkInfo, entries) {
const localMeaning = firstLocalMeaning(entries);
return firstFactCandidate([
{ value: map?.meaning, source: "Kanji Alive / Jisho" },
{ value: jpdbInfo?.keyword, source: "JPDB" },
{ value: rtkInfo?.keyword, source: "RTK" },
...localMeaning ? [localMeaning] : []
]);
}
function kanjiStrokeValue(kanjiVGInfo, local, map) {
return kanjiVGInfo?.strokeCount ? String(kanjiVGInfo.strokeCount) : local.strokes ?? normalizeNumber(map?.strokeCount) ?? "";
}
function kanjiStrokeSource(kanjiVGInfo, local) {
return kanjiVGInfo?.strokeCount ? "KanjiVG" : local.strokesSource ?? "Kanji Alive / Jisho";
}
function kanjiRadicalValue(map) {
return map?.radical ? [map.radical.symbol, map.radical.meaning].filter(Boolean).join(" ") : "";
}
function addKanjiFact(facts, label, value, source) {
const normalized = value?.trim();
if (!normalized || facts.has(label)) return;
facts.set(label, { label, value: normalized, source: source || "source unknown" });
}
function buildKanjiOriginGraph(kanji, jpdbInfo, rtkInfo, entries, sourceInfo = null, kanjiVGInfo = null) {
const nodes = /* @__PURE__ */ new Map();
const edges = [];
const meanings = entries.flatMap((entry) => entry.meanings).filter(Boolean);
const kanjiVGPositions = kanjiVGComponentPositionMap(kanjiVGInfo);
nodes.set(kanji, {
id: kanji,
label: kanji,
kind: "current",
detail: first([jpdbInfo?.keyword, rtkInfo?.keyword, sourceInfo?.kanjiMap?.meaning, meanings[0]]) ?? "current kanji",
source: "current lookup"
});
const addEdge = (from, to, label) => {
if (!from || !to || from === to) return;
if (!edges.some((edge) => edge.from === from && edge.to === to && edge.label === label)) {
edges.push({ from, to, label });
}
};
const addComponentNode = (id, detail, source, position, geometry) => {
if (!id || id === kanji) return null;
const existing = nodes.get(id);
if (!existing) {
nodes.set(id, { id, label: id, kind: "component", detail, source, position, geometry });
} else {
if (!existing.detail && detail) existing.detail = detail;
if (!existing.position && position) existing.position = position;
if (!existing.geometry && geometry) existing.geometry = geometry;
}
return id;
};
const addComponent = (id, detail, label, source, position, geometry) => {
const kanjiVGPosition = kanjiVGPositions.get(id);
const resolvedPosition = position || kanjiVGPosition?.position;
const resolvedGeometry = geometry ?? kanjiVGPosition?.geometry;
addEdge(addComponentNode(id, detail, source, resolvedPosition, resolvedGeometry) ?? void 0, kanji, label);
};
const addUsedInKanji = (id, detail, source) => {
if (!id || id === kanji) return;
const existing = nodes.get(id);
if (!existing) {
nodes.set(id, { id, label: id, kind: "component", detail, source });
} else if (!existing.detail && detail) {
existing.detail = detail;
}
addEdge(kanji, id, "used in kanji");
};
const resolveKanjiVGId = (component, original) => nodes.has(component) ? component : original && nodes.has(original) ? original : component;
const hasDirectComponentEdge = (id) => Boolean(id && edges.some((edge) => edge.from === id && edge.to === kanji && edge.label !== "subcomponent"));
const addSubcomponent = (component) => {
if (!component.component || component.component === kanji || component.variant) return;
const parent = component.parent && component.parent !== kanji ? resolveKanjiVGId(component.parent, component.parentOriginal) : kanji;
if (!parent || parent === kanji) return;
const child = resolveKanjiVGId(component.component, component.original);
if (hasDirectComponentEdge(child) || hasDirectComponentEdge(component.component) || hasDirectComponentEdge(component.original)) return;
const parentPosition = kanjiVGPositions.get(parent);
const parentId = addComponentNode(parent, "visual component", "KanjiVG", parentPosition?.position, parentPosition?.geometry) ?? parent;
const childId = addComponentNode(child, "visual subcomponent", "KanjiVG", component.position, kanjiVGComponentGeometry(component)) ?? child;
addEdge(childId, parentId, "subcomponent");
};
sourceInfo?.kanjiMap?.radical?.symbol && addComponent(
sourceInfo.kanjiMap.radical.symbol,
first([sourceInfo.kanjiMap.radical.meaning, sourceInfo.kanjiMap.radical.name]) ?? "radical",
"radical",
"Kanji Alive / Jisho",
sourceInfo.kanjiMap.radical.position
);
sourceInfo?.kanjiMap?.parts.forEach((part) => addComponent(part, "structural part", "structural part", "Kanji structure"));
jpdbInfo?.components.forEach((component) => addComponent(component.kanji, component.keyword, "JPDB component", "JPDB"));
jpdbInfo?.usedInKanji?.forEach((component) => addUsedInKanji(component.kanji, component.keyword, "JPDB"));
rtkInfo?.componentKanji.forEach((component) => addComponent(component, "RTK element", "RTK element", "RTK"));
kanjiVGInfo?.componentPositions?.filter((component) => component.direct).forEach((component) => {
const id = nodes.has(component.component) ? component.component : component.original && nodes.has(component.original) ? component.original : component.component;
addComponent(id, "visual component", "KanjiVG component", "KanjiVG", component.position, kanjiVGComponentGeometry(component));
});
kanjiVGInfo?.componentPositions?.filter((component) => !component.direct).sort((a, b) => a.depth - b.depth).forEach(addSubcomponent);
splitRtkElements(rtkInfo?.elements ?? "").filter((element2) => !Array.from(element2).some((character) => character === kanji)).slice(0, 6).forEach((element2, index) => {
const id = `rtk:${index}:${element2}`;
nodes.set(id, { id, label: element2, kind: "related", detail: "RTK keyword", source: "RTK" });
edges.push({ from: id, to: kanji, label: "memory cue" });
});
const graph = { nodes: Array.from(nodes.values()).slice(0, 24), edges: edges.slice(0, 36) };
return graph;
}
function kanjiVGComponentPositionMap(info) {
const positions = /* @__PURE__ */ new Map();
info?.componentPositions?.forEach((component) => {
const position = normalizeKanjiVGPosition(component.position);
if (!position) return;
const geometry = kanjiVGComponentGeometry(component);
kanjiVGPositionKeys(component).forEach((key) => {
const existing = positions.get(key);
if (!existing || !existing.direct && component.direct) {
positions.set(key, { position, direct: component.direct, geometry });
} else if (!existing.geometry && geometry) {
positions.set(key, { ...existing, geometry });
}
});
});
return positions;
}
function kanjiVGComponentGeometry(component) {
return component.center ? {
x: component.center.x,
y: component.center.y,
width: component.bounds?.width,
height: component.bounds?.height
} : void 0;
}
function kanjiVGPositionKeys(component) {
const componentAliases = KANJIVG_COMPONENT_ALIASES.get(component.component) ?? [];
const originalAliases = component.original ? KANJIVG_COMPONENT_ALIASES.get(component.original) ?? [] : [];
return uniqueStrings$1([
component.component,
component.original,
...componentAliases,
...originalAliases
]);
}
function uniqueStrings$1(values) {
return Array.from(new Set(values.filter((value) => Boolean(value))));
}
function normalizeKanjiVGPosition(value) {
const normalized = value.toLowerCase().trim();
return KANJIVG_POSITION_ALIASES.get(normalized) ?? normalized;
}
const KANJIVG_COMPONENT_ALIASES = /* @__PURE__ */ new Map([
["⻖", ["阝", "阜"]],
["阜", ["⻖", "阝"]]
]);
const KANJIVG_POSITION_ALIASES = /* @__PURE__ */ new Map([
["top", "top"],
["tare", "top"],
["bottom", "bottom"],
["nyo", "bottom"],
["left", "left"],
["right", "right"],
["inside", "center"],
["kamae", "center"],
["middle", "center"]
]);
function firstFactCandidate(candidates) {
return candidates.find((candidate) => candidate.value?.trim());
}
function firstLocalMeaning(entries) {
for (const entry of entries) {
const value = first(entry.meanings);
if (value) return { value, source: entry.dictionary || "local dictionary" };
}
return void 0;
}
function readKanjiMapRadical(kanjiAlive, jisho) {
const aliveRadical = asRecord$1(kanjiAlive?.radical);
const jishoRadical = asRecord$1(jisho?.radical);
const basics = readKanjiMapRadicalBasics(kanjiAlive, aliveRadical, jishoRadical);
if (!hasKanjiMapRadical(basics)) return void 0;
const position = asRecord$1(aliveRadical?.position);
const name = asRecord$1(aliveRadical?.name);
return {
symbol: basics.symbol,
forms: stringArray(jishoRadical?.forms),
...readKanjiMapRadicalNames(kanjiAlive, name),
meaning: basics.meaning,
strokes: normalizeNumber(aliveRadical?.strokes ?? kanjiAlive?.rad_stroke) ?? "",
position: stringValue(position?.hiragana) || stringValue(kanjiAlive?.rad_position_ja),
image: basics.image,
animation: basics.animation
};
}
function readKanjiMapRadicalNames(kanjiAlive, name) {
return {
name: stringValue(name?.romaji) || stringValue(kanjiAlive?.rad_name),
reading: stringValue(name?.hiragana) || stringValue(kanjiAlive?.rad_name_ja)
};
}
function readKanjiMapRadicalBasics(kanjiAlive, aliveRadical, jishoRadical) {
return {
symbol: stringValue(jishoRadical?.symbol) || stringValue(kanjiAlive?.rad_utf) || stringValue(aliveRadical?.character),
meaning: stringValue(asRecord$1(aliveRadical?.meaning)?.english) || stringValue(jishoRadical?.meaning) || stringValue(kanjiAlive?.rad_meaning),
image: safeMediaUrl(stringValue(aliveRadical?.image)),
animation: unknownArray(aliveRadical?.animation).map(stringValue).map(safeMediaUrl).filter(Boolean).slice(0, 4)
};
}
function hasKanjiMapRadical(radical) {
return Boolean(radical.symbol || radical.meaning || radical.image);
}
function readKanjiMapExamples(kanjiAlive, jisho) {
const examples = [];
const add = (expression, reading, meaning) => {
const item = {
expression: stringValue(expression),
reading: stringValue(reading),
meaning: stringValue(meaning)
};
if (!item.expression || examples.some((existing) => existing.expression === item.expression)) return;
examples.push(item);
};
unknownArray(kanjiAlive?.examples).forEach((example) => {
const record = asRecord$1(example);
add(record?.japanese, "", asRecord$1(record?.meaning)?.english);
});
[...unknownArray(jisho?.onyomiExamples), ...unknownArray(jisho?.kunyomiExamples)].forEach((example) => {
const record = asRecord$1(example);
add(record?.example, record?.reading, record?.meaning);
});
return examples.slice(0, 6);
}
function readKanjiMapReferences(kanjiAlive, jisho) {
const references = asRecord$1(kanjiAlive?.references);
const facts = [];
const add = (label, value, source) => {
const text2 = stringValue(value);
if (text2) facts.push({ label, value: text2, source });
};
add("Kodansha", references?.kodansha, "Kanji Alive");
add("Classic Nelson", references?.classic_nelson, "Kanji Alive");
add("Jisho", jisho?.uri, "Jisho");
return facts.slice(0, 4);
}
function extractLocalKanjiFacts(entries) {
const facts = {};
for (const entry of entries) {
const source = entry.dictionary || "local dictionary";
for (const tag of entry.tags) {
readTagFact(tag, facts, source);
}
readStatsFacts(entry.stats, facts, source);
}
return facts;
}
function readTagFact(tag, facts, source) {
const normalized = tag.trim().toLowerCase().replace(/[__]/g, " ");
readTagTypeFact(normalized, facts, source);
readTagJlptFact(normalized, facts, source);
readTagGradeFact(normalized, facts, source);
readTagStrokeFact(normalized, facts, source);
readTagFrequencyFact(normalized, facts, source);
}
function readTagTypeFact(normalized, facts, source) {
if (facts.type) return;
if (/\b(jōyō|jouyou|joyo)\b/.test(normalized)) setFact(facts, "type", "Jōyō kanji", source);
else if (/\b(jinmeiyō|jinmeiyou|jinmeiyo)\b/.test(normalized)) setFact(facts, "type", "Jinmeiyō kanji", source);
else if (/\b(hyōgai|hyougai|hyogai|outside|neither)\b/.test(normalized)) setFact(facts, "type", "Outside jōyō/jinmeiyō", source);
}
function readTagJlptFact(normalized, facts, source) {
const jlpt = normalized.match(/\b(?:jlpt\s*)?n?([1-5])\b/);
if (!facts.jlpt && jlpt && /jlpt|^n[1-5]$/.test(normalized)) setFact(facts, "jlpt", `N${jlpt[1]}`, source);
}
function readTagGradeFact(normalized, facts, source) {
const grade = normalized.match(/\b(?:grade|gakunen|school)\s*([1-6])\b/);
if (!facts.grade && grade) setFact(facts, "grade", `Grade ${grade[1]}`, source);
}
function readTagStrokeFact(normalized, facts, source) {
const strokes = normalized.match(/\b(?:strokes?|画数)\s*:?\s*(\d{1,2})\b/) ?? normalized.match(/\b(\d{1,2})\s*strokes?\b/);
if (!facts.strokes && strokes) setFact(facts, "strokes", strokes[1], source);
}
function readTagFrequencyFact(normalized, facts, source) {
const frequency = normalized.match(/\b(?:freq|frequency)\s*:?\s*(\d{1,5})\b/);
if (!facts.frequency && frequency) setFact(facts, "frequency", `#${frequency[1]}`, source);
}
function readStatsFacts(stats, facts, source) {
if (!stats || typeof stats !== "object") return;
const values = flattenStats(stats);
setFact(facts, "jlpt", normalizeJlpt(firstValue(values, ["jlpt", "jlptLevel", "jlpt_level"])), source);
setFact(facts, "grade", normalizeGrade(firstValue(values, ["grade", "schoolGrade", "gradeLevel", "jouyouGrade"])), source);
setFact(facts, "strokes", normalizeNumber(firstValue(values, ["strokes", "strokeCount", "stroke_count"])), source);
setFact(facts, "frequency", normalizeFrequency(firstValue(values, ["frequency", "freq", "frequencyRank"])), source);
}
function setFact(facts, key, value, source) {
if (!value || facts[key]) return;
facts[key] = value;
facts[`${key}Source`] = source;
}
function flattenStats(stats, prefix = "") {
const values = /* @__PURE__ */ new Map();
if (!isPlainStatsRecord(stats)) return values;
for (const [key, value] of Object.entries(stats)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
values.set(key, value);
values.set(fullKey, value);
if (isPlainStatsRecord(value)) flattenStats(value, fullKey).forEach((nestedValue, nestedKey) => values.set(nestedKey, nestedValue));
}
return values;
}
function isPlainStatsRecord(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function firstValue(values, keys) {
for (const key of keys) {
if (values.has(key)) return values.get(key);
}
return void 0;
}
function normalizeKanjiType(value) {
if (!value) return void 0;
if (/jinmeiy/i.test(value)) return "Jinmeiyō kanji";
if (/j[oō]y[oō]|grade/i.test(value)) return "Jōyō kanji";
return value;
}
function typeFromGrade(value) {
if (!value) return void 0;
return /grade/i.test(value) ? "Jōyō kanji" : void 0;
}
function normalizeJlpt(value) {
if (value === void 0 || value === null || value === "") return void 0;
const match = String(value).match(/[nN]?([1-5])/);
return match ? `N${match[1]}` : void 0;
}
function normalizeGrade(value) {
if (value === void 0 || value === null || value === "") return "";
const text2 = String(value).trim();
const match = text2.match(/(?:grade\s*)?([1-6])/i);
return match ? `Grade ${match[1]}` : text2;
}
function normalizeNumber(value) {
if (typeof value === "number" && Number.isFinite(value)) return String(value);
const match = String(value ?? "").match(/\d{1,5}/);
return match?.[0];
}
function normalizeFrequency(value) {
const number = normalizeNumber(value);
return number ? `#${number}` : "";
}
function splitRtkElements(value) {
return [...new Set(value.split(/[、,;++]/).map((item) => item.trim()).filter(Boolean))].slice(0, 16);
}
function stringArray(value, fallback = "") {
const values = Array.isArray(value) ? value : fallback ? fallback.split(/[,、]\s*/) : [];
return values.map((item) => stringValue(item)).map((item) => item.trim()).filter(Boolean);
}
function unknownArray(value) {
return Array.isArray(value) ? value : [];
}
function stringValue(value) {
if (value === void 0 || value === null) return "";
if (typeof value === "string") return value.trim();
if (isFiniteNumber(value)) return String(value);
return "";
}
function isFiniteNumber(value) {
return typeof value === "number" && Number.isFinite(value);
}
function numberValue(value) {
if (typeof value === "number" && Number.isFinite(value)) return value;
const match = String(value ?? "").match(/\d+/);
return match ? Number(match[0]) : void 0;
}
function safeMediaUrl(value) {
return /^https:\/\/media\.kanjialive\.com\//i.test(value) ? value : "";
}
function asRecord$1(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
}
function parseJson(value) {
try {
return JSON.parse(value);
} catch {
return null;
}
}
function requestText$3(url) {
return requestText$7(url, {
timeoutMs: 1e4,
failureLabel: "Kanji origin request",
timeoutLabel: "Kanji origin request timed out."
}).catch((error) => {
log$e.warn("Kanji origin request failed", { host: safeHost$2(url), error });
throw error;
});
}
function first(values) {
return values.find((value) => value?.trim())?.trim();
}
function safeHost$2(url) {
try {
return new URL(url, location.href).host;
} catch {
return "";
}
}
const log$d = Logger.scope("KanjiDoodle");
const PEN_MIN_DISTANCE = 8e-4;
const POINTER_MIN_DISTANCE = 35e-4;
const ACTIVE_DOODLE_CLASS = "jpdb-reader-doodle-active";
const NATIVE_GESTURE_SUPPRESS_MS = 900;
const KANJI_DOODLE_CLEAR_EVENT = "yomu:kanji-doodle-clear";
function installKanjiDoodle(popover, getLanguage, options = {}) {
const root = popover;
root.__yomuKanjiDoodleCleanup?.();
delete root.__yomuKanjiDoodleCleanup;
const elements = kanjiDoodleElements(popover);
const clear = popover.querySelector("[data-doodle-clear]");
const trace = popover.querySelector("[data-doodle-trace]");
if (!elements) return;
const { stage, canvas, ghost } = elements;
const context = canvas.getContext("2d");
if (!context) {
log$d.warn("Kanji doodle install failed", { reason: "missing-2d-context" });
return;
}
let dpr = 1;
let drawing = false;
let pointerId = -1;
let pointerType = "";
let traceVisible = !ghost.hidden && !stage.classList.contains("trace-hidden");
let points = [];
let strokes = [];
let canvasRect = canvas.getBoundingClientRect();
let suppressNativeGestureUntil = 0;
let activeClassRemovalTimer = 0;
const controller = new AbortController();
const signal = controller.signal;
const keepDoodleInteractionActive = (durationMs = NATIVE_GESTURE_SUPPRESS_MS) => {
suppressNativeGestureUntil = Math.max(suppressNativeGestureUntil, Date.now() + durationMs);
document.documentElement.classList.add(ACTIVE_DOODLE_CLASS);
if (activeClassRemovalTimer) {
window.clearTimeout(activeClassRemovalTimer);
activeClassRemovalTimer = 0;
}
};
const shouldSuppressNativeGesture = () => drawing || Date.now() < suppressNativeGestureUntil;
const releaseDoodleInteractionSoon = () => {
if (activeClassRemovalTimer) window.clearTimeout(activeClassRemovalTimer);
activeClassRemovalTimer = window.setTimeout(() => {
activeClassRemovalTimer = 0;
if (shouldSuppressNativeGesture()) {
releaseDoodleInteractionSoon();
return;
}
document.documentElement.classList.remove(ACTIVE_DOODLE_CLASS);
}, NATIVE_GESTURE_SUPPRESS_MS);
};
const suppressNativeGestureIfActive = (event) => {
if (!shouldSuppressNativeGesture()) return;
suppressNativeCanvasGesture(event);
};
const resize = () => {
const rect = stage.getBoundingClientRect();
dpr = Math.max(window.devicePixelRatio || 1, 1);
const width = Math.max(1, Math.round(rect.width * dpr));
const height = Math.max(1, Math.round(rect.height * dpr));
canvasRect = canvas.getBoundingClientRect();
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
redraw();
}
};
const toPoint = (event) => {
return {
x: Math.max(0, Math.min(1, (event.clientX - canvasRect.left) / Math.max(canvasRect.width, 1))),
y: Math.max(0, Math.min(1, (event.clientY - canvasRect.top) / Math.max(canvasRect.height, 1))),
pressure: Math.max(0.12, Math.min(1, event.pressure || 0.55))
};
};
const strokeWidth = (point) => Math.max(3.2, Math.min(9.5, canvas.width * 0.014)) * dpr * (0.78 + (point?.pressure ?? 0.55) * 0.42);
const setupStroke = (point) => {
const style = getComputedStyle(stage);
context.strokeStyle = style.getPropertyValue("--jpdb-reader-doodle-ink").trim() || "#141820";
context.lineCap = "round";
context.lineJoin = "round";
context.lineWidth = strokeWidth(point);
};
const drawStroke = (stroke) => {
if (!stroke.length) return;
if (stroke.length === 1) {
drawPoint(stroke[0]);
return;
}
for (let index = 1; index < stroke.length; index += 1) {
drawSegment(stroke[index - 1], stroke[index]);
}
};
const drawPoint = (point) => {
context.save();
setupStroke(point);
context.beginPath();
if (typeof context.arc === "function" && typeof context.fill === "function") {
context.fillStyle = context.strokeStyle;
context.arc(point.x * canvas.width, point.y * canvas.height, Math.max(1.2, context.lineWidth / 2), 0, Math.PI * 2);
context.fill();
} else {
const x = point.x * canvas.width;
const y = point.y * canvas.height;
context.moveTo(x, y);
context.lineTo(x + Math.max(1, context.lineWidth / 2), y);
context.stroke();
}
context.restore();
};
const drawSegment = (from, to) => {
context.save();
setupStroke(to);
context.beginPath();
context.moveTo(from.x * canvas.width, from.y * canvas.height);
context.lineTo(to.x * canvas.width, to.y * canvas.height);
context.stroke();
context.restore();
};
const redraw = () => {
context.clearRect(0, 0, canvas.width, canvas.height);
for (const stroke of strokes) drawStroke(stroke);
drawStroke(points);
};
const appendPoint = (point) => {
const last = points.at(-1);
const minDistance = pointerType === "pen" ? PEN_MIN_DISTANCE : POINTER_MIN_DISTANCE;
if (last && Math.hypot(point.x - last.x, point.y - last.y) < minDistance) return;
points.push(point);
if (last) drawSegment(last, point);
else drawPoint(point);
};
const applyPointerSamples = (event) => {
for (const sample of pointerSamples(event)) appendPoint(toPoint(sample));
};
const start = (event) => {
const computedCanvas = getComputedStyle(canvas);
if (computedCanvas.pointerEvents === "none" || computedCanvas.visibility === "hidden") return;
if (drawing) return;
event.preventDefault();
event.stopPropagation();
drawing = true;
pointerId = event.pointerId;
pointerType = event.pointerType;
keepDoodleInteractionActive();
clearSelection();
canvasRect = canvas.getBoundingClientRect();
points = [];
appendPoint(toPoint(event));
setDoodlePointerCapture(canvas, event.pointerId);
};
const move = (event) => {
if (!drawing || event.pointerId !== pointerId) return;
event.preventDefault();
event.stopPropagation();
keepDoodleInteractionActive();
applyPointerSamples(event);
};
const end = (event) => {
if (!drawing || event.pointerId !== pointerId) return;
event.preventDefault();
event.stopPropagation();
applyPointerSamples(event);
finishStroke();
};
const finishAfterLostCapture = (event) => {
if (!drawing || event.pointerId !== pointerId) return;
finishStroke(false);
};
const clearActiveSelection = () => {
if (shouldSuppressNativeGesture()) clearSelection();
};
const finishStroke = (releaseCapture = true) => {
if (points.length) strokes = [...strokes, points];
points = [];
drawing = false;
const activePointerId = pointerId;
pointerId = -1;
pointerType = "";
if (releaseCapture) releaseDoodlePointerCapture(canvas, activePointerId);
keepDoodleInteractionActive();
releaseDoodleInteractionSoon();
clearSelection();
options.onChange?.(strokes.map((stroke) => [...stroke]));
};
const clearDoodle = () => {
strokes = [];
points = [];
redraw();
options.onClear?.();
options.onChange?.([]);
};
canvas.addEventListener("pointerdown", start, { passive: false, signal });
canvas.addEventListener("lostpointercapture", finishAfterLostCapture, { signal });
document.addEventListener("pointermove", move, { passive: false, signal });
document.addEventListener("pointerup", end, { passive: false, signal });
document.addEventListener("pointercancel", end, { passive: false, signal });
window.addEventListener("pointermove", move, { passive: false, signal });
window.addEventListener("pointerup", end, { passive: false, signal });
window.addEventListener("pointercancel", end, { passive: false, signal });
document.addEventListener("selectionchange", clearActiveSelection, { signal });
document.addEventListener("contextmenu", suppressNativeGestureIfActive, { capture: true, signal });
document.addEventListener("selectstart", suppressNativeGestureIfActive, { capture: true, signal });
document.addEventListener("dragstart", suppressNativeGestureIfActive, { capture: true, signal });
window.addEventListener("contextmenu", suppressNativeGestureIfActive, { capture: true, signal });
window.addEventListener("selectstart", suppressNativeGestureIfActive, { capture: true, signal });
window.addEventListener("dragstart", suppressNativeGestureIfActive, { capture: true, signal });
popover.addEventListener(KANJI_DOODLE_CLEAR_EVENT, clearDoodle, { signal });
for (const target of [stage, canvas, clear, trace]) {
if (!target) continue;
target.addEventListener("contextmenu", suppressNativeCanvasGesture, { signal });
target.addEventListener("selectstart", suppressNativeCanvasGesture, { signal });
target.addEventListener("dragstart", suppressNativeCanvasGesture, { signal });
}
clear?.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
clearDoodle();
}, { signal });
trace?.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
traceVisible = !traceVisible;
ghost.hidden = !traceVisible;
stage.classList.toggle("trace-hidden", !traceVisible);
trace.textContent = uiText(getLanguage(), traceVisible ? "hideTrace" : "showTrace");
}, { signal });
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(stage);
root.__yomuKanjiDoodleCleanup = () => {
controller.abort();
resizeObserver.disconnect();
if (activeClassRemovalTimer) window.clearTimeout(activeClassRemovalTimer);
document.documentElement.classList.remove(ACTIVE_DOODLE_CLASS);
clearSelection();
if (root.__yomuKanjiDoodleCleanup) delete root.__yomuKanjiDoodleCleanup;
};
const disconnectWhenDetached = () => {
if (!popover.isConnected) {
root.__yomuKanjiDoodleCleanup?.();
return;
}
requestAnimationFrame(disconnectWhenDetached);
};
requestAnimationFrame(resize);
requestAnimationFrame(disconnectWhenDetached);
}
function suppressNativeCanvasGesture(event) {
event.preventDefault();
event.stopPropagation();
clearSelection();
}
function pointerSamples(event) {
const coalesced = safeCoalescedPointerEvents(event);
if (!coalesced.length) return [event];
const last = coalesced.at(-1);
return last && samePointerPosition(last, event) ? coalesced : [...coalesced, event];
}
function safeCoalescedPointerEvents(event) {
try {
return typeof event.getCoalescedEvents === "function" ? event.getCoalescedEvents() : [];
} catch {
return [];
}
}
function samePointerPosition(a, b) {
return a.clientX === b.clientX && a.clientY === b.clientY && a.pressure === b.pressure;
}
function setDoodlePointerCapture(canvas, activePointerId) {
try {
canvas.setPointerCapture?.(activePointerId);
} catch {
}
}
function releaseDoodlePointerCapture(canvas, activePointerId) {
try {
canvas.releasePointerCapture?.(activePointerId);
} catch {
}
}
function clearSelection() {
const selection = document.getSelection?.();
if (selection && !selection.isCollapsed) selection.removeAllRanges();
}
function kanjiDoodleElements(popover) {
const stage = popover.querySelector(".jpdb-reader-doodle-stage");
const canvas = popover.querySelector(".jpdb-reader-doodle-canvas");
const ghost = popover.querySelector(".jpdb-reader-doodle-ghost");
if (stage && canvas && ghost) return { stage, canvas, ghost };
return null;
}
const FEATURE_INTERVAL = 20;
const NORMALIZED_SIZE = 256;
const SHAPE_PASS_SCORE = 0.56;
function assessKanjiStrokes(strokes, expectedStrokes, referenceStrokes) {
const validStrokes = strokes.filter((stroke) => stroke.length > 1);
const actualStrokes = validStrokes.length;
const expected = Math.max(1, Math.round(expectedStrokes || actualStrokes || 1));
const strokeScore = Math.max(0, 1 - Math.abs(actualStrokes - expected) / Math.max(expected, 1));
const coverageScore = Math.min(1, totalDistance(strokes) / Math.max(expected * 0.28, 0.28));
const directionScore = averageForwardMotion(strokes);
const shapeScore = assessStrokeShape(validStrokes, referenceStrokes, expected);
const score = Math.round((shapeScore == null ? strokeScore * 0.62 + coverageScore * 0.24 + directionScore * 0.14 : strokeScore * 0.18 + coverageScore * 0.06 + directionScore * 0.04 + shapeScore * 0.72) * 100);
const shapePassed = shapeScore == null || shapeScore >= SHAPE_PASS_SCORE;
const passed = actualStrokes === expected && score >= 68 && shapePassed;
const message = assessmentMessage(passed, actualStrokes, expected, shapeScore);
return { passed, score, expectedStrokes: expected, actualStrokes, shapeScore: shapeScore ?? void 0, message };
}
function totalDistance(strokes) {
return strokes.reduce((sum, stroke) => {
let distance = 0;
for (let index = 1; index < stroke.length; index += 1) {
const previous = stroke[index - 1];
const current = stroke[index];
distance += Math.hypot(current.x - previous.x, current.y - previous.y);
}
return sum + distance;
}, 0);
}
function averageForwardMotion(strokes) {
const scored = strokes.filter((stroke) => stroke.length > 1).map((stroke) => {
const first2 = stroke[0];
const last = stroke[stroke.length - 1];
const horizontal = Math.abs(last.x - first2.x);
const vertical = Math.abs(last.y - first2.y);
if (horizontal >= vertical) return last.x >= first2.x ? 1 : 0.45;
return last.y >= first2.y ? 1 : 0.45;
});
return scored.length ? scored.reduce((sum, value) => sum + value, 0) / scored.length : 0;
}
function assessmentMessage(passed, actualStrokes, expectedStrokes, shapeScore) {
if (passed) return `Looks right: ${actualStrokes}/${expectedStrokes} strokes`;
if (actualStrokes !== expectedStrokes) return `Check stroke count: ${actualStrokes}/${expectedStrokes} strokes`;
if (shapeScore != null && shapeScore < SHAPE_PASS_SCORE) return `Check stroke shape/order: ${actualStrokes}/${expectedStrokes} strokes`;
return `Check stroke count/order: ${actualStrokes}/${expectedStrokes} strokes`;
}
function assessStrokeShape(strokes, referenceStrokes, expectedStrokes) {
if (!referenceStrokes || strokes.length !== expectedStrokes || referenceStrokes.length !== expectedStrokes) return null;
const written = extractFeatures(momentNormalize(toPattern(strokes)), FEATURE_INTERVAL);
const reference = extractFeatures(momentNormalize(toPattern(referenceStrokes)), FEATURE_INTERVAL);
if (written.length !== reference.length || written.some((stroke, index) => stroke.length < 2 || reference[index].length < 2)) return null;
const scores = written.map((stroke, index) => strokeCorrespondenceScore(stroke, reference[index]));
const average = scores.reduce((sum, score) => sum + score, 0) / scores.length;
const worst = Math.min(...scores);
return average * 0.72 + worst * 0.28;
}
function toPattern(strokes) {
return strokes.map((stroke) => stroke.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y)).map((point) => ({
x: Math.max(0, Math.min(1, point.x)) * NORMALIZED_SIZE,
y: Math.max(0, Math.min(1, point.y)) * NORMALIZED_SIZE
}))).filter((stroke) => stroke.length > 1);
}
function momentNormalize(pattern) {
const points = pattern.flat();
if (!points.length) return pattern;
const width = NORMALIZED_SIZE;
const height = NORMALIZED_SIZE;
const minX = Math.min(...points.map((point) => point.x));
const maxX = Math.max(...points.map((point) => point.x));
const minY = Math.min(...points.map((point) => point.y));
const maxY = Math.max(...points.map((point) => point.y));
const oldWidth = Math.max(maxX - minX, 1e-3);
const oldHeight = Math.max(maxY - minY, 1e-3);
const aspectScale = aspectPreservingScale(oldWidth, oldHeight);
const targetWidth = oldHeight > oldWidth ? aspectScale * width : width;
const targetHeight = oldHeight > oldWidth ? height : aspectScale * height;
const offsetX = (width - targetWidth) / 2;
const offsetY = (height - targetHeight) / 2;
const centerX = points.reduce((sum, point) => sum + point.x, 0) / points.length;
const centerY = points.reduce((sum, point) => sum + point.y, 0) / points.length;
const varianceX = points.reduce((sum, point) => sum + (point.x - centerX) ** 2, 0) / points.length;
const varianceY = points.reduce((sum, point) => sum + (point.y - centerY) ** 2, 0) / points.length;
const scaleX = finiteScale(targetWidth / (4 * Math.sqrt(varianceX)));
const scaleY = finiteScale(targetHeight / (4 * Math.sqrt(varianceY)));
return pattern.map((stroke) => stroke.map((point) => ({
x: clamp(scaleX * (point.x - centerX) + targetWidth / 2 + offsetX, 0, NORMALIZED_SIZE),
y: clamp(scaleY * (point.y - centerY) + targetHeight / 2 + offsetY, 0, NORMALIZED_SIZE)
})));
}
function aspectPreservingScale(width, height) {
const ratio = height > width ? width / height : height / width;
return Math.sqrt(Math.sin(Math.PI / 2 * ratio));
}
function finiteScale(value) {
return Number.isFinite(value) ? value : 0;
}
function extractFeatures(pattern, interval) {
return pattern.map((stroke) => {
const extracted = [];
let distance = 0;
for (let index = 0; index < stroke.length; index += 1) {
if (index === 0) extracted.push(stroke[0]);
if (index > 0) distance += euclid(stroke[index - 1], stroke[index]);
if (distance >= interval && index > 1) {
distance -= interval;
extracted.push(stroke[index]);
}
}
if (extracted.length === 1) extracted.push(stroke[stroke.length - 1]);
else if (distance > interval * 0.75) extracted.push(stroke[stroke.length - 1]);
return extracted;
});
}
function strokeCorrespondenceScore(stroke, reference) {
const whole = wholeWholeDistance(stroke, reference);
const endpoints = endPointDistance(stroke, reference) / 2;
const direction = directionDistance(stroke, reference) * 128;
const distance = whole * 0.58 + endpoints * 0.32 + direction * 0.1;
return clamp(1 - distance / 96, 0, 1);
}
function wholeWholeDistance(pattern1, pattern2) {
const [larger, smaller] = pattern1.length >= pattern2.length ? [pattern1, pattern2] : [pattern2, pattern1];
if (!larger.length || !smaller.length) return NORMALIZED_SIZE;
let distance = 0;
for (let index = 0; index < smaller.length; index += 1) {
const largerIndex = Math.min(larger.length - 1, Math.floor(larger.length / smaller.length * index));
distance += manhattan(larger[largerIndex], smaller[index]);
}
return distance / smaller.length;
}
function endPointDistance(pattern1, pattern2) {
if (!pattern1.length || !pattern2.length) return NORMALIZED_SIZE;
return manhattan(pattern1[0], pattern2[0]) + manhattan(pattern1[pattern1.length - 1], pattern2[pattern2.length - 1]);
}
function directionDistance(pattern1, pattern2) {
const vector1 = strokeVector(pattern1);
const vector2 = strokeVector(pattern2);
const length1 = Math.hypot(vector1.x, vector1.y);
const length2 = Math.hypot(vector2.x, vector2.y);
if (!length1 || !length2) return 1;
const dot = (vector1.x * vector2.x + vector1.y * vector2.y) / (length1 * length2);
return (1 - clamp(dot, -1, 1)) / 2;
}
function strokeVector(stroke) {
return {
x: stroke[stroke.length - 1].x - stroke[0].x,
y: stroke[stroke.length - 1].y - stroke[0].y
};
}
function euclid(point1, point2) {
return Math.hypot(point1.x - point2.x, point1.y - point2.y);
}
function manhattan(point1, point2) {
return Math.abs(point1.x - point2.x) + Math.abs(point1.y - point2.y);
}
function clamp(value, min, max2) {
return Math.max(min, Math.min(max2, value));
}
function installKanjiPracticeDoodle(root, getLanguage, getKanjiVGInfo) {
let latestStrokes = [];
const clear = () => {
latestStrokes = [];
clearKanjiPracticeAssessment(root);
};
const reassess = () => {
renderKanjiPracticeAssessment(root, getKanjiVGInfo(), latestStrokes);
};
installKanjiDoodle(root, getLanguage, {
onChange: (strokes) => {
latestStrokes = strokes;
reassess();
},
onClear: clear
});
return { reassess, clear };
}
function renderKanjiPracticeAssessment(root, info, strokes) {
if (!info || !strokes.length) {
clearKanjiPracticeAssessment(root);
return;
}
if (shouldWaitForMorePracticeStrokes(strokes, info.strokeCount)) {
clearKanjiPracticeAssessment(root);
return;
}
renderKanjiPracticeResult(root, assessKanjiStrokes(strokes, info.strokeCount, info.strokeShapes));
}
function renderKanjiPracticeResult(root, assessment) {
const section = kanjiPracticeSection(root);
const result = section?.querySelector("[data-newtab-doodle-result]");
section?.classList.toggle("jpdb-reader-doodle-pass", assessment.passed);
section?.classList.toggle("jpdb-reader-doodle-fail", !assessment.passed);
if (result) result.textContent = `${assessment.passed ? "✓" : "✕"} ${assessment.message}`;
}
function clearKanjiPracticeAssessment(root) {
const section = kanjiPracticeSection(root);
const result = section?.querySelector("[data-newtab-doodle-result]");
section?.classList.remove("jpdb-reader-doodle-pass", "jpdb-reader-doodle-fail");
if (result) result.textContent = "";
}
function kanjiPracticeSection(root) {
return root.matches(".jpdb-reader-kanjivg") ? root : root.querySelector(".jpdb-reader-kanjivg");
}
function shouldWaitForMorePracticeStrokes(strokes, expectedStrokes) {
return expectedStrokes > 0 && strokes.filter((stroke) => stroke.length > 1).length < expectedStrokes;
}
const KANJIVG_RAW_BASE = "https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji";
const log$c = Logger.scope("KanjiVG");
class KanjiVGClient {
cache = /* @__PURE__ */ new Map();
lookup(kanji) {
const character = Array.from(kanji)[0] ?? "";
if (!character) return Promise.resolve(null);
let promise = this.cache.get(character);
if (!promise) {
promise = this.fetchSvg(character);
this.cache.set(character, promise);
}
return promise;
}
async fetchSvg(kanji) {
const url = kanjiVGUrl(kanji);
const svgText = await requestText$2(url).catch((error) => {
log$c.warn("Stroke-order request failed", { kanji }, error);
return "";
});
if (!svgText) return null;
const info = parseKanjiVGSvg(svgText, kanji);
return info;
}
}
function kanjiVGUrl(kanji) {
const codePoint = kanji.codePointAt(0) ?? 0;
return `${KANJIVG_RAW_BASE}/${codePoint.toString(16).padStart(5, "0")}.svg`;
}
function parseKanjiVGSvg(svgText, kanji) {
const doc = parseXmlDocument(svgText, "image/svg+xml");
const sourceSvg = doc.querySelector("svg");
if (!sourceSvg) return null;
const viewBox = sourceSvg.getAttribute("viewBox") || "0 0 109 109";
const componentPositions = readKanjiVGComponentPositions(sourceSvg, kanji);
const parsedPaths = Array.from(sourceSvg.querySelectorAll("path")).map((path, index) => {
const d = path.getAttribute("d");
if (!d || !/^[MmZzLlHhVvCcSsQqTtAa0-9,.\-\s]+$/.test(d)) return null;
return {
d,
svg: ` `,
shape: readKanjiVGStrokeShape(d, viewBox)
};
}).filter((path) => Boolean(path));
const paths = parsedPaths.map((path) => path.svg);
if (!paths.length) return null;
const strokeShapes = parsedPaths.map((path) => path.shape);
const numbers = Array.from(sourceSvg.querySelectorAll("text")).map((text2) => {
const transform = text2.getAttribute("transform") ?? "";
const label = (text2.textContent ?? "").trim();
if (!/^[\d]+$/.test(label) || !/^matrix\([0-9,.\-\s]+\)$/.test(transform)) return "";
return `${escapeHtml$1(label)} `;
}).filter(Boolean);
const svg = `
${paths.join("")}
${numbers.join("")}
`;
return {
kanji,
svg,
strokeCount: paths.length,
strokeShapes: strokeShapes.every(Boolean) ? strokeShapes : void 0,
componentPositions
};
}
function readKanjiVGStrokeShape(pathData, viewBox) {
const box = parseViewBox(viewBox);
const points = parseSvgPathPoints(pathData).map((point) => ({
x: (point.x - box.x) / box.width,
y: (point.y - box.y) / box.height
})).filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y));
return points.length > 1 ? points : null;
}
function parseViewBox(viewBox) {
const values = viewBox.trim().split(/[\s,]+/).map(Number);
const [x, y, width, height] = values;
if (values.length === 4 && values.every(Number.isFinite) && width > 0 && height > 0) {
return { x, y, width, height };
}
return { x: 0, y: 0, width: 109, height: 109 };
}
const SVG_PATH_TOKEN = /[MmZzLlHhVvCcSsQqTtAa]|[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?/gi;
const CURVE_STEPS = 10;
function parseSvgPathPoints(pathData) {
const tokens = pathData.match(SVG_PATH_TOKEN) ?? [];
const points = [];
let index = 0;
let command = "";
let current = { x: 0, y: 0 };
let start = { x: 0, y: 0 };
let lastCubicControl = null;
let lastQuadraticControl = null;
const push = (point) => {
const previous = points.at(-1);
if (!previous || Math.hypot(previous.x - point.x, previous.y - point.y) > 1e-3) points.push(point);
};
const isCommand = (token) => Boolean(token && /^[A-Za-z]$/.test(token));
const hasNumbers = (count) => index + count <= tokens.length && tokens.slice(index, index + count).every((token) => !isCommand(token));
const read = () => Number(tokens[index++]);
const absolute = (x, y, relative) => relative ? { x: current.x + x, y: current.y + y } : { x, y };
const lineTo = (point) => {
current = point;
push(current);
lastCubicControl = null;
lastQuadraticControl = null;
};
const horizontalLineTo = (relative) => {
while (hasNumbers(1)) {
const x = read();
lineTo({ x: relative ? current.x + x : x, y: current.y });
}
};
const verticalLineTo = (relative) => {
while (hasNumbers(1)) {
const y = read();
lineTo({ x: current.x, y: relative ? current.y + y : y });
}
};
while (index < tokens.length) {
if (isCommand(tokens[index])) command = tokens[index++];
if (!command) break;
const relative = command === command.toLowerCase();
const before = index;
switch (command.toUpperCase()) {
case "M": {
if (!hasNumbers(2)) return points;
current = absolute(read(), read(), relative);
start = current;
push(current);
command = relative ? "l" : "L";
lastCubicControl = null;
lastQuadraticControl = null;
break;
}
case "L":
while (hasNumbers(2)) lineTo(absolute(read(), read(), relative));
break;
case "H":
horizontalLineTo(relative);
break;
case "V":
verticalLineTo(relative);
break;
case "C":
while (hasNumbers(6)) {
const c1 = absolute(read(), read(), relative);
const c2 = absolute(read(), read(), relative);
const end = absolute(read(), read(), relative);
sampleCubic(current, c1, c2, end, push);
current = end;
lastCubicControl = c2;
lastQuadraticControl = null;
}
break;
case "S":
while (hasNumbers(4)) {
const c1 = lastCubicControl ? reflect(current, lastCubicControl) : current;
const c2 = absolute(read(), read(), relative);
const end = absolute(read(), read(), relative);
sampleCubic(current, c1, c2, end, push);
current = end;
lastCubicControl = c2;
lastQuadraticControl = null;
}
break;
case "Q":
while (hasNumbers(4)) {
const c = absolute(read(), read(), relative);
const end = absolute(read(), read(), relative);
sampleQuadratic(current, c, end, push);
current = end;
lastQuadraticControl = c;
lastCubicControl = null;
}
break;
case "T":
while (hasNumbers(2)) {
const c = lastQuadraticControl ? reflect(current, lastQuadraticControl) : { ...current };
const end = absolute(read(), read(), relative);
sampleQuadratic(current, c, end, push);
current = end;
lastQuadraticControl = c;
lastCubicControl = null;
}
break;
case "A":
while (hasNumbers(7)) {
read();
read();
read();
read();
read();
lineTo(absolute(read(), read(), relative));
}
break;
case "Z":
lineTo(start);
command = "";
break;
default:
return points;
}
if (index === before && !isCommand(tokens[index])) return points;
}
return points;
}
function reflect(origin, control) {
return {
x: origin.x * 2 - control.x,
y: origin.y * 2 - control.y
};
}
function sampleCubic(from, c1, c2, to, push) {
for (let step = 1; step <= CURVE_STEPS; step += 1) {
const t = step / CURVE_STEPS;
const mt = 1 - t;
push({
x: mt ** 3 * from.x + 3 * mt ** 2 * t * c1.x + 3 * mt * t ** 2 * c2.x + t ** 3 * to.x,
y: mt ** 3 * from.y + 3 * mt ** 2 * t * c1.y + 3 * mt * t ** 2 * c2.y + t ** 3 * to.y
});
}
}
function sampleQuadratic(from, c, to, push) {
for (let step = 1; step <= CURVE_STEPS; step += 1) {
const t = step / CURVE_STEPS;
const mt = 1 - t;
push({
x: mt ** 2 * from.x + 2 * mt * t * c.x + t ** 2 * to.x,
y: mt ** 2 * from.y + 2 * mt * t * c.y + t ** 2 * to.y
});
}
}
function readKanjiVGComponentPositions(sourceSvg, kanji) {
const root = Array.from(sourceSvg.querySelectorAll("g")).find((group) => group.getAttribute("kvg:element") === kanji);
const viewBox = parseViewBox(sourceSvg.getAttribute("viewBox") || "0 0 109 109");
const positions = /* @__PURE__ */ new Map();
const add = (entry) => {
const key = `${entry.component}\0${entry.original ?? ""}\0${entry.parent ?? ""}\0${entry.position}`;
const existing = positions.get(key);
if (!existing || !existing.direct && entry.direct || existing.variant && !entry.variant) positions.set(key, entry);
};
Array.from(sourceSvg.querySelectorAll("g")).forEach((group) => {
const component = cleanComponent(group.getAttribute("kvg:element") ?? "");
if (!component || component === kanji) return;
const parentGroup = nearestKanjiVGComponentParent(group, root);
const parent = cleanComponent(parentGroup?.getAttribute("kvg:element") ?? "");
const parentOriginal = cleanComponent(parentGroup?.getAttribute("kvg:original") ?? "");
const position = cleanComponent(group.getAttribute("kvg:position") ?? geometricKanjiVGPosition(group, parentGroup, viewBox) ?? inheritedKanjiVGPosition(group, root));
if (!position) return;
const bounds = normalizedKanjiVGElementBounds(group, viewBox);
const geometryAttrs = bounds ? {
bounds,
center: {
x: roundKanjiVGGeometry(bounds.x + bounds.width / 2),
y: roundKanjiVGGeometry(bounds.y + bounds.height / 2)
}
} : {};
const original = cleanComponent(group.getAttribute("kvg:original") ?? "");
const direct = Boolean(root && parentGroup === root);
const variant = group.getAttribute("kvg:variant") === "true";
const parentAttrs = parent ? {
parent,
parentOriginal: parentOriginal || void 0
} : {};
const depth = kanjiVGComponentDepth(group, root);
const variantAttr = variant ? { variant } : {};
add({ component, original: original || void 0, ...parentAttrs, position, direct, depth, ...variantAttr, ...geometryAttrs });
if (original && original !== component) add({ component: original, original: component, ...parentAttrs, position, direct, depth, ...variantAttr, ...geometryAttrs });
});
return Array.from(positions.values());
}
function nearestKanjiVGComponentParent(group, root) {
let parent = group.parentElement;
while (parent) {
if (parent === root || cleanComponent(parent.getAttribute("kvg:element") ?? "")) return parent;
parent = parent.parentElement;
}
return void 0;
}
function kanjiVGComponentDepth(group, root) {
let depth = 0;
let parent = group.parentElement;
while (parent && parent !== root) {
if (cleanComponent(parent.getAttribute("kvg:element") ?? "")) depth += 1;
parent = parent.parentElement;
}
return depth + 1;
}
function geometricKanjiVGPosition(group, parent, viewBox) {
if (!parent) return "";
const groupBox = kanjiVGElementBox(group, viewBox);
const parentBox = kanjiVGElementBox(parent, viewBox);
if (!groupBox || !parentBox || groupBox.width <= 0 || groupBox.height <= 0 || parentBox.width <= 0 || parentBox.height <= 0) return "";
const dx = (groupBox.x + groupBox.width / 2 - (parentBox.x + parentBox.width / 2)) / parentBox.width;
const dy = (groupBox.y + groupBox.height / 2 - (parentBox.y + parentBox.height / 2)) / parentBox.height;
const threshold = 0.12;
if (Math.abs(dx) > Math.abs(dy) * 1.12 && Math.abs(dx) > threshold) return dx < 0 ? "left" : "right";
if (Math.abs(dy) > threshold) return dy < 0 ? "top" : "bottom";
return "center";
}
function kanjiVGElementBox(element2, viewBox) {
const points = Array.from(element2.querySelectorAll("path")).flatMap((path) => parseSvgPathPoints(path.getAttribute("d") ?? "")).filter((point) => point.x >= viewBox.x - viewBox.width && point.y >= viewBox.y - viewBox.height);
if (!points.length) return null;
const xs = points.map((point) => point.x);
const ys = points.map((point) => point.y);
const left = Math.min(...xs);
const right = Math.max(...xs);
const top = Math.min(...ys);
const bottom = Math.max(...ys);
return { x: left, y: top, width: right - left, height: bottom - top };
}
function normalizedKanjiVGElementBounds(element2, viewBox) {
const box = kanjiVGElementBox(element2, viewBox);
if (!box || box.width <= 0 || box.height <= 0) return null;
const left = clampUnit((box.x - viewBox.x) / viewBox.width);
const top = clampUnit((box.y - viewBox.y) / viewBox.height);
const right = clampUnit((box.x + box.width - viewBox.x) / viewBox.width);
const bottom = clampUnit((box.y + box.height - viewBox.y) / viewBox.height);
if (right <= left || bottom <= top) return null;
return {
x: roundKanjiVGGeometry(left),
y: roundKanjiVGGeometry(top),
width: roundKanjiVGGeometry(right - left),
height: roundKanjiVGGeometry(bottom - top)
};
}
function clampUnit(value) {
return Math.max(0, Math.min(1, value));
}
function roundKanjiVGGeometry(value) {
return Number(value.toFixed(4));
}
function inheritedKanjiVGPosition(group, root) {
let parent = group.parentElement;
while (parent && parent !== root) {
const position = parent.getAttribute("kvg:position");
if (position) return position;
parent = parent.parentElement;
}
return "";
}
function cleanComponent(value) {
return value.replace(/\s+/g, " ").trim();
}
function requestText$2(url) {
return requestText$7(url, {
timeoutMs: 8e3,
failureLabel: "Stroke-order request",
timeoutLabel: "Stroke-order request timed out."
});
}
const AUTO_SCAN_OBSERVER_OPTIONS = {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ["class", "style", "hidden", "open", "aria-hidden", "aria-expanded"]
};
const HAS_JAPANESE = /[\u3040-\u30ff\u3400-\u9fff]/;
function mutationTouchesAsbPlayer(mutation) {
const nodes = [
mutation.target,
...Array.from(mutation.addedNodes)
];
return nodes.some((node) => {
const element2 = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return Boolean(element2?.closest?.(".asbplayer-offscreen, .asbplayer-subtitles-container-bottom"));
});
}
function mutationInsideReaderRoot$1(mutation) {
const nodes = [
mutation.target,
...Array.from(mutation.addedNodes),
...Array.from(mutation.removedNodes)
];
return nodes.every((node) => {
const element2 = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return Boolean(element2?.closest?.("[data-jpdb-reader-root]"));
});
}
function mutationMayContainJapaneseText(mutation) {
if (mutation.type === "characterData") return HAS_JAPANESE.test(mutation.target.textContent ?? "");
if (mutation.type === "attributes") return HAS_JAPANESE.test(mutation.target.textContent ?? "");
return Array.from(mutation.addedNodes).some((node) => HAS_JAPANESE.test(node.textContent ?? ""));
}
const PARSEABLE_SELECTOR = ".jpdb-reader-parseable";
function nestedTextParsePlan(root, limit) {
const targets = Array.from(root.querySelectorAll(PARSEABLE_SELECTOR)).flatMap((parseRoot) => collectFragmentTextTargetsIn(parseRoot, limit, false, "", { includeReaderRoot: true, allowUiText: true, minLength: 1 })).slice(0, limit);
return targets.length ? { targets, parseKey: nestedParseKey(targets) } : null;
}
function nestedParseAlreadyScheduled(root, parseKey) {
return root.dataset.jpdbReaderParseKey === parseKey || root.dataset.jpdbReaderParseLoadingKey === parseKey;
}
function applyNestedParsePlan(plan, parsed, settings) {
plan.targets.forEach((target, index) => applyTokensToScanTarget(target, parsed[index] ?? [], settings));
}
function clearNestedParseLoadingKey(root, parseKey) {
if (root.dataset.jpdbReaderParseLoadingKey === parseKey) delete root.dataset.jpdbReaderParseLoadingKey;
}
function clearNestedParseState(root) {
delete root.dataset.jpdbReaderParseKey;
delete root.dataset.jpdbReaderParseLoadingKey;
}
function nestedParseKey(targets) {
return targets.map((target) => target.text).join("\n\n");
}
const log$b = Logger.scope("Onboarding");
function selectedOnboardingLanguage(value, fallback) {
return value === "en" || value === "ja" || value === "auto" ? value : fallback;
}
class OnboardingController {
constructor(options) {
this.options = options;
}
panel;
backdrop;
languageSelect;
async showIfNeeded() {
if (this.options.getSettings().onboardingSeen) {
return false;
}
this.show();
return true;
}
show() {
log$b.info("Showing onboarding", { language: this.options.getSettings().interfaceLanguage });
this.close();
this.backdrop = document.createElement("div");
this.backdrop.className = "jpdb-reader-backdrop jpdb-reader-onboarding-backdrop";
this.backdrop.dataset.jpdbReaderRoot = "true";
this.panel = document.createElement("section");
this.panel.className = "jpdb-reader-onboarding";
this.panel.dataset.jpdbReaderRoot = "true";
this.panel.setAttribute("role", "dialog");
this.panel.setAttribute("aria-modal", "true");
this.panel.setAttribute("aria-label", uiText(this.options.getSettings().interfaceLanguage, "welcomeLabel"));
this.panel.tabIndex = -1;
const closeButton = button("");
closeButton.className = "jpdb-reader-icon-mini jpdb-reader-onboarding-close";
closeButton.dataset.onboardingAction = "close";
closeButton.title = uiText(this.options.getSettings().interfaceLanguage, "closeOnboarding");
closeButton.setAttribute("aria-label", uiText(this.options.getSettings().interfaceLanguage, "closeOnboarding"));
closeButton.innerHTML = closeIcon$1();
closeButton.addEventListener("click", () => void this.complete(false));
const eyebrow = element("div", "jpdb-reader-onboarding-eyebrow", uiText(this.options.getSettings().interfaceLanguage, "onboardingEyebrow"));
const title = element("h2", "", APP_NAME);
const copy = element(
"p",
"",
uiText(this.options.getSettings().interfaceLanguage, "onboardingCopy")
);
const featureGrid = document.createElement("div");
featureGrid.className = "jpdb-reader-onboarding-grid";
const featureKeys = [
["featureText", "featureTextBody"],
["featureImages", "featureImagesBody"],
["featureVideo", "featureVideoBody"],
["featureControl", "featureControlBody"]
];
featureKeys.forEach(([headingKey, textKey]) => {
const card = document.createElement("div");
card.append(
element("strong", "", uiText(this.options.getSettings().interfaceLanguage, headingKey)),
element("span", "", uiText(this.options.getSettings().interfaceLanguage, textKey))
);
featureGrid.append(card);
});
const language = document.createElement("label");
language.className = "jpdb-reader-onboarding-language";
const languageText = element("span", "", uiText(this.options.getSettings().interfaceLanguage, "onboardingLanguage"));
this.languageSelect = document.createElement("select");
this.languageSelect.name = "interfaceLanguage";
[
["auto", uiText(this.options.getSettings().interfaceLanguage, "automatic")],
["en", uiText(this.options.getSettings().interfaceLanguage, "english")],
["ja", uiText(this.options.getSettings().interfaceLanguage, "japanese")]
].forEach(([value, text2]) => {
const option = document.createElement("option");
option.value = value;
option.textContent = text2;
option.selected = value === this.options.getSettings().interfaceLanguage;
this.languageSelect?.append(option);
});
language.append(languageText, this.languageSelect);
const actions = document.createElement("div");
actions.className = "jpdb-reader-onboarding-actions";
const setup = button(uiText(this.options.getSettings().interfaceLanguage, "onboardingAddApiKey"));
setup.className = "jpdb-reader-btn add";
setup.dataset.onboardingAction = "api-key";
setup.addEventListener("click", () => void this.complete(true));
const dictionaries = button(uiText(this.options.getSettings().interfaceLanguage, "onboardingUseWithoutApiKey"));
dictionaries.className = "jpdb-reader-btn";
dictionaries.dataset.onboardingAction = "without-api";
dictionaries.addEventListener("click", () => void this.complete("dictionaries"));
actions.append(setup, dictionaries);
this.languageSelect.addEventListener("change", () => {
const language2 = normalizeLanguage(this.languageSelect?.value, this.options.getSettings().interfaceLanguage);
log$b.info("Onboarding language changed", { language: language2 });
this.options.setSettings({ ...this.options.getSettings(), interfaceLanguage: language2 });
this.localize(language2);
});
this.panel.append(closeButton, eyebrow, title, copy, language, actions, featureGrid);
document.body.append(this.backdrop, this.panel);
this.panel.focus();
}
localize(language) {
const panel = this.panel;
if (!panel) return;
panel.setAttribute("aria-label", uiText(language, "welcomeLabel"));
panel.querySelector(".jpdb-reader-onboarding-eyebrow")?.replaceChildren(uiText(language, "onboardingEyebrow"));
const copy = panel.querySelector("p");
copy?.replaceChildren(uiText(language, "onboardingCopy"));
panel.querySelector(".jpdb-reader-onboarding-language span")?.replaceChildren(uiText(language, "onboardingLanguage"));
const options = [
["auto", uiText(language, "automatic")],
["en", uiText(language, "english")],
["ja", uiText(language, "japanese")]
];
options.forEach(([value, text2]) => {
const option = this.languageSelect?.querySelector(`option[value="${value}"]`);
if (option) option.textContent = text2;
});
const cards = Array.from(panel.querySelectorAll(".jpdb-reader-onboarding-grid > div"));
const cardKeys = [
["featureText", "featureTextBody"],
["featureImages", "featureImagesBody"],
["featureVideo", "featureVideoBody"],
["featureControl", "featureControlBody"]
];
cards.forEach((card, index) => {
const [headingKey, bodyKey] = cardKeys[index] ?? cardKeys[0];
card.querySelector("strong")?.replaceChildren(uiText(language, headingKey));
card.querySelector("span")?.replaceChildren(uiText(language, bodyKey));
});
panel.querySelector('[data-onboarding-action="api-key"]')?.replaceChildren(uiText(language, "onboardingAddApiKey"));
panel.querySelector('[data-onboarding-action="without-api"]')?.replaceChildren(uiText(language, "onboardingUseWithoutApiKey"));
const closeButton = panel.querySelector('[data-onboarding-action="close"]');
closeButton?.setAttribute("aria-label", uiText(language, "closeOnboarding"));
closeButton?.setAttribute("title", uiText(language, "closeOnboarding"));
}
async complete(openSettings) {
const done = log$b.time("Onboarding complete", { openSettings });
const settings = this.completedOnboardingSettings(openSettings);
try {
this.options.setSettings(settings);
await saveSettings(settings);
this.close();
this.openPostOnboardingSettings(openSettings);
log$b.info("Onboarding completed", { openSettings, language: settings.interfaceLanguage });
} catch (error) {
log$b.warn("Onboarding completion failed", { openSettings, error });
throw error;
} finally {
done();
}
}
completedOnboardingSettings(openSettings) {
const current = this.options.getSettings();
return {
...current,
onboardingSeen: true,
jpdbDefinitionsEnabled: openSettings === true,
localDictionariesEnabled: openSettings !== true,
dictionaryLookupLinks: defaultDictionaryLookupLinks(openSettings === true ? "jpdb" : "local"),
interfaceLanguage: selectedOnboardingLanguage(this.languageSelect?.value, current.interfaceLanguage)
};
}
openPostOnboardingSettings(openSettings) {
if (openSettings === "dictionaries") this.options.showSettings("dictionaries");
else if (openSettings) this.options.showSettings();
}
close() {
this.panel?.remove();
this.backdrop?.remove();
this.panel = void 0;
this.backdrop = void 0;
this.languageSelect = void 0;
}
}
function normalizeLanguage(value, fallback) {
return value === "en" || value === "ja" || value === "auto" ? value : fallback;
}
function element(tag, className, text2) {
const node = document.createElement(tag);
if (className) node.className = className;
node.textContent = text2;
return node;
}
function button(text2) {
const node = document.createElement("button");
node.type = "button";
node.textContent = text2;
return node;
}
function closeIcon$1() {
return ' ';
}
const ORIGIN_GRAPH_DRAG_THRESHOLD_PX = 6;
const ORIGIN_GRAPH_EDGE_PADDING_PERCENT = 1.8;
function installOriginGraphInteractions(root) {
root.querySelectorAll(".jpdb-reader-origin-graph-wrap").forEach((wrap) => {
if (wrap.dataset.graphDragInstalled === "true") {
refreshOriginGraphEdgesAfterLayout(wrap);
return;
}
wrap.dataset.graphDragInstalled = "true";
installOriginGraphDrag(wrap);
installOriginGraphRefreshHooks(wrap);
refreshOriginGraphEdgesAfterLayout(wrap);
});
}
function installOriginGraphDrag(wrap) {
let active = null;
let suppressClick = false;
wrap.addEventListener("pointerdown", (event) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
const target = event.target instanceof Element ? event.target : null;
const node = target?.closest(".jpdb-reader-origin-graph-node");
if (!node || !wrap.contains(node)) return;
const pointer = originGraphPointerPercent(wrap, event);
const center = originGraphNodeCenter(node);
active = {
node,
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
grabOffsetX: center.x - pointer.x,
grabOffsetY: center.y - pointer.y,
moved: false
};
node.classList.add("dragging");
node.setPointerCapture?.(event.pointerId);
});
wrap.addEventListener("pointermove", (event) => {
if (!active || active.pointerId !== event.pointerId) return;
if (!active.moved && pointerDistance(active, event) < ORIGIN_GRAPH_DRAG_THRESHOLD_PX) return;
event.preventDefault();
active.moved = true;
const pointer = originGraphPointerPercent(wrap, event);
const next = clampOriginGraphNodePosition(wrap, active.node, pointer.x + active.grabOffsetX, pointer.y + active.grabOffsetY);
moveOriginGraphNode(active.node, next.x, next.y);
refreshOriginGraphEdges(wrap);
});
const finish = (event) => {
if (!active || active.pointerId !== event.pointerId) return;
active.node.classList.remove("dragging");
active.node.releasePointerCapture?.(event.pointerId);
if (active.moved) {
suppressClick = true;
event.preventDefault();
event.stopPropagation();
}
active = null;
};
wrap.addEventListener("pointerup", finish);
wrap.addEventListener("pointercancel", finish);
wrap.addEventListener("click", (event) => {
if (!suppressClick) return;
suppressClick = false;
event.preventDefault();
event.stopPropagation();
}, true);
}
function installOriginGraphRefreshHooks(wrap) {
wrap.closest("details")?.addEventListener("toggle", () => refreshOriginGraphEdgesAfterLayout(wrap));
wrap.querySelectorAll(".jpdb-reader-origin-graph-toggle input").forEach((input2) => {
input2.addEventListener("change", () => refreshOriginGraphEdgesAfterLayout(wrap));
});
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(() => refreshOriginGraphEdgesAfterLayout(wrap));
observer.observe(wrap);
wrap.querySelectorAll(".jpdb-reader-origin-graph-node").forEach((node) => observer.observe(node));
}
}
function pointerDistance(active, event) {
return Math.hypot(event.clientX - active.startX, event.clientY - active.startY);
}
function refreshOriginGraphEdgesAfterLayout(wrap) {
setOriginGraphReady(wrap, refreshOriginGraphEdges(wrap));
requestOriginGraphFrame(() => {
setOriginGraphReady(wrap, refreshOriginGraphEdges(wrap));
});
}
function requestOriginGraphFrame(callback) {
const requestFrame = typeof window.requestAnimationFrame === "function" ? window.requestAnimationFrame.bind(window) : (frameCallback) => window.setTimeout(() => frameCallback(performance.now()), 0);
requestFrame(callback);
}
function setOriginGraphReady(wrap, ready) {
if (ready) {
wrap.dataset.graphReady = "true";
} else {
delete wrap.dataset.graphReady;
}
}
function originGraphPointerPercent(wrap, event) {
const rect = wrap.getBoundingClientRect();
if (!rect.width || !rect.height) return { x: 50, y: 50 };
return {
x: (event.clientX - rect.left) / rect.width * 100,
y: (event.clientY - rect.top) / rect.height * 100
};
}
function clampOriginGraphNodePosition(wrap, node, x, y) {
const measured = measuredOriginGraphNodeRadii(wrap, node);
const fallbackRx = Number(node.dataset.rx || 5);
const fallbackRy = Number(node.dataset.ry || 5);
const rx = measured.rx || fallbackRx;
const ry = measured.ry || fallbackRy;
return {
x: clampGraphPercent(x, rx + ORIGIN_GRAPH_EDGE_PADDING_PERCENT, 100 - rx - ORIGIN_GRAPH_EDGE_PADDING_PERCENT),
y: clampGraphPercent(y, ry + ORIGIN_GRAPH_EDGE_PADDING_PERCENT, 100 - ry - ORIGIN_GRAPH_EDGE_PADDING_PERCENT)
};
}
function moveOriginGraphNode(node, x, y) {
node.dataset.x = String(x);
node.dataset.y = String(y);
node.style.left = `${x}%`;
node.style.top = `${y}%`;
}
function refreshOriginGraphEdges(wrap) {
const wrapRect = wrap.getBoundingClientRect();
if (!wrapRect.width || !wrapRect.height) return false;
wrap.querySelectorAll(".jpdb-reader-origin-edge-group").forEach((group) => {
const from = originGraphNodeGeometry(wrap, group.dataset.from);
const to = originGraphNodeGeometry(wrap, group.dataset.to);
if (!from || !to) return;
const edgePath = graphEdgePath(from, to, originGraphTargetZone(group.dataset.targetZone));
const path = group.querySelector(".jpdb-reader-origin-edge");
path?.setAttribute("d", edgePath.d);
});
return true;
}
function originGraphNodeGeometry(wrap, id) {
if (!id) return null;
const node = Array.from(wrap.querySelectorAll(".jpdb-reader-origin-graph-node")).find((candidate) => candidate.dataset.graphNode === id);
if (!node) return null;
const measured = measuredOriginGraphNodeRadii(wrap, node);
return {
...originGraphNodeCenter(node),
rx: measured.rx || Number(node.dataset.rx || 5),
ry: measured.ry || Number(node.dataset.ry || 5)
};
}
function originGraphNodeCenter(node) {
return {
x: Number(node.dataset.x || 0),
y: Number(node.dataset.y || 0)
};
}
function measuredOriginGraphNodeRadii(wrap, node) {
const wrapRect = wrap.getBoundingClientRect();
if (!wrapRect.width || !wrapRect.height) return { rx: 0, ry: 0 };
const width = node.offsetWidth || node.getBoundingClientRect().width;
const height = node.offsetHeight || node.getBoundingClientRect().height;
return {
rx: width > 0 ? width / 2 / wrapRect.width * 100 : 0,
ry: height > 0 ? height / 2 / wrapRect.height * 100 : 0
};
}
function originGraphTargetZone(value) {
return value === "top" || value === "upper" || value === "left" || value === "right" || value === "lower" || value === "bottom" || value === "center" ? value : "auto";
}
function clampGraphPercent(value, min = 0, max2 = 100) {
return Math.max(min, Math.min(max2, Number(value.toFixed(2))));
}
const MAX_CACHE_ITEMS = 36;
const GOOGLE_LENS_ENDPOINT = "https://lensfrontend-pa.googleapis.com/v1/crupload";
const GOOGLE_LENS_API_KEY = "AIzaSyDr2UxVnv_U85AbhhY8XSHSIavUW0DC-sY";
const DEFAULT_LOCAL_OCR_ENDPOINT_URL = "http://127.0.0.1:7331/ocr";
const LENS_PLATFORM_WEB = 3;
const LENS_SURFACE_CHROMIUM = 4;
const LENS_AUTO_FILTER = 7;
const LENS_WRITING_TOP_TO_BOTTOM = 2;
const log$a = Logger.scope("OCR");
const OCR_RECOGNIZERS = {
"google-lens": recognizeViaGoogleLens,
"cloud-vision": recognizeViaCloudVision,
"local-service": recognizeViaLocalService
};
const OCR_PROVIDER_CONFIGURED = {
"google-lens": () => true,
"cloud-vision": (settings) => Boolean(settings.ocrCloudVisionApiKey.trim()),
"local-service": () => true
};
const OCR_PROVIDER_LABELS = {
"google-lens": () => "google-lens",
"cloud-vision": (settings) => settings.ocrCloudVisionApiKey.trim() ? "cloud-vision" : null,
"local-service": localServiceProviderLabel
};
function shouldSkipOcrRequest(state, userRequested) {
return state.autoSkipped && !userRequested;
}
function updateOcrRequestFlags(state, image, userRequested) {
state.overlayRequested ||= userRequested || Boolean(readFallbackOcrResult(image, false));
state.manualRequested ||= userRequested;
if (userRequested) state.autoSkipped = false;
}
function isOcrImageStateIdle(state) {
return !state.result && !state.loading && !state.autoSkipped;
}
function showOcrReadingStatus(state, settings) {
state.status.hidden = false;
state.status.textContent = uiText(settings.interfaceLanguage, "ocrReadingImage");
}
function beginOcrScan(state, image, settings, manualRequested) {
state.loading = true;
state.status.hidden = !state.overlayRequested;
state.status.textContent = uiText(settings.interfaceLanguage, "ocrReadingImage");
const provider = inlineProviderLabel(settings);
return {
provider,
done: log$a.time("scanImage", { provider, image: imageSummary(image), manualRequested })
};
}
function finishOcrScan(state) {
state.loading = false;
state.manualRequested = false;
}
function renderNoOcrLines(state, settings, manualRequested) {
state.autoSkipped = !manualRequested;
state.status.textContent = uiText(settings.interfaceLanguage, "ocrNoJapaneseText");
state.status.hidden = !state.overlayRequested || state.autoSkipped;
}
function renderOcrErrorStatus(state, settings, provider, manualRequested, error) {
state.status.textContent = ocrVisibleErrorMessage(settings, error);
state.autoSkipped = !manualRequested;
state.status.hidden = !state.overlayRequested || state.autoSkipped;
log$a.warn("OCR scan failed", { provider, manualRequested }, error);
}
function ocrVisibleErrorMessage(settings, error) {
if (resolveUiLanguage(settings.interfaceLanguage) === "ja") return uiText(settings.interfaceLanguage, "ocrFailed");
return error instanceof Error ? error.message : uiText(settings.interfaceLanguage, "ocrFailed");
}
class ImageOcrController {
constructor(options) {
this.options = options;
}
states = /* @__PURE__ */ new Map();
cache = /* @__PURE__ */ new Map();
observer;
observerMargin = "";
mutationObserver;
queue = [];
busy = false;
positionFrame = 0;
refreshTimer = 0;
init() {
this.refresh();
window.addEventListener("scroll", () => {
if (!this.options.getSettings().ocrEnabled) return;
this.schedulePosition();
this.scheduleRefresh(240);
}, { passive: true });
window.addEventListener("resize", () => {
if (!this.options.getSettings().ocrEnabled) return;
this.schedulePosition();
this.scheduleRefresh(300);
}, { passive: true });
this.mutationObserver = new MutationObserver((mutations) => {
const settings = this.options.getSettings();
if (!settings.ocrEnabled) return;
if (mutations.some((mutation) => mutationTouchesRenderableMedia(mutation))) {
this.schedulePosition();
if (settings.ocrAutoScanImages && this.options.shouldAutoScan?.() !== false) this.scheduleRefresh(80);
}
});
this.mutationObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class", "style", "hidden", "src", "srcset", "sizes", "loading", "poster"]
});
log$a.info("OCR controller initialized");
}
refresh(options = {}) {
const settings = this.options.getSettings();
if (!settings.ocrEnabled) {
this.clear();
return;
}
if (this.shouldSkipRefresh(settings, options)) {
this.clear();
return;
}
this.pruneDisconnectedStates();
this.ensureObserver(settings);
const images = this.refreshImages(settings);
for (const image of images) {
this.observeRefreshImage(image, settings);
}
this.schedulePosition();
}
shouldSkipRefresh(settings, options) {
return !options.userRequested && !Array.from(document.images).some(hasFallbackOcrMetadata) && (!settings.ocrAutoScanImages || this.options.shouldAutoScan?.() === false);
}
refreshImages(settings) {
return Array.from(document.images).filter((image) => isCandidateImage(image, settings) && shouldObserveImage(image, settings)).sort((a, b) => this.compareRefreshImages(a, b)).slice(0, settings.ocrMaxImagesPerPage);
}
compareRefreshImages(a, b) {
const priorityDelta = this.observePriority(a) - this.observePriority(b);
return priorityDelta || imageViewportDistance(a) - imageViewportDistance(b);
}
observeRefreshImage(image, settings) {
const state = this.ensureState(image);
this.observer?.observe(image);
if (this.shouldAutoEnqueueImage(image, state, settings)) this.enqueue(image);
}
shouldAutoEnqueueImage(image, state, settings) {
return this.canAutoScanImage(settings) && isOcrImageStateIdle(state) && isNearViewport(image, settings.ocrPrefetchMargin);
}
canAutoScanImage(settings) {
return settings.ocrAutoScanImages && this.options.shouldAutoScan?.() !== false;
}
toggle() {
const settings = this.options.getSettings();
settings.ocrEnabled = !settings.ocrEnabled;
this.options.onToast(uiText(settings.interfaceLanguage, settings.ocrEnabled ? "ocrEnabledToast" : "ocrHiddenToast"));
this.refresh();
log$a.info("OCR toggled", { enabled: settings.ocrEnabled });
}
async scanVisible() {
this.refresh({ userRequested: true });
const images = [...this.states.keys()].filter((image) => isNearViewport(image, 120));
if (!images.length) {
this.options.onToast(uiText(this.options.getSettings().interfaceLanguage, "ocrNoReadableImages"));
return;
}
images.forEach((image) => this.enqueue(image, true));
log$a.info("Manual OCR scan queued images", { images: images.length });
}
captureSourceImageForElement(element2) {
const line = element2?.closest?.(".jpdb-ocr-line");
if (!line) return void 0;
const state = [...this.states.values()].find((candidate) => candidate.overlay.contains(line));
if (!state) return void 0;
const image = captureImageElement(state.image);
return image;
}
ensureObserver(settings) {
const rootMargin = `${settings.ocrPrefetchMargin}px 0px`;
if (this.observer && this.observerMargin === rootMargin) return;
this.observer?.disconnect();
this.observerMargin = rootMargin;
this.observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const image = entry.target;
this.positionState(image);
const current = this.options.getSettings();
if (current.ocrAutoScanImages && shouldObserveImage(image, current)) this.enqueue(image);
}
}, { rootMargin });
}
ensureState(image) {
const existing = this.states.get(image);
if (existing) return existing;
const overlay = document.createElement("div");
overlay.className = "jpdb-ocr-layer";
overlay.dataset.jpdbReaderRoot = "true";
const status = document.createElement("div");
status.className = "jpdb-ocr-status";
status.hidden = true;
overlay.append(status);
document.body.append(overlay);
const state = { image, overlay, status, key: imageCacheKey(image), loading: false, overlayRequested: false, manualRequested: false, autoSkipped: false };
image.addEventListener("load", () => {
this.resetStateIfImageChanged(state);
this.schedulePosition();
this.scheduleRefresh(80);
});
this.states.set(image, state);
return state;
}
enqueue(image, userRequested = false) {
const state = this.states.get(image) ?? this.ensureState(image);
if (!this.shouldQueueOcrRequest(state, image, userRequested)) return;
this.queueOcrRequest(image, state, userRequested);
}
shouldQueueOcrRequest(state, image, userRequested) {
if (shouldSkipOcrRequest(state, userRequested)) return false;
updateOcrRequestFlags(state, image, userRequested);
if (this.renderExistingOcrResult(state, userRequested)) return false;
return !state.loading;
}
queueOcrRequest(image, state, userRequested) {
this.queueImageForOcr(image);
if (userRequested) showOcrReadingStatus(state, this.options.getSettings());
this.drainQueue();
}
renderExistingOcrResult(state, userRequested) {
if (!state.result) return false;
if (userRequested) void this.renderResult(state, state.result, true);
return true;
}
queueImageForOcr(image) {
if (!this.queue.includes(image)) this.queue.push(image);
}
drainQueue() {
if (this.busy) return;
const image = this.queue.shift();
if (!image) return;
this.busy = true;
const hasFastText = Boolean(readFallbackOcrResult(image, false));
const delay2 = this.states.get(image)?.overlayRequested || hasFastText ? 0 : 900;
void waitForIdle(delay2).then(() => this.scanImage(image)).finally(() => {
this.busy = false;
this.drainQueue();
});
}
async scanImage(image) {
const state = this.states.get(image) ?? this.ensureState(image);
const settings = this.options.getSettings();
const key = imageCacheKey(image);
const manualRequested = state.manualRequested;
this.resetStateIfImageChanged(state);
if (await this.renderCachedOcrResult(state, key)) return;
const scan = beginOcrScan(state, image, settings, manualRequested);
try {
await this.scanUncachedImage(state, image, key, settings, scan.provider, manualRequested);
} catch (error) {
await this.renderOcrFailure(state, image, scan.provider, manualRequested, error);
} finally {
finishOcrScan(state);
scan.done();
}
}
async renderCachedOcrResult(state, key) {
const cached = this.cache.get(key);
if (!cached) return false;
await this.renderResult(state, cached);
state.manualRequested = false;
return true;
}
async scanUncachedImage(state, image, key, settings, provider, manualRequested) {
const inlineFallback = readFallbackOcrResult(image, false);
const providerResult = inlineFallback ? null : await this.recognizeImage(image, settings);
const result = inlineFallback ?? providerResult;
if (!result?.lines.length) {
renderNoOcrLines(state, settings, manualRequested);
return;
}
this.remember(key, result);
state.key = key;
await this.renderResult(state, result);
log$a.info("OCR result rendered", { provider, lines: result.lines.length, manualRequested });
}
async renderOcrFailure(state, image, provider, manualRequested, error) {
const fallback = readFallbackOcrResult(image, false);
if (fallback?.lines.length) {
log$a.warn("OCR provider failed; rendered fallback metadata", { provider }, error);
await this.renderResult(state, fallback);
return;
}
renderOcrErrorStatus(state, this.options.getSettings(), provider, manualRequested, error);
}
recognizeImage(image, settings) {
const recognizer = ocrRecognizer(settings);
return recognizer ? recognizer(image, settings) : Promise.resolve(null);
}
async renderResult(state, result, forceOverlay = false) {
state.result = result;
state.status.hidden = true;
state.overlay.querySelectorAll(".jpdb-ocr-line").forEach((node) => node.remove());
const settings = this.options.getSettings();
const showText = settings.ocrShowTextOverlay || forceOverlay;
const sentence = result.lines.map((line) => line.text).join("\n");
const parsed = await this.parseOcrLines(result.lines, settings);
applyOcrOverlayStyle(state.overlay, settings);
for (const [index, line] of result.lines.entries()) {
state.overlay.append(this.renderOcrLineElement(state, result, line, parsed[index] ?? [], sentence, showText, settings));
}
this.positionState(state.image);
}
async parseOcrLines(lines, settings) {
if (!settings.apiKey.trim() && !settings.localDictionariesEnabled) return lines.map(() => []);
return Promise.all(lines.map((line) => this.options.parseJapanese(line.text).catch(() => {
return [];
})));
}
renderOcrLineElement(state, result, line, tokens, sentence, showText, settings) {
const element2 = createOcrLineElement(result, line, tokens, sentence, showText, settings);
element2.addEventListener("pointerenter", (event) => {
if (event.pointerType !== "touch") this.activateLine(state, element2, false);
});
element2.addEventListener("pointerleave", (event) => {
if (event.pointerType !== "touch" && element2.dataset.pinned !== "true") this.deactivateLine(element2);
});
element2.addEventListener("focus", () => {
if (element2.dataset.pinned !== "true") this.activateLine(state, element2, false);
});
element2.addEventListener("blur", () => {
if (element2.dataset.pinned !== "true") this.deactivateLine(element2);
});
element2.addEventListener("click", (event) => this.toggleOcrLinePinned(state, element2, event));
return element2;
}
toggleOcrLinePinned(state, element2, event) {
if (event.target.closest(".jpdb-reader-word")) return;
event.preventDefault();
event.stopPropagation();
if (element2.classList.contains("jpdb-ocr-line-active") && element2.dataset.pinned === "true") {
this.deactivateLine(element2);
return;
}
element2.focus({ preventScroll: true });
this.activateLine(state, element2, true);
}
activateLine(state, element2, pinned) {
const pinnedLine = state.overlay.querySelector('.jpdb-ocr-line-active[data-pinned="true"]');
if (!pinned && pinnedLine && pinnedLine !== element2) return;
state.overlay.querySelectorAll(".jpdb-ocr-line-active").forEach((line) => {
if (line === element2) return;
this.deactivateLine(line);
});
element2.classList.add("jpdb-ocr-line-active");
element2.dataset.pinned = pinned ? "true" : "false";
}
deactivateLine(element2) {
element2.classList.remove("jpdb-ocr-line-active");
element2.dataset.pinned = "false";
}
observePriority(image) {
const state = this.states.get(image);
if (!state) return 0;
if (!state.result) return state.autoSkipped ? 2 : 0;
return 1;
}
resetStateIfImageChanged(state) {
const key = imageCacheKey(state.image);
if (key === state.key) return;
state.key = key;
state.result = void 0;
state.loading = false;
state.overlayRequested = false;
state.manualRequested = false;
state.autoSkipped = false;
state.overlay.querySelectorAll(".jpdb-ocr-line").forEach((node) => node.remove());
state.status.hidden = true;
}
remember(key, result) {
this.cache.set(key, result);
while (this.cache.size > MAX_CACHE_ITEMS) {
const oldest = this.cache.keys().next().value;
if (!oldest) break;
this.cache.delete(oldest);
}
}
schedulePosition() {
if (this.positionFrame) return;
this.positionFrame = requestAnimationFrame(() => {
this.positionFrame = 0;
for (const image of this.states.keys()) this.positionState(image);
});
}
scheduleRefresh(delay2) {
window.clearTimeout(this.refreshTimer);
this.refreshTimer = window.setTimeout(() => this.refresh(), delay2);
}
positionState(image) {
const state = this.states.get(image);
if (!state) return;
const rect = image.getBoundingClientRect();
const visible = isImageVisibleForOcr(image, rect);
state.overlay.hidden = !visible;
if (!visible) return;
state.overlay.style.left = `${rect.left}px`;
state.overlay.style.top = `${rect.top}px`;
state.overlay.style.width = `${rect.width}px`;
state.overlay.style.height = `${rect.height}px`;
this.fitLineFonts(state, rect.width, rect.height);
}
fitLineFonts(state, imageWidth, imageHeight) {
const scale = this.options.getSettings().ocrFontScale;
state.overlay.querySelectorAll(".jpdb-ocr-line").forEach((element2) => {
const boxLeft = Number(element2.dataset.boxLeft) * imageWidth;
const boxTop = Number(element2.dataset.boxTop) * imageHeight;
const boxWidth = Number(element2.dataset.boxWidth) * imageWidth;
const boxHeight = Number(element2.dataset.boxHeight) * imageHeight;
if (!Number.isFinite(boxWidth) || !Number.isFinite(boxHeight) || boxWidth <= 0 || boxHeight <= 0) return;
const text2 = element2.dataset.ocrText ?? "";
const vertical = element2.dataset.vertical === "true";
element2.style.fontSize = `${ocrFontPx(text2, boxWidth, boxHeight, vertical, scale)}px`;
this.fitLineFrame(element2, boxLeft, boxTop, boxWidth, boxHeight, imageWidth, imageHeight, vertical);
});
}
fitLineFrame(element2, boxLeft, boxTop, boxWidth, boxHeight, imageWidth, imageHeight, vertical) {
const textElement = element2.querySelector(".jpdb-ocr-line-text");
if (!textElement) return;
const hasFurigana = element2.dataset.hasFuri === "true";
const fontSize = Number.parseFloat(element2.style.fontSize) || 16;
const padX = Math.max(4, Math.round(fontSize * 0.16));
const padTop = hasFurigana ? Math.max(3, Math.round(fontSize * 0.1)) : Math.max(2, Math.round(fontSize * 0.08));
const padBottom = Math.max(3, Math.round(fontSize * 0.1));
element2.style.setProperty("--jpdb-ocr-pad-x", `${padX}px`);
element2.style.setProperty("--jpdb-ocr-pad-top", `${padTop}px`);
element2.style.setProperty("--jpdb-ocr-pad-bottom", `${padBottom}px`);
const contentRect = textElement.getBoundingClientRect();
const contentWidth = Math.max(1, contentRect.width);
const contentHeight = Math.max(1, contentRect.height);
const minHitSize = Math.max(24, Math.round(fontSize * 1.25));
const frameWidth = Math.min(imageWidth, Math.max(boxWidth, minHitSize, contentWidth + padX * 2));
const frameHeight = Math.min(imageHeight, Math.max(boxHeight, minHitSize, contentHeight + padTop + padBottom));
const left = clampNumber$3(boxLeft + boxWidth / 2 - frameWidth / 2, 0, Math.max(0, imageWidth - frameWidth));
const centeredTop = boxTop + boxHeight / 2 - frameHeight / 2;
const baselineAlignedTop = boxTop + boxHeight - frameHeight + padBottom;
const top = clampNumber$3(!vertical ? baselineAlignedTop : centeredTop, 0, Math.max(0, imageHeight - frameHeight));
element2.style.left = `${left}px`;
element2.style.top = `${top}px`;
element2.style.width = `${frameWidth}px`;
element2.style.height = `${frameHeight}px`;
}
clear() {
this.observer?.disconnect();
this.observer = void 0;
this.observerMargin = "";
window.clearTimeout(this.refreshTimer);
this.queue = [];
for (const state of this.states.values()) state.overlay.remove();
this.states.clear();
}
pruneDisconnectedStates() {
for (const [image, state] of this.states) {
if (image.isConnected) continue;
this.observer?.unobserve(image);
state.overlay.remove();
this.states.delete(image);
}
}
}
function applyOcrOverlayStyle(overlay, settings) {
overlay.style.setProperty("--jpdb-ocr-text-color", settings.ocrTextColor);
overlay.style.setProperty("--jpdb-ocr-outline-color", settings.ocrOutlineColor);
overlay.style.setProperty("--jpdb-ocr-background-rgba", accentToRgba(settings.ocrBackgroundColor, settings.ocrBackgroundOpacity));
overlay.style.setProperty("--jpdb-ocr-background-active-rgba", accentToRgba(settings.ocrBackgroundColor, Math.min(1, settings.ocrBackgroundOpacity + 0.12)));
}
function createOcrLineElement(result, line, tokens, sentence, showText, settings) {
const element2 = document.createElement("div");
element2.className = showText ? "jpdb-ocr-line jpdb-ocr-line-visible" : "jpdb-ocr-line";
setOcrLineDataset(element2, result, line, sentence);
element2.title = line.text;
element2.tabIndex = 0;
element2.style.writingMode = line.vertical ? "vertical-rl" : "horizontal-tb";
element2.setAttribute("aria-label", line.text);
const textElement = createOcrLineText(line, tokens, settings);
element2.append(textElement);
element2.dataset.hasFuri = String(Boolean(textElement.querySelector(".jpdb-reader-has-furi")));
setOcrLinePosition(element2, result, line);
return element2;
}
function setOcrLineDataset(element2, result, line, sentence) {
element2.dataset.ocrText = line.text;
element2.dataset.boxLeft = String(line.box.left / result.width);
element2.dataset.boxTop = String(line.box.top / result.height);
element2.dataset.vertical = String(line.vertical);
element2.dataset.boxWidth = String(line.box.width / result.width);
element2.dataset.boxHeight = String(line.box.height / result.height);
element2.dataset.sentence = sentence;
}
function createOcrLineText(line, tokens, settings) {
const textElement = document.createElement("span");
textElement.className = "jpdb-ocr-line-text";
setInnerHtml(textElement, tokens.length ? renderTokensToHtml(line.text, tokens, settings) : escapeHtml$1(line.text));
normalizeOcrRuby(textElement);
normalizeOcrPlainText(textElement);
return textElement;
}
function setOcrLinePosition(element2, result, line) {
element2.style.left = `${100 * line.box.left / result.width}%`;
element2.style.top = `${100 * line.box.top / result.height}%`;
element2.style.width = `${100 * line.box.width / result.width}%`;
element2.style.height = `${100 * line.box.height / result.height}%`;
}
function captureImageElement(image) {
try {
if (!image.naturalWidth || !image.naturalHeight) return void 0;
const canvas = document.createElement("canvas");
const maxWidth = 960;
const scale = Math.min(1, maxWidth / image.naturalWidth);
canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
const context = canvas.getContext("2d");
if (!context) return void 0;
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/jpeg", 0.84);
} catch {
return void 0;
}
}
function normalizeOcrResult(value, fallbackWidth = 1, fallbackHeight = 1) {
if (!value || typeof value !== "object") return null;
const record = value;
const cloudVision = normalizeCloudVisionResponse(record, fallbackWidth, fallbackHeight);
if (cloudVision) return cloudVision;
const { width, height } = ocrResultDimensions(record, fallbackWidth, fallbackHeight);
const lines = collectGenericOcrLines(record, width, height);
return japaneseOcrResult(width, height, lines);
}
function ocrResultDimensions(record, fallbackWidth, fallbackHeight) {
const resolution = record.context_resolution;
const width = numberFrom(record.width) || numberFrom(resolution?.width) || fallbackWidth;
const height = numberFrom(record.height) || numberFrom(resolution?.height) || fallbackHeight;
return { width, height };
}
function collectGenericOcrLines(record, width, height) {
const lines = [];
appendGenericOcrLines(lines, genericRawLines(record), width, height, normalizeSimpleLines);
appendGenericOcrLines(lines, record.results, width, height, normalizeStructuredOcrResults);
appendGenericOcrLines(lines, record.ocr_regions, width, height, normalizeOcrRegionResults);
return lines;
}
function genericRawLines(record) {
return Array.isArray(record.lines) ? record.lines : record.regions;
}
function appendGenericOcrLines(lines, value, width, height, normalize) {
if (Array.isArray(value)) lines.push(...normalize(value, width, height));
}
function normalizeSimpleLines(values, width, height) {
return values.map((item) => normalizeSimpleLine(item, width, height)).filter((line) => Boolean(line));
}
function normalizeStructuredOcrResults(values, width, height) {
return values.flatMap((item) => normalizeStructuredOcrResult(item, width, height));
}
function normalizeOcrRegionResults(regions, width, height) {
return regions.flatMap((region) => normalizeSingleOcrRegionResults(region, width, height));
}
function normalizeSingleOcrRegionResults(region, width, height) {
const regionRecord = asRecord(region);
if (!regionRecord) return [];
const regionBox = normalizeOcrRegion(regionRecord, width, height);
const { scaleWidth, scaleHeight } = ocrRegionScale(regionBox, width, height);
if (!Array.isArray(regionRecord.results)) return [];
const lines = normalizeStructuredOcrResults(regionRecord.results, scaleWidth, scaleHeight);
return offsetRegionLines(lines, regionBox, width, height);
}
function ocrRegionScale(regionBox, width, height) {
return {
scaleWidth: regionBox?.width ?? width,
scaleHeight: regionBox?.height ?? height
};
}
function offsetRegionLines(lines, regionBox, width, height) {
if (!regionBox) return lines;
return lines.map((line) => offsetLineToRegion(line, regionBox, width, height)).filter((line) => Boolean(line));
}
function japaneseOcrResult(width, height, lines) {
const japaneseLines = lines.filter((line) => line.text.length > 0 && HAS_JAPANESE$1.test(line.text));
return japaneseLines.length ? { width, height, lines: japaneseLines } : null;
}
function readFallbackOcrResult(image, _includeAccessibleText = false) {
const width = image.naturalWidth || image.width || 1;
const height = image.naturalHeight || image.height || 1;
return parseFallbackOcrLines(image.dataset.ocrLines, width, height);
}
function parseFallbackOcrLines(data, width, height) {
if (!data) return null;
try {
return normalizeOcrResult({ width, height, lines: JSON.parse(data) }, width, height);
} catch {
return null;
}
}
function ocrFontPx(text2, boxWidth, boxHeight, vertical, scale) {
const safeScale = Math.max(0.7, Math.min(1.8, scale));
const length = Math.max(1, visualTextLength(text2));
const byBoxThickness = vertical ? boxWidth * 0.72 : boxHeight * 0.58;
const byBoxLength = vertical ? boxHeight / length * 1.12 : boxWidth / length * 1.08;
const fitted = Math.min(byBoxThickness, byBoxLength) * safeScale;
return Math.max(11, Math.min(38, fitted));
}
function visualTextLength(text2) {
return [...text2.trim()].reduce((total, char) => {
if (/\s/.test(char)) return total + 0.35;
if (/[\u0000-\u00ff]/.test(char)) return total + 0.62;
return total + 1;
}, 0);
}
function normalizeOcrRuby(root) {
root.querySelectorAll("ruby").forEach((ruby) => {
const replacement = document.createElement("span");
replacement.className = "jpdb-ocr-ruby";
const furi = document.createElement("span");
furi.className = "jpdb-ocr-furi";
const base = document.createElement("span");
base.className = "jpdb-ocr-ruby-base";
for (const child of Array.from(ruby.childNodes)) {
if (child instanceof HTMLElement && child.tagName === "RT") {
furi.textContent += child.textContent ?? "";
} else if (!(child instanceof HTMLElement && child.tagName === "RP")) {
base.append(child.cloneNode(true));
}
}
replacement.append(furi, base);
ruby.replaceWith(replacement);
});
}
function normalizeOcrPlainText(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
if (!node.textContent?.trim()) return NodeFilter.FILTER_REJECT;
if (parent.classList.contains("jpdb-ocr-furi") || parent.classList.contains("jpdb-ocr-ruby-base")) return NodeFilter.FILTER_REJECT;
return parent === root || parent.classList.contains("jpdb-reader-word") ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
}
});
const textNodes = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node instanceof Text) textNodes.push(node);
}
for (const textNode of textNodes) {
const replacement = document.createElement("span");
replacement.className = "jpdb-ocr-plain";
replacement.textContent = textNode.textContent ?? "";
textNode.replaceWith(replacement);
}
}
function clampNumber$3(value, min, max2) {
return Math.min(max2, Math.max(min, value));
}
async function recognizeViaLocalService(image, settings) {
const payload = await imageToBase64Payload(image, settings.ocrMaxImagePixels);
const engine = settings.ocrEngine === "auto" ? "" : settings.ocrEngine;
const body = JSON.stringify({
id: imageCacheKey(image),
language_code: settings.ocrLanguage || "ja-JP",
language: {
bcp47_tag: settings.ocrLanguage || "ja-JP",
two_letter_code: (settings.ocrLanguage || "ja").slice(0, 2)
},
base64_image: payload.base64,
image: payload.base64,
image_bytes: payload.base64,
ocr_engine: engine,
ocr_adapter_name: engine,
detection_only: false
});
const response = await requestJson(localOcrEndpointUrl(settings), body, settings.audioTimeoutMs);
return normalizeOcrResult(response, payload.width, payload.height);
}
async function recognizeViaCloudVision(image, settings) {
const apiKey = settings.ocrCloudVisionApiKey.trim();
if (!apiKey) return null;
const payload = await imageToBase64Payload(image, settings.ocrMaxImagePixels);
const body = JSON.stringify({
requests: [{
image: { content: payload.base64 },
features: [{ type: "TEXT_DETECTION", maxResults: 50, model: "builtin/latest" }],
imageContext: { languageHints: [(settings.ocrLanguage || "ja-JP").slice(0, 2)] }
}]
});
const url = `https://vision.googleapis.com/v1/images:annotate?key=${encodeURIComponent(apiKey)}`;
const response = await requestJson(url, body, settings.audioTimeoutMs);
return normalizeOcrResult(response, payload.width, payload.height);
}
async function recognizeViaGoogleLens(image, settings) {
const { canvas, blob } = await imageToBlobPayload(image, settings.ocrMaxImagePixels, "image/jpeg", 0.88);
const bytes = new Uint8Array(await blob.arrayBuffer());
const body = createGoogleLensRequest(bytes, canvas.width, canvas.height, settings.ocrLanguage);
try {
const response = await requestArrayBuffer(GOOGLE_LENS_ENDPOINT, body, settings.audioTimeoutMs);
return parseGoogleLensResponse(new Uint8Array(response), canvas.width, canvas.height);
} catch (error) {
log$a.warn("Google Lens protobuf endpoint failed; trying upload fallback", error);
return recognizeViaGoogleLensUpload(blob, canvas.width, canvas.height, settings.audioTimeoutMs);
}
}
function ocrRecognizer(settings) {
const recognizer = OCR_RECOGNIZERS[settings.ocrProvider] ?? null;
return recognizer && isOcrProviderConfigured(settings) ? recognizer : null;
}
function isOcrProviderConfigured(settings) {
return OCR_PROVIDER_CONFIGURED[settings.ocrProvider]?.(settings) ?? false;
}
async function imageToBase64Payload(image, maxPixels) {
const { canvas, blob } = await imageToBlobPayload(image, maxPixels, "image/jpeg", 0.86);
return { base64: (await blobToDataUrl(blob)).split(",")[1] ?? "", width: canvas.width, height: canvas.height };
}
async function imageToBlobPayload(image, maxPixels, type, quality) {
const canvas = await imageToCanvas(image, maxPixels);
try {
return { canvas, blob: await canvasToBlob(canvas, type, quality) };
} catch {
const fallbackCanvas = await imageBlobToCanvas(image, maxPixels);
return { canvas: fallbackCanvas, blob: await canvasToBlob(fallbackCanvas, type, quality) };
}
}
async function recognizeViaGoogleLensUpload(blob, width, height, timeout) {
const data = new FormData();
data.append("encoded_image", blob, "image.jpg");
const response = await requestTextForm(`https://lens.google.com/v3/upload?stcs=${Date.now().toString().slice(0, 10)}`, data, timeout);
return parseGoogleLensUploadHtml(response, width, height);
}
async function imageToCanvas(image, maxPixels) {
try {
const canvas = drawImageToCanvas(image, maxPixels);
assertCanvasReadable(canvas);
return canvas;
} catch {
return imageBlobToCanvas(image, maxPixels);
}
}
async function imageBlobToCanvas(image, maxPixels) {
const url = image.currentSrc || image.src;
if (!url || url.startsWith("data:")) throw new Error("Image cannot be read by OCR.");
const blob = await requestBlob$1(url);
const objectUrl = URL.createObjectURL(blob);
try {
const loaded = await loadImage(objectUrl);
const canvas = drawImageToCanvas(loaded, maxPixels);
assertCanvasReadable(canvas);
return canvas;
} finally {
URL.revokeObjectURL(objectUrl);
}
}
function drawImageToCanvas(image, maxPixels) {
const size = loadedImageSize(image);
const canvas = scaledCanvas(size, maxPixels);
drawableCanvasContext(canvas).drawImage(image, 0, 0, canvas.width, canvas.height);
return canvas;
}
function loadedImageSize(image) {
const width = image.naturalWidth || image.width;
const height = image.naturalHeight || image.height;
if (!width || !height) throw new Error("Image is not loaded yet.");
return { width, height };
}
function scaledCanvas(size, maxPixels) {
const scale = Math.min(1, Math.sqrt(Math.max(16e4, maxPixels) / (size.width * size.height)));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(size.width * scale));
canvas.height = Math.max(1, Math.round(size.height * scale));
return canvas;
}
function drawableCanvasContext(canvas) {
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas unavailable.");
return context;
}
function assertCanvasReadable(canvas) {
canvas.getContext("2d")?.getImageData(0, 0, 1, 1);
}
function createGoogleLensRequest(imageBytes, width, height, locale) {
const [language = "ja", region = "US"] = (locale || "ja-JP").split(/[-_]/);
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const requestId = protoMessage(
protoVarintField(1, BigInt(Date.now()) * 1000000n + BigInt(Math.floor(Math.random() * 1e6))),
protoVarintField(2, 1),
protoVarintField(3, 1),
protoBytesField(4, randomBytes(16))
);
const localeContext = protoMessage(
protoStringField(1, language || "ja"),
protoStringField(2, region || "US"),
protoStringField(3, timeZone)
);
const clientFilters = protoMessage(protoMessageField(1, protoMessage(protoVarintField(1, LENS_AUTO_FILTER))));
const clientContext = protoMessage(
protoVarintField(1, LENS_PLATFORM_WEB),
protoVarintField(2, LENS_SURFACE_CHROMIUM),
protoMessageField(4, localeContext),
protoMessageField(17, clientFilters)
);
const requestContext = protoMessage(
protoMessageField(3, requestId),
protoMessageField(4, clientContext)
);
const imageData = protoMessage(
protoMessageField(1, protoMessage(protoBytesField(1, imageBytes))),
protoMessageField(3, protoMessage(protoVarintField(1, width), protoVarintField(2, height)))
);
return protoMessage(protoMessageField(1, protoMessage(
protoMessageField(1, requestContext),
protoMessageField(3, imageData)
)));
}
function parseGoogleLensResponse(bytes, width, height) {
const root = decodeProtoMessage(bytes);
const objectsResponse = protoFirstMessage(root, 2);
const text2 = objectsResponse ? protoFirstMessage(objectsResponse, 3) : null;
const layout = text2 ? protoFirstMessage(text2, 1) : null;
if (!layout) return null;
const lines = [];
for (const paragraph of protoMessages(layout, 1)) {
const paragraphVertical = protoNumber(paragraph, 4) === LENS_WRITING_TOP_TO_BOTTOM;
const paragraphBox = protoBox(protoFirstMessage(paragraph, 3), width, height);
for (const line of protoMessages(paragraph, 2)) {
const lineBox = protoBox(protoFirstMessage(line, 2), width, height);
const words = protoMessages(line, 1).map((word) => ({
text: protoString(word, 2),
separator: protoString(word, 3),
box: protoBox(protoFirstMessage(word, 4), width, height)
})).filter((word) => word.text);
const orderedWords = paragraphVertical ? words : [...words].sort((a, b) => (a.box?.left ?? 0) - (b.box?.left ?? 0));
const rawText = orderedWords.map((word, index) => word.text + (word.separator || (index < orderedWords.length - 1 ? " " : ""))).join("");
const textValue = cleanOcrText(rawText);
if (!textValue || !HAS_JAPANESE$1.test(textValue)) continue;
const box = lineBox ?? unionBoxes(words.map((word) => word.box).filter((item) => Boolean(item))) ?? paragraphBox;
if (!box) continue;
lines.push({
text: textValue,
box,
vertical: paragraphVertical || box.height > box.width * 1.25 && textValue.length > 1
});
}
}
return lines.length ? { width, height, lines } : null;
}
function parseGoogleLensUploadHtml(html, width, height) {
const literal = googleLensUploadCallbackLiteral(html, "ds:1");
if (!literal) return null;
try {
const callback = parseJsDataLiteral(literal);
const blocks = callback.data?.[2]?.[3]?.[0] ?? [];
const lines = [];
for (const block of blocks) {
const blockData = block;
const rawLines = blockData[2]?.[0]?.[5]?.[3];
const lineItems = rawLines?.[0];
if (!Array.isArray(lineItems)) continue;
for (const item of lineItems) {
const lineData = item;
const words = Array.isArray(lineData[0]) ? lineData[0] : [];
const boxData = Array.isArray(lineData[1]) ? lineData[1] : [];
const text2 = cleanOcrText(words.map((word) => {
const wordData = word;
return `${wordData[0] ?? ""}${wordData[3] ?? ""}`;
}).join(""));
const box = boxData.length >= 4 ? clampBox({
top: Number(boxData[0]) * height,
left: Number(boxData[1]) * width,
width: Number(boxData[2]) * width,
height: Number(boxData[3]) * height
}, width, height) : null;
if (text2 && box && HAS_JAPANESE$1.test(text2)) {
lines.push({ text: text2, box, vertical: box.height > box.width * 1.25 && text2.length > 1 });
}
}
}
return lines.length ? { width, height, lines } : null;
} catch {
return null;
}
}
function googleLensUploadCallbackLiteral(html, key) {
const marker = "AF_initDataCallback(";
let searchIndex = 0;
while (searchIndex < html.length) {
const markerIndex = html.indexOf(marker, searchIndex);
if (markerIndex < 0) return null;
const literalStart = markerIndex + marker.length;
const literal = readBalancedLiteral(html, literalStart);
if (literal && callbackLiteralHasKey(literal, key)) return literal;
searchIndex = literalStart + Math.max(1, literal?.length ?? 1);
}
return null;
}
function callbackLiteralHasKey(literal, key) {
return new RegExp(`\\bkey\\s*:\\s*['"]${escapeRegex(key)}['"]`).test(literal);
}
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function readBalancedLiteral(source, startIndex) {
let index = startIndex;
while (/\s/.test(source[index] ?? "")) index += 1;
if (source[index] !== "{") return null;
let depth = 0;
let quote = "";
for (let current = index; current < source.length; current += 1) {
const char = source[current];
if (quote) {
if (char === "\\") {
current += 1;
} else if (char === quote) {
quote = "";
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === "{" || char === "[" || char === "(") depth += 1;
if (char === "}" || char === "]" || char === ")") depth -= 1;
if (depth === 0) return source.slice(index, current + 1);
}
return null;
}
function parseJsDataLiteral(source) {
let index = 0;
const value = parseValue();
skipWhitespace();
if (index !== source.length) throw new Error("Unexpected trailing data.");
return value;
function parseValue() {
skipWhitespace();
const char = source[index];
if (char === "{") return parseObject();
if (char === "[") return parseArray();
if (char === '"' || char === "'") return parseString();
if (char === "-" || /\d/.test(char ?? "")) return parseNumber();
return parseIdentifierValue();
}
function parseObject() {
const record = {};
index += 1;
skipWhitespace();
while (source[index] !== "}") {
const key = parseObjectKey();
skipWhitespace();
expect(":");
record[key] = parseValue();
skipWhitespace();
if (source[index] === ",") {
index += 1;
skipWhitespace();
continue;
}
break;
}
expect("}");
return record;
}
function parseObjectKey() {
skipWhitespace();
const char = source[index];
if (char === '"' || char === "'") return parseString();
return parseIdentifier();
}
function parseArray() {
const values = [];
index += 1;
skipWhitespace();
while (source[index] !== "]") {
if (source[index] === ",") {
values.push(null);
index += 1;
skipWhitespace();
continue;
}
values.push(parseValue());
skipWhitespace();
if (source[index] === ",") {
index += 1;
skipWhitespace();
continue;
}
break;
}
expect("]");
return values;
}
function parseString() {
const quote = source[index];
let value2 = "";
index += 1;
while (index < source.length) {
const char = source[index++];
if (char === quote) return value2;
if (char !== "\\") {
value2 += char;
continue;
}
value2 += parseEscapeSequence();
}
throw new Error("Unterminated string.");
}
function parseEscapeSequence() {
const escaped = source[index++];
if (escaped === "n") return "\n";
if (escaped === "r") return "\r";
if (escaped === "t") return " ";
if (escaped === "b") return "\b";
if (escaped === "f") return "\f";
if (escaped === "v") return "\v";
if (escaped === "0") return "\0";
if (escaped === "\n") return "";
if (escaped === "\r") {
if (source[index] === "\n") index += 1;
return "";
}
if (escaped === "x") return codePointEscape(2);
if (escaped === "u") return parseUnicodeEscape();
return escaped ?? "";
}
function parseUnicodeEscape() {
if (source[index] === "{") {
const end = source.indexOf("}", index + 1);
if (end < 0) throw new Error("Invalid unicode escape.");
const value2 = Number.parseInt(source.slice(index + 1, end), 16);
index = end + 1;
return Number.isFinite(value2) ? String.fromCodePoint(value2) : "";
}
return codePointEscape(4);
}
function codePointEscape(length) {
const hex = source.slice(index, index + length);
if (!new RegExp(`^[0-9a-fA-F]{${length}}$`).test(hex)) throw new Error("Invalid character escape.");
index += length;
return String.fromCharCode(Number.parseInt(hex, 16));
}
function parseNumber() {
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(source.slice(index));
if (!match) throw new Error("Invalid number.");
index += match[0].length;
return Number(match[0]);
}
function parseIdentifierValue() {
const identifier = parseIdentifier();
if (identifier === "null" || identifier === "undefined" || identifier === "NaN") return null;
if (identifier === "true") return true;
if (identifier === "false") return false;
if (identifier === "Infinity") return Infinity;
return identifier;
}
function parseIdentifier() {
const match = /^[A-Za-z_$][\w$]*/.exec(source.slice(index));
if (!match) throw new Error("Expected identifier.");
index += match[0].length;
return match[0];
}
function skipWhitespace() {
while (/\s/.test(source[index] ?? "")) index += 1;
}
function expect(char) {
if (source[index] !== char) throw new Error(`Expected ${char}.`);
index += 1;
}
}
function normalizeCloudVisionResponse(record, fallbackWidth, fallbackHeight) {
const responses = Array.isArray(record.responses) ? record.responses : "fullTextAnnotation" in record ? [record] : [];
const lines = [];
let width = fallbackWidth;
let height = fallbackHeight;
for (const response of responses) {
const annotation = response?.fullTextAnnotation;
const pages = Array.isArray(annotation?.pages) ? annotation.pages : [];
for (const page of pages) {
const pageRecord = page;
width = numberFrom(pageRecord.width) || width;
height = numberFrom(pageRecord.height) || height;
const blocks = Array.isArray(pageRecord.blocks) ? pageRecord.blocks : [];
for (const block of blocks) {
const paragraphs = Array.isArray(block.paragraphs) ? block.paragraphs : [];
for (const paragraph of paragraphs) {
pushCloudVisionParagraphLines(paragraph, lines, width, height);
}
}
}
const annotations = Array.isArray(response?.textAnnotations) ? response.textAnnotations : [];
if (!lines.length && annotations.length > 1) {
for (const annotationItem of annotations.slice(1)) {
const item = annotationItem;
const text2 = cleanOcrText(item.description);
const box = normalizeCloudVisionVertices(item.boundingPoly?.vertices, width, height);
if (text2 && box && HAS_JAPANESE$1.test(text2)) lines.push({ text: text2, box, vertical: box.height > box.width * 1.25 && text2.length > 1 });
}
}
}
return lines.length ? { width, height, lines } : null;
}
function pushCloudVisionParagraphLines(paragraph, lines, width, height) {
const words = Array.isArray(paragraph.words) ? paragraph.words : [];
let text2 = "";
let boxes = [];
const pushLine = () => {
const value = cleanOcrText(text2);
const box = unionBoxes(boxes);
if (value && box && HAS_JAPANESE$1.test(value)) {
lines.push({ text: value, box, vertical: box.height > box.width * 1.25 && value.length > 1 });
}
text2 = "";
boxes = [];
};
for (const word of words) {
const symbols = Array.isArray(word.symbols) ? word.symbols : [];
for (const symbol of symbols) {
const symbolRecord = symbol;
text2 += String(symbolRecord.text ?? "");
const box = normalizeCloudVisionVertices(symbolRecord.boundingBox?.vertices, width, height);
if (box) boxes.push(box);
const breakType = symbolRecord.property?.detectedBreak?.type;
if (breakType === "SPACE" || breakType === "SURE_SPACE" || breakType === "UNKNOWN") text2 += " ";
if (breakType === "LINE_BREAK" || breakType === "EOL_SURE_SPACE" || breakType === "HYPHEN") pushLine();
}
}
pushLine();
}
function normalizeCloudVisionVertices(value, width, height) {
if (!Array.isArray(value) || value.length < 2) return null;
const xs = value.map((vertex) => numberFrom(vertex?.x) ?? 0);
const ys = value.map((vertex) => numberFrom(vertex?.y) ?? 0);
const left = Math.min(...xs);
const top = Math.min(...ys);
return clampBox({ left, top, width: Math.max(...xs) - left, height: Math.max(...ys) - top }, width, height);
}
function normalizeSimpleLine(value, width, height) {
const record = asRecord(value);
if (!record) return null;
const text2 = simpleLineText(record);
const box = simpleLineBox(record, width, height);
if (!text2 || !box) return null;
return { text: text2, box, vertical: simpleLineIsVertical(record) };
}
function simpleLineText(record) {
return stringFrom(record.text) || stringFrom(record.content) || stringFrom(record.sentence);
}
function simpleLineBox(record, width, height) {
return normalizeBox(record.box ?? record.boundingBox ?? record, width, height);
}
function simpleLineIsVertical(record) {
return Boolean(record.vertical ?? record.is_vertical);
}
function normalizeStructuredOcrResult(value, width, height) {
if (!value || typeof value !== "object") return [];
const record = value;
const textLines = structuredOcrTextLines(record);
const vertical = structuredOcrVertical(record);
const lines = textLines.map((item) => normalizeStructuredOcrLine(item, width, height, vertical)).filter((line) => line !== null);
if (lines.length) return lines;
return normalizeStructuredOcrFallback(record, textLines, width, height, vertical);
}
function structuredOcrTextLines(record) {
if (Array.isArray(record.text_lines)) return record.text_lines;
return Array.isArray(record.text) ? record.text : [];
}
function structuredOcrVertical(record) {
return Boolean(record.is_vertical ?? record.box?.isVertical);
}
function normalizeStructuredOcrLine(item, width, height, inheritedVertical) {
const lineRecord = asRecord(item);
if (!lineRecord) return null;
const text2 = structuredOcrLineText(lineRecord);
const box = structuredOcrLineBox(lineRecord, width, height);
if (!text2 || !box) return null;
return { text: text2, box, vertical: structuredOcrLineVertical(lineRecord, inheritedVertical) };
}
function structuredOcrLineText(record) {
return stringFrom(record.content ?? record.text ?? record.word);
}
function structuredOcrLineBox(record, width, height) {
return normalizeBox(record.box ?? record.boundingBox ?? record, width, height);
}
function structuredOcrLineVertical(record, inheritedVertical) {
return Boolean(record.is_vertical ?? record.box?.isVertical ?? inheritedVertical);
}
function normalizeStructuredOcrFallback(record, textLines, width, height, vertical) {
const text2 = textLines.map((item) => stringFrom(item?.content)).filter(Boolean).join("");
const box = normalizeBox(record.box, width, height);
return text2 && box ? [{ text: text2, box, vertical }] : [];
}
function normalizeOcrRegion(record, width, height) {
const region = readOcrRegion(record);
if (!region) return null;
const box = clampBox(scaleOcrRegion(region, width, height), width, height);
return box && !isFullImageOcrRegion(box, width, height) ? box : null;
}
function readOcrRegion(record) {
const position = record.position;
const size = record.size;
if (!position || !size) return null;
return completeOcrRegionParts({
left: numberFrom(position.left),
top: numberFrom(position.top),
width: numberFrom(size.width),
height: numberFrom(size.height)
});
}
function completeOcrRegionParts(parts) {
if (parts.left === null) return null;
if (parts.top === null) return null;
if (parts.width === null) return null;
if (parts.height === null) return null;
return { left: parts.left, top: parts.top, width: parts.width, height: parts.height };
}
function scaleOcrRegion(region, width, height) {
const divisor = Math.max(region.left, region.top, region.width, region.height) <= 1 ? 1 : 100;
return {
left: region.left / divisor * width,
top: region.top / divisor * height,
width: region.width / divisor * width,
height: region.height / divisor * height
};
}
function isFullImageOcrRegion(box, width, height) {
return box.left <= 1 && box.top <= 1 && box.width >= width - 2 && box.height >= height - 2;
}
function offsetLineToRegion(line, region, width, height) {
const box = clampBox({
left: region.left + line.box.left,
top: region.top + line.box.top,
width: line.box.width,
height: line.box.height
}, width, height);
return box ? { ...line, box } : null;
}
function normalizeBox(value, width, height) {
if (!value || typeof value !== "object") return null;
const record = value;
return normalizePositionDimensionsBox(record, width, height) ?? normalizeDirectBox(record, width, height) ?? normalizePointBox(record, width, height);
}
function normalizePositionDimensionsBox(record, width, height) {
const position = asRecord(record.position);
const dimensions = asRecord(record.dimensions);
if (!position || !dimensions) return null;
return boxFromNumbers({
left: numberFrom(position.left),
top: numberFrom(position.top),
width: numberFrom(dimensions.width),
height: numberFrom(dimensions.height)
}, width, height, "percent-100");
}
function normalizeDirectBox(record, width, height) {
const box = directBoxNumbers(record);
return boxFromNumbers(box, width, height, directBoxScale(box));
}
function directBoxNumbers(record) {
return {
left: numberFrom(record.left ?? record.x),
top: numberFrom(record.top ?? record.y),
width: numberFrom(record.width ?? record.w),
height: numberFrom(record.height ?? record.h)
};
}
function directBoxScale(box) {
return Object.values(box).every((value) => value !== null && value <= 1) ? "fraction" : "pixels";
}
function normalizePointBox(record, width, height) {
const points = ["top_left", "top_right", "bottom_right", "bottom_left"].map((key) => asRecord(record[key])).filter((point) => Boolean(point));
if (points.length < 2) return null;
const xs = points.map((point) => numberFrom(point?.x)).filter((item) => item !== null);
const ys = points.map((point) => numberFrom(point?.y)).filter((item) => item !== null);
if (!xs.length || !ys.length) return null;
const percent = coordinatesAreFractional(xs, ys);
const scaledXs = scaleCoordinates(xs, width, percent);
const scaledYs = scaleCoordinates(ys, height, percent);
const left = Math.min(...scaledXs);
const top = Math.min(...scaledYs);
return clampBox({ left, top, width: Math.max(...scaledXs) - left, height: Math.max(...scaledYs) - top }, width, height);
}
function coordinatesAreFractional(xs, ys) {
return xs.every(isFractionalCoordinate) && ys.every(isFractionalCoordinate);
}
function isFractionalCoordinate(value) {
return value >= 0 && value <= 1;
}
function scaleCoordinates(values, scale, enabled) {
return enabled ? values.map((value) => value * scale) : values;
}
function boxFromNumbers(box, imageWidth, imageHeight, scale) {
if (!hasCompleteBoxNumbers(box)) return null;
const scaleInfo = boxScaleInfo(scale);
return clampBox({
left: scaleBoxNumber(box.left, imageWidth, scaleInfo),
top: scaleBoxNumber(box.top, imageHeight, scaleInfo),
width: scaleBoxNumber(box.width, imageWidth, scaleInfo),
height: scaleBoxNumber(box.height, imageHeight, scaleInfo)
}, imageWidth, imageHeight);
}
function hasCompleteBoxNumbers(box) {
return box.left !== null && box.top !== null && box.width !== null && box.height !== null;
}
function boxScaleInfo(scale) {
return {
fractional: scale !== "pixels",
factor: scale === "percent-100" ? 100 : 1
};
}
function scaleBoxNumber(value, dimension, scale) {
return scale.fractional ? value / scale.factor * dimension : value;
}
function clampBox(box, width, height) {
const left = Math.max(0, Math.min(width, box.left));
const top = Math.max(0, Math.min(height, box.top));
const right = Math.max(left, Math.min(width, box.left + Math.max(0, box.width)));
const bottom = Math.max(top, Math.min(height, box.top + Math.max(0, box.height)));
if (right - left < 2 || bottom - top < 2) return null;
return { left, top, width: right - left, height: bottom - top };
}
function unionBoxes(boxes) {
if (!boxes.length) return null;
const left = Math.min(...boxes.map((box) => box.left));
const top = Math.min(...boxes.map((box) => box.top));
const right = Math.max(...boxes.map((box) => box.left + box.width));
const bottom = Math.max(...boxes.map((box) => box.top + box.height));
return { left, top, width: right - left, height: bottom - top };
}
function cleanOcrText(value) {
const text2 = typeof value === "string" ? value : String(value ?? "");
const normalized = text2.replace(/[ \t\r\n]+/g, HAS_JAPANESE$1.test(text2) ? "" : " ").trim();
return normalized.replaceAll("...", "…");
}
function isCandidateImage(image, settings) {
if (isIgnoredOcrImage(image)) return false;
const rect = image.getBoundingClientRect();
const area = rect.width * rect.height;
if (area < settings.ocrMinImageArea) return false;
if (!isNearViewport(image, settings.ocrPrefetchMargin)) return false;
if (isImageOccludedByVideo(image, rect)) return false;
return isVisibleOcrImage(image);
}
function isIgnoredOcrImage(image) {
return Boolean(image.closest("[data-jpdb-reader-root]") || image.closest('[aria-hidden="true"], [hidden], .slick-cloned'));
}
function isVisibleOcrImage(image) {
const style = getComputedStyle(image);
return style.visibility !== "hidden" && style.display !== "none" && Number(style.opacity || "1") > 0 && !isInsideHiddenAncestor(image);
}
function isImageVisibleForOcr(image, rect) {
return rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.top <= window.innerHeight && !isImageOccludedByVideo(image, rect);
}
function isInsideHiddenAncestor(element2) {
for (let current = element2.parentElement; current && current !== document.body; current = current.parentElement) {
if (isHiddenByCss(current) || isHiddenByAttribute(current)) return true;
}
return false;
}
function isHiddenByCss(element2) {
const style = getComputedStyle(element2);
return style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") <= 0;
}
function isHiddenByAttribute(element2) {
return element2.getAttribute("aria-hidden") === "true" || element2.hasAttribute("hidden");
}
function mutationTouchesRenderableMedia(mutation) {
if (mutation.type === "childList") {
return [...mutation.addedNodes, ...mutation.removedNodes].some(nodeContainsRenderableMedia);
}
return mutation.target instanceof Element && nodeContainsRenderableMedia(mutation.target);
}
function nodeContainsRenderableMedia(node) {
return node instanceof HTMLImageElement || node instanceof HTMLVideoElement || node instanceof HTMLSourceElement || node instanceof Element && Boolean(node.querySelector("img, video, source"));
}
function isImageOccludedByVideo(image, rect = image.getBoundingClientRect()) {
const imageArea = rect.width * rect.height;
if (imageArea < 4) return false;
const imageRoot = image.getRootNode();
for (const video of document.querySelectorAll("video")) {
if (!isVisiblePeerVideo(video, image, imageRoot)) continue;
if (videoOccludesImage(video, rect, imageArea)) return true;
}
return false;
}
function isVisiblePeerVideo(video, image, imageRoot) {
return video.isConnected && video.getRootNode() === imageRoot && !isSameMediaNode(video, image) && visibleVideoRect(video) !== null && isVisibleElement(video);
}
function visibleVideoRect(video) {
const rect = video.getBoundingClientRect();
return rect.width >= 2 && rect.height >= 2 ? rect : null;
}
function isVisibleElement(element2) {
const style = getComputedStyle(element2);
return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity || "1") > 0;
}
function videoOccludesImage(video, imageRect, imageArea) {
const videoRect = visibleVideoRect(video);
return Boolean(videoRect && intersectionArea(imageRect, videoRect) / imageArea >= 0.6);
}
function isSameMediaNode(video, image) {
return video === image.parentElement || image === video.parentElement;
}
function intersectionArea(a, b) {
const left = Math.max(a.left, b.left);
const top = Math.max(a.top, b.top);
const right = Math.min(a.right, b.right);
const bottom = Math.min(a.bottom, b.bottom);
return Math.max(0, right - left) * Math.max(0, bottom - top);
}
function shouldObserveImage(image, settings) {
if (settings.ocrProvider === "off") return false;
if (readFallbackOcrResult(image, false)) return true;
if (settings.ocrProvider === "local-service") return true;
if (settings.ocrProvider === "cloud-vision") return Boolean(settings.ocrCloudVisionApiKey.trim());
return settings.ocrProvider === "google-lens";
}
function hasFallbackOcrMetadata(image) {
return Boolean(readFallbackOcrResult(image, false));
}
function isNearViewport(element2, margin) {
const rect = element2.getBoundingClientRect();
return rect.bottom >= -margin && rect.top <= window.innerHeight + margin && rect.right >= -margin && rect.left <= window.innerWidth + margin;
}
function imageViewportDistance(image) {
const rect = image.getBoundingClientRect();
if (rect.bottom < 0) return -rect.bottom;
if (rect.top > window.innerHeight) return rect.top - window.innerHeight;
if (rect.right < 0) return -rect.right;
if (rect.left > window.innerWidth) return rect.left - window.innerWidth;
return 0;
}
function imageCacheKey(image) {
return `${image.currentSrc || image.src}|${image.naturalWidth}x${image.naturalHeight}`;
}
function protoMessage(...parts) {
return concatBytes(parts);
}
function protoMessageField(field, value) {
return concatBytes([protoTag(field, 2), encodeVarint(value.length), value]);
}
function protoBytesField(field, value) {
return protoMessageField(field, value);
}
function protoStringField(field, value) {
return protoBytesField(field, new TextEncoder().encode(value));
}
function protoVarintField(field, value) {
return concatBytes([protoTag(field, 0), encodeVarint(value)]);
}
function protoTag(field, wire) {
return encodeVarint(field << 3 | wire);
}
function encodeVarint(value) {
let item = BigInt(value);
const bytes = [];
do {
let byte = Number(item & 0x7fn);
item >>= 7n;
if (item) byte |= 128;
bytes.push(byte);
} while (item);
return new Uint8Array(bytes);
}
function decodeProtoMessage(bytes) {
const fields = [];
let offset = 0;
while (offset < bytes.length) {
const [tag, nextOffset] = readVarint(bytes, offset);
offset = nextOffset;
const field = Number(tag >> 3n);
const wire = Number(tag & 7n);
if (!field) break;
if (wire === 0) {
const [value, afterValue] = readVarint(bytes, offset);
offset = afterValue;
fields.push({ field, wire, value });
} else if (wire === 1) {
fields.push({ field, wire, value: new DataView(bytes.buffer, bytes.byteOffset + offset, 8).getFloat64(0, true) });
offset += 8;
} else if (wire === 2) {
const [length, afterLength] = readVarint(bytes, offset);
offset = afterLength;
const end = offset + Number(length);
fields.push({ field, wire, value: bytes.slice(offset, end) });
offset = end;
} else if (wire === 5) {
fields.push({ field, wire, value: new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getFloat32(0, true) });
offset += 4;
} else {
break;
}
}
return fields;
}
function readVarint(bytes, offset) {
let shift = 0n;
let result = 0n;
while (offset < bytes.length) {
const byte = bytes[offset++];
result |= BigInt(byte & 127) << shift;
if (!(byte & 128)) return [result, offset];
shift += 7n;
}
return [result, offset];
}
function protoMessages(fields, field) {
return fields.filter((item) => item.field === field && item.wire === 2 && item.value instanceof Uint8Array).map((item) => decodeProtoMessage(item.value));
}
function protoFirstMessage(fields, field) {
return protoMessages(fields, field)[0] ?? null;
}
function protoString(fields, field) {
const item = fields.find((value) => value.field === field && value.wire === 2 && value.value instanceof Uint8Array);
return item ? new TextDecoder().decode(item.value) : "";
}
function protoNumber(fields, field) {
const item = fields.find((value) => value.field === field);
if (!item) return 0;
return typeof item.value === "bigint" ? Number(item.value) : typeof item.value === "number" ? item.value : 0;
}
function protoBox(geometry, width, height) {
const box = geometry ? protoFirstMessage(geometry, 1) : null;
if (!box) return null;
const centerX = protoNumber(box, 1);
const centerY = protoNumber(box, 2);
const boxWidth = protoNumber(box, 3);
const boxHeight = protoNumber(box, 4);
if (!boxWidth || !boxHeight) return null;
const normalized = centerX <= 2 && centerY <= 2 && boxWidth <= 2 && boxHeight <= 2;
return clampBox({
left: (normalized ? centerX * width : centerX) - (normalized ? boxWidth * width : boxWidth) / 2,
top: (normalized ? centerY * height : centerY) - (normalized ? boxHeight * height : boxHeight) / 2,
width: normalized ? boxWidth * width : boxWidth,
height: normalized ? boxHeight * height : boxHeight
}, width, height);
}
function concatBytes(parts) {
const length = parts.reduce((sum, part) => sum + part.length, 0);
const result = new Uint8Array(length);
let offset = 0;
for (const part of parts) {
result.set(part, offset);
offset += part.length;
}
return result;
}
function randomBytes(length) {
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return bytes;
}
function requestJson(url, data, timeout) {
const userscriptRequest = getUserscriptHttpRequest();
if (userscriptRequest) {
return new Promise((resolve, reject) => {
userscriptRequest({
method: "POST",
url,
headers: { "content-type": "application/json" },
data,
responseType: "json",
timeout,
onload: (response) => response.status >= 200 && response.status < 300 ? resolve(response.response ?? (response.responseText ? JSON.parse(response.responseText) : null)) : reject(new Error(`OCR endpoint returned ${response.status}.`)),
onerror: reject,
ontimeout: () => reject(new Error("OCR timed out."))
});
});
}
return fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: data }).then((response) => response.ok ? response.json() : Promise.reject(new Error(`OCR endpoint returned ${response.status}.`)));
}
function requestArrayBuffer(url, data, timeout) {
const body = new Uint8Array(data);
const headers = {
"content-type": "application/x-protobuf",
"x-goog-api-key": GOOGLE_LENS_API_KEY,
accept: "*/*",
"accept-language": "ja,en-US;q=0.9,en;q=0.8"
};
const userscriptRequest = getUserscriptHttpRequest();
if (userscriptRequest) {
return new Promise((resolve, reject) => {
userscriptRequest({
method: "POST",
url,
headers,
data: body.buffer,
responseType: "arraybuffer",
timeout,
onload: (response) => response.status >= 200 && response.status < 300 ? resolve(response.response) : reject(new Error(`Google Lens returned ${response.status}.`)),
onerror: reject,
ontimeout: () => reject(new Error("Google Lens timed out."))
});
});
}
return fetch(url, {
method: "POST",
headers,
body: body.buffer
}).then((response) => response.ok ? response.arrayBuffer() : Promise.reject(new Error(`Google Lens returned ${response.status}.`)));
}
function requestTextForm(url, data, timeout) {
const userscriptRequest = getUserscriptHttpRequest();
if (userscriptRequest) {
return new Promise((resolve, reject) => {
userscriptRequest({
method: "POST",
url,
data,
responseType: "text",
timeout,
onload: (response) => response.status >= 200 && response.status < 300 ? resolve(String(response.responseText ?? response.response ?? "")) : reject(new Error(`Google Lens upload returned ${response.status}.`)),
onerror: reject,
ontimeout: () => reject(new Error("Google Lens upload timed out."))
});
});
}
return fetch(url, { method: "POST", body: data }).then((response) => response.ok ? response.text() : Promise.reject(new Error(`Google Lens upload returned ${response.status}.`)));
}
function requestBlob$1(url) {
const userscriptRequest = getUserscriptHttpRequest();
if (userscriptRequest) {
return new Promise((resolve, reject) => {
userscriptRequest({
method: "GET",
url,
responseType: "blob",
onload: (response) => response.status >= 200 && response.status < 300 ? resolve(response.response) : reject(new Error(`Image fetch returned ${response.status}.`)),
onerror: reject
});
});
}
return fetch(url).then((response) => response.ok ? response.blob() : Promise.reject(new Error(`Image fetch returned ${response.status}.`)));
}
function waitForIdle(timeout) {
if (!timeout) return Promise.resolve();
return new Promise((resolve) => {
if ("requestIdleCallback" in window) {
window.requestIdleCallback(() => resolve(), { timeout });
} else {
globalThis.setTimeout(resolve, timeout);
}
});
}
function loadImage(url) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Image decode failed."));
image.src = url;
});
}
function canvasToBlob(canvas, type, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob((result) => result ? resolve(result) : reject(new Error("Image encoding failed.")), type, quality);
});
}
function blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ""));
reader.onerror = () => reject(reader.error ?? new Error("Blob read failed."));
reader.readAsDataURL(blob);
});
}
function stringFrom(value) {
return typeof value === "string" ? value.replace(/\s+/g, "").trim() : "";
}
function asRecord(value) {
return value && typeof value === "object" ? value : null;
}
function numberFrom(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function imageSummary(image) {
return {
host: safeHost$1(image.currentSrc || image.src),
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
altLength: image.alt?.length ?? 0
};
}
function inlineProviderLabel(settings) {
return configuredOcrProviderLabel(settings) ?? settings.ocrProvider;
}
function configuredOcrProviderLabel(settings) {
return OCR_PROVIDER_LABELS[settings.ocrProvider]?.(settings) ?? null;
}
function localServiceProviderLabel(settings) {
return `local-service:${ocrEngineLabel(settings)}`;
}
function ocrEngineLabel(settings) {
return settings.ocrEngine || "auto";
}
function localOcrEndpointUrl(settings) {
return settings.ocrEndpointUrl.trim() || DEFAULT_LOCAL_OCR_ENDPOINT_URL;
}
function safeHost$1(value) {
try {
return new URL(value, location.href).host;
} catch {
return "inline-or-invalid";
}
}
const JAPANESE_RUN_RE = /[\u3040-\u30ff\u3400-\u9fff々〆ヵヶー]/u;
const POINTER_TEXT_SKIP_SELECTOR = [
"script",
"style",
"noscript",
"textarea",
"input",
"select",
"button",
"option",
"summary",
"svg",
"use",
"rt",
"rp",
'[contenteditable="true"]',
'[role="button"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="tab"]',
"[onclick]",
"[data-jpdb-reader-root]"
].join(",");
const SCREEN_READER_ONLY_CLASS_RE = /(^|[-_\s])(sr-only|screen-reader-text|visually-hidden|visuallyhidden)([-_\s]|$)/i;
const YOUTUBE_METADATA_SELECTOR = [
"#metadata",
"#metadata-line",
"#metadata-text",
"#video-info",
"#stats",
"ytd-video-meta-block",
"yt-content-metadata-view-model",
".inline-metadata-item",
".badge-style-type-simple"
].join(",");
const METADATA_TOKEN_RE = /^(?:[\d0-9][\d0-9,.,]*\s*)?(?:万|億)?(?:回視聴|視聴|再生|回再生|件|コメント|高評価|日前|時間前|分前|秒前|か月前|ヶ月前|週間前|年前|ライブ配信中|新着)$/u;
function caretTextPositionFromPoint(x, y) {
const doc = document;
const position = doc.caretPositionFromPoint?.(x, y);
if (position?.offsetNode.nodeType === Node.TEXT_NODE) {
return { node: position.offsetNode, offset: position.offset };
}
const range = doc.caretRangeFromPoint?.(x, y);
if (range?.startContainer.nodeType === Node.TEXT_NODE) {
return { node: range.startContainer, offset: range.startOffset };
}
return null;
}
function japaneseRunAt(text2, offset) {
const index = japaneseRunIndexAt(text2, offset);
if (index === null) return null;
return {
start: japaneseRunStart(text2, index),
end: japaneseRunEnd(text2, index),
offset: index
};
}
function japaneseRunIndexAt(text2, offset) {
let index = Math.min(Math.max(offset, 0), text2.length - 1);
if (!isJapaneseCharacterAt(text2, index) && index > 0 && isJapaneseCharacterAt(text2, index - 1)) index--;
return isJapaneseCharacterAt(text2, index) ? index : null;
}
function japaneseRunStart(text2, index) {
let start = index;
while (start > 0 && isJapaneseCharacterAt(text2, start - 1)) start--;
return start;
}
function japaneseRunEnd(text2, index) {
let end = index + 1;
while (end < text2.length && isJapaneseCharacterAt(text2, end)) end++;
return end;
}
function isJapaneseCharacterAt(text2, index) {
return JAPANESE_RUN_RE.test(text2[index] ?? "");
}
function pointerTextCharacterOffset(node, caretOffset, x, y) {
const parent = node.parentElement;
if (!parent || !isPointerTextParentEligible(parent)) return null;
const clamped = Math.min(Math.max(caretOffset, 0), node.data.length - 1);
const candidates = [clamped, clamped - 1, clamped + 1].filter((offset, index, offsets) => offset >= 0 && offset < node.data.length && offsets.indexOf(offset) === index);
return candidates.find((offset) => textCharacterContainsPoint(node, offset, x, y)) ?? null;
}
function isLowValuePointerText(text2, parent) {
const compact = text2.replace(/\s+/g, "");
if (!compact) return true;
if (parent?.closest(YOUTUBE_METADATA_SELECTOR)) return true;
const parts = compact.split(/[・•||//()[\]【】「」『』<>〈〉《》]+/u).map((part) => part.trim()).filter(Boolean);
if (!parts.length) return false;
return parts.every((part) => METADATA_TOKEN_RE.test(part));
}
function isPointerTextParentEligible(parent) {
let current = parent;
while (current) {
if (!isPointerTextElementEligible(current)) return false;
current = current.parentElement;
}
return true;
}
function isPointerTextElementEligible(element2) {
const style = getComputedStyle(element2);
return elementPassesPointerTextAttributes(element2) && stylePassesPointerTextLookup(style) && !isScreenReaderOnlyElement(element2, style);
}
function elementPassesPointerTextAttributes(element2) {
return !element2.matches(POINTER_TEXT_SKIP_SELECTOR) && !element2.hasAttribute("hidden") && !element2.hasAttribute("inert") && element2.getAttribute("aria-hidden")?.toLowerCase() !== "true";
}
function stylePassesPointerTextLookup(style) {
return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse" && Number(style.opacity || "1") > 0;
}
function isScreenReaderOnlyElement(element2, style) {
if (hasScreenReaderOnlyClass(element2)) return true;
const rect = element2.getBoundingClientRect();
return isTinyClippedElement(rect, style) || isTinyHiddenAbsoluteElement(rect, style);
}
function hasScreenReaderOnlyClass(element2) {
return SCREEN_READER_ONLY_CLASS_RE.test(element2.className || "");
}
function isTinyClippedElement(rect, style) {
const clipped = Boolean(style.clip && style.clip !== "auto" || style.clipPath && style.clipPath !== "none");
return clipped && isTinyRect(rect);
}
function isTinyHiddenAbsoluteElement(rect, style) {
return style.position === "absolute" && style.overflow === "hidden" && isTinyRect(rect);
}
function isTinyRect(rect) {
return rect.width <= 2 && rect.height <= 2;
}
function textCharacterContainsPoint(node, offset, x, y) {
if (!node.data.length) return false;
const start = Math.min(Math.max(offset, 0), node.data.length - 1);
const range = document.createRange();
try {
range.setStart(node, start);
range.setEnd(node, start + 1);
return Array.from(range.getClientRects()).some((rect) => rectContainsPoint(rect, x, y));
} finally {
range.detach?.();
}
}
function rectContainsPoint(rect, x, y) {
const right = rect.right || rect.left + rect.width;
const bottom = rect.bottom || rect.top + rect.height;
const slack = 1;
return hasPositiveRectArea(rect, right, bottom) && coordinateInRange(x, rect.left, right, slack) && coordinateInRange(y, rect.top, bottom, slack);
}
function hasPositiveRectArea(rect, right, bottom) {
return right > rect.left && bottom > rect.top;
}
function coordinateInRange(value, start, end, slack) {
return value >= start - slack && value <= end + slack;
}
const SHEET_HEIGHT_STORAGE_KEY = "jpdb-reader-sheet-height-ratio";
const SETTINGS_DRAWER_HEIGHT_STORAGE_KEY = "jpdb-reader-settings-drawer-height-ratio";
const DEFAULT_SHEET_HEIGHT_RATIO = 0.7;
const DEFAULT_SETTINGS_DRAWER_HEIGHT_RATIO = 0.88;
const MIN_SHEET_HEIGHT_PX = 180;
const MIN_SETTINGS_DRAWER_HEIGHT_PX = 280;
const SHEET_DISMISS_OVERSHOOT_PX = 72;
const SHEET_FULL_HEIGHT_THRESHOLD_PX = 12;
const SETTINGS_DRAWER_FULL_HEIGHT_THRESHOLD_PX = 12;
const SHEET_TAP_MOVEMENT_PX = 8;
const SHEET_KEYBOARD_STEP_PX = 48;
const SETTINGS_DRAWER_TAP_MOVEMENT_PX = 8;
const SETTINGS_DRAWER_KEYBOARD_STEP_PX = 56;
const MINING_DRAWER_DRAG_THRESHOLD_PX = 22;
const MINING_DRAWER_TAP_MOVEMENT_PX = 8;
function createReaderPopover(appName, settings) {
const popover = document.createElement("div");
popover.className = "jpdb-reader-popover";
popover.dataset.jpdbReaderRoot = "true";
popover.setAttribute("role", "dialog");
popover.setAttribute("aria-label", `${appName} lookup`);
popover.setAttribute("aria-modal", "true");
popover.tabIndex = -1;
if (shouldUseSheet(settings)) popover.classList.add("jpdb-reader-sheet");
else popover.style.width = `${settings.popoverWidth}px`;
return popover;
}
function createReaderBackdrop(onDismiss) {
const backdrop = document.createElement("div");
backdrop.className = "jpdb-reader-backdrop";
backdrop.dataset.jpdbReaderRoot = "true";
backdrop.addEventListener("click", onDismiss);
return backdrop;
}
function popoverMaxHeightSetting(settings) {
return settings.popoverHeightMode === "fixed" ? settings.popoverHeight : void 0;
}
function installSheetHandle(popover, onDismiss) {
if (popover.dataset.jpdbReaderSheetHandleInstalled === "true") return;
popover.dataset.jpdbReaderSheetHandleInstalled = "true";
let viewportHeight = 0;
let sheetHeight = 0;
let startHeight = 0;
let rawDragHeight = 0;
const isFullHeight = () => viewportHeight > 0 && sheetHeight >= viewportHeight - SHEET_FULL_HEIGHT_THRESHOLD_PX;
const syncHandle = (handle) => {
handle.setAttribute("role", "button");
handle.setAttribute("tabindex", "0");
handle.setAttribute("aria-label", "Drag to resize lookup sheet, or tap to close");
handle.setAttribute("aria-expanded", String(isFullHeight()));
handle.setAttribute("aria-valuemin", String(sheetMinHeight(viewportHeight)));
handle.setAttribute("aria-valuemax", String(viewportHeight));
handle.setAttribute("aria-valuenow", String(Math.round(sheetHeight)));
};
const syncHandleState = () => {
popover.querySelectorAll(".jpdb-reader-sheet-handle").forEach(syncHandle);
};
const applySheetHeight = (height, persist = false) => {
const nextHeight = clampSheetHeight(height, viewportHeight);
sheetHeight = nextHeight;
popover.style.setProperty("--jpdb-reader-sheet-height", `${Math.round(nextHeight)}px`);
popover.classList.toggle("jpdb-reader-sheet-expanded", isFullHeight());
syncHandleState();
if (persist) storeSheetHeightRatio(nextHeight, viewportHeight);
};
const applyViewportSize = () => {
const previousViewportHeight = viewportHeight;
viewportHeight = Math.max(0, Math.round(window.visualViewport?.height ?? window.innerHeight));
popover.style.setProperty("--jpdb-reader-sheet-viewport-height", `${viewportHeight}px`);
popover.style.setProperty("--jpdb-reader-sheet-collapsed-height", `${Math.round(viewportHeight * DEFAULT_SHEET_HEIGHT_RATIO)}px`);
popover.style.setProperty("--jpdb-reader-sheet-min-height", `${sheetMinHeight(viewportHeight)}px`);
const ratio = previousViewportHeight > 0 && sheetHeight > 0 ? sheetHeight / previousViewportHeight : readSheetHeightRatio();
applySheetHeight(viewportHeight * ratio);
};
const clearSheetPositionStyles = () => {
popover.style.removeProperty("left");
popover.style.removeProperty("right");
popover.style.removeProperty("top");
popover.style.removeProperty("bottom");
popover.style.removeProperty("width");
popover.style.removeProperty("height");
popover.style.removeProperty("max-width");
popover.style.removeProperty("max-height");
};
const clearDragStyles = () => {
popover.style.transform = "";
popover.style.removeProperty("--jpdb-reader-sheet-drag-up");
popover.classList.remove("jpdb-reader-sheet-resizing");
};
const resetSheetLayout = () => {
clearSheetPositionStyles();
applyViewportSize();
clearDragStyles();
};
resetSheetLayout();
syncHandleState();
let handleObserver;
if (typeof MutationObserver !== "undefined") {
handleObserver = new MutationObserver(syncHandleState);
handleObserver.observe(popover, { childList: true, subtree: true });
}
const getHandleFromEvent = (event) => {
if (!(event instanceof Element)) return null;
const handle = event.closest(".jpdb-reader-sheet-handle");
if (!handle) return null;
if (!popover.contains(handle)) return null;
syncHandle(handle);
return handle;
};
let startY = 0;
let lastY = 0;
let pointerId = 0;
let dragging = false;
let moved = false;
let activeInput = null;
let touchId = 0;
let suppressNextHandleClick = false;
let activeHandle = null;
const reset = () => {
popover.style.transition = "height .16s ease, max-height .16s ease, border-radius .16s ease, transform .16s ease";
clearDragStyles();
window.setTimeout(() => {
popover.style.transition = "";
}, 180);
};
const cleanupPointerListeners = () => {
if (typeof document === "undefined") return;
document.removeEventListener("pointermove", handlePointerMove, true);
document.removeEventListener("pointerup", handlePointerUp, true);
document.removeEventListener("pointercancel", handlePointerCancel, true);
};
const cleanupTouchListeners = () => {
if (typeof document === "undefined") return;
document.removeEventListener("touchmove", handleTouchMove, true);
document.removeEventListener("touchend", handleTouchEnd, true);
document.removeEventListener("touchcancel", handleTouchCancel, true);
};
const releasePointerCapture = (handle, id) => {
try {
handle?.releasePointerCapture?.(id);
} catch {
}
};
const setPointerCapture = (handle, id) => {
try {
handle.setPointerCapture?.(id);
} catch {
}
};
const finish = (closeOnTap = false) => {
if (!dragging) return;
const wasMoved = moved;
const handle = activeHandle;
const finishHeight = rawDragHeight;
dragging = false;
moved = false;
activeInput = null;
activeHandle = null;
popover.classList.remove("jpdb-reader-sheet-resizing");
cleanupPointerListeners();
cleanupTouchListeners();
releasePointerCapture(handle, pointerId);
if (!wasMoved && closeOnTap) {
suppressNextHandleClick = true;
onDismiss();
return;
}
if (wasMoved) suppressNextHandleClick = true;
if (wasMoved && finishHeight < sheetMinHeight(viewportHeight) - SHEET_DISMISS_OVERSHOOT_PX) {
onDismiss();
return;
}
if (wasMoved) applySheetHeight(finishHeight, true);
reset();
};
const cancelDrag = () => {
if (!dragging) return;
dragging = false;
moved = false;
activeInput = null;
cleanupPointerListeners();
cleanupTouchListeners();
releasePointerCapture(activeHandle, pointerId);
activeHandle = null;
reset();
};
const updateDrag = (clientY) => {
lastY = clientY;
const delta = startY - lastY;
rawDragHeight = startHeight + delta;
if (Math.abs(lastY - startY) > SHEET_TAP_MOVEMENT_PX) moved = true;
applySheetHeight(rawDragHeight);
};
const beginDrag = (handle, clientY, input2) => {
if (dragging || activeInput) return false;
startY = clientY;
lastY = clientY;
startHeight = sheetHeight || restoredSheetHeight(viewportHeight);
rawDragHeight = startHeight;
dragging = true;
moved = false;
activeInput = input2;
activeHandle = handle;
popover.style.transition = "";
popover.classList.add("jpdb-reader-sheet-resizing");
return true;
};
const handlePointerMove = (event) => {
if (!dragging || activeInput !== "pointer" || event.pointerId !== pointerId) return;
event.preventDefault();
event.stopPropagation();
updateDrag(event.clientY);
};
const handlePointerUp = (event) => {
if (!dragging || activeInput !== "pointer" || event.pointerId !== pointerId) return;
event.preventDefault();
event.stopPropagation();
lastY = event.clientY;
finish(true);
};
const handlePointerCancel = (event) => {
if (activeInput !== "pointer" || event.pointerId !== pointerId) return;
cancelDrag();
};
const changedTouch = (event) => {
for (const touch of Array.from(event.changedTouches)) {
if (touch.identifier === touchId) return touch;
}
return null;
};
const firstChangedTouch = (event) => event.changedTouches.item(0);
const handleTouchMove = (event) => {
if (!dragging || activeInput !== "touch") return;
const touch = changedTouch(event);
if (!touch) return;
event.preventDefault();
event.stopPropagation();
updateDrag(touch.clientY);
};
const handleTouchEnd = (event) => {
if (!dragging || activeInput !== "touch") return;
const touch = changedTouch(event);
if (!touch) return;
event.preventDefault();
event.stopPropagation();
lastY = touch.clientY;
finish(true);
};
const handleTouchCancel = (event) => {
if (activeInput !== "touch" || !changedTouch(event)) return;
cancelDrag();
};
const handleViewportChange = () => {
if (dragging) cancelDrag();
popover.style.transition = "";
resetSheetLayout();
syncHandleState();
};
const viewportController = new AbortController();
let disposed = false;
let disposeObserver;
const dispose = () => {
if (disposed) return;
disposed = true;
cleanupPointerListeners();
cleanupTouchListeners();
viewportController.abort();
handleObserver?.disconnect();
disposeObserver?.disconnect();
};
disposeObserver = new MutationObserver(() => {
if (!popover.isConnected) dispose();
});
if (document.documentElement) {
disposeObserver.observe(document.documentElement, { childList: true, subtree: true });
}
popover.addEventListener("click", (event) => {
const handle = getHandleFromEvent(event.target);
if (!handle) return;
event.preventDefault();
event.stopPropagation();
if (suppressNextHandleClick) {
suppressNextHandleClick = false;
return;
}
onDismiss();
});
popover.addEventListener("pointerdown", (event) => {
const handle = getHandleFromEvent(event.target);
if (!handle) return;
if (activeInput) return;
if (event.button !== void 0 && event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
if (!beginDrag(handle, event.clientY, "pointer")) return;
pointerId = event.pointerId;
setPointerCapture(handle, event.pointerId);
document.addEventListener("pointermove", handlePointerMove, { capture: true, passive: false });
document.addEventListener("pointerup", handlePointerUp, true);
document.addEventListener("pointercancel", handlePointerCancel, true);
});
popover.addEventListener("touchstart", (event) => {
const handle = getHandleFromEvent(event.target);
if (!handle || activeInput) return;
const touch = firstChangedTouch(event);
if (!touch) return;
event.preventDefault();
event.stopPropagation();
if (!beginDrag(handle, touch.clientY, "touch")) return;
touchId = touch.identifier;
document.addEventListener("touchmove", handleTouchMove, { capture: true, passive: false });
document.addEventListener("touchend", handleTouchEnd, true);
document.addEventListener("touchcancel", handleTouchCancel, true);
}, { capture: true, passive: false });
popover.addEventListener("keydown", (event) => {
const handle = getHandleFromEvent(event.target);
if (!handle) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
onDismiss();
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
event.stopPropagation();
applySheetHeight(sheetHeight + (event.key === "ArrowUp" ? SHEET_KEYBOARD_STEP_PX : -SHEET_KEYBOARD_STEP_PX), true);
reset();
}
if (event.key === "Escape") onDismiss();
});
const viewportListenerOptions = { passive: true, signal: viewportController.signal };
window.addEventListener("resize", handleViewportChange, viewportListenerOptions);
window.addEventListener("orientationchange", handleViewportChange, viewportListenerOptions);
window.visualViewport?.addEventListener?.("resize", handleViewportChange, viewportListenerOptions);
window.visualViewport?.addEventListener?.("scroll", handleViewportChange, viewportListenerOptions);
}
function installSheetCloseButton(popover, onDismiss, label = "Close drawer") {
if (popover.dataset.jpdbReaderSheetCloseInstalled === "true") return;
popover.dataset.jpdbReaderSheetCloseInstalled = "true";
const close = (event) => {
event.preventDefault();
event.stopPropagation();
onDismiss();
};
const createButton = () => {
const button2 = document.createElement("button");
button2.type = "button";
button2.className = "jpdb-reader-sheet-close";
button2.dataset.jpdbReaderSheetClose = "true";
button2.setAttribute("aria-label", label);
button2.title = label;
button2.innerHTML = ' ';
button2.addEventListener("click", close);
return button2;
};
const ensureButton = () => {
if (!popover.isConnected) return;
if (popover.querySelector('[data-jpdb-reader-sheet-close="true"]')) return;
popover.append(createButton());
};
ensureButton();
let disposed = false;
let contentObserver;
let disposeObserver;
const dispose = () => {
if (disposed) return;
disposed = true;
contentObserver?.disconnect();
disposeObserver?.disconnect();
};
contentObserver = new MutationObserver(ensureButton);
contentObserver.observe(popover, { childList: true });
disposeObserver = new MutationObserver(() => {
if (!popover.isConnected) dispose();
});
if (document.documentElement) {
disposeObserver.observe(document.documentElement, { childList: true, subtree: true });
}
}
function installSettingsDrawerHandle(drawer) {
if (drawer.dataset.jpdbReaderSettingsDrawerHandleInstalled === "true") return;
drawer.dataset.jpdbReaderSettingsDrawerHandleInstalled = "true";
let viewportHeight = 0;
let drawerHeight = 0;
let startHeight = 0;
let rawDragHeight = 0;
let startY = 0;
let lastY = 0;
let pointerId = 0;
let dragging = false;
let moved = false;
let activeInput = null;
let touchId = 0;
let activeHandle = null;
const isFullHeight = () => viewportHeight > 0 && drawerHeight >= viewportHeight - SETTINGS_DRAWER_FULL_HEIGHT_THRESHOLD_PX;
const syncHandle = (handle) => {
handle.setAttribute("role", "separator");
handle.setAttribute("tabindex", "0");
handle.setAttribute("aria-label", "Resize Settings");
handle.setAttribute("aria-orientation", "horizontal");
handle.setAttribute("aria-valuemin", String(settingsDrawerMinHeight(viewportHeight)));
handle.setAttribute("aria-valuemax", String(viewportHeight));
handle.setAttribute("aria-valuenow", String(Math.round(drawerHeight)));
};
const syncHandleState = () => {
drawer.querySelectorAll(".jpdb-reader-settings-drag-handle").forEach(syncHandle);
};
const applyDrawerHeight = (height, persist = false) => {
const nextHeight = clampDrawerHeight(height, viewportHeight, settingsDrawerMinHeight(viewportHeight));
drawerHeight = nextHeight;
drawer.style.setProperty("--jpdb-reader-settings-drawer-height", `${Math.round(nextHeight)}px`);
drawer.classList.toggle("jpdb-reader-settings-drawer-expanded", isFullHeight());
syncHandleState();
if (persist) storeHeightRatio(SETTINGS_DRAWER_HEIGHT_STORAGE_KEY, nextHeight, viewportHeight);
};
const applyViewportSize = () => {
const previousViewportHeight = viewportHeight;
viewportHeight = Math.max(0, Math.round(window.visualViewport?.height ?? window.innerHeight));
drawer.style.setProperty("--jpdb-reader-settings-drawer-viewport-height", `${viewportHeight}px`);
drawer.style.setProperty("--jpdb-reader-settings-drawer-min-height", `${settingsDrawerMinHeight(viewportHeight)}px`);
const ratio = previousViewportHeight > 0 && drawerHeight > 0 ? drawerHeight / previousViewportHeight : readHeightRatio(SETTINGS_DRAWER_HEIGHT_STORAGE_KEY, DEFAULT_SETTINGS_DRAWER_HEIGHT_RATIO);
applyDrawerHeight(viewportHeight * ratio);
};
const clearDragStyles = () => {
drawer.classList.remove("jpdb-reader-settings-drawer-resizing");
};
const reset = () => {
drawer.style.transition = "height .16s ease, max-height .16s ease, border-radius .16s ease";
clearDragStyles();
window.setTimeout(() => {
drawer.style.transition = "";
}, 180);
};
const getHandleFromEvent = (event) => {
if (!(event instanceof Element)) return null;
const handle = event.closest(".jpdb-reader-settings-drag-handle");
if (!handle || !drawer.contains(handle)) return null;
syncHandle(handle);
return handle;
};
const cleanupPointerListeners = () => {
if (typeof document === "undefined") return;
document.removeEventListener("pointermove", handlePointerMove, true);
document.removeEventListener("pointerup", handlePointerUp, true);
document.removeEventListener("pointercancel", handlePointerCancel, true);
};
const cleanupTouchListeners = () => {
if (typeof document === "undefined") return;
document.removeEventListener("touchmove", handleTouchMove, true);
document.removeEventListener("touchend", handleTouchEnd, true);
document.removeEventListener("touchcancel", handleTouchCancel, true);
};
const releasePointerCapture = (handle, id) => {
try {
handle?.releasePointerCapture?.(id);
} catch {
}
};
const setPointerCapture = (handle, id) => {
try {
handle.setPointerCapture?.(id);
} catch {
}
};
const updateDrag = (clientY) => {
lastY = clientY;
const delta = startY - lastY;
rawDragHeight = startHeight + delta;
if (Math.abs(lastY - startY) > SETTINGS_DRAWER_TAP_MOVEMENT_PX) moved = true;
applyDrawerHeight(rawDragHeight);
};
const beginDrag = (handle, clientY, input2) => {
if (dragging || activeInput) return false;
startY = clientY;
lastY = clientY;
startHeight = drawerHeight || restoredSettingsDrawerHeight(viewportHeight);
rawDragHeight = startHeight;
dragging = true;
moved = false;
activeInput = input2;
activeHandle = handle;
drawer.style.transition = "";
drawer.classList.add("jpdb-reader-settings-drawer-resizing");
return true;
};
const finish = () => {
if (!dragging) return;
const wasMoved = moved;
const handle = activeHandle;
const finishHeight = rawDragHeight;
dragging = false;
moved = false;
activeInput = null;
activeHandle = null;
cleanupPointerListeners();
cleanupTouchListeners();
releasePointerCapture(handle, pointerId);
if (wasMoved) {
applyDrawerHeight(finishHeight, true);
}
reset();
};
const cancelDrag = () => {
if (!dragging) return;
dragging = false;
moved = false;
activeInput = null;
cleanupPointerListeners();
cleanupTouchListeners();
releasePointerCapture(activeHandle, pointerId);
activeHandle = null;
reset();
};
const handlePointerMove = (event) => {
if (!dragging || activeInput !== "pointer" || event.pointerId !== pointerId) return;
event.preventDefault();
event.stopPropagation();
updateDrag(event.clientY);
};
const handlePointerUp = (event) => {
if (!dragging || activeInput !== "pointer" || event.pointerId !== pointerId) return;
event.preventDefault();
event.stopPropagation();
lastY = event.clientY;
finish();
};
const handlePointerCancel = (event) => {
if (activeInput !== "pointer" || event.pointerId !== pointerId) return;
cancelDrag();
};
const changedTouch = (event) => {
for (const touch of Array.from(event.changedTouches)) {
if (touch.identifier === touchId) return touch;
}
return null;
};
const firstChangedTouch = (event) => event.changedTouches.item(0);
const handleTouchMove = (event) => {
if (!dragging || activeInput !== "touch") return;
const touch = changedTouch(event);
if (!touch) return;
event.preventDefault();
event.stopPropagation();
updateDrag(touch.clientY);
};
const handleTouchEnd = (event) => {
if (!dragging || activeInput !== "touch") return;
const touch = changedTouch(event);
if (!touch) return;
event.preventDefault();
event.stopPropagation();
lastY = touch.clientY;
finish();
};
const handleTouchCancel = (event) => {
if (activeInput !== "touch" || !changedTouch(event)) return;
cancelDrag();
};
const handleViewportChange = () => {
if (dragging) cancelDrag();
drawer.style.transition = "";
applyViewportSize();
clearDragStyles();
syncHandleState();
};
applyViewportSize();
syncHandleState();
const viewportController = new AbortController();
let disposed = false;
let disposeObserver;
const dispose = () => {
if (disposed) return;
disposed = true;
cleanupPointerListeners();
cleanupTouchListeners();
viewportController.abort();
disposeObserver?.disconnect();
};
disposeObserver = new MutationObserver(() => {
if (!drawer.isConnected) dispose();
});
if (document.documentElement) {
disposeObserver.observe(document.documentElement, { childList: true, subtree: true });
}
drawer.addEventListener("click", (event) => {
const handle = getHandleFromEvent(event.target);
if (!handle) return;
event.preventDefault();
event.stopPropagation();
});
drawer.addEventListener("pointerdown", (event) => {
const handle = getHandleFromEvent(event.target);
if (!handle || activeInput) return;
if (event.button !== void 0 && event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
if (!beginDrag(handle, event.clientY, "pointer")) return;
pointerId = event.pointerId;
setPointerCapture(handle, event.pointerId);
document.addEventListener("pointermove", handlePointerMove, { capture: true, passive: false });
document.addEventListener("pointerup", handlePointerUp, true);
document.addEventListener("pointercancel", handlePointerCancel, true);
});
drawer.addEventListener("touchstart", (event) => {
const handle = getHandleFromEvent(event.target);
if (!handle || activeInput) return;
const touch = firstChangedTouch(event);
if (!touch) return;
event.preventDefault();
event.stopPropagation();
if (!beginDrag(handle, touch.clientY, "touch")) return;
touchId = touch.identifier;
document.addEventListener("touchmove", handleTouchMove, { capture: true, passive: false });
document.addEventListener("touchend", handleTouchEnd, true);
document.addEventListener("touchcancel", handleTouchCancel, true);
}, { capture: true, passive: false });
drawer.addEventListener("keydown", (event) => {
const handle = getHandleFromEvent(event.target);
if (!handle) return;
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
event.stopPropagation();
applyDrawerHeight(drawerHeight + (event.key === "ArrowUp" ? SETTINGS_DRAWER_KEYBOARD_STEP_PX : -SETTINGS_DRAWER_KEYBOARD_STEP_PX), true);
reset();
}
});
const viewportListenerOptions = { passive: true, signal: viewportController.signal };
window.addEventListener("resize", handleViewportChange, viewportListenerOptions);
window.addEventListener("orientationchange", handleViewportChange, viewportListenerOptions);
window.visualViewport?.addEventListener?.("resize", handleViewportChange, viewportListenerOptions);
window.visualViewport?.addEventListener?.("scroll", handleViewportChange, viewportListenerOptions);
}
function installMiningDrawerHandle(root, setExpanded) {
if (root.dataset.jpdbReaderMiningDrawerHandleInstalled === "true") return;
root.dataset.jpdbReaderMiningDrawerHandleInstalled = "true";
let startX = 0;
let startY = 0;
let lastX = 0;
let lastY = 0;
let pointerId = 0;
let touchId = 0;
let dragging = false;
let moved = false;
let activeInput = null;
let activeHandle = null;
let suppressNextHandleClick = false;
const getHandleFromEvent = (event) => {
if (!(event instanceof Element)) return null;
const target = event.closest(".jpdb-reader-mining-drawer-handle, .jpdb-reader-actions-gutter");
if (!target || !root.contains(target)) return null;
const handle = target.matches(".jpdb-reader-mining-drawer-handle") ? target : target.querySelector(".jpdb-reader-mining-drawer-handle");
if (!handle) return null;
return handle;
};
const cleanupPointerListeners = () => {
document.removeEventListener("pointermove", handlePointerMove, true);
document.removeEventListener("pointerup", handlePointerUp, true);
document.removeEventListener("pointercancel", handlePointerCancel, true);
};
const cleanupTouchListeners = () => {
document.removeEventListener("touchmove", handleTouchMove, true);
document.removeEventListener("touchend", handleTouchEnd, true);
document.removeEventListener("touchcancel", handleTouchCancel, true);
};
const setPointerCapture = (handle, id) => {
try {
handle.setPointerCapture?.(id);
} catch {
}
};
const releasePointerCapture = (handle, id) => {
try {
handle?.releasePointerCapture?.(id);
} catch {
}
};
const beginDrag = (handle, clientX, clientY, input2) => {
if (dragging || activeInput) return false;
startX = clientX;
startY = clientY;
lastX = clientX;
lastY = clientY;
dragging = true;
moved = false;
activeInput = input2;
activeHandle = handle;
handle.closest(".jpdb-reader-actions")?.classList.add("jpdb-reader-mining-drawer-dragging");
return true;
};
const updateDrag = (clientX, clientY) => {
lastX = clientX;
lastY = clientY;
if (Math.hypot(lastX - startX, lastY - startY) > MINING_DRAWER_TAP_MOVEMENT_PX) moved = true;
};
const finish = () => {
if (!dragging) return;
const wasMoved = moved;
const handle = activeHandle;
dragging = false;
moved = false;
activeInput = null;
activeHandle = null;
cleanupPointerListeners();
cleanupTouchListeners();
releasePointerCapture(handle, pointerId);
handle?.closest(".jpdb-reader-actions")?.classList.remove("jpdb-reader-mining-drawer-dragging");
if (!wasMoved || !handle) return;
suppressNextHandleClick = true;
const expanded = miningDrawerDragExpandedState(handle, lastX - startX, lastY - startY);
if (expanded !== void 0) setExpanded(handle, expanded);
};
const cancelDrag = () => {
if (!dragging) return;
const handle = activeHandle;
dragging = false;
moved = false;
activeInput = null;
activeHandle = null;
cleanupPointerListeners();
cleanupTouchListeners();
releasePointerCapture(handle, pointerId);
handle?.closest(".jpdb-reader-actions")?.classList.remove("jpdb-reader-mining-drawer-dragging");
};
const handlePointerMove = (event) => {
if (!dragging || activeInput !== "pointer" || event.pointerId !== pointerId) return;
event.preventDefault();
event.stopPropagation();
updateDrag(event.clientX, event.clientY);
};
const handlePointerUp = (event) => {
if (!dragging || activeInput !== "pointer" || event.pointerId !== pointerId) return;
event.preventDefault();
event.stopPropagation();
updateDrag(event.clientX, event.clientY);
finish();
};
const handlePointerCancel = (event) => {
if (activeInput !== "pointer" || event.pointerId !== pointerId) return;
cancelDrag();
};
const changedTouch = (event) => {
for (const touch of Array.from(event.changedTouches)) {
if (touch.identifier === touchId) return touch;
}
return null;
};
const firstChangedTouch = (event) => event.changedTouches.item(0);
const handleTouchMove = (event) => {
if (!dragging || activeInput !== "touch") return;
const touch = changedTouch(event);
if (!touch) return;
event.preventDefault();
event.stopPropagation();
updateDrag(touch.clientX, touch.clientY);
};
const handleTouchEnd = (event) => {
if (!dragging || activeInput !== "touch") return;
const touch = changedTouch(event);
if (!touch) return;
event.preventDefault();
event.stopPropagation();
updateDrag(touch.clientX, touch.clientY);
finish();
};
const handleTouchCancel = (event) => {
if (activeInput !== "touch" || !changedTouch(event)) return;
cancelDrag();
};
root.addEventListener("click", (event) => {
if (!suppressNextHandleClick) return;
const handle = getHandleFromEvent(event.target);
if (!handle) return;
suppressNextHandleClick = false;
event.preventDefault();
event.stopPropagation();
}, true);
root.addEventListener("pointerdown", (event) => {
const handle = getHandleFromEvent(event.target);
if (!handle || activeInput) return;
if (event.button !== void 0 && event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
if (!beginDrag(handle, event.clientX, event.clientY, "pointer")) return;
pointerId = event.pointerId;
setPointerCapture(handle, event.pointerId);
document.addEventListener("pointermove", handlePointerMove, { capture: true, passive: false });
document.addEventListener("pointerup", handlePointerUp, true);
document.addEventListener("pointercancel", handlePointerCancel, true);
});
root.addEventListener("touchstart", (event) => {
const handle = getHandleFromEvent(event.target);
if (!handle || activeInput) return;
const touch = firstChangedTouch(event);
if (!touch) return;
event.preventDefault();
event.stopPropagation();
if (!beginDrag(handle, touch.clientX, touch.clientY, "touch")) return;
touchId = touch.identifier;
document.addEventListener("touchmove", handleTouchMove, { capture: true, passive: false });
document.addEventListener("touchend", handleTouchEnd, true);
document.addEventListener("touchcancel", handleTouchCancel, true);
}, { capture: true, passive: false });
}
function shouldUseSheet(settings) {
if (settings.popupMode === "sheet") return true;
if (settings.popupMode === "popover") return false;
return window.innerWidth <= 768 || matchMedia("(pointer: coarse)").matches;
}
function sheetMinHeight(viewportHeight) {
if (viewportHeight <= 0) return MIN_SHEET_HEIGHT_PX;
return Math.min(viewportHeight, MIN_SHEET_HEIGHT_PX, Math.max(140, Math.round(viewportHeight * 0.32)));
}
function settingsDrawerMinHeight(viewportHeight) {
if (viewportHeight <= 0) return MIN_SETTINGS_DRAWER_HEIGHT_PX;
return Math.min(viewportHeight, MIN_SETTINGS_DRAWER_HEIGHT_PX, Math.max(220, Math.round(viewportHeight * 0.38)));
}
function restoredSheetHeight(viewportHeight) {
return clampSheetHeight(viewportHeight * readSheetHeightRatio(), viewportHeight);
}
function restoredSettingsDrawerHeight(viewportHeight) {
return clampDrawerHeight(
viewportHeight * readHeightRatio(SETTINGS_DRAWER_HEIGHT_STORAGE_KEY, DEFAULT_SETTINGS_DRAWER_HEIGHT_RATIO),
viewportHeight,
settingsDrawerMinHeight(viewportHeight)
);
}
function clampSheetHeight(height, viewportHeight) {
return clampDrawerHeight(height, viewportHeight, sheetMinHeight(viewportHeight));
}
function clampDrawerHeight(height, viewportHeight, minHeight) {
if (viewportHeight <= 0) return Math.max(minHeight, Math.round(height));
return Math.max(minHeight, Math.min(viewportHeight, Math.round(height)));
}
function miningDrawerDragExpandedState(handle, deltaX, deltaY) {
const axis = miningDrawerDragAxis(handle);
if (axis === "horizontal") {
if (Math.abs(deltaX) < MINING_DRAWER_DRAG_THRESHOLD_PX) return void 0;
return miningDrawerHorizontalOpenDirection(handle) === "right" ? deltaX > 0 : deltaX < 0;
}
if (Math.abs(deltaY) < MINING_DRAWER_DRAG_THRESHOLD_PX) return void 0;
return deltaY < 0;
}
function miningDrawerDragAxis(handle) {
const rect = handle.getBoundingClientRect();
if (rect.height > rect.width * 1.2) return "horizontal";
const actions = handle.closest(".jpdb-reader-actions");
if (!actions) return "vertical";
const actionsRect = actions.getBoundingClientRect();
return actionsRect.height > actionsRect.width * 1.2 ? "horizontal" : "vertical";
}
function miningDrawerHorizontalOpenDirection(handle) {
const actions = handle.closest(".jpdb-reader-actions");
if (!actions) return "left";
const handleRect = handle.getBoundingClientRect();
const actionsRect = actions.getBoundingClientRect();
const handleCenter = handleRect.left + handleRect.width / 2;
const actionsCenter = actionsRect.left + actionsRect.width / 2;
return handleCenter < actionsCenter ? "right" : "left";
}
function readSheetHeightRatio() {
return readHeightRatio(SHEET_HEIGHT_STORAGE_KEY, DEFAULT_SHEET_HEIGHT_RATIO);
}
function storeSheetHeightRatio(height, viewportHeight) {
storeHeightRatio(SHEET_HEIGHT_STORAGE_KEY, height, viewportHeight);
}
function readHeightRatio(storageKey, fallback) {
const value = gmStorageGetSync(storageKey, fallback);
return Number.isFinite(value) && value > 0 && value <= 1 ? value : fallback;
}
function storeHeightRatio(storageKey, height, viewportHeight) {
if (viewportHeight <= 0) return;
const ratio = Math.max(0, Math.min(1, height / viewportHeight));
gmStorageSetSync(storageKey, Number(ratio.toFixed(4)));
}
class PopupNavigationController {
constructor(hasActiveKanjiPopover) {
this.hasActiveKanjiPopover = hasActiveKanjiPopover;
}
wordStack = [];
currentWord;
kanjiStack = [];
currentKanji;
updateWord(card, sentence, trigger, mode, previousNavigationEntry) {
if (trigger !== "modal") {
this.clearWord();
return;
}
const next = { card, sentence };
if (mode === "reset") this.wordStack = [];
else this.pushPreviousWord(mode, next, previousNavigationEntry);
this.currentWord = next;
}
updateKanji(card, kanji, sentence, mode) {
const next = { card, kanji, sentence };
if (mode === "reset") {
this.kanjiStack = [];
this.currentKanji = next;
return;
}
const previous = this.previousKanjiToPush(mode, next);
if (previous) this.pushDistinctKanjiEntry(previous);
this.currentKanji = next;
}
clearWord() {
this.wordStack = [];
this.currentWord = void 0;
}
clearKanji() {
this.kanjiStack = [];
this.currentKanji = void 0;
}
activeKanjiEntry() {
if (!this.currentKanji || !this.hasActiveKanjiPopover()) return void 0;
return this.kanjiEntry(this.currentKanji);
}
popPreviousWord() {
return this.wordStack.pop();
}
popPreviousKanji() {
return this.kanjiStack.pop();
}
renderWordHistory(language, trigger) {
if (trigger !== "modal") return "";
const previous = this.wordStack[this.wordStack.length - 1];
if (!previous) return "";
return renderModalNavigation({
backAction: "word-history-back",
backTitle: previous.kind === "kanji" ? `${uiText(language, "backToKanji")}: ${previous.kanji}` : `${uiText(language, "backToWord")}: ${previous.card.spelling}`,
label: previous.kind === "kanji" ? previous.kanji : previous.card.spelling
});
}
kanjiModalBack(card, language) {
const previousKanji = this.kanjiStack[this.kanjiStack.length - 1];
return previousKanji ? {
backAction: "kanji-history-back",
backTitle: `${uiText(language, "backToKanji")}: ${previousKanji.kanji}`,
label: previousKanji.kanji
} : {
backAction: "word-back",
backTitle: `${uiText(language, "backToWord")}: ${card.spelling}`,
label: card.spelling
};
}
pushPreviousWord(mode, next, previousNavigationEntry) {
const previous = previousNavigationEntry ?? this.previousWordEntry(next);
if (!this.shouldPushPreviousWord(mode, previous, next)) return;
this.pushDistinctWordEntry(previous);
}
shouldPushPreviousWord(mode, previous, next) {
if (mode !== "push-current") return false;
if (!previous) return false;
return !this.isSameEntryAsWord(previous, next);
}
pushDistinctWordEntry(previous) {
const lastStackEntry = this.wordStack[this.wordStack.length - 1];
if (!lastStackEntry || !this.isSamePopupEntry(lastStackEntry, previous)) this.wordStack.push(previous);
}
previousWordEntry(next) {
return this.currentWord && !this.isSameCard(this.currentWord, next) ? this.wordEntry(this.currentWord) : void 0;
}
previousKanjiToPush(mode, next) {
const current = this.currentKanji;
if (mode !== "push-current") return void 0;
if (!current) return void 0;
return this.isSameKanji(current, next) ? void 0 : current;
}
pushDistinctKanjiEntry(entry) {
const lastStackEntry = this.kanjiStack[this.kanjiStack.length - 1];
if (!lastStackEntry || !this.isSameKanji(lastStackEntry, entry)) this.kanjiStack.push(entry);
}
wordEntry(entry) {
return { kind: "word", card: entry.card, sentence: entry.sentence };
}
kanjiEntry(entry) {
return { kind: "kanji", card: entry.card, sentence: entry.sentence, kanji: entry.kanji };
}
isSameCard(first2, second) {
return cardKey$1(first2.card) === cardKey$1(second.card);
}
isSameKanji(first2, second) {
return this.isSameCard(first2, second) && first2.kanji === second.kanji;
}
isSameEntryAsWord(entry, word) {
return entry.kind === "word" && this.isSameCard(entry, word);
}
isSamePopupEntry(first2, second) {
if (first2.kind !== second.kind) return false;
if (first2.kind === "kanji" && second.kind === "kanji") return this.isSameKanji(first2, second);
return this.isSameCard(first2, second);
}
}
function renderModalNavigation(options) {
return `
←
${escapeHtml$1(options.label)}
${options.controlsHtml ?? ""}
`;
}
const RTK_BASE_URL = "https://hrussellzfac023.github.io/rtk";
const RTK_SEARCH_INDEX_URL = `${RTK_BASE_URL}/assets/js/search.js`;
const KANJI_RE = /[\u3400-\u9fff]/u;
const log$9 = Logger.scope("RTK");
class RtkClient {
cache = /* @__PURE__ */ new Map();
keywordIndex;
lookup(kanji) {
if (!KANJI_RE.test(kanji)) return Promise.resolve(null);
const key = Array.from(kanji)[0] ?? kanji;
let promise = this.cache.get(key);
if (!promise) {
promise = this.fetchInfo(key);
this.cache.set(key, promise);
}
return promise;
}
async fetchInfo(kanji) {
const html = await requestText$1(`${RTK_BASE_URL}/${encodeURIComponent(kanji)}/index.html`).catch((error) => {
log$9.warn("RTK request failed", { kanji }, error);
return "";
});
if (!html) return null;
const info = parseRtkHtml(html, kanji);
return info ? this.withElementGlyphs(info) : null;
}
async withElementGlyphs(info) {
const index = await this.lookupKeywordIndex().catch(() => {
return /* @__PURE__ */ new Map();
});
const elementGlyphs = {};
splitRtkElements$1(info.elements).filter((keyword) => rtkElementKey(keyword) !== rtkElementKey(info.keyword)).forEach((keyword) => {
const key = rtkElementKey(keyword);
const fallback = rtkElementFallbackGlyph(keyword);
const indexedKanji = index.get(key) ?? index.get(compactRtkElementKey(key));
const glyph = fallback ?? (indexedKanji ? { glyph: indexedKanji, kanji: indexedKanji } : void 0);
if (glyph) elementGlyphs[key] = glyph;
});
return Object.keys(elementGlyphs).length ? { ...info, elementGlyphs } : info;
}
lookupKeywordIndex() {
if (!this.keywordIndex) {
this.keywordIndex = requestText$1(RTK_SEARCH_INDEX_URL).then(parseRtkSearchIndex).catch((error) => {
this.keywordIndex = void 0;
throw error;
});
}
return this.keywordIndex;
}
}
function parseRtkHtml(html, kanji) {
const doc = parseHtmlDocument(html);
const keywordElement = doc.querySelector("h2 code");
const keyword = rtkKeywordText(keywordElement);
if (!keyword) return null;
const { onYomi, kunYomi } = rtkReadings(doc);
const elements = textAfterHeading(doc, "Elements:");
const heisigStory = textAfterHeading(doc, "Heisig story:");
const heisigComment = textAfterHeading(doc, "Heisig comment:");
const koohiiStories = paragraphsAfterHeading(doc, "Koohii stories:").slice(0, 3);
return {
kanji,
keyword,
frameNumber: rtkFrameNumber(keywordElement),
onYomi,
kunYomi,
elements,
componentKanji: [...new Set(Array.from(elements).filter((character) => KANJI_RE.test(character) && character !== kanji))],
heisigStory,
heisigComment,
koohiiStories
};
}
function rtkKeywordText(keywordElement) {
return keywordElement?.textContent?.trim() ?? "";
}
function rtkReadings(doc) {
const yomiText = doc.querySelector("h3")?.textContent ?? "";
return {
onYomi: yomiText.match(/On-Yomi:\s*([^—]+)/)?.[1]?.trim() ?? "",
kunYomi: yomiText.match(/Kun-Yomi:\s*(.+)/)?.[1]?.trim() ?? ""
};
}
function rtkFrameNumber(keywordElement) {
return keywordElement?.getAttribute("title")?.trim() ?? "";
}
function parseRtkSearchIndex(script) {
const searchEntries = rtkSearchIndexEntries(script);
const entries = /* @__PURE__ */ new Map();
const collisions = /* @__PURE__ */ new Set();
const canonicalKeys = /* @__PURE__ */ new Set();
searchEntries.forEach((entry) => {
rtkIndexKeys(entry.keyword).forEach((key) => {
canonicalKeys.add(key);
addRtkKeywordIndexEntry(entries, collisions, key, entry.kanji);
});
});
addRtkElementAliasEntries(entries, collisions, canonicalKeys, searchEntries);
return entries;
}
function rtkSearchIndexEntries(script) {
const entries = [];
const entryRe = /\{[\s\S]*?\}/g;
let match;
while (match = entryRe.exec(script)) {
const entry = rtkSearchIndexEntry(match[0]);
if (entry) entries.push(entry);
}
return entries;
}
function rtkSearchIndexEntry(rawEntry) {
const kanji = firstKanjiCharacter(rtkSearchIndexField(rawEntry, "kanji"));
const keyword = rtkSearchIndexField(rawEntry, "keyword");
if (!kanji || !keyword) return null;
return {
kanji,
keyword,
elements: rtkSearchIndexField(rawEntry, "elements")
};
}
function rtkSearchIndexField(rawEntry, field) {
const match = rawEntry.match(new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`));
if (!match?.[1]) return "";
try {
return JSON.parse(`"${match[1]}"`);
} catch {
return match[1];
}
}
function firstKanjiCharacter(value) {
return Array.from(value ?? "").find(isKanjiCharacter) ?? "";
}
function isKanjiCharacter(character) {
return KANJI_RE.test(character);
}
function addRtkKeywordIndexEntry(entries, collisions, key, kanji) {
if (!key || collisions.has(key)) return;
const existing = entries.get(key);
if (existing && existing !== kanji) {
entries.delete(key);
collisions.add(key);
return;
}
entries.set(key, kanji);
}
function addRtkElementAliasEntries(entries, collisions, canonicalKeys, searchEntries) {
const introduced = /* @__PURE__ */ new Map();
const introducedCollisions = /* @__PURE__ */ new Set();
searchEntries.forEach((entry) => {
rtkIndexKeys(entry.keyword).forEach((key) => addRtkKeywordIndexEntry(introduced, introducedCollisions, key, entry.kanji));
const elements = splitRtkElements$1(entry.elements);
addLeadingRtkElementAliases(entries, collisions, canonicalKeys, introduced, introducedCollisions, entry, elements);
addGroupedRtkElementAliases(entries, collisions, canonicalKeys, introduced, introducedCollisions, elements);
});
}
function addLeadingRtkElementAliases(entries, collisions, canonicalKeys, introduced, introducedCollisions, entry, elements) {
const keywordKeys = rtkIndexKeys(entry.keyword);
const keywordIndex = elements.findIndex((element2) => rtkIndexKeys(element2).some((key) => keywordKeys.includes(key)));
if (keywordIndex <= 0) return;
elements.slice(0, keywordIndex).forEach((element2) => {
addRtkElementAliasEntry(entries, collisions, canonicalKeys, introduced, introducedCollisions, element2, entry.kanji);
});
}
function addGroupedRtkElementAliases(entries, collisions, canonicalKeys, introduced, introducedCollisions, elements) {
let owner = "";
elements.forEach((element2) => {
const introducedOwner = rtkIntroducedElementOwner(introduced, element2);
if (introducedOwner) {
owner = introducedOwner;
return;
}
if (owner) addRtkElementAliasEntry(entries, collisions, canonicalKeys, introduced, introducedCollisions, element2, owner);
});
}
function addRtkElementAliasEntry(entries, collisions, canonicalKeys, introduced, introducedCollisions, element2, kanji) {
if (rtkElementFallbackGlyph(element2)) return;
rtkIndexKeys(element2).filter((key) => !canonicalKeys.has(key)).forEach((key) => {
addRtkKeywordIndexEntry(entries, collisions, key, kanji);
addRtkKeywordIndexEntry(introduced, introducedCollisions, key, kanji);
});
}
function rtkIntroducedElementOwner(introduced, element2) {
for (const key of rtkIndexKeys(element2)) {
const owner = introduced.get(key);
if (owner) return owner;
}
return "";
}
function rtkIndexKeys(value) {
return [...new Set([rtkElementKey(value), compactRtkElementKey(value)].filter(Boolean))];
}
function compactRtkElementKey(value) {
return rtkElementKey(value).replace(/\s+/g, "");
}
function textAfterHeading(doc, label) {
const heading = Array.from(doc.querySelectorAll("h2")).find((element2) => element2.textContent?.includes(label));
const next = heading?.nextElementSibling;
return next?.tagName === "P" ? cleanText(next.textContent ?? "") : "";
}
function paragraphsAfterHeading(doc, label) {
const heading = Array.from(doc.querySelectorAll("h2")).find((element2) => element2.textContent?.includes(label));
const paragraphs = [];
let next = heading?.nextElementSibling;
while (next?.tagName === "P") {
const text2 = cleanText(next.textContent ?? "");
if (text2) paragraphs.push(text2);
next = next.nextElementSibling;
}
return paragraphs;
}
function cleanText(value) {
return value.replace(/\s+/g, " ").trim();
}
function requestText$1(url) {
return requestText$7(url, {
timeoutMs: 8e3,
failureLabel: "RTK request",
timeoutLabel: "RTK request timed out."
});
}
const log$8 = Logger.scope("ReaderAudioActions");
class ReaderAudioActions {
constructor(dependencies) {
this.dependencies = dependencies;
}
loadingRequest = 0;
async playTermAudio(card, options = {}) {
if (!this.dependencies.getSettings().audioEnabled) {
this.dependencies.toast(uiText(this.dependencies.getSettings().interfaceLanguage, "audioPlaybackDisabledToast"));
return;
}
const isCurrent = options.hoverLookupGeneration === void 0 ? void 0 : () => this.dependencies.getHoverLookupGeneration() === options.hoverLookupGeneration;
const loadingPopover = this.dependencies.getActivePopover();
const loadingRequest = ++this.loadingRequest;
this.setLoading(loadingPopover, loadingRequest);
try {
this.dependencies.stopImmersionAudio();
const played = await this.dependencies.audio.play(card, { isCurrent, userGesture: options.userGesture });
if (!played) return;
} catch (error) {
log$8.warn("Term audio playback failed", { term: card.spelling }, error);
this.dependencies.toast(this.audioErrorMessage(error));
} finally {
this.clearLoading(loadingPopover, loadingRequest);
}
}
async playSentenceAudio(sentence) {
const text2 = sentence?.trim();
if (!text2) throw new Error(uiText(this.dependencies.getSettings().interfaceLanguage, "noSentenceToRead"));
const voice = this.dependencies.getSettings().audioSources.find(
(source) => source.enabled && (source.type === "text-to-speech" || source.type === "text-to-speech-reading") && source.voice.trim()
)?.voice.trim() ?? "";
this.dependencies.stopImmersionAudio();
await this.dependencies.audio.playJapaneseText(text2, voice);
}
async playJpdbExampleAudio(audioIds, fallbackSentence) {
if (!this.dependencies.getSettings().audioEnabled) {
this.dependencies.toast(uiText(this.dependencies.getSettings().interfaceLanguage, "audioPlaybackDisabledToast"));
return;
}
this.dependencies.stopImmersionAudio();
const played = await this.dependencies.audio.playJpdbAudio(audioIds, { userGesture: true });
if (!played && fallbackSentence) await this.playSentenceAudio(fallbackSentence);
}
async playMediaUrl(audioUrl) {
if (!this.dependencies.getSettings().audioEnabled) {
this.dependencies.toast(uiText(this.dependencies.getSettings().interfaceLanguage, "audioPlaybackDisabledToast"));
return;
}
this.dependencies.stopImmersionAudio();
await this.dependencies.audio.playMediaUrl(audioUrl);
}
audioErrorMessage(error) {
const language = this.dependencies.getSettings().interfaceLanguage;
if (resolveUiLanguage(language) === "ja") return uiText(language, "audioPlaybackFailed");
return error instanceof Error ? error.message : uiText(language, "audioPlaybackFailed");
}
setLoading(popover, requestId) {
if (!popover?.isConnected) return;
popover.dataset.audioLoading = "true";
popover.dataset.audioLoadingRequest = String(requestId);
}
clearLoading(popover, requestId) {
if (!popover?.isConnected || popover.dataset.audioLoadingRequest !== String(requestId)) return;
delete popover.dataset.audioLoading;
delete popover.dataset.audioLoadingRequest;
}
}
const LOCAL_MATCH_LIMIT = 40;
const JPDB_PARSE_FALLBACK_TIMEOUT_MS = 6e3;
const JAPANESE_SCRIPT_GROUP_RE = /[\u3400-\u9fff々〆ヵヶ]+|[\u3040-\u309fー]+|[\u30a0-\u30ffー]+/gu;
const log$7 = Logger.scope("ReaderParser");
class ReaderParser {
constructor(dependencies) {
this.dependencies = dependencies;
}
localCardCache = /* @__PURE__ */ new Map();
async parse(paragraphs, options = {}) {
const { getSettings, jpdb } = this.dependencies;
const settings = getSettings();
const done = log$7.time("parse", {
paragraphs: paragraphs.length,
hasApiKey: Boolean(settings.apiKey.trim()),
localFallback: settings.localDictionariesEnabled
});
if (settings.apiKey.trim()) {
try {
const parsePromise = jpdb.parse(paragraphs);
const timeoutMs = options.jpdbTimeoutMs ?? JPDB_PARSE_FALLBACK_TIMEOUT_MS;
const result = timeoutMs > 0 && this.canUseLocalDictionaryFallback() ? await withTimeout(parsePromise, timeoutMs, () => new Error("JPDB parse timed out.")) : await parsePromise;
done();
return result;
} catch (error) {
if (!this.canUseLocalDictionaryFallback()) {
log$7.warn("JPDB parse failed without local fallback", error);
done();
throw error;
}
log$7.warn("JPDB parse failed; using local dictionary fallback", error);
}
}
if (!this.canUseLocalDictionaryFallback()) {
done();
return paragraphs.map(() => []);
}
try {
const result = await Promise.all(paragraphs.map((text2) => this.parseLocalDictionaryText(text2)));
return result;
} finally {
done();
}
}
canParse() {
const settings = this.dependencies.getSettings();
return Boolean(settings.apiKey.trim()) || this.canUseLocalDictionaryFallback();
}
isJpdbBackedCard(card) {
return (!card.source || card.source === "jpdb") && card.vid > 0 && card.sid > 0;
}
getCachedCard(vid, sid) {
return this.dependencies.jpdb.getCard(vid, sid) ?? this.localCardCache.get(cardCacheKey(vid, sid));
}
cacheCards(cards) {
cards.forEach((card) => {
if (card.source && card.source !== "jpdb" || card.vid <= 0 || card.sid <= 0) {
this.localCardCache.set(cardCacheKey(card.vid, card.sid), card);
}
});
}
clearLocalCache() {
this.localCardCache.clear();
}
localCardFromEntry(entry) {
const id = -stableLocalId(`${entry.dictionary}
${entry.expression}
${entry.reading}`);
const card = {
vid: id,
sid: id,
rid: 0,
spelling: entry.expression,
reading: entry.reading || entry.expression,
frequencyRank: entry.jpdbFrequency ?? null,
partOfSpeech: [],
meanings: [{
glosses: entry.glossary.map(glossaryToText).filter(Boolean).slice(0, 8),
partOfSpeech: []
}],
cardState: ["not-in-deck"],
pitchAccent: [],
wordWithReading: null,
source: "local"
};
this.localCardCache.set(cardCacheKey(card.vid, card.sid), card);
return card;
}
fallbackCardFromText(text2) {
const spelling = normalizeFallbackTerm(text2);
const id = -stableLocalId(`fallback
${spelling}`);
const card = {
vid: id,
sid: id,
rid: 0,
spelling,
reading: spelling,
frequencyRank: null,
partOfSpeech: [],
meanings: [],
cardState: ["not-in-deck"],
pitchAccent: [],
wordWithReading: null,
source: "fallback"
};
this.localCardCache.set(cardCacheKey(card.vid, card.sid), card);
return card;
}
canUseLocalDictionaryFallback() {
return this.dependencies.getSettings().localDictionariesEnabled;
}
async parseLocalDictionaryText(text2) {
const { dictionaries, getSettings } = this.dependencies;
const settings = getSettings();
const matches = await dictionaries.findTermMatches(text2, LOCAL_MATCH_LIMIT, settings.dictionaryPreferences).catch((error) => {
log$7.warn("Local dictionary parse failed", { length: text2.length }, error);
return [];
});
return Promise.all(matches.map(async (match) => {
const card = this.localCardFromEntry(match.entry);
const pitch = await this.localPitchPattern(card);
if (pitch && !card.pitchAccent.length) card.pitchAccent = [pitch];
const reading = !match.deinflected && card.reading && card.reading !== match.surface ? card.reading : "";
return {
card,
start: match.start,
end: match.end,
length: match.end - match.start,
rubies: reading ? [{ text: reading, start: match.start, end: match.end, length: match.end - match.start }] : [],
pitchClass: pitch ? getPitchClass([pitch], card.reading) : "",
sentence: text2
};
}));
}
async localPitchPattern(card) {
const settings = this.dependencies.getSettings();
if (!settings.showPitchAccent || !settings.localDictionariesEnabled) return "";
const lookupTermMeta = this.dependencies.dictionaries.lookupTermMeta;
if (typeof lookupTermMeta !== "function") return "";
const metaEntries = await lookupTermMeta.call(this.dependencies.dictionaries, card.spelling, 12, settings.dictionaryPreferences).catch((error) => {
log$7.warn("Local pitch lookup failed while parsing text", { term: card.spelling }, error);
return [];
});
return localPitchPatternFromMeta(card.reading, metaEntries);
}
}
function fallbackLookupTermAtOffset(text2, offset) {
const clampedOffset = Math.max(0, Math.min(offset, Math.max(0, text2.length - 1)));
for (const match of text2.matchAll(JAPANESE_SCRIPT_GROUP_RE)) {
const start = match.index ?? 0;
const end = start + match[0].length;
if (offsetInsideFallbackMatch(start, end, clampedOffset)) {
return normalizeFallbackTerm(match[0]);
}
}
return normalizeFallbackTerm(text2);
}
function offsetInsideFallbackMatch(start, end, offset) {
return start <= offset && offset < end || start < offset && offset <= end;
}
function normalizeFallbackTerm(text2) {
return text2.replace(/\s+/g, " ").trim().slice(0, 80);
}
function stableLocalId(value) {
let hash = 2166136261;
for (let index = 0; index < value.length; index++) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0 || 1;
}
function cardCacheKey(vid, sid) {
return `${vid}:${sid}`;
}
function withTimeout(promise, timeoutMs, errorFactory) {
let timeoutId = 0;
const timeout = new Promise((_resolve, reject) => {
timeoutId = window.setTimeout(() => reject(errorFactory()), timeoutMs);
});
return Promise.race([
promise,
timeout
]).finally(() => window.clearTimeout(timeoutId));
}
const COLOR_SOURCE_CLASSES = ["status", "jpdb", "anki", "pitch"];
const COLOR_CHANNELS = ["highlight", "underline", "text"];
function applyReaderTheme(settings, root = document.documentElement) {
const theme = appliedReaderTheme(settings);
root.classList.toggle("jpdb-reader-theme-dark", settings.theme === "dark");
root.classList.toggle("jpdb-reader-theme-light", settings.theme === "light");
applyReaderAccentColor(settings.accentColor, root);
applyReaderWordColors(settings, root);
root.classList.toggle("jpdb-reader-hide-known", theme.furiganaMode === "known-status");
root.classList.remove("jpdb-reader-highlight-status", "jpdb-reader-highlight-pitch", "jpdb-reader-highlight-off");
applyReaderColorSourceClasses(root, "word", theme.wordColorSources);
applyReaderColorSourceClasses(root, "subtitle", theme.subtitleColorSources);
return theme;
}
function applyReaderAccentColor(color, root = document.documentElement) {
const accentColor = sanitizeAccentColor(color);
root.style.setProperty("--jpdb-reader-accent", accentColor);
root.style.setProperty("--jpdb-reader-accent-soft", accentToRgba(accentColor, 0.18));
root.style.setProperty("--jpdb-reader-accent-readable", readableAccentOnSurface(accentColor, root));
root.style.setProperty("--jpdb-reader-accent-text", readableTextOnAccent(accentColor));
}
function applyReaderWordColors(settings, root = document.documentElement) {
Object.entries(readerStateColors(settings)).forEach(([state, color]) => {
root.style.setProperty(`--jpdb-reader-state-${state}`, color);
root.style.setProperty(`--jpdb-reader-state-${state}-soft`, accentToRgba(color, 0.16));
root.style.setProperty(`--jpdb-reader-state-${state}-strong`, accentToRgba(color, 0.28));
});
Object.entries(readerPitchColors(settings)).forEach(([pattern, { color, alpha }]) => {
root.style.setProperty(`--jpdb-reader-pitch-${pattern}`, color);
root.style.setProperty(`--jpdb-reader-pitch-${pattern}-soft`, alpha > 0 ? accentToRgba(color, alpha) : "transparent");
});
}
function appliedReaderTheme(settings) {
return {
furiganaMode: effectiveFuriganaMode(settings),
wordColorSources: {
highlight: effectiveReaderColorSource(settings, settings.wordHighlightColorSource, "jpdb"),
underline: effectiveReaderColorSource(settings, settings.wordUnderlineColorSource, "pitch"),
text: effectiveReaderColorSource(settings, settings.wordTextColorSource, "off")
},
subtitleColorSources: {
highlight: effectiveSubtitleColorSource(settings, settings.subtitleHighlightColorSource, "jpdb"),
underline: effectiveSubtitleColorSource(settings, settings.subtitleUnderlineColorSource, "pitch"),
text: effectiveSubtitleColorSource(settings, settings.subtitleTextColorSource, "jpdb")
}
};
}
function readerStateColors(settings) {
return {
new: sanitizeAccentColor(settings.wordColorNew),
learning: sanitizeAccentColor(settings.wordColorLearning),
known: sanitizeAccentColor(settings.wordColorKnown),
due: sanitizeAccentColor(settings.wordColorDue),
failed: sanitizeAccentColor(settings.wordColorFailed),
ignored: sanitizeAccentColor(settings.wordColorIgnored)
};
}
function readerPitchColors(settings) {
return {
heiban: { color: sanitizeAccentColor(settings.pitchColorHeiban), alpha: 0.14 },
atamadaka: { color: sanitizeAccentColor(settings.pitchColorAtamadaka), alpha: 0.14 },
nakadaka: { color: sanitizeAccentColor(settings.pitchColorNakadaka), alpha: 0.16 },
odaka: { color: sanitizeAccentColor(settings.pitchColorOdaka), alpha: 0.14 },
kifuku: { color: sanitizeAccentColor(settings.pitchColorKifuku), alpha: 0.14 },
unknown: { color: sanitizeAccentColor(settings.pitchColorUnknown), alpha: 0 }
};
}
function applyReaderColorSourceClasses(root, scope, sources) {
COLOR_CHANNELS.forEach((channel) => {
COLOR_SOURCE_CLASSES.forEach((source) => {
root.classList.toggle(`jpdb-reader-${scope}-${channel}-${source}`, sources[channel] === source);
});
});
}
function readableAccentOnSurface(accentColor, root) {
const surface = readerSurfaceColor(root);
const safeAccent = sanitizeAccentColor(accentColor);
return readableOnAll(safeAccent, [
surface,
mixHex(surface, safeAccent, 0.18),
mixHex(surface, safeAccent, 0.26)
], 4.5);
}
function readableTextOnAccent(accentColor) {
const darkText = "#11161d";
const lightText = "#ffffff";
return contrastRatio(accentColor, darkText) >= contrastRatio(accentColor, lightText) ? darkText : lightText;
}
function readerSurfaceColor(root) {
const computed = typeof getComputedStyle === "function" ? getComputedStyle(root).getPropertyValue("--jpdb-reader-surface").trim() : "";
if (isHexColor(computed)) return sanitizeAccentColor(computed);
if (root.classList.contains("jpdb-reader-theme-light")) return "#f7f8fa";
return prefersLightMode() ? "#f7f8fa" : "#20242b";
}
function prefersLightMode() {
return typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: light)").matches;
}
function readableOnAll(color, backgrounds, targetContrast) {
const safe = sanitizeAccentColor(color);
if (backgrounds.every((background) => contrastRatio(safe, background) >= targetContrast)) return safe;
const candidates = ["#000000", "#ffffff"].map((toward) => closestReadableMix(safe, toward, backgrounds, targetContrast)).filter((candidate) => Boolean(candidate)).sort((a, b) => a.amount - b.amount || b.contrast - a.contrast);
if (candidates[0]) return candidates[0].color;
return ["#000000", "#ffffff"].map((fallback) => ({ color: fallback, contrast: minContrast(fallback, backgrounds) })).sort((a, b) => b.contrast - a.contrast)[0]?.color ?? "#000000";
}
function closestReadableMix(color, toward, backgrounds, targetContrast) {
for (let amount = 0.04; amount <= 1; amount += 0.04) {
const mixed = mixHex(color, toward, amount);
const contrast = minContrast(mixed, backgrounds);
if (contrast >= targetContrast) return { color: mixed, amount, contrast };
}
return null;
}
function minContrast(color, backgrounds) {
return Math.min(...backgrounds.map((background) => contrastRatio(color, background)));
}
function contrastRatio(a, b) {
const l1 = relativeLuminance(a);
const l2 = relativeLuminance(b);
const light = Math.max(l1, l2);
const dark = Math.min(l1, l2);
return (light + 0.05) / (dark + 0.05);
}
function relativeLuminance(color) {
const [red, green, blue] = hexToRgb(color).map((value) => {
const channel = value / 255;
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}
function mixHex(from, to, amount) {
const a = hexToRgb(from);
const b = hexToRgb(to);
return `#${a.map((value, index) => Math.round(value + (b[index] - value) * amount).toString(16).padStart(2, "0")).join("")}`;
}
function hexToRgb(color) {
const safe = sanitizeAccentColor(color);
return [
parseInt(safe.slice(1, 3), 16),
parseInt(safe.slice(3, 5), 16),
parseInt(safe.slice(5, 7), 16)
];
}
function isHexColor(value) {
return /^#[0-9a-f]{6}$/i.test(value);
}
Logger.scope("NewTab");
const UCHISEN_INDEX_PREFIX = "yomu-jpdb-uchisen-index:";
const UCHISEN_PAYWALL_STORY_RE = /\bplease\s+subscribe\s+to\s+uchisen\s*pro\b/i;
const UCHISEN_PAYWALL_IMAGE_RE = /(?:^|\/)(?:kanji\/)?enrollment\.(?:png|jpe?g|webp)$/i;
function parseUchisenData(html) {
if (!html.trim()) return { images: [], componentGroups: [], kanjiKeyword: null };
const doc = parseHtmlDocument(html);
return {
images: parseUchisenImagesFromDocument(doc),
componentGroups: parseUchisenComponentGroupsFromDocument(doc),
kanjiKeyword: parseUchisenKanjiKeywordFromDocument(doc)
};
}
function parseUchisenImagesFromDocument(doc) {
const images = [];
const mainImage = mainUchisenImageUrl(doc);
const mainStory = cleanText$3(doc.querySelector("#mnemonic_story")?.textContent ?? "");
if (mainImage) {
const url = canonicalUchisenUrl(mainImage);
images.push({
url,
story: mainStory || "No story available",
paywall: isUchisenPaywallImage(url) || isUchisenPaywallStory(mainStory)
});
}
doc.querySelectorAll(".mnemonic_card").forEach((card) => {
const image = uchisenCardImage(card, mainStory);
if (image) images.push(image);
});
return orderedUchisenImages(images);
}
function parseUchisenComponentGroupsFromDocument(doc) {
const root = doc.querySelector(".kanji_info_container .components") ?? doc.querySelector(".components");
if (!root) return [];
return Array.from(root.children).filter((child) => child instanceof HTMLElement && child.classList.contains("KP_primes")).map(uchisenComponentGroup).filter((group) => Boolean(group?.components.length)).slice(0, 4);
}
function parseUchisenKanjiKeywordFromDocument(doc) {
const candidates = [
doc.querySelector("#kanji_keyword_container > span")?.textContent,
doc.querySelector("#kanji_keyword_container")?.textContent,
doc.querySelector(".kanji_name > span")?.textContent,
doc.querySelector(".mnemonic_studio_right h2.kanji_info")?.textContent
];
for (const candidate of candidates) {
const keyword = uchisenKanjiKeyword(candidate ?? "");
if (keyword) return keyword;
}
return null;
}
function uchisenKanjiKeyword(value) {
const match = /^(.+?)\s*[-\u2013\u2014]\s*(.+)$/u.exec(cleanText$3(value));
if (!match) return null;
const kanji = cleanText$3(match[1].replace(/[「」]/g, ""));
const keyword = cleanText$3(match[2]);
if (!kanji || !keyword) return null;
return {
kanji,
keyword,
url: `https://uchisen.com/kanji/${encodeURIComponent(kanji)}`
};
}
function uchisenComponentGroup(group) {
const components2 = Array.from(group.querySelectorAll(".name_combo")).map(uchisenComponent).filter((component) => Boolean(component?.symbol || component?.name)).slice(0, 8);
if (!components2.length) return null;
return {
title: uchisenComponentGroupTitle(group),
components: components2
};
}
function uchisenComponentGroupTitle(group) {
if (group.querySelector(".prime_label")) return "Kanji Primes";
if (group.querySelector(".compound_label")) return "Compound Kanji";
return cleanText$3(group.querySelector(".prime_label, .compound_label")?.textContent ?? "") || "Components";
}
function uchisenComponent(item) {
const link = item.querySelector("a[href]");
if (!link) return null;
const symbol = cleanText$3(link.querySelector(".component_symbol")?.textContent ?? "");
const name = uchisenComponentName(link, symbol);
return {
name,
symbol,
url: absoluteUchisenUrl(link.getAttribute("href") ?? "")
};
}
function uchisenComponentName(link, symbol) {
const text2 = cleanText$3((link.textContent ?? "").replace(/\u00a0/g, " "));
const withoutSymbol = symbol ? cleanText$3(text2.replace(symbol, "")) : text2;
return cleanText$3(withoutSymbol.replace(/[::].*$/u, "")) || symbol;
}
function absoluteUchisenUrl(value) {
try {
return new URL(value, "https://uchisen.com").href;
} catch {
return value;
}
}
function orderedUchisenImages(images) {
const seen = /* @__PURE__ */ new Set();
const deduped = images.filter((item) => {
const key = uchisenImageDedupeKey(item);
if (!item.url || seen.has(key)) return false;
seen.add(key);
return true;
});
return [
...deduped.filter((item) => !item.paywall),
...deduped.filter((item) => item.paywall)
].map(({ url, story }) => ({ url, story }));
}
function uchisenImageDedupeKey(item) {
return item.paywall && isUchisenPaywallImage(item.url) ? "paywall:enrollment" : `url:${item.url}`;
}
function mainUchisenImageUrl(doc) {
const mainLoader = doc.querySelector(".kanji_image_loader[data-large]");
return mainLoader?.getAttribute("data-large") || doc.querySelector("#full_kanji_image")?.getAttribute("src") || "";
}
function uchisenCardImage(card, mainStory) {
const rawUrl = card.querySelector("input.image_url")?.value.trim() ?? "";
if (!rawUrl) return null;
const url = canonicalUchisenUrl(rawUrl);
const story = uchisenCardStory(card, mainStory);
return {
url,
story,
paywall: isUchisenPaywallCard(card, url, story)
};
}
function uchisenCardStory(card, mainStory) {
const rawStory = card.querySelector("input.story")?.value ?? "";
const story = cleanText$3(decodeEntities(rawStory).replace(/<[^>]+>/g, " "));
return story || mainStory || "No story available";
}
function isUchisenPaywallCard(card, url, story) {
const thumbnailUrl = card.querySelector(".mnemonic_card_thumbnail img")?.getAttribute("src") ?? "";
return isUchisenPaywallImage(url) || isUchisenPaywallImage(thumbnailUrl) || isUchisenPaywallStory(story);
}
function isUchisenPaywallImage(url) {
try {
return UCHISEN_PAYWALL_IMAGE_RE.test(new URL(url).pathname);
} catch {
return UCHISEN_PAYWALL_IMAGE_RE.test(url.split(/[?#]/)[0]);
}
}
function isUchisenPaywallStory(story) {
return UCHISEN_PAYWALL_STORY_RE.test(cleanText$3(story));
}
async function loadUchisenData(kanji, proxyUrl = DEFAULT_YOMU_PUBLIC_PROXY_URL) {
const html = await requestText(`https://uchisen.com/kanji/${encodeURIComponent(kanji)}`, 9e3, proxyUrl);
return parseUchisenData(html);
}
async function installUchisenCarousel(container, kanji, images, options = {}) {
const storedIndex = await gmStorageGet(`${UCHISEN_INDEX_PREFIX}${kanji}`, 0);
let index = preferredUchisenIndex(storedIndex, images);
if (!isValidUchisenIndex(index, images)) index = 0;
const proxyUrl = options.proxyUrl ?? DEFAULT_YOMU_PUBLIC_PROXY_URL;
let currentImageUrl = "";
const cleanup = () => {
if (!currentImageUrl) return;
revokePageMediaUrl(currentImageUrl);
currentImageUrl = "";
};
const render = () => {
const item = images[index];
const detailsClass = options.detailsClass ?? "jpdb-reader-local-entry jpdb-reader-dictionary-group yomu-jpdb-uchisen-source";
const summaryClass = options.summaryClass ?? "jpdb-reader-local-head";
const bodyClass = options.bodyClass ?? "jpdb-reader-local-glossary yomu-jpdb-uchisen-body";
const sourceAttributes = options.sourceAttributes ?? "open";
const summaryHtml = options.summaryHtml?.(index + 1, images.length) ?? `
Uchisen
${index + 1}/${images.length}
`;
const bodyMeta = options.summaryHtml ? `${index + 1}/${images.length}
` : "";
setInnerHtml(container, `
${summaryHtml}
‹
›
View on Uchisen ${externalLinkIcon()}
${bodyMeta}
${renderUchisenComponentGroups(options.kanjiKeyword, options.componentGroups ?? [])}
${escapeHtml$1(item.story || "No story available")}
`);
const image = container.querySelector("[data-uchisen-image]");
if (!image) return;
const srcUrl = item.url;
requestBlobUrl(srcUrl, 9e3, proxyUrl).then((url) => {
if (!image.isConnected || images[index]?.url !== srcUrl) {
revokePageMediaUrl(url);
return;
}
cleanup();
currentImageUrl = url;
image.src = url;
}).catch(() => {
if (image.isConnected) image.remove();
});
};
container.addEventListener("click", (event) => {
const action = event.target.closest("[data-uchisen-action]")?.dataset.uchisenAction;
if (!action) return;
event.preventDefault();
event.stopPropagation();
if (action !== "previous" && action !== "next") return;
if (action === "previous") index = (index - 1 + images.length) % images.length;
if (action === "next") index = (index + 1) % images.length;
void gmStorageSet(`${UCHISEN_INDEX_PREFIX}${kanji}`, index);
render();
});
render();
return cleanup;
}
function renderUchisenComponentGroups(kanjiKeyword, groups) {
const keywordGroup = uchisenKanjiKeywordGroup(kanjiKeyword);
const visibleGroups = [
...keywordGroup ? [keywordGroup] : [],
...groups.filter((group) => group.components.length)
];
if (!visibleGroups.length) return "";
return `
${visibleGroups.map((group) => `
${escapeHtml$1(group.title)}
${group.components.map((component) => renderUchisenComponentChip(component)).join("")}
`).join("")}
`;
}
function uchisenKanjiKeywordGroup(keyword) {
if (!keyword || !keyword.kanji && !keyword.keyword) return null;
return {
title: "Kanji Keyword",
components: [{
name: keyword.keyword,
symbol: keyword.kanji,
url: keyword.url
}]
};
}
function renderUchisenComponentChip(component) {
const label = [component.name, component.symbol].filter(Boolean).join(": ");
const content = `
${component.symbol ? `${escapeHtml$1(component.symbol)} ` : ""}
${component.name ? `${escapeHtml$1(component.name)} ` : ""}
`;
return component.url ? `${content} ` : `${content} `;
}
function preferredUchisenIndex(storedIndex, images) {
if (isValidUchisenIndex(storedIndex, images)) return storedIndex;
const firstNonPaywall = images.findIndex((item) => !isUchisenPaywallItem(item));
return firstNonPaywall >= 0 ? firstNonPaywall : storedIndex;
}
function isValidUchisenIndex(index, images) {
return Number.isInteger(index) && index >= 0 && index < images.length;
}
function isUchisenPaywallItem(item) {
return Boolean(item && (isUchisenPaywallImage(item.url) || isUchisenPaywallStory(item.story)));
}
function requestText(url, timeout, proxyUrl) {
return requestText$7(url, {
proxyUrl,
timeoutMs: timeout,
failureLabel: "Uchisen request",
timeoutLabel: "Uchisen request timed out."
});
}
function requestBlobUrl(url, timeout, proxyUrl) {
return requestBlob(url, timeout, proxyUrl).then((blob) => createPageMediaUrl(blob));
}
function requestBlob(url, timeout, proxyUrl) {
return requestBlob$4(url, {
proxyUrl,
timeoutMs: timeout,
failureLabel: "Uchisen image request",
timeoutLabel: "Uchisen image request timed out."
});
}
Logger.scope("NewTab");
const NEW_TAB_CACHE_KEY = "jpdb-reader-newtab-card-cache";
function clearNewTabOfflineCache() {
return gmStorageDelete(NEW_TAB_CACHE_KEY);
}
const RECOMMENDED_JAPANESE_DICTIONARIES = [
{
id: "jitendex",
category: "terms",
name: "Jitendex",
descriptionKey: "recommendedJitendex",
homepage: "https://jitendex.org",
downloadUrl: "https://github.com/stephenmk/stephenmk.github.io/releases/latest/download/jitendex-yomitan.zip"
},
{
id: "jmdict",
category: "terms",
name: "JMdict",
descriptionKey: "recommendedJmdict",
homepage: "https://github.com/yomidevs/jmdict-yomitan#jmdict-for-yomitan",
downloadUrl: "https://github.com/yomidevs/jmdict-yomitan/releases/latest/download/JMdict_english.zip"
},
{
id: "jmnedict",
category: "terms",
name: "JMnedict",
descriptionKey: "recommendedJmnedict",
homepage: "https://github.com/yomidevs/jmdict-yomitan?tab=readme-ov-file#jmnedict-for-yomitan",
downloadUrl: "https://github.com/yomidevs/jmdict-yomitan/releases/latest/download/JMnedict.zip"
},
{
id: "kanjidic",
category: "kanji",
name: "KANJIDIC",
descriptionKey: "recommendedKanjidic",
homepage: "https://github.com/yomidevs/jmdict-yomitan?tab=readme-ov-file#kanjidic-for-yomitan",
downloadUrl: "https://github.com/yomidevs/jmdict-yomitan/releases/latest/download/KANJIDIC_english.zip"
},
{
id: "jpdbv2-kana",
category: "frequency",
name: "JPDBv2㋕",
descriptionKey: "recommendedJpdbv2Kana",
homepage: "https://github.com/Kuuuube/yomitan-dictionaries?tab=readme-ov-file#jpdb-v22-frequency",
downloadUrl: "https://github.com/Kuuuube/yomitan-dictionaries/releases/download/yomitan-permalink/JPDB_v2.2_Frequency_Kana.zip"
},
{
id: "bccwj",
category: "frequency",
name: "BCCWJ",
descriptionKey: "recommendedBccwj",
homepage: "https://github.com/Kuuuube/yomitan-dictionaries?tab=readme-ov-file#bccwj-suw-luw-combined",
downloadUrl: "https://github.com/Kuuuube/yomitan-dictionaries/releases/download/yomitan-permalink/BCCWJ_SUW_LUW_combined.zip"
},
{
id: "jiten",
category: "frequency",
name: "Jiten",
descriptionKey: "recommendedJiten",
homepage: "https://jiten.moe/other",
downloadUrl: "https://api.jiten.moe/api/frequency-list/download?downloadType=yomitan"
}
];
function findRecommendedDictionary(id) {
return RECOMMENDED_JAPANESE_DICTIONARIES.find((dictionary) => dictionary.id === id);
}
const log$6 = Logger.scope("SettingsForm");
const COLOR_SOURCE_VALUES = ["status", "jpdb", "anki", "pitch", "off"];
const COLOR_SOURCE_OPTIONS = [
["status", "Available status"],
["jpdb", "JPDB status"],
["anki", "Anki status"],
["pitch", "Pitch accent"],
["off", "Off"]
];
const DEFAULT_COLOR_SOURCE_VALUES = {
wordHighlightColorSource: "jpdb",
wordUnderlineColorSource: "pitch",
wordTextColorSource: "off",
subtitleHighlightColorSource: "jpdb",
subtitleUnderlineColorSource: "pitch",
subtitleTextColorSource: "jpdb"
};
const COLOR_SOURCE_CLASS_VALUES = ["status", "jpdb", "anki", "pitch"];
function renderHelpLinksPanel() {
return `
Useful pages
Open the hosted reader tools and docs from here.
Support よむ
よむ brings popup lookup, JPDB mining, imported dictionaries, subtitles, image reading, and Anki export into one free userscript. Comparable study suites such as Migaku currently advertise paid plans from $10/month; よむ offers the same core reading-and-mining workflow for free.
Donations are optional. They help cover the time, testing devices, services, maintenance, and AI tokens that keep the reader polished. Realistically, I have already spent far more on AI/API tokens building よむ than donations are ever likely to make back, but even a small donation helps soften that cost. On a personal level, my dream is to save enough money to move to Japan and marry my long-distance Japanese girlfriend. Every bit of support helps bring that future closer and encourages me to keep maintaining よむ, fixing bugs, and adding the features learners ask for.
`;
}
function renderSettingsForm(settings, jpdbSettingsUrl) {
return `
${renderSettingsTabs()}
${renderJpdbSettingsPanel(settings, jpdbSettingsUrl)}
${renderInterfaceSettingsPanel(settings)}
${renderAudioSettingsPanel(settings)}
${renderImmersionKitSettingsPanel(settings)}
${renderReaderSettingsPanel(settings)}
${renderKanjiSettingsPanel(settings)}
${renderImageSettingsPanel(settings)}
${renderVideoSettingsPanel(settings)}
${renderMiningSettingsPanel(settings)}
${renderDictionariesSettingsPanel(settings)}
${renderShortcutSettingsPanel(settings)}
${renderHelpSettingsPanel()}
${renderSettingsFooter()}
`;
}
function renderSettingsTabs() {
return `
${settingsTabButton("basics", "Basics", true)}
${settingsTabButton("dictionaries", "Dictionaries")}
${settingsTabButton("media", "Media")}
${settingsTabButton("mining", "Mining")}
${settingsTabButton("shortcuts", "Shortcuts")}
${settingsTabButton("help", "Help")}
`;
}
function renderJpdbSettingsPanel(settings, jpdbSettingsUrl) {
return `
JPDB
${input("apiKey", `API key JPDB settings `, settings.apiKey, "password")}
${renderDeckControls(settings, [], Boolean(settings.apiKey.trim()))}
${checkbox("jpdbMiningEnabled", "Allow JPDB review/deck changes", settings.jpdbMiningEnabled)}
${checkbox("addToForq", "Also copy JPDB adds to forq", settings.jpdbMiningEnabled && settings.addToForq, { disabled: !settings.jpdbMiningEnabled })}
${checkbox("enableReviews", "Show review buttons", settings.enableReviews)}
${select("twoButtonReviews", "Review rating scale", settings.twoButtonReviews ? "true" : "false", [["false", "Five point: NOTHING to EASY"], ["true", "Two point: FAIL / PASS"]])}
`;
}
function renderInterfaceSettingsPanel(settings) {
return `
Interface
${select("interfaceLanguage", "Settings language", settings.interfaceLanguage, [["auto", "Automatic"], ["en", "English"], ["ja", "日本語"]])}
${themeSegmentedControl(settings.theme)}
${select("popupMode", "Popup mode", settings.popupMode, [["auto", "Auto"], ["sheet", "Bottom sheet"], ["popover", "Popover"]])}
${renderStickyBottomSheetControl(settings)}
${input("popoverWidth", "Popover width (px)", String(settings.popoverWidth), "number", { min: 280, max: 900, step: 10 })}
${input("popoverHeight", "Popover height (px)", String(settings.popoverHeight), "number", { min: 220, max: 900, step: 10 })}
${select("popoverHeightMode", "Popover height", settings.popoverHeightMode, [["available", "Grow to available space"], ["fixed", "Use height setting"]])}
${checkbox("enableLogging", "Enable console logging", settings.enableLogging)}
${input("accentColor", "Accent color", sanitizeAccentColor(settings.accentColor), "color")}
${renderNewTabSettingsSubsection(settings)}
${renderWordColorSettingsSubsection(settings)}
${renderPitchColorSettingsSubsection(settings)}
${renderColorChannelSettingsSubsection(settings)}
`;
}
function renderStickyBottomSheetControl(settings) {
const unavailable = settings.popupMode === "popover";
return `
${checkbox("stickyBottomSheet", "Keep bottom sheet open until closed", settings.stickyBottomSheet && !unavailable, { disabled: unavailable })}
`;
}
function renderNewTabSettingsSubsection(settings) {
return `
New tab
${checkbox("newTabEnabled", "Use Yomu new tab study page", settings.newTabEnabled)}
${select("newTabSource", "New tab review source", settings.newTabSource, [["auto", "Auto: JPDB + Anki"], ["jpdb", "JPDB"], ["anki", "Anki"], ["dictionary", "Dictionary fallback"]])}
${select("newTabJpdbReviewMode", "JPDB review mode", settings.newTabJpdbReviewMode, [["auto", "Auto: live kanji + API vocabulary"], ["live-review", "Live JPDB review session"], ["api-vocabulary", "API vocabulary only"]])}
${select("newTabKanjiKeywordSource", "Kanji keyword source", settings.newTabKanjiKeywordSource, [["auto", "Auto: RTK, then JPDB, then local"], ["rtk", "RTK / Heisig"], ["jpdb", "JPDB"], ["local", "Local card meaning"]])}
${checkbox("newTabParsingEnabled", "Parse sentences on new tab", settings.newTabParsingEnabled)}
${checkbox("newTabFrontSentenceEnabled", "Show sentence on word fronts", settings.newTabFrontSentenceEnabled)}
${checkbox("newTabKanjiAutogradeEnabled", "Autograde kanji drawing", settings.newTabKanjiAutogradeEnabled)}
${checkbox("newTabKanjiAutoSubmit", "Submit kanji grade after autograde", settings.newTabKanjiAutoSubmit)}
${checkbox("newTabOfflineEnabled", "Cache new tab for offline use", settings.newTabOfflineEnabled)}
${input("newTabOfflineLimit", "Offline review cache limit", String(settings.newTabOfflineLimit), "number", { min: 0, max: 500, step: 10 })}
New tab address
Use this page as your browser new-tab URL or add it to the iPad Home Screen. Offline caching is eventually consistent: よむ refreshes the next cached review list and card assets when the source is reachable, uses the last good cache while offline, and queues JPDB or Anki grades until the source reconnects.
`;
}
function renderWordColorSettingsSubsection(settings) {
return `
Word colors
${input("wordColorNew", "New and suspended", settings.wordColorNew, "color")}
${input("wordColorLearning", "Learning", settings.wordColorLearning, "color")}
${input("wordColorKnown", "Known and never forget", settings.wordColorKnown, "color")}
${input("wordColorDue", "Due", settings.wordColorDue, "color")}
${input("wordColorFailed", "Failed", settings.wordColorFailed, "color")}
${input("wordColorIgnored", "Ignored and blacklisted", settings.wordColorIgnored, "color")}
`;
}
function renderPitchColorSettingsSubsection(settings) {
return `
Pitch accent colors
${input("pitchColorHeiban", "Heiban (flat)", settings.pitchColorHeiban, "color")}
${input("pitchColorAtamadaka", "Atamadaka (head-high)", settings.pitchColorAtamadaka, "color")}
${input("pitchColorNakadaka", "Nakadaka (middle-high)", settings.pitchColorNakadaka, "color")}
${input("pitchColorOdaka", "Odaka (tail-high)", settings.pitchColorOdaka, "color")}
${input("pitchColorKifuku", "Kifuku (variable)", settings.pitchColorKifuku, "color")}
${input("pitchColorUnknown", "Unknown / inherited", settings.pitchColorUnknown, "color")}
`;
}
function renderColorChannelSettingsSubsection(settings) {
return `
Color channels
${select("wordHighlightColorSource", "Word highlight color", settingsColorSourceValue(settings, "wordHighlightColorSource"), COLOR_SOURCE_OPTIONS)}
${select("wordUnderlineColorSource", "Word underline color", settingsColorSourceValue(settings, "wordUnderlineColorSource"), COLOR_SOURCE_OPTIONS)}
${select("wordTextColorSource", "Word text color", settingsColorSourceValue(settings, "wordTextColorSource"), COLOR_SOURCE_OPTIONS)}
${select("subtitleHighlightColorSource", "Subtitle highlight color", settingsColorSourceValue(settings, "subtitleHighlightColorSource"), COLOR_SOURCE_OPTIONS)}
${select("subtitleUnderlineColorSource", "Subtitle underline color", settingsColorSourceValue(settings, "subtitleUnderlineColorSource"), COLOR_SOURCE_OPTIONS)}
${select("subtitleTextColorSource", "Subtitle text color", settingsColorSourceValue(settings, "subtitleTextColorSource"), COLOR_SOURCE_OPTIONS)}
Each channel uses the source shown here. Defaults keep page text readable, show mining status in highlights, and keep subtitle status and pitch visible.
`;
}
function settingsColorSourceValue(settings, name) {
const source = settings[name];
return source === "auto" ? DEFAULT_COLOR_SOURCE_VALUES[name] : source;
}
function renderAudioSettingsPanel(settings) {
return `
Audio
${checkbox("audioEnabled", "Enable audio playback for terms", settings.audioEnabled)}
${checkbox("autoPlayAudio", "Auto-play when a word card opens", settings.autoPlayAudio)}
${checkbox("audioEnableDefaultSources", "Use built-in audio sources", settings.audioEnableDefaultSources)}
${checkbox("audioFallbackChimeEnabled", "Play a soft chime when no audio is available", settings.audioFallbackChimeEnabled)}
${select("audioAutoPlayMode", "Auto-play trigger", settings.audioAutoPlayMode, [["all", "Hover and tap/click"], ["hover", "Hover only"], ["tap", "Tap/click only"]])}
${select("audioSelectionMode", "When several sources or clips exist", settings.audioSelectionMode, [["first", "First audio"], ["random", "Random audio"]])}
${select("audioTtsMode", "Text-to-speech handling", settings.audioTtsMode, [["fallback", "Fallback after recorded audio"], ["source-order", "Follow source order / random"]])}
${input("audioTimeoutMs", "Audio timeout (ms)", String(settings.audioTimeoutMs), "number")}
${input("corsProxyUrl", "Cross-origin proxy URL", settings.corsProxyUrl, "url", { placeholder: "https://yomu-jpdb-public-proxy.henry-robert-christopher-russell.workers.dev" })}
${renderAudioSourceEditor(settings.audioSources)}
Supports {term}, {reading}, and {language}. The proxy is shared by hosted-page audio and public lookup requests. In fallback mode, JPDB and browser text-to-speech rows are tried only after recorded audio misses. See the
Yomitan audio guide .
`;
}
function renderImmersionKitSettingsPanel(settings) {
return `
Immersion Kit
${checkbox("immersionKitEnabled", "Show Immersion Kit examples", settings.immersionKitEnabled)}
${select("immersionKitExampleSource", "Example provider", settings.immersionKitExampleSource, [["immersion-kit", "Immersion Kit"], ["nadeshiko", "Nadeshiko"], ["combined", "Immersion Kit + Nadeshiko"]])}
${renderNadeshikoApiKeyField(settings)}
${checkbox("immersionKitShowTranslation", "Show example translations", settings.immersionKitShowTranslation)}
${checkbox("immersionKitRevealTranslationOnClick", "Blur example translations until clicked", settings.immersionKitRevealTranslationOnClick, { disabled: !settings.immersionKitShowTranslation })}
${checkbox("immersionKitShowImages", "Show example thumbnails", settings.immersionKitShowImages)}
${checkbox("immersionKitAutoPlayAudio", "Play example audio after reveal or next/previous", settings.immersionKitAutoPlayAudio)}
${checkbox("immersionKitPlayOnHover", "Play example audio when hovering thumbnails", settings.immersionKitPlayOnHover)}
${checkbox("immersionKitPlayOnImageClick", "Play example audio when clicking thumbnails", settings.immersionKitPlayOnImageClick)}
${select("immersionKitCategory", "Immersion Kit category", settings.immersionKitCategory, [["all", "All"], ["anime", "Anime"], ["drama", "Drama"], ["games", "Games"]])}
${select("immersionKitSort", "Example order", settings.immersionKitSort, [["sentence_length:asc", "Shortest first"], ["sentence_length:desc", "Longest first"]])}
${radioGroup("immersionKitLimitEnabled", "Examples per word limit", settings.immersionKitLimitEnabled ? "on" : "off", [["off", "All examples"], ["on", "Limit examples"]])}
${input("immersionKitLimit", "Examples per word", String(settings.immersionKitLimit), "number", { min: 1, max: 12, step: 1 })}
${input("immersionKitMinLength", "Minimum sentence length", String(settings.immersionKitMinLength), "number", { min: 0, max: 120, step: 1 })}
${input("immersionKitMaxLength", "Maximum sentence length", String(settings.immersionKitMaxLength), "number", { min: 0, max: 240, step: 1 })}
${input("immersionKitPlaybackRate", "Example audio speed", String(settings.immersionKitPlaybackRate), "number", { min: 0.5, max: 2, step: 0.05 })}
${checkbox("immersionKitExactMatch", "Prefer exact matches", settings.immersionKitExactMatch)}
Immersion examples appear inside word popups and on JPDB pages. Blurred translations reveal when you click or tap the translation text.
`;
}
function renderNadeshikoApiKeyField(settings) {
return `
${input("nadeshikoApiKey", `Nadeshiko API key
Get a key `, settings.nadeshikoApiKey, "password")}
`;
}
function usesNadeshikoExamples(source) {
return source === "nadeshiko" || source === "combined";
}
function renderReaderSettingsPanel(settings) {
return `
Reader
${checkbox("parseSelection", "Lookup selected text", settings.parseSelection)}
${checkbox("lookupOnClick", "Tap or click scanned words", settings.lookupOnClick)}
${checkbox("lookupOnHover", "Hover scanned words", settings.lookupOnHover)}
${checkbox("lookupOnMiddleMouse", "Hold middle mouse button to scan words", settings.lookupOnMiddleMouse)}
${checkbox("autoScanJapanese", "Auto-scan when Japanese is detected", settings.autoScanJapanese)}
${checkbox("scanVisiblePage", "Scan visible page on load", settings.scanVisiblePage)}
${checkbox("showFloatingButton", "Toggle floating puck on pages", settings.showFloatingButton)}
${select("furiganaMode", "Furigana", settings.furiganaMode, [["auto", "Automatic"], ["difficult-kanji", "Difficult kanji only"], ["known-status", "Hide known words"], ["all", "All parsed words"], ["off", "Off"]])}
${checkbox("showPitchAccent", "Show pitch accent", settings.showPitchAccent)}
Hover lookup uses the shortcut below. Leave it blank for plain hover; keep click enabled if you also want tap lookup.
`;
}
function renderKanjiSettingsPanel(settings) {
return `
Kanji
${renderKanjiSourceRows(settings)}
${checkbox("kanjiOriginKanjiMapEnabled", "Use Kanji Alive and Kanji Map facts", settings.kanjiOriginKanjiMapEnabled)}
${checkbox("kanjiOriginGraphEnabled", "Show component graph", settings.kanjiOriginGraphEnabled)}
${checkbox("kanjiOriginRadicalImagesEnabled", "Show radical images", settings.kanjiOriginRadicalImagesEnabled)}
${input("similarKanjiWordLimit", "Similar word limit", String(settings.similarKanjiWordLimit), "number", { min: 2, max: 24, step: 1 })}
Click a kanji inside the popup word to see RTK, local kanji dictionary meanings, component keywords, and related words.
`;
}
function renderImageSettingsPanel(settings) {
const localOcrHidden = settings.ocrProvider === "local-service" ? "" : "hidden";
const cloudOcrHidden = settings.ocrProvider === "cloud-vision" ? "" : "hidden";
return `
OCR
${checkbox("ocrEnabled", "Read text in images", settings.ocrEnabled)}
${checkbox("ocrAutoScanImages", "Read images automatically", settings.ocrAutoScanImages)}
${checkbox("ocrShowTextOverlay", "Show recognized text on images", settings.ocrShowTextOverlay)}
${select("ocrProvider", "Image reading", settings.ocrProvider, [["google-lens", "Google Lens (recommended)"], ["cloud-vision", "Google Cloud Vision"], ["local-service", "Local OCR engine"], ["off", "Off"]])}
${select("ocrMaxImagesPerPage", "Images to read per page", String(settings.ocrMaxImagesPerPage), [["3", "Light"], ["8", "Normal"], ["16", "More"]])}
${select("ocrMinImageArea", "Smallest image to read", String(settings.ocrMinImageArea), [["80000", "Large images only"], ["45000", "Normal"], ["15000", "Include small images"]])}
${select("ocrMaxImagePixels", "Image detail", String(settings.ocrMaxImagePixels), [["640000", "Faster"], ["1200000", "Balanced"], ["2000000", "Sharper"]])}
${input("ocrTextColor", "Image text color", settings.ocrTextColor, "color")}
${input("ocrOutlineColor", "Image text outline", settings.ocrOutlineColor, "color")}
${input("ocrBackgroundColor", "Image highlight background", settings.ocrBackgroundColor, "color")}
${input("ocrBackgroundOpacity", "Image highlight opacity", String(settings.ocrBackgroundOpacity), "number")}
${input("ocrFontScale", "Image text scale", String(settings.ocrFontScale), "number")}
${select("ocrEngine", "Local OCR engine", settings.ocrEngine, [["auto", "Automatic"], ["MangaOCR", "MangaOCR"], ["PaddleOCR", "PaddleOCR"], ["AppleVision", "Apple Vision"]])}
Custom local OCR server
Custom local OCR URL
Cloud Vision API key
Images are read quietly near the viewport. Google Lens handles normal images by default; Cloud Vision can be used with an API key, and embedded OCR metadata is instant. Recognized areas stay transparent until you tap or hover.
`;
}
function renderVideoSettingsPanel(settings) {
return `
Video
${checkbox("subtitlePlayerEnabled", "Enable video subtitle player", settings.subtitlePlayerEnabled)}
${checkbox("subtitleAutoDetect", "Auto-detect page subtitles", settings.subtitleAutoDetect)}
${checkbox("subtitleOverlayVisible", "Show subtitle overlay", settings.subtitleOverlayVisible)}
${checkbox("subtitleSecondaryVisible", "Show native subtitles when available", settings.subtitleSecondaryVisible)}
${checkbox("subtitleNativeBlurred", "Blur native subtitles until hover", settings.subtitleNativeBlurred)}
${checkbox("subtitleKaraokeMode", "Karaoke word timing", settings.subtitleKaraokeMode)}
${checkbox("subtitleTranscriptVisible", "Open transcript panel by default", settings.subtitleTranscriptVisible)}
${checkbox("subtitleTranscriptAutoScroll", "Scroll transcript with playback", settings.subtitleTranscriptAutoScroll)}
${checkbox("subtitleAutoCopyLine", "Auto-copy each subtitle line as it plays", settings.subtitleAutoCopyLine)}
${checkbox("subtitleMiningPause", "Pause video when mining subtitle", settings.subtitleMiningPause)}
${select("subtitleControlsMode", "Subtitle controls", settings.subtitleControlsMode, [["auto", "Compact controls"], ["hidden", "Hide controls"], ["always", "Always visible"]])}
${input("subtitleFontSize", "Subtitle font size", String(settings.subtitleFontSize), "number")}
${input("subtitleBottomOffset", "Subtitle bottom offset (%)", String(settings.subtitleBottomOffset), "number")}
${input("subtitleTextColor", "Subtitle color", settings.subtitleTextColor, "color")}
${input("subtitleOutlineColor", "Subtitle outline", settings.subtitleOutlineColor, "color")}
${input("subtitleBackgroundColor", "Subtitle background", settings.subtitleBackgroundColor, "color")}
${input("subtitleBackgroundOpacity", "Subtitle background opacity", String(settings.subtitleBackgroundOpacity), "number")}
${input("subtitleFontFamily", "Subtitle font family", settings.subtitleFontFamily)}
${input("subtitleFontWeight", "Subtitle font weight", String(settings.subtitleFontWeight), "number")}
${input("subtitleSeekPadding", "Subtitle seek padding (seconds)", String(settings.subtitleSeekPadding), "number")}
${renderSubtitlePreview()}
`;
}
function renderSubtitlePreview() {
return `
新しい
言葉
を
読む
Live subtitle preview
`;
}
function renderMiningSettingsPanel(settings) {
return `
Anki
${checkbox("ankiEnabled", "Enable Anki mining", settings.ankiEnabled)}
${checkbox("ankiMineWithJpdb", "Also add to Anki when adding to JPDB", settings.jpdbMiningEnabled && settings.ankiMineWithJpdb, { disabled: !settings.jpdbMiningEnabled })}
${checkbox("ankiCaptureScreenshot", "Attach context image when possible", settings.ankiCaptureScreenshot)}
${checkbox("ankiMobileHandoff", "Use mobile Anki handoff when AnkiConnect is unavailable", settings.ankiMobileHandoff)}
${input("ankiConnectUrl", "AnkiConnect URL", settings.ankiConnectUrl)}
${input("ankiDeck", "Anki deck", settings.ankiDeck)}
${input("ankiModel", "Anki note type", settings.ankiModel)}
${select("ankiTemplateMode", "Anki card template", settings.ankiTemplateMode, [["recognition", "Word first"], ["context", "Sentence first"]])}
${checkbox("ankiFrontReading", "Word-first front: show reading", settings.ankiFrontReading)}
${checkbox("ankiFrontSentence", "Word-first front: show sentence", settings.ankiFrontSentence)}
${checkbox("ankiFrontImage", "Show image on front", settings.ankiFrontImage)}
${input("ankiTags", "Tags", settings.ankiTags)}
Test Anki
Anki uses AnkiConnect on this device. The default creates a small Yomu note type automatically.
${renderAnkiTemplatePreview(settings)}
`;
}
function renderDictionariesSettingsPanel(settings) {
return `
Dictionaries
${checkbox("jpdbDefinitionsEnabled", "Show JPDB definitions", settings.jpdbDefinitionsEnabled)}
${checkbox("localDictionariesEnabled", "Show imported dictionary definitions", settings.localDictionariesEnabled)}
${checkbox("dictionarySourcesInitiallyExpanded", "Open popup sources by default", settings.dictionarySourcesInitiallyExpanded)}
${input("localDictionaryMaxResults", "Dictionary result limit", String(settings.localDictionaryMaxResults), "number")}
Checking imported dictionaries...
${renderDictionarySourceRows(settings)}
Lookup pills
These small buttons open the current word in an external dictionary. Use {query} for normal search URLs; it fills in the current word. {word} and {reading} are available for sites that need them separately.
${renderDictionaryLookupLinkEditor(settings.dictionaryLookupLinks)}
${renderRecommendedDictionaries([])}
Import settings JSON
Export settings JSON
Import dictionaries
Export dictionaries
Import Yomitan settings exports, Yomitan dictionary ZIPs, or exported dictionary backups.
`;
}
function renderShortcutSettingsPanel(settings) {
return `
Shortcuts
${shortcutInput("shortcuts.hoverLookup", "Hold while hovering", settings.shortcuts.hoverLookup, "Blank means hover without a key")}
${input("hoverOpenDelayMs", "Hover open delay (ms)", String(settings.hoverOpenDelayMs), "number")}
${input("hoverCloseDelayMs", "Hover close delay (ms)", String(settings.hoverCloseDelayMs), "number")}
${shortcutInput("shortcuts.scanPage", "Scan page", settings.shortcuts.scanPage)}
${shortcutInput("shortcuts.openSettings", "Open settings", settings.shortcuts.openSettings)}
${shortcutInput("shortcuts.playAudio", "Play audio", settings.shortcuts.playAudio)}
${shortcutInput("shortcuts.closePopup", "Close popup", settings.shortcuts.closePopup)}
${shortcutInput("shortcuts.previousSubtitle", "Previous subtitle", settings.shortcuts.previousSubtitle)}
${shortcutInput("shortcuts.nextSubtitle", "Next subtitle", settings.shortcuts.nextSubtitle)}
${shortcutInput("shortcuts.copySubtitle", "Copy subtitle", settings.shortcuts.copySubtitle)}
${shortcutInput("shortcuts.toggleOcr", "Toggle image reading", settings.shortcuts.toggleOcr)}
${shortcutInput("shortcuts.scanImages", "Read images now", settings.shortcuts.scanImages)}
${renderReviewShortcutInputs(settings)}
This shortcut only opens hover lookups when Hover scanned words is enabled in Reader settings.
`;
}
function renderHelpSettingsPanel() {
return `
Help
${renderHelpLinksPanel()}
`;
}
function renderSettingsFooter() {
return `
`;
}
function input(name, label, value, type = "text", attributes = {}) {
const attributeHtml = Object.entries(attributes).map(([key, attributeValue]) => ` ${key}="${escapeHtml$1(String(attributeValue))}"`).join("");
return `${label} `;
}
function shortcutInput(name, label, value, placeholder = "Press keys") {
return `${label} `;
}
function checkbox(name, label, checked, attributes = {}) {
const attributeHtml = Object.entries(attributes).filter(([, value]) => value).map(([key]) => ` ${key}`).join("");
return ` ${label} `;
}
function select(name, label, value, options) {
return `${label}${options.map(
([optionValue, text2]) => `${escapeHtml$1(text2)} `
).join("")} `;
}
function radioGroup(name, label, value, options) {
return `${label} ${options.map(
([optionValue, text2]) => ` ${escapeHtml$1(text2)} `
).join("")} `;
}
function themeSegmentedControl(value) {
const isLight = value === "light";
return `
Theme
`;
}
function getFormInterfaceLanguage(form, fallback) {
const value = getNamedControl(form, "interfaceLanguage")?.value;
return value === "auto" || value === "en" || value === "ja" ? value : fallback;
}
function localizeSettingsForm(form, language) {
const text2 = (key) => uiText(language, key);
localizeSettingsShell(form, language, text2);
localizeSettingsLabels(form, text2);
localizeSettingsSectionTitles(form, text2);
localizeSettingsSelects(form, text2);
localizeSettingsShortcuts(form, text2);
localizeSettingsHelpText(form, text2);
localizeSettingsActions(form, text2);
localizeSettingsEditorChrome(form, text2);
localizeHelpLinksPanel(form, language);
}
function localizeSettingsShell(form, language, text2) {
form.lang = resolveUiLanguage(language);
form.setAttribute("aria-label", text2("settingsTitle"));
form.querySelector("h2")?.replaceChildren(text2("settingsTitle"));
form.querySelector(".jpdb-reader-settings-tabs")?.setAttribute("aria-label", text2("settingsSections"));
localizeThemeSwitch(form, text2);
localizeSettingsTabs(form, text2);
localizeSettingsLegends(form, text2);
}
function localizeThemeSwitch(form, text2) {
const switchButton = form.querySelector("[data-theme-switch]");
if (!switchButton) return;
const isLight = switchButton.getAttribute("aria-checked") === "true";
const label = isLight ? text2("switchToDarkTheme") : text2("switchToLightTheme");
switchButton.setAttribute("aria-label", label);
switchButton.title = label;
}
function localizeSettingsTabs(form, text2) {
const tabLabels = {
basics: "basics",
dictionaries: "dictionaries",
media: "media",
mining: "mining",
shortcuts: "shortcuts",
help: "help"
};
Object.entries(tabLabels).forEach(([panel, key]) => {
form.querySelector(`[data-action="settings-panel"][data-panel="${panel}"]`)?.replaceChildren(text2(key));
});
}
function localizeSettingsLegends(form, text2) {
const fieldsets = getSettingsPanelFieldsets(form);
[
"JPDB",
text2("interface"),
text2("audio"),
text2("immersionKit"),
text2("reader"),
text2("kanji"),
text2("images"),
text2("video"),
text2("anki"),
text2("dictionaries"),
text2("shortcuts"),
text2("help")
].forEach((label, index) => {
const legend = directFieldsetLegend(fieldsets[index]);
legend?.replaceChildren(label);
});
}
function localizeSettingsLabels(form, text2) {
settingsControlLabelKeys().forEach(([name, key]) => setControlLabel(form, name, text2(key)));
const jpdbSettings = form.querySelector('label a[href*="jpdb.io/settings"]');
if (jpdbSettings) jpdbSettings.textContent = text2("jpdbSettings");
const nadeshikoKeyLink = form.querySelector('label a[href*="nadeshiko.co/user/developer"]');
if (nadeshikoKeyLink) nadeshikoKeyLink.textContent = text2("getNadeshikoKey");
localizeBlockControlLabel(form, "ocrEndpointUrl", text2("ocrEndpointUrl"));
localizeBlockControlLabel(form, "ocrCloudVisionApiKey", text2("cloudVisionApiKey"));
}
function localizeBlockControlLabel(form, name, label) {
const labelElement = getNamedControl(form, name)?.closest("label");
if (labelElement) setBlockLabelText(labelElement, label);
}
function localizeSettingsSectionTitles(form, text2) {
replaceLocalTitle(form, /Word colors|単語の色/, text2("wordColors"));
replaceLocalTitle(form, /Pitch accent colors|ピッチアクセント/, text2("pitchAccentColors"));
replaceLocalTitle(form, /Color channels|色チャンネル/, text2("colorChannels"));
replaceLocalTitle(form, /New tab|新規タブ/, text2("newTab"));
replaceLocalTitle(form, /Lookup pills|検索ピル/, text2("lookupPills"));
form.querySelector("[data-color-channels-help]")?.replaceChildren(text2("colorChannelsHelp"));
form.querySelector("[data-subtitle-preview] .jpdb-subtitle-secondary")?.replaceChildren(text2("subtitlePreview"));
}
function replaceLocalTitle(form, pattern, value) {
const title = Array.from(form.querySelectorAll(".jpdb-reader-local-title")).find((element2) => pattern.test(element2.textContent ?? ""));
title?.replaceChildren(value);
}
function localizeSettingsSelects(form, text2) {
localizeBasicSettingsSelects(form, text2);
localizeColorAndReaderSelects(form, text2);
localizeMediaSettingsSelects(form, text2);
localizeMiningSettingsSelects(form, text2);
}
function localizeBasicSettingsSelects(form, text2) {
setSelectOptionLabels(form, "interfaceLanguage", [
["auto", text2("automatic")],
["en", text2("english")],
["ja", text2("japanese")]
]);
form.querySelector("[data-theme-title]")?.replaceChildren(text2("theme"));
setSelectOptionLabels(form, "popupMode", [
["auto", text2("auto")],
["sheet", text2("bottomSheet")],
["popover", text2("popover")]
]);
setSelectOptionLabels(form, "popoverHeightMode", [
["available", text2("popoverHeightAvailable")],
["fixed", text2("popoverHeightFixed")]
]);
setSelectOptionLabels(form, "newTabSource", [
["auto", text2("newTabAuto")],
["jpdb", "JPDB"],
["anki", "Anki"],
["dictionary", text2("dictionaryFallback")]
]);
setSelectOptionLabels(form, "newTabJpdbReviewMode", [
["auto", text2("newTabJpdbReviewAuto")],
["live-review", text2("newTabLiveReview")],
["api-vocabulary", text2("newTabApiVocabulary")]
]);
setSelectOptionLabels(form, "newTabKanjiKeywordSource", [
["auto", text2("newTabKanjiKeywordAuto")],
["rtk", text2("newTabKanjiKeywordRtk")],
["jpdb", "JPDB"],
["local", text2("newTabKanjiKeywordLocal")]
]);
setSelectOptionLabels(form, "twoButtonReviews", [
["false", text2("fivePoint")],
["true", text2("twoPoint")]
]);
}
function localizeColorAndReaderSelects(form, text2) {
localizeColorSourceSelects(form, text2);
setSelectOptionLabels(form, "furiganaMode", [
["auto", text2("automatic")],
["difficult-kanji", text2("furiganaDifficultKanji")],
["known-status", text2("furiganaHideKnown")],
["all", text2("furiganaAllParsed")],
["off", text2("off")]
]);
}
function localizeColorSourceSelects(form, text2) {
[
"wordHighlightColorSource",
"wordUnderlineColorSource",
"wordTextColorSource",
"subtitleHighlightColorSource",
"subtitleUnderlineColorSource",
"subtitleTextColorSource"
].forEach((name) => setSelectOptionLabels(form, name, [
["status", text2("colorSourceStatus")],
["jpdb", text2("colorSourceJpdb")],
["anki", text2("colorSourceAnki")],
["pitch", text2("colorSourcePitch")],
["off", text2("off")]
]));
}
function localizeMediaSettingsSelects(form, text2) {
setSelectOptionLabels(form, "audioAutoPlayMode", [
["all", text2("audioAutoPlayAll")],
["hover", text2("audioAutoPlayHover")],
["tap", text2("audioAutoPlayTap")]
]);
setSelectOptionLabels(form, "audioSelectionMode", [
["first", text2("firstAudio")],
["random", text2("randomAudio")]
]);
setSelectOptionLabels(form, "audioTtsMode", [
["fallback", text2("audioTtsFallback")],
["source-order", text2("audioTtsSourceOrder")]
]);
setSelectOptionLabels(form, "immersionKitCategory", [
["all", text2("allCategories")],
["anime", text2("anime")],
["drama", text2("drama")],
["games", text2("games")]
]);
setSelectOptionLabels(form, "immersionKitExampleSource", [
["immersion-kit", "Immersion Kit"],
["nadeshiko", "Nadeshiko"],
["combined", text2("immersionKitAndNadeshiko")]
]);
setSelectOptionLabels(form, "immersionKitSort", [
["sentence_length:asc", text2("shortestFirst")],
["sentence_length:desc", text2("longestFirst")]
]);
localizeOcrSettingsSelects(form, text2);
setSelectOptionLabels(form, "subtitleControlsMode", [
["auto", text2("showWhenNeeded")],
["hidden", text2("hideControls")],
["always", text2("alwaysVisible")]
]);
}
function localizeOcrSettingsSelects(form, text2) {
setSelectOptionLabels(form, "ocrProvider", [
["google-lens", text2("googleLens")],
["cloud-vision", text2("cloudVision")],
["local-service", text2("localOcr")],
["off", text2("off")]
]);
setSelectOptionLabels(form, "ocrMaxImagesPerPage", [
["3", text2("lightWork")],
["8", text2("normal")],
["16", text2("more")]
]);
setSelectOptionLabels(form, "ocrMinImageArea", [
["80000", text2("largeOnly")],
["45000", text2("normal")],
["15000", text2("includeSmall")]
]);
setSelectOptionLabels(form, "ocrMaxImagePixels", [
["640000", text2("faster")],
["1200000", text2("balanced")],
["2000000", text2("sharper")]
]);
setSelectOptionLabels(form, "ocrEngine", [
["auto", text2("automatic")],
["MangaOCR", "MangaOCR"],
["PaddleOCR", "PaddleOCR"],
["AppleVision", "Apple Vision"]
]);
}
function localizeMiningSettingsSelects(form, text2) {
setSelectOptionLabels(form, "ankiTemplateMode", [
["recognition", text2("wordFirst")],
["context", text2("sentenceFirst")]
]);
}
function localizeSettingsShortcuts(form, text2) {
setShortcutPlaceholder(form, "shortcuts.hoverLookup", text2("blankPlainHover"));
form.querySelector("[data-hover-shortcut-help]")?.replaceChildren(text2("hoverShortcutHelp"));
form.querySelectorAll("[data-shortcut-input]").forEach((inputEl) => {
if (inputEl.name !== "shortcuts.hoverLookup") inputEl.placeholder = text2("pressKeys");
});
const immersionLimitLegend = getNamedControl(form, "immersionKitLimitEnabled")?.closest(".jpdb-reader-radio-group")?.querySelector("legend");
immersionLimitLegend?.replaceChildren(text2("immersionKitLimitEnabled"));
setRadioLabel(form, "immersionKitLimitEnabled", "off", text2("allExamples"));
setRadioLabel(form, "immersionKitLimitEnabled", "on", text2("limitExamples"));
}
function localizeSettingsHelpText(form, text2) {
setFieldsetHelp(form, 1, text2("interfaceHelp"));
setFieldsetHelp(form, 3, text2("immersionKitHelp"));
setFieldsetHelp(form, 4, text2("readerHelp"));
setFieldsetHelp(form, 5, text2("kanjiHelp"));
setFieldsetHelp(form, 6, text2("ocrHelp"));
setFieldsetHelp(form, 8, text2("ankiHelp"));
localizeNewTabHelp(form, text2);
localizeAudioHelp(form, text2);
localizeDictionaryImportHelp(form, text2);
localizeLookupPillsHelp(form, text2);
form.querySelector("details[data-local-ocr] > summary")?.replaceChildren(text2("ocrCustomLocalServer"));
}
function localizeNewTabHelp(form, text2) {
const subsection = getNamedControl(form, "newTabUrl")?.closest(".jpdb-reader-settings-subsection");
subsection?.querySelector(":scope > .jpdb-reader-help")?.replaceChildren(text2("newTabOfflineHelp"));
}
function localizeAudioHelp(form, text2) {
const audioHelp = getFieldsetHelp(form, 2);
if (!audioHelp) return;
const copy = text2("audioHelp").replace("Yomitan audio guide.", "").replace("Yomitan音声ガイドも参照できます。", "");
const linkLabel = resolveUiLanguageFromText(text2) === "ja" ? "Yomitan音声ガイド" : "Yomitan audio guide";
setInnerHtml(audioHelp, `${escapeHtml$1(copy)}${escapeHtml$1(linkLabel)} .`);
}
function resolveUiLanguageFromText(text2) {
return text2("save") === "保存" ? "ja" : "en";
}
function localizeLookupPillsHelp(form, text2) {
const lookupLinks = form.querySelector(".jpdb-reader-lookup-links");
lookupLinks?.closest(".jpdb-reader-settings-subsection")?.querySelector(":scope > .jpdb-reader-help")?.replaceChildren(text2("lookupPillsHelp"));
}
function localizeDictionaryImportHelp(form, text2) {
const importStatus = form.querySelector("[data-import-status]");
if (importStatus && /Import Yomitan|Yomitan設定/.test(importStatus.textContent ?? "")) importStatus.textContent = text2("dictionaryImportHelp");
}
function localizeSettingsActions(form, text2) {
form.querySelector('[data-action="test-anki"]')?.replaceChildren(text2("testAnki"));
form.querySelector('[data-action="copy-newtab-url"]')?.replaceChildren(text2("copyAddress"));
form.querySelector("[data-newtab-url-link]")?.replaceChildren(text2("openNewTabPage"));
form.querySelector('[data-action="import-yomitan-settings"]')?.replaceChildren(text2("importSettings"));
form.querySelector('[data-action="export-reader-settings"]')?.replaceChildren(text2("exportSettings"));
form.querySelector('[data-action="import-yomitan-dictionary"]')?.replaceChildren(text2("importDictionaries"));
form.querySelector('[data-action="export-yomitan-dictionary"]')?.replaceChildren(text2("exportDictionaries"));
form.querySelector('[data-action="audio-source-add"]')?.replaceChildren(text2("addAudioSource"));
form.querySelector('[data-action="cancel"]')?.replaceChildren(text2("cancel"));
form.querySelector('button[type="submit"]')?.replaceChildren(text2("save"));
localizePreviewAudioButtons(form, text2);
}
function localizePreviewAudioButtons(form, text2) {
form.querySelectorAll('[data-action="preview-audio"]').forEach((button2) => {
button2.title = text2("previewAudio");
button2.setAttribute("aria-label", text2("previewAudio"));
});
}
function localizeSettingsEditorChrome(form, text2) {
const audioHead = form.querySelectorAll(".jpdb-reader-audio-source-head span");
audioHead[0]?.replaceChildren(text2("enabledHeader"));
audioHead[1]?.replaceChildren(text2("audioSource"));
audioHead[2]?.replaceChildren(text2("urlVoice"));
audioHead[3]?.replaceChildren(text2("orderHeader"));
audioHead[4]?.replaceChildren(text2("removeHeader"));
form.querySelector('[data-action="lookup-link-add"]')?.replaceChildren(text2("add"));
form.querySelector(".jpdb-reader-recommended-title")?.replaceChildren(text2("recommendedDownloads"));
form.querySelector("[data-recommended-dictionary-help]")?.replaceChildren(text2("dictionaryInstallQueueHelp"));
form.querySelectorAll(".jpdb-reader-recommended-name a").forEach((link) => {
link.textContent = text2("homepage");
});
localizeOrderButtons(form, text2);
localizeLookupLinkEditor(form, text2);
localizeDeckControls(form, text2);
localizeSourceRows(form, text2);
localizeRecommendedDictionaryGroups(form, text2);
localizeRecommendedDictionaryDescriptions(form, text2);
localizeAnkiTemplatePreview(form, text2);
localizeAudioSourceFields(form, text2);
localizeRecommendedDictionaryButtons(form, text2);
localizeDictionaryStatus(form, text2);
}
function localizeOrderButtons(form, text2) {
form.querySelectorAll("[data-source-drag-handle]").forEach((button2) => setButtonTitle(button2, text2("dragToReorder")));
form.querySelectorAll('[data-action$="-up"]').forEach((button2) => setButtonTitle(button2, text2("moveUp")));
form.querySelectorAll('[data-action$="-down"]').forEach((button2) => setButtonTitle(button2, text2("moveDown")));
form.querySelectorAll('[data-action$="-remove"]').forEach((button2) => setButtonTitle(button2, text2("remove")));
form.querySelectorAll('[data-action="delete-yomitan-dictionary"]').forEach((button2) => setButtonTitle(button2, text2("removeImportedDictionary")));
}
function setButtonTitle(button2, label) {
button2.title = label;
button2.setAttribute("aria-label", label);
}
function localizeLookupLinkEditor(form, text2) {
const lookupHead = form.querySelectorAll(".jpdb-reader-lookup-link-head span");
lookupHead[0]?.replaceChildren(text2("enabledHeader"));
lookupHead[1]?.replaceChildren(text2("labelHeader"));
lookupHead[2]?.replaceChildren(text2("lookupUrlTemplate"));
lookupHead[3]?.replaceChildren(text2("orderHeader"));
lookupHead[4]?.replaceChildren(text2("removeHeader"));
form.querySelectorAll(".jpdb-reader-lookup-link-note").forEach((note) => note.replaceChildren(text2("copiesCurrentWord")));
form.querySelectorAll(".jpdb-reader-lookup-link-fixed").forEach((note) => note.setAttribute("aria-label", text2("builtInAction")));
form.querySelectorAll('input[name^="dictionaryLookupLinks."][name$=".label"]').forEach((input2, index) => {
input2.setAttribute("aria-label", text2("lookupPillLabelNumber").replace("{number}", String(index + 1)));
});
form.querySelectorAll('input[name^="dictionaryLookupLinks."][name$=".urlTemplate"]').forEach((input2, index) => {
input2.setAttribute("aria-label", text2("lookupUrlTemplateNumber").replace("{number}", String(index + 1)));
});
form.querySelectorAll(".jpdb-reader-lookup-link-row .jpdb-reader-row-order-tools").forEach((row) => {
row.setAttribute("aria-label", text2("lookupPillOrder"));
});
}
function localizeDeckControls(form, text2) {
setSelectOptionLabels(form, "newTabJpdbDeck", [
["all", text2("allStudyDecks")],
["never-forget", text2("never")]
]);
const deckHelp = form.querySelector("[data-jpdb-decks] .jpdb-reader-help");
if (!deckHelp) return;
const content = deckHelp.textContent ?? "";
if (/Decks are loaded|JPDBアカウント/.test(content)) deckHelp.replaceChildren(text2("decksLoaded"));
else if (/Could not load decks|まだデッキ/.test(content)) deckHelp.replaceChildren(text2("decksUnavailable"));
else if (/Add your JPDB API key|JPDB APIキー/.test(content)) deckHelp.replaceChildren(text2("addApiKeyChooseDecks"));
}
function localizeSourceRows(form, text2) {
form.querySelectorAll(".jpdb-reader-dictionary-head").forEach((head) => localizeSourceHead(head, text2));
replaceSourceHelp(form, /Import Yomitan dictionaries|Yomitan辞書をインポート/, text2("importLocalDefinitionsHelp"));
replaceSourceHelp(form, /Frequency, pitch, and kanji metadata|頻度、ピッチ、漢字メタデータ/, text2("frequencyMetadataHelp"));
const rows = [
["Translation", "sourceNameTranslation", "sourceHelpTranslation"],
["Grammar", "sourceNameGrammar", "sourceHelpGrammar"],
["Stroke practice", "sourceNameStrokePractice", "sourceHelpStrokePractice"],
["Readings and components", "readingsComponents", "sourceHelpReadingsComponents"],
["Imported kanji dictionaries", "sourceNameImportedKanjiDictionaries", "sourceHelpImportedKanjiDictionaries"],
["Words using this kanji", "sourceNameWordsUsingKanji", "sourceHelpWordsUsingKanji"],
["Component graph", "originStructure", "sourceHelpComponentGraph"]
];
rows.forEach(([sourceName, nameKey, helpKey]) => {
form.querySelectorAll("[data-dictionary-source-row]").forEach((row) => {
const display = row.querySelector(".jpdb-reader-field-display");
if (display?.textContent === sourceName) display.replaceChildren(text2(nameKey));
const help = row.querySelector(".jpdb-reader-dictionary-row-help");
if (help && sourceRowHelpMatches(help.textContent ?? "", sourceName)) help.replaceChildren(text2(helpKey));
});
});
replaceSourceHelp(form, /JPDB meanings shown/, text2("sourceHelpJpdb"));
replaceSourceHelp(form, /Example sentences, images, and audio/, text2("sourceHelpImmersionKit"));
replaceSourceHelp(form, /Remembering the Kanji/, text2("sourceHelpRtk"));
replaceSourceHelp(form, /Uchisen mnemonic/, text2("sourceHelpUchisen"));
replaceSourceHelp(form, /Imported Yomitan kanji dictionary/, text2("sourceHelpImportedKanjiDictionary"));
}
function localizeSourceHead(head, text2) {
const spans = head.querySelectorAll("span");
spans[0]?.replaceChildren(text2("enabledHeader"));
const sourceLabel2 = spans[1]?.textContent === "Kanji section" ? text2("kanjiSection") : text2("definitionSource");
spans[1]?.replaceChildren(sourceLabel2);
if (spans.length === 5) {
spans[2]?.replaceChildren(text2("displayName"));
spans[3]?.replaceChildren(text2("orderHeader"));
spans[4]?.replaceChildren(text2("removeHeader"));
} else {
spans[2]?.replaceChildren(text2("orderHeader"));
}
}
function replaceSourceHelp(form, pattern, value) {
form.querySelectorAll(".jpdb-reader-help, .jpdb-reader-dictionary-row-help").forEach((help) => {
if (pattern.test(help.textContent ?? "")) help.replaceChildren(value);
});
}
function sourceRowHelpMatches(value, sourceName) {
return value.includes(sourceName) || value.includes("Automatic") || value.includes("Stroke") || value.includes("Kanji entries") || value.includes("Related");
}
function localizeRecommendedDictionaryGroups(form, text2) {
const labels = [text2("termDictionaries"), text2("kanjiDictionaries"), text2("frequencyDictionaries")];
form.querySelectorAll(".jpdb-reader-recommended-group-title").forEach((title, index) => {
if (labels[index]) title.replaceChildren(labels[index]);
});
}
function localizeRecommendedDictionaryDescriptions(form, text2) {
RECOMMENDED_JAPANESE_DICTIONARIES.forEach((dictionary) => {
const button2 = form.querySelector(`[data-action="download-recommended-dictionary"][data-dictionary-id="${dictionary.id}"]`);
button2?.closest(".jpdb-reader-recommended-item")?.querySelector(".jpdb-reader-help")?.replaceChildren(text2(dictionary.descriptionKey));
});
}
function localizeAnkiTemplatePreview(form, text2) {
const preview = form.querySelector(".jpdb-reader-template-preview");
if (!preview) return;
const contextMode = getNamedControl(form, "ankiTemplateMode")?.value === "context";
preview.querySelector(".jpdb-reader-template-preview-title")?.replaceChildren(text2(contextMode ? "sentenceFirstPreset" : "wordFirstPreset"));
const headings = preview.querySelectorAll("strong");
headings[0]?.replaceChildren(text2("front"));
headings[1]?.replaceChildren(text2("back"));
preview.querySelector(".jpdb-reader-template-meaning")?.replaceChildren(text2("exampleMeaning"));
preview.querySelectorAll("small").forEach((small) => {
const value = small.textContent ?? "";
if (/above the prompt/.test(value)) small.replaceChildren(text2("imageAbovePrompt"));
else if (/highlighted word/.test(value)) small.replaceChildren(text2("recallHighlightedWord"));
else if (/front when available/.test(value)) small.replaceChildren(text2("imageOnFront"));
else if (/meaning first/.test(value)) small.replaceChildren(text2("recallMeaning"));
else if (/Includes dictionary/.test(value)) small.replaceChildren(text2("ankiBackIncludes"));
});
}
function localizeAudioSourceFields(form, text2) {
form.querySelectorAll('select[name^="audioSources."][name$=".type"]').forEach((select2, index) => {
select2.setAttribute("aria-label", text2("audioSourceNumber").replace("{number}", String(index + 1)));
localizeAudioSourceTypeOptions(select2, text2);
});
form.querySelectorAll("select[data-audio-voice-field]").forEach((select2, index) => {
select2.setAttribute("aria-label", text2("textToSpeechVoiceNumber").replace("{number}", String(index + 1)));
});
form.querySelectorAll("[data-audio-url-field]").forEach((input2) => {
input2.placeholder = localizedAudioUrlPlaceholder(input2, text2);
});
}
function localizeAudioSourceTypeOptions(select2, text2) {
if (resolveUiLanguageFromText(text2) !== "ja") return;
const labels = {
jpod101: "JapanesePod101",
"language-pod-101": "LanguagePod101",
jisho: "Jisho.org",
"lingua-libre": "(Commons) リングア・リブレ",
wiktionary: "(Commons) ウィクショナリー",
"jpdb-tts": "JPDB読み上げ",
"text-to-speech": "ブラウザ読み上げ",
"text-to-speech-reading": "ブラウザ読み上げ (かな読み)",
custom: "直接音声ファイルURL (詳細)",
"custom-json": "カスタムURL"
};
Array.from(select2.options).forEach((option) => {
option.textContent = labels[option.value] ?? option.textContent;
});
}
function localizedAudioUrlPlaceholder(input2, text2) {
const type = input2.closest("[data-audio-source-row]")?.querySelector('select[name$=".type"]')?.value;
if (type === "custom-json") return text2("audioCustomJsonPlaceholder");
if (type === "custom") return text2("audioCustomUrlPlaceholder");
return text2("audioBuiltInPlaceholder");
}
function localizeRecommendedDictionaryButtons(form, text2) {
form.querySelectorAll('[data-action="download-recommended-dictionary"]').forEach((button2) => {
const installed = button2.dataset.installed === "true";
const state = button2.dataset.importState;
const label = state === "installing" ? text2("installing") : state === "queued" ? text2("queued") : installed ? text2("update") : text2("install");
button2.textContent = label;
button2.title = button2.dataset.importMessage || label;
button2.setAttribute("aria-label", button2.title);
});
}
function localizeDictionaryStatus(form, text2) {
const dictionaryStatus = form.querySelector("[data-dictionary-status]");
if (dictionaryStatus && /Checking imported|インポート済み辞書を確認/.test(dictionaryStatus.textContent ?? "")) {
dictionaryStatus.textContent = text2("checkingDictionaries");
}
}
function settingsControlLabelKeys() {
return [
["apiKey", "apiKey"],
["miningDeck", "miningDeck"],
["newTabJpdbDeck", "newTabJpdbDeck"],
["neverForgetDeck", "neverForgetDeck"],
["blacklistDeck", "blacklistDeck"],
["jpdbMiningEnabled", "jpdbMiningEnabled"],
["addToForq", "addToForq"],
["enableReviews", "enableReviews"],
["twoButtonReviews", "reviewRatingScale"],
["interfaceLanguage", "settingsLanguage"],
["popupMode", "popupMode"],
["stickyBottomSheet", "stickyBottomSheet"],
["popoverWidth", "popoverWidth"],
["popoverHeight", "popoverHeight"],
["popoverHeightMode", "popoverHeightMode"],
["enableLogging", "enableLogging"],
["accentColor", "accentColor"],
["newTabEnabled", "newTabEnabled"],
["newTabSource", "newTabSource"],
["newTabJpdbReviewMode", "newTabJpdbReviewMode"],
["corsProxyUrl", "corsProxyUrl"],
["newTabKanjiKeywordSource", "newTabKanjiKeywordSource"],
["newTabParsingEnabled", "newTabParsingEnabled"],
["newTabFrontSentenceEnabled", "newTabFrontSentenceEnabled"],
["newTabKanjiAutogradeEnabled", "newTabKanjiAutogradeEnabled"],
["newTabKanjiAutoSubmit", "newTabKanjiAutoSubmit"],
["newTabOfflineEnabled", "newTabOfflineEnabled"],
["newTabOfflineLimit", "newTabOfflineLimit"],
["newTabUrl", "newTabUrl"],
["wordColorNew", "wordColorNew"],
["wordColorLearning", "wordColorLearning"],
["wordColorKnown", "wordColorKnown"],
["wordColorDue", "wordColorDue"],
["wordColorFailed", "wordColorFailed"],
["wordColorIgnored", "wordColorIgnored"],
["pitchColorHeiban", "pitchColorHeiban"],
["pitchColorAtamadaka", "pitchColorAtamadaka"],
["pitchColorNakadaka", "pitchColorNakadaka"],
["pitchColorOdaka", "pitchColorOdaka"],
["pitchColorKifuku", "pitchColorKifuku"],
["pitchColorUnknown", "pitchColorUnknown"],
["wordHighlightColorSource", "wordHighlightColorSource"],
["wordUnderlineColorSource", "wordUnderlineColorSource"],
["wordTextColorSource", "wordTextColorSource"],
["subtitleHighlightColorSource", "subtitleHighlightColorSource"],
["subtitleUnderlineColorSource", "subtitleUnderlineColorSource"],
["subtitleTextColorSource", "subtitleTextColorSource"],
["parseSelection", "parseSelection"],
["lookupOnClick", "lookupOnClick"],
["lookupOnHover", "lookupOnHover"],
["lookupOnMiddleMouse", "lookupOnMiddleMouse"],
["autoScanJapanese", "autoScanJapanese"],
["scanVisiblePage", "scanVisiblePage"],
["showFloatingButton", "showFloatingButton"],
["furiganaMode", "furiganaMode"],
["showPitchAccent", "showPitchAccent"],
["kanjivgEnabled", "kanjivgEnabled"],
["kanjiOriginsEnabled", "kanjiOriginsEnabled"],
["kanjiOriginKanjiMapEnabled", "kanjiOriginKanjiMapEnabled"],
["kanjiOriginGraphEnabled", "kanjiOriginGraphEnabled"],
["kanjiOriginRadicalImagesEnabled", "kanjiOriginRadicalImagesEnabled"],
["rtkEnabled", "rtkEnabled"],
["similarKanjiWords", "similarKanjiWords"],
["similarKanjiWordLimit", "similarKanjiWordLimit"],
["audioEnabled", "audioEnabled"],
["autoPlayAudio", "autoPlayAudio"],
["audioAutoPlayMode", "audioAutoPlayMode"],
["audioEnableDefaultSources", "audioEnableDefaultSources"],
["audioFallbackChimeEnabled", "audioFallbackChimeEnabled"],
["audioSelectionMode", "audioSelectionMode"],
["audioTtsMode", "audioTtsMode"],
["audioTimeoutMs", "audioTimeoutMs"],
["immersionKitEnabled", "immersionKitEnabled"],
["immersionKitExampleSource", "immersionKitExampleSource"],
["nadeshikoApiKey", "nadeshikoApiKey"],
["immersionKitShowTranslation", "immersionKitShowTranslation"],
["immersionKitRevealTranslationOnClick", "immersionKitRevealTranslationOnClick"],
["immersionKitShowImages", "immersionKitShowImages"],
["immersionKitAutoPlayAudio", "immersionKitAutoPlayAudio"],
["immersionKitPlayOnHover", "immersionKitPlayOnHover"],
["immersionKitPlayOnImageClick", "immersionKitPlayOnImageClick"],
["immersionKitCategory", "immersionKitCategory"],
["immersionKitSort", "immersionKitSort"],
["immersionKitLimit", "immersionKitLimit"],
["immersionKitMinLength", "immersionKitMinLength"],
["immersionKitMaxLength", "immersionKitMaxLength"],
["immersionKitPlaybackRate", "immersionKitPlaybackRate"],
["immersionKitExactMatch", "immersionKitExactMatch"],
["ocrEnabled", "ocrEnabled"],
["ocrAutoScanImages", "ocrAutoScanImages"],
["ocrShowTextOverlay", "ocrShowTextOverlay"],
["ocrProvider", "ocrProvider"],
["ocrMaxImagesPerPage", "ocrMaxImagesPerPage"],
["ocrMinImageArea", "ocrMinImageArea"],
["ocrMaxImagePixels", "ocrMaxImagePixels"],
["ocrTextColor", "ocrTextColor"],
["ocrOutlineColor", "ocrOutlineColor"],
["ocrBackgroundColor", "ocrBackgroundColor"],
["ocrBackgroundOpacity", "ocrBackgroundOpacity"],
["ocrFontScale", "ocrFontScale"],
["ocrEndpointUrl", "ocrEndpointUrl"],
["ocrEngine", "ocrEngine"],
["ocrCloudVisionApiKey", "cloudVisionApiKey"],
["subtitlePlayerEnabled", "subtitlePlayerEnabled"],
["subtitleAutoDetect", "subtitleAutoDetect"],
["subtitleOverlayVisible", "subtitleOverlayVisible"],
["subtitleSecondaryVisible", "subtitleSecondaryVisible"],
["subtitleNativeBlurred", "subtitleNativeBlurred"],
["subtitleKaraokeMode", "subtitleKaraokeMode"],
["subtitleTranscriptVisible", "subtitleTranscriptVisible"],
["subtitleTranscriptAutoScroll", "subtitleTranscriptAutoScroll"],
["subtitleAutoCopyLine", "subtitleAutoCopyLine"],
["subtitleMiningPause", "subtitleMiningPause"],
["subtitleControlsMode", "subtitleControlsMode"],
["subtitleFontSize", "subtitleFontSize"],
["subtitleBottomOffset", "subtitleBottomOffset"],
["subtitleTextColor", "subtitleTextColor"],
["subtitleOutlineColor", "subtitleOutlineColor"],
["subtitleBackgroundColor", "subtitleBackgroundColor"],
["subtitleBackgroundOpacity", "subtitleBackgroundOpacity"],
["subtitleFontFamily", "subtitleFontFamily"],
["subtitleFontWeight", "subtitleFontWeight"],
["subtitleSeekPadding", "subtitleSeekPadding"],
["ankiEnabled", "ankiEnabled"],
["ankiMineWithJpdb", "ankiMineWithJpdb"],
["ankiCaptureScreenshot", "ankiCaptureScreenshot"],
["ankiMobileHandoff", "mobileAnkiHandoff"],
["ankiConnectUrl", "ankiConnectUrl"],
["ankiDeck", "ankiDeck"],
["ankiModel", "ankiModel"],
["ankiTemplateMode", "ankiTemplateMode"],
["ankiFrontReading", "ankiFrontReading"],
["ankiFrontSentence", "ankiFrontSentence"],
["ankiFrontImage", "ankiFrontImage"],
["ankiTags", "ankiTags"],
["studyTranslationEnabled", "studyTranslationEnabled"],
["studyGrammarEnabled", "studyGrammarEnabled"],
["jpdbDefinitionsEnabled", "jpdbDefinitionsEnabled"],
["localDictionariesEnabled", "localDictionariesEnabled"],
["localDictionaryShowKanji", "localDictionaryShowKanji"],
["dictionarySourcesInitiallyExpanded", "dictionarySourcesInitiallyExpanded"],
["localDictionaryMaxResults", "localDictionaryMaxResults"],
["shortcuts.hoverLookup", "holdWhileHovering"],
["hoverOpenDelayMs", "hoverOpenDelayMs"],
["hoverCloseDelayMs", "hoverCloseDelayMs"],
["shortcuts.scanPage", "scanPage"],
["shortcuts.openSettings", "openSettings"],
["shortcuts.playAudio", "playAudio"],
["shortcuts.closePopup", "closePopup"],
["shortcuts.previousSubtitle", "previousSubtitle"],
["shortcuts.nextSubtitle", "nextSubtitle"],
["shortcuts.copySubtitle", "copySubtitle"],
["shortcuts.toggleOcr", "toggleImageReading"],
["shortcuts.scanImages", "readImagesNow"],
["shortcuts.gradeNothing", "gradeNothing"],
["shortcuts.gradeSomething", "gradeSomething"],
["shortcuts.gradeHard", "gradeHard"],
["shortcuts.gradeOkay", "gradeOkay"],
["shortcuts.gradeEasy", "gradeEasy"],
["shortcuts.gradeFail", "gradeFail"],
["shortcuts.gradePass", "gradePass"]
];
}
function getNamedControl(form, name) {
return Array.from(form.elements).find(
(element2) => (element2 instanceof HTMLInputElement || element2 instanceof HTMLSelectElement || element2 instanceof HTMLTextAreaElement) && element2.name === name
) ?? null;
}
function setControlLabel(form, name, label) {
const control = getNamedControl(form, name);
const labelElement = control?.closest("label");
if (!labelElement) return;
if (labelElement.classList.contains("inline")) setInlineLabelText(labelElement, label);
else setBlockLabelText(labelElement, label);
}
function setBlockLabelText(label, text2) {
const textNode = Array.from(label.childNodes).find((node) => node.nodeType === Node.TEXT_NODE);
if (textNode) textNode.textContent = text2;
else label.insertBefore(document.createTextNode(text2), label.firstChild);
}
function setInlineLabelText(label, text2) {
const textNode = Array.from(label.childNodes).find((node) => node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim());
if (textNode) textNode.textContent = text2;
else label.append(document.createTextNode(text2));
}
function setRadioLabel(form, name, value, label) {
const radio = Array.from(form.elements).find(
(element2) => element2 instanceof HTMLInputElement && element2.type === "radio" && element2.name === name && element2.value === value
);
const labelElement = radio?.closest("label");
if (labelElement) setInlineLabelText(labelElement, label);
}
function setSelectOptionLabels(form, name, options) {
const selectElement = getNamedControl(form, name);
if (!selectElement) return;
options.forEach(([value, label]) => {
const option = Array.from(selectElement.options).find((item) => item.value === value);
if (option) option.textContent = label;
});
}
function setShortcutPlaceholder(form, name, placeholder) {
const inputElement = getNamedControl(form, name);
if (inputElement) inputElement.placeholder = placeholder;
}
function getFieldsetHelp(form, index) {
const fieldset = getSettingsPanelFieldsets(form)[index];
return Array.from(fieldset?.children ?? []).find(
(child) => child instanceof HTMLElement && child.classList.contains("jpdb-reader-help")
) ?? null;
}
function getSettingsPanelFieldsets(form) {
return Array.from(form.querySelectorAll("fieldset[data-settings-panel]"));
}
function directFieldsetLegend(fieldset) {
return Array.from(fieldset?.children ?? []).find(
(child) => child instanceof HTMLLegendElement
) ?? null;
}
function setFieldsetHelp(form, index, text2) {
const help = getFieldsetHelp(form, index);
if (help) help.textContent = text2;
}
function localizeHelpLinksPanel(form, language) {
const panel = form.querySelector(".jpdb-reader-help-links-card");
if (!panel) return;
const text2 = (key) => uiText(language, key);
panel.querySelector("[data-help-links-title]")?.replaceChildren(text2("helpLinksTitle"));
panel.querySelector("[data-help-links-copy]")?.replaceChildren(text2("helpLinksCopy"));
panel.querySelector("[data-help-support-title]")?.replaceChildren(text2("helpSupportTitle"));
panel.querySelector("[data-help-support-copy]")?.replaceChildren(text2("helpSupportCopy"));
panel.querySelector("[data-help-support-copy-extra]")?.replaceChildren(text2("helpSupportCopyExtra"));
panel.querySelector('[data-help-link="video-player"]')?.replaceChildren(text2("videoPlayer"));
panel.querySelector('[data-help-link="new-tab"]')?.replaceChildren(text2("newTabPage"));
panel.querySelector('[data-help-link="docs"]')?.replaceChildren(text2("docs"));
panel.querySelector('[data-help-link="factory-reset"]')?.replaceChildren(text2("factoryReset"));
panel.querySelector('[data-help-link="issues"]')?.replaceChildren(text2("issues"));
panel.querySelector('[data-help-link="donate"]')?.replaceChildren(text2("donate"));
panel.querySelector('[data-help-link="discord"]')?.replaceChildren(text2("discord"));
}
function renderReviewShortcutInputs(settings) {
const fivePointHidden = !settings.enableReviews || settings.twoButtonReviews;
const passFailHidden = !settings.enableReviews || !settings.twoButtonReviews;
return `
${shortcutInput("shortcuts.gradeNothing", "Grade NOTHING", settings.shortcuts.gradeNothing)}
${shortcutInput("shortcuts.gradeSomething", "Grade SOMETHING", settings.shortcuts.gradeSomething)}
${shortcutInput("shortcuts.gradeHard", "Grade HARD", settings.shortcuts.gradeHard)}
${shortcutInput("shortcuts.gradeOkay", "Grade OKAY", settings.shortcuts.gradeOkay)}
${shortcutInput("shortcuts.gradeEasy", "Grade EASY", settings.shortcuts.gradeEasy)}
${shortcutInput("shortcuts.gradeFail", "Pass/fail: FAIL", settings.shortcuts.gradeFail)}
${shortcutInput("shortcuts.gradePass", "Pass/fail: PASS", settings.shortcuts.gradePass)}
`;
}
function activateSettingsPanel(form, panel) {
form.querySelectorAll("[data-settings-panel]").forEach((section) => {
section.hidden = section.dataset.settingsPanel !== panel;
});
form.querySelectorAll('[data-action="settings-panel"]').forEach((button2) => {
const active = button2.dataset.panel === panel;
button2.setAttribute("aria-selected", String(active));
});
}
function renderAudioSourceEditor(sources) {
return `
On
Audio source
URL / voice
Order
Remove
${renderAudioSourceRows(audioSourceRowsForSettings(sources))}
Add audio source
`;
}
function miniIcon(name) {
const paths = {
drag: ' ',
up: ' ',
down: ' ',
remove: ' '
};
return `${paths[name]} `;
}
function renderAudioSourceRows(rows) {
const count = rows.length;
return `
${rows.map((source, index) => `
`).join("")}
`;
}
function audioSourceSelectOptions(type) {
if (type === "custom") {
return [...AUDIO_SOURCE_UI_OPTIONS, ["custom", `${AUDIO_SOURCE_LABELS.custom} (advanced)`]];
}
return AUDIO_SOURCE_UI_OPTIONS;
}
function audioSourceRowsForSettings(sources) {
const rows = sources.map((source) => ({ ...source }));
return rows.length ? rows : DEFAULT_AUDIO_SOURCES.map((source) => ({ ...source }));
}
function audioUrlPlaceholder(type) {
if (type === "custom-json") return "Yomitan or Ultimate audio source URL";
if (type === "custom") return "Direct audio file URL";
return "Built-in source, no URL needed";
}
function audioSourceUsesUrl(type) {
return type === "custom" || type === "custom-json";
}
function audioSourceUsesVoice(type) {
return type === "text-to-speech" || type === "text-to-speech-reading";
}
function syncAudioSourceRow(row, type) {
if (!row) return;
row.querySelectorAll("[data-audio-url-field]").forEach((node) => {
node.hidden = !audioSourceUsesUrl(type);
});
row.querySelectorAll("[data-audio-voice-field]").forEach((node) => {
node.hidden = !audioSourceUsesVoice(type);
});
}
function syncBrowserTtsVoiceOptions(form) {
const voices = "speechSynthesis" in window ? window.speechSynthesis.getVoices() : [];
const language = form.lang === "ja" ? "ja" : "en";
const text2 = (key) => uiText(language, key);
const sortedVoices = voices.slice().sort((a, b) => {
const aJapanese = a.lang.toLowerCase().startsWith("ja") ? 0 : 1;
const bJapanese = b.lang.toLowerCase().startsWith("ja") ? 0 : 1;
return aJapanese - bJapanese || a.lang.localeCompare(b.lang) || a.name.localeCompare(b.name);
});
form.querySelectorAll("select[data-audio-voice-field]").forEach((select2) => {
const selected = select2.value || select2.dataset.selectedVoice || "";
const options = [
`${escapeHtml$1(text2("automaticBrowserVoice"))} `,
...sortedVoices.map((voice) => {
const label = `${voice.name}${voice.lang ? ` (${voice.lang})` : ""}${voice.default ? language === "ja" ? " - 標準" : " - default" : ""}`;
return `${escapeHtml$1(label)} `;
})
];
if (selected && !sortedVoices.some((voice) => voice.name === selected)) {
options.push(`${escapeHtml$1(`${text2("savedVoice")}: ${selected}`)} `);
}
setInnerHtml(select2, options.join(""));
});
}
function updateAudioSourceEditor(form, action, control) {
const container = form.querySelector(".jpdb-reader-audio-sources");
if (!container) return;
const row = control?.closest("[data-audio-source-row]");
const rows = Array.from(container.querySelectorAll("[data-audio-source-row]"));
const index = row ? rows.indexOf(row) : -1;
if (isAudioSourceMoveAction(action)) {
moveSourceRow(container, index, audioSourceMoveTargetIndex(action, index));
return;
}
const sources = audioSourceRowsForSettings(readAudioSources(new FormData(form)));
updateAudioSourceRows(sources, action, index);
setInnerHtml(container, renderAudioSourceEditor(sources));
}
function isAudioSourceMoveAction(action) {
return action === "audio-source-up" || action === "audio-source-down";
}
function audioSourceMoveTargetIndex(action, index) {
return action === "audio-source-up" ? index - 1 : index + 1;
}
function updateAudioSourceRows(sources, action, index) {
if (action === "audio-source-add") addAudioSourceRow(sources);
if (action === "audio-source-remove") removeAudioSourceRow(sources, index);
}
function addAudioSourceRow(sources) {
if (sources.length < 12) sources.push({ type: "custom-json", url: "", voice: "", enabled: true });
}
function removeAudioSourceRow(sources, index) {
if (index >= 0 && sources.length > 1) sources.splice(index, 1);
}
function renderDictionaryLookupLinkEditor(links) {
const rows = normalizeDictionaryLookupLinks(links);
return `
On
Label
URL template
Order
Remove
${renderDictionaryLookupLinkRows(rows)}
Add
`;
}
function renderDictionaryLookupLinkRows(rows) {
return `
${rows.map((link, index) => {
const isCopyAction = link.action === "copy";
const urlControl = isCopyAction ? `Copies the current word ` : ` `;
const removeControl = isCopyAction ? ' ' : `${miniIcon("remove")} `;
return `
`;
}).join("")}
`;
}
function readDictionaryLookupLinks(data) {
const get = (key) => String(data.get(key) ?? "");
const count = Math.max(0, Math.min(MAX_DICTIONARY_LOOKUP_LINKS, Number(get("dictionaryLookupLinkCount")) || 0));
const links = [];
for (let index = 0; index < count; index++) {
const link = readDictionaryLookupLinkRow(data, get, index);
if (link) links.push(link);
}
return normalizeDictionaryLookupLinks(links);
}
function readDictionaryLookupLinkRow(data, get, index) {
const label = get(`dictionaryLookupLinks.${index}.label`).trim();
const urlTemplate = get(`dictionaryLookupLinks.${index}.urlTemplate`).trim();
const action = dictionaryLookupLinkAction(get(`dictionaryLookupLinks.${index}.action`));
if (!shouldKeepDictionaryLookupLink(label, urlTemplate, action)) return null;
return {
id: get(`dictionaryLookupLinks.${index}.id`).trim() || `custom-${index}`,
label: dictionaryLookupLinkLabel(label, action),
urlTemplate: dictionaryLookupLinkUrlTemplate(urlTemplate, action),
enabled: data.has(`dictionaryLookupLinks.${index}.enabled`),
action
};
}
function dictionaryLookupLinkAction(value) {
return value === "copy" ? "copy" : "open";
}
function shouldKeepDictionaryLookupLink(label, urlTemplate, action) {
return Boolean(label || urlTemplate || action === "copy");
}
function dictionaryLookupLinkLabel(label, action) {
return action === "copy" && !label ? COPY_LOOKUP_LINK.label : label;
}
function dictionaryLookupLinkUrlTemplate(urlTemplate, action) {
return action === "copy" ? "" : urlTemplate;
}
function updateDictionaryLookupLinkEditor(form, action, control) {
const container = form.querySelector(".jpdb-reader-lookup-links");
if (!container) return;
const links = readDictionaryLookupLinks(new FormData(form));
const row = control?.closest("[data-lookup-link-row]");
const index = row ? Array.from(container.querySelectorAll("[data-lookup-link-row]")).indexOf(row) : -1;
updateDictionaryLookupLinks(links, action, index);
setInnerHtml(container, renderDictionaryLookupLinkEditor(links));
}
function updateDictionaryLookupLinks(links, action, index) {
if (action === "lookup-link-add") addDictionaryLookupLink(links);
if (action === "lookup-link-remove") removeDictionaryLookupLink(links, index);
if (action === "lookup-link-up") moveDictionaryLookupLink(links, index, index - 1);
if (action === "lookup-link-down") moveDictionaryLookupLink(links, index, index + 1);
}
function addDictionaryLookupLink(links) {
if (links.length >= MAX_DICTIONARY_LOOKUP_LINKS) return;
links.push({
id: `custom-${Date.now().toString(36)}`,
label: "",
urlTemplate: "https://takoboto.jp/?q={query}",
enabled: true
});
}
function removeDictionaryLookupLink(links, index) {
if (index >= 0 && links.length > 1 && links[index]?.action !== "copy") links.splice(index, 1);
}
function moveDictionaryLookupLink(links, from, to) {
if (from < 0 || to < 0 || from >= links.length || to >= links.length) return;
const [link] = links.splice(from, 1);
links.splice(to, 0, link);
}
function updateSourceRowEditor(action, control) {
const row = control?.closest("[data-source-row]");
const container = row?.closest("[data-source-editor]");
if (!container || !row) return;
const rows = Array.from(container.querySelectorAll("[data-source-row]"));
const index = rows.indexOf(row);
const targetIndex = action === "dictionary-source-up" ? index - 1 : index + 1;
moveSourceRow(container, index, targetIndex);
}
function installSourceRowDrag(root) {
let drag = null;
const dragDocument = root.ownerDocument;
root.addEventListener("pointerdown", (event) => {
if (drag) return;
if (event.pointerType === "mouse" && event.button !== 0) return;
const handle = event.target.closest("[data-source-drag-handle]");
if (!handle || !root.contains(handle)) return;
const row = handle.closest("[data-source-row]");
const container = row?.closest("[data-source-editor]");
if (!row || !container) return;
event.preventDefault();
setSourceRowPointerCapture(handle, event.pointerId);
drag = { active: false, container, handle, pointerId: event.pointerId, row, startY: event.clientY };
row.classList.add("jpdb-reader-order-row-drag-pending");
dragDocument.addEventListener("pointermove", moveDrag);
dragDocument.addEventListener("pointerup", finishDrag);
dragDocument.addEventListener("pointercancel", finishDrag);
});
const moveDrag = (event) => {
if (!drag || event.pointerId !== drag.pointerId) return;
if (!drag.active && Math.abs(event.clientY - drag.startY) < 4) return;
event.preventDefault();
drag.active = true;
drag.row.classList.add("jpdb-reader-order-row-dragging");
moveSourceRowToPointer(drag.container, drag.row, event.clientY);
};
const finishDrag = (event) => {
if (!drag || event.pointerId !== drag.pointerId) return;
releaseSourceRowPointerCapture(drag.handle, event.pointerId);
drag.row.classList.remove("jpdb-reader-order-row-drag-pending", "jpdb-reader-order-row-dragging");
syncSourceRowOrder(drag.container);
drag = null;
dragDocument.removeEventListener("pointermove", moveDrag);
dragDocument.removeEventListener("pointerup", finishDrag);
dragDocument.removeEventListener("pointercancel", finishDrag);
};
root.addEventListener("pointermove", moveDrag);
root.addEventListener("pointerup", finishDrag);
root.addEventListener("pointercancel", finishDrag);
}
function setSourceRowPointerCapture(handle, pointerId) {
try {
handle.setPointerCapture?.(pointerId);
} catch {
}
}
function releaseSourceRowPointerCapture(handle, pointerId) {
try {
handle.releasePointerCapture?.(pointerId);
} catch {
}
}
function moveSourceRowToPointer(container, row, clientY) {
const rows = Array.from(container.querySelectorAll("[data-source-row]")).filter((candidate) => candidate !== row);
const target = rows.find((candidate) => {
const rect = candidate.getBoundingClientRect();
return clientY < rect.top + rect.height / 2;
});
if (target) container.insertBefore(row, target);
else container.appendChild(row);
syncSourceRowOrder(container);
}
function moveSourceRow(container, index, targetIndex) {
const rows = Array.from(container.querySelectorAll("[data-source-row]"));
if (!canMoveSourceRow(index, targetIndex, rows.length)) return;
const row = rows[index];
const target = rows[targetIndex];
if (targetIndex < index) container.insertBefore(row, target);
else container.insertBefore(row, target.nextSibling);
syncSourceRowOrder(container);
}
function canMoveSourceRow(index, targetIndex, rowCount) {
return index >= 0 && targetIndex >= 0 && index < rowCount && targetIndex < rowCount && index !== targetIndex;
}
function syncSourceRowOrder(container) {
const rows = Array.from(container.querySelectorAll("[data-source-row]"));
rows.forEach((row, index) => {
const priority = row.querySelector('input[name$=".priority"]');
if (priority) priority.value = String(index);
const indexLabel = row.querySelector(".jpdb-reader-order-toggle span");
if (indexLabel) indexLabel.textContent = String(index + 1);
});
if (container.matches("[data-audio-source-editor]")) syncAudioSourceIndexes(container, rows);
if (container.classList.contains("jpdb-reader-lookup-links")) syncDictionaryLookupLinkIndexes(container, rows);
}
function syncAudioSourceIndexes(container, rows = Array.from(container.querySelectorAll("[data-audio-source-row]"))) {
rows.forEach((row, index) => {
row.dataset.sourceId = `audio-${index}`;
row.querySelectorAll('[name^="audioSources."]').forEach((control) => {
control.name = control.name.replace(/^audioSources\.\d+\./, `audioSources.${index}.`);
if (control instanceof HTMLSelectElement && control.name.endsWith(".type")) {
control.setAttribute("aria-label", `Audio source ${index + 1}`);
}
if (control instanceof HTMLSelectElement && control.name.endsWith(".voice")) {
control.setAttribute("aria-label", `Text-to-speech voice ${index + 1}`);
}
});
});
}
function syncDictionaryLookupLinkIndexes(container, rows = Array.from(container.querySelectorAll("[data-lookup-link-row]"))) {
rows.forEach((row, index) => {
row.dataset.index = String(index);
row.dataset.sourceId = `lookup-link-${index}`;
row.querySelectorAll('[name^="dictionaryLookupLinks."]').forEach((control) => {
control.name = control.name.replace(/^dictionaryLookupLinks\.\d+\./, `dictionaryLookupLinks.${index}.`);
if (control.name.endsWith(".label")) control.setAttribute("aria-label", `Lookup pill ${index + 1} label`);
if (control.name.endsWith(".urlTemplate")) control.setAttribute("aria-label", `Lookup pill ${index + 1} URL template`);
});
});
}
function installShortcutCapture(root) {
root.querySelectorAll("[data-shortcut-input]").forEach((inputEl) => {
inputEl.addEventListener("keydown", (event) => {
event.preventDefault();
event.stopPropagation();
if (event.key === "Backspace" || event.key === "Delete") {
inputEl.value = "";
return;
}
inputEl.value = formatShortcutEvent(event);
});
inputEl.addEventListener("paste", (event) => event.preventDefault());
});
}
function syncReviewSettingsVisibility(form) {
const reviewsEnabled = form.querySelector('input[name="enableReviews"]')?.checked ?? true;
const passFail = form.querySelector('select[name="twoButtonReviews"]')?.value === "true";
form.querySelectorAll("[data-review-config]").forEach((node) => {
node.hidden = !reviewsEnabled;
});
form.querySelectorAll('[data-review-scale="five"]').forEach((node) => {
node.hidden = !reviewsEnabled || passFail;
});
form.querySelectorAll('[data-review-scale="pass-fail"]').forEach((node) => {
node.hidden = !reviewsEnabled || !passFail;
});
}
function syncJpdbMiningDependentSettings(form) {
const jpdbDeckActionsEnabled = form.querySelector('input[name="jpdbMiningEnabled"]')?.checked ?? true;
for (const name of ["addToForq", "ankiMineWithJpdb"]) {
const input2 = form.querySelector(`input[name="${name}"]`);
if (!input2) continue;
input2.disabled = !jpdbDeckActionsEnabled;
if (!jpdbDeckActionsEnabled) input2.checked = false;
}
}
function syncStickyBottomSheetAvailability(form) {
const popupMode = form.querySelector('select[name="popupMode"]')?.value;
const unavailable = popupMode === "popover";
const input2 = form.querySelector('input[name="stickyBottomSheet"]');
const field = input2?.closest("[data-sticky-bottom-sheet-field]") ?? input2?.closest("label");
if (field) field.hidden = unavailable;
if (!input2) return;
input2.disabled = unavailable;
if (unavailable) input2.checked = false;
}
function syncSubtitlePreview(form) {
const preview = form.querySelector("[data-subtitle-preview]");
if (!preview) return;
const value = (name, fallback) => getNamedControl(form, name)?.value || fallback;
const numberValue2 = (name, fallback) => {
const number = Number(value(name, String(fallback)));
return Number.isFinite(number) ? number : fallback;
};
preview.style.setProperty("--subtitle-font-size", `${Math.max(16, Math.min(64, numberValue2("subtitleFontSize", 28)))}px`);
preview.style.setProperty("--subtitle-color", sanitizeAccentColor(value("subtitleTextColor", "#ffffff"), "#ffffff"));
preview.style.setProperty("--subtitle-outline", sanitizeAccentColor(value("subtitleOutlineColor", "#000000"), "#000000"));
preview.style.setProperty(
"--subtitle-background-rgba",
accentToRgba(sanitizeAccentColor(value("subtitleBackgroundColor", "#181b20"), "#181b20"), Math.max(0, Math.min(1, numberValue2("subtitleBackgroundOpacity", 0))))
);
preview.style.setProperty("--subtitle-family", value("subtitleFontFamily", "system-ui"));
preview.style.setProperty("--subtitle-weight", String(Math.max(100, Math.min(900, numberValue2("subtitleFontWeight", 760)))));
syncSubtitlePreviewColorClasses(form, preview);
}
function syncSubtitlePreviewColorClasses(form, preview) {
const value = (name, fallback) => getNamedControl(form, name)?.value || fallback;
const classes = {
highlight: readOption(value("subtitleHighlightColorSource", "jpdb"), COLOR_SOURCE_VALUES, "jpdb"),
underline: readOption(value("subtitleUnderlineColorSource", "pitch"), COLOR_SOURCE_VALUES, "pitch"),
text: readOption(value("subtitleTextColorSource", "jpdb"), COLOR_SOURCE_VALUES, "jpdb")
};
Object.keys(classes).forEach((channel) => {
COLOR_SOURCE_CLASS_VALUES.forEach((source) => {
preview.classList.toggle(`jpdb-reader-subtitle-${channel}-${source}`, classes[channel] === source);
});
});
}
function renderDeckControls(settings, decks, hasApiKey) {
const disabled = !hasApiKey || !decks.length;
const deckOptions = decks.map((deck) => [deck.id, deck.name]);
const miningOptions = [["forq", "FORQ"], ...deckOptions];
const newTabOptions = [["all", "All study decks"], ["never-forget", "Never forget"], ...deckOptions];
return `
${deckSelect("miningDeck", "Mining deck", settings.miningDeck, miningOptions, disabled)}
${deckSelect("newTabJpdbDeck", "New tab JPDB deck", settings.newTabJpdbDeck, newTabOptions, disabled)}
${deckSelect("neverForgetDeck", "Never forget deck", settings.neverForgetDeck, deckOptions, disabled)}
${deckSelect("blacklistDeck", "Blacklist deck", settings.blacklistDeck, deckOptions, disabled)}
${hasApiKey ? decks.length ? "Decks are loaded from your JPDB account." : "Could not load decks yet; saved deck IDs will be kept." : "Add your JPDB API key to choose decks."}
`;
}
function deckSelect(name, label, value, options, disabled) {
const hasValue = options.some(([optionValue]) => optionValue === value);
const merged = hasValue || !value ? options : [[value, `Saved: ${value}`], ...options];
return `${label}
${merged.map(([optionValue, text2]) => `${escapeHtml$1(text2)} `).join("")}
${disabled ? ` ` : ""}
`;
}
function settingsTabButton(panel, label, active = false) {
return `${escapeHtml$1(label)} `;
}
function renderAnkiTemplatePreview(settings) {
const contextMode = settings.ankiTemplateMode === "context";
const front = contextMode ? `${settings.ankiFrontImage ? "Image appears above the prompt when available. " : ""}今日は本を読む 。
Recall the highlighted word from context. ` : [
'読む
',
settings.ankiFrontReading ? 'よむ
' : "",
settings.ankiFrontSentence ? '今日は本を読む 。
' : "",
settings.ankiFrontImage ? "Image appears on the front when available. " : "",
"Recall the meaning first. "
].filter(Boolean).join("");
return `
${contextMode ? "Sentence first preset" : "Word first preset"}
Front
${front}
Back
読む
よむ
to read
Includes dictionary, kanji, pitch, frequency, source, and image fields when available.
`;
}
function renderDictionarySourceRows(settings) {
const rows = definitionSourceRows(settings);
const showAlias = rows.some((row) => !row.readonly);
const visibleNames = new Set(rows.filter((row) => row.removable).map((row) => row.name));
const hiddenPreferences = settings.dictionaryPreferences.filter((preference) => !visibleNames.has(preference.name));
const hidden = hiddenPreferences.map((preference) => {
const index = settings.dictionaryPreferences.indexOf(preference);
return `
${preference.enabled ? ` ` : ""}
`;
}).join("");
const metadataHelp = hiddenPreferences.length ? 'Frequency, pitch, and kanji metadata dictionaries are detected automatically and shown as popup badges or kanji data instead of definition source cards.
' : "";
if (!rows.some((row) => row.removable)) return `
Import Yomitan dictionaries to add local or native-language definitions alongside JPDB and Immersion Kit examples.
${renderSourceRowsList(rows, { sourceLabel: "Definition source", countName: "dictionaryPreferenceCount", countValue: settings.dictionaryPreferences.length, showAlias })}
${metadataHelp}
${hidden}
`;
return `${renderSourceRowsList(rows, { sourceLabel: "Definition source", countName: "dictionaryPreferenceCount", countValue: settings.dictionaryPreferences.length, showAlias })}${metadataHelp}${hidden}`;
}
function renderKanjiSourceRows(settings) {
return renderSourceRowsList(kanjiSourceRows(settings), { sourceLabel: "Kanji section", showAlias: false });
}
function renderSourceRowsList(rows, options) {
const removableCount = rows.filter((row) => row.removable).length;
const showRemove = removableCount > 0;
const layoutClass = [
options.showAlias ? "" : "compact",
showRemove ? "has-remove" : "no-remove"
].filter(Boolean).join(" ");
return `
On
${escapeHtml$1(options.sourceLabel)}
${options.showAlias ? "Display name " : ""}
Order
${showRemove ? "Remove " : ""}
${options.countName ? ` ` : ""}
${rows.map((row, index) => `
${index + 1}
${sourceField(sourceRowDisplayName(row, options.showAlias), row.name, row.prefix, "name", options.sourceLabel)}
${options.showAlias ? row.readonly ? sourceField(row.alias, row.alias, row.prefix, "alias", "Display name") : `
` : ""}
${miniIcon("drag")}
${miniIcon("up")}
${miniIcon("down")}
${showRemove ? `
${row.removable ? `${miniIcon("remove")} ` : ""}
` : ""}
${row.removable ? `
` : ""}
${row.help ? `
${escapeHtml$1(row.help)}
` : ""}
`).join("")}
`;
}
function sourceRowDisplayName(row, showAlias) {
return !showAlias && !row.readonly && row.alias ? row.alias : row.name;
}
function sourceField(displayValue, formValue, prefix, field, label) {
return `
${escapeHtml$1(displayValue)}
`;
}
function renderRecommendedDictionaries(installed) {
const groups = [
["terms", "Term dictionaries"],
["kanji", "Kanji dictionaries"],
["frequency", "Frequency dictionaries"]
];
return `
Recommended dictionaries
${escapeHtml$1(uiText("en", "dictionaryInstallQueueHelp"))}
${groups.map(([category, label]) => {
const dictionaries = RECOMMENDED_JAPANESE_DICTIONARIES.filter((dictionary) => dictionary.category === category);
if (!dictionaries.length) return "";
return `
${escapeHtml$1(label)}
${dictionaries.map((dictionary) => renderRecommendedDictionary(dictionary, installed)).join("")}
`;
}).join("")}
`;
}
function renderRecommendedDictionary(dictionary, installed) {
const alreadyInstalled = isRecommendedDictionaryInstalled(dictionary, installed);
return `
${escapeHtml$1(dictionary.name)}
Homepage
${escapeHtml$1(uiText("en", dictionary.descriptionKey))}
${alreadyInstalled ? "Update" : "Install"}
`;
}
function isRecommendedDictionaryInstalled(dictionary, installed) {
const targetName = normalizedDictionaryName(dictionary.name);
return installed.some((item) => item.downloadUrl === dictionary.downloadUrl || normalizedDictionaryName(item.title).includes(targetName));
}
function normalizedDictionaryName(value) {
return value.toLowerCase().replace(/[^a-z0-9ぁ-んァ-ン一-龯]/g, "");
}
function recommendedDictionaryFilename(dictionary) {
try {
const parsed = new URL(dictionary.downloadUrl);
const lastPath = parsed.pathname.split("/").filter(Boolean).pop();
if (lastPath && /\.zip$/i.test(lastPath)) return decodeURIComponent(lastPath);
} catch {
}
return `${dictionary.id}.zip`;
}
function readFormSettings(data, current) {
const get = (key) => String(data.get(key) ?? "");
const has = (key) => data.has(key);
const number = (key, fallback) => readNumber(get(key), fallback);
const audioSources = readAudioSources(data);
const furiganaMode = readOption(get("furiganaMode"), ["auto", "all", "difficult-kanji", "known-status", "off"], current.furiganaMode);
const colorSource = (key, fallback) => readOption(get(key), COLOR_SOURCE_VALUES, colorSourceFallback(key, fallback));
const reader = { get, has, number, colorSource };
const jpdbDefinitionsRowPresent = hasJpdbDefinitionsRow(has);
const dictionaryPreferences = readDictionaryPreferences(data, current.dictionaryPreferences);
const kanjiDictionaryPreferences = dictionaryPreferences.filter((preference) => preference.type === "kanji");
const settings = {
...current,
apiKey: get("apiKey").trim(),
interfaceLanguage: readOption(get("interfaceLanguage"), ["auto", "en", "ja"], current.interfaceLanguage),
...readJpdbFormSettings(reader, current, jpdbDefinitionsRowPresent),
...readKanjiAddonFormSettings(reader, current),
...readAudioFormSettings(reader, current, audioSources),
...readColorFormSettings(reader, current),
...readImmersionKitFormSettings(reader, current),
...readLookupBehaviorFormSettings(reader, current),
...readNewTabFormSettings(reader, current),
...readReadingDisplayFormSettings(reader, furiganaMode),
...readOcrFormSettings(reader, current),
...readLocalDictionaryFormSettings(reader, current, kanjiDictionaryPreferences),
dictionaryPreferences,
dictionaryLookupLinks: readDictionaryLookupLinks(data),
...readSubtitleFormSettings(reader, current),
...readAnkiFormSettings(reader, current),
...readStudyToolFormSettings(reader, current),
enableLogging: has("enableLogging"),
...readPopupFormSettings(reader, current),
...readMiningFormSettings(reader),
shortcuts: readShortcutFormSettings(reader)
};
log$6.info("Read settings form data", {
enableLogging: settings.enableLogging,
dictionaries: settings.dictionaryPreferences.length,
lookupLinks: settings.dictionaryLookupLinks.length,
audioSources: settings.audioSources.length,
ocrEnabled: settings.ocrEnabled,
subtitlePlayerEnabled: settings.subtitlePlayerEnabled,
ankiEnabled: settings.ankiEnabled
});
return settings;
}
function colorSourceFallback(key, fallback) {
if (fallback !== "auto") return fallback;
return isColorSourceSettingName(key) ? DEFAULT_COLOR_SOURCE_VALUES[key] : "jpdb";
}
function isColorSourceSettingName(value) {
return Object.prototype.hasOwnProperty.call(DEFAULT_COLOR_SOURCE_VALUES, value);
}
function hasJpdbDefinitionsRow(has) {
return has("jpdbDefinitions.name") || has("jpdbDefinitions.priority") || has("jpdbDefinitions.enabled");
}
function readJpdbFormSettings(reader, current, definitionsRowPresent) {
const { has, number } = reader;
return {
jpdbDefinitionsEnabled: definitionsRowPresent ? has("jpdbDefinitions.enabled") : has("jpdbDefinitionsEnabled"),
jpdbDefinitionsPriority: Math.max(0, Math.min(999, number("jpdbDefinitions.priority", current.jpdbDefinitionsPriority)))
};
}
function readKanjiAddonFormSettings(reader, current) {
const { has, number } = reader;
return {
jpdbKanjiEnabled: has("jpdbKanji.enabled"),
jpdbKanjiPriority: Math.max(0, Math.min(999, number("jpdbKanji.priority", current.jpdbKanjiPriority))),
uchisenEnabled: has("uchisen.enabled"),
uchisenPriority: Math.max(0, Math.min(999, number("uchisen.priority", current.uchisenPriority))),
rtkEnabled: has("rtk.enabled"),
rtkPriority: Math.max(0, Math.min(999, number("rtk.priority", current.rtkPriority))),
kanjivgEnabled: has("kanjivg.enabled"),
kanjivgPriority: Math.max(0, Math.min(999, number("kanjivg.priority", current.kanjivgPriority))),
kanjiOriginsEnabled: has("kanjiOrigins.enabled"),
kanjiOriginsPriority: Math.max(0, Math.min(999, number("kanjiOrigins.priority", current.kanjiOriginsPriority))),
kanjiOriginKanjiMapEnabled: has("kanjiOriginKanjiMapEnabled"),
kanjiOriginGraphEnabled: has("kanjiOriginGraphEnabled"),
kanjiOriginRadicalImagesEnabled: has("kanjiOriginRadicalImagesEnabled"),
similarKanjiWords: has("similarKanjiWords.enabled"),
similarKanjiWordsPriority: Math.max(0, Math.min(999, number("similarKanjiWords.priority", current.similarKanjiWordsPriority))),
similarKanjiWordLimit: Math.max(2, Math.min(24, number("similarKanjiWordLimit", current.similarKanjiWordLimit)))
};
}
function readAudioFormSettings(reader, current, audioSources) {
const { get, has, number } = reader;
return {
audioEnabled: has("audioEnabled"),
autoPlayAudio: has("autoPlayAudio"),
audioAutoPlayMode: readOption(get("audioAutoPlayMode"), ["all", "hover", "tap"], current.audioAutoPlayMode),
audioSources,
audioEnableDefaultSources: has("audioEnableDefaultSources"),
audioSourceUrl: audioSources.find((source) => source.url.trim())?.url.trim() ?? current.audioSourceUrl,
audioViaBlob: current.audioViaBlob,
audioFallbackChimeEnabled: has("audioFallbackChimeEnabled"),
audioTimeoutMs: Math.max(1e3, number("audioTimeoutMs", current.audioTimeoutMs)),
audioSelectionMode: readOption(get("audioSelectionMode"), ["first", "random"], current.audioSelectionMode),
audioTtsMode: readOption(get("audioTtsMode"), ["fallback", "source-order"], current.audioTtsMode)
};
}
function readColorFormSettings(reader, current) {
const { get, colorSource } = reader;
return {
accentColor: sanitizeAccentColor(get("accentColor"), current.accentColor),
wordColorNew: sanitizeAccentColor(get("wordColorNew"), current.wordColorNew),
wordColorLearning: sanitizeAccentColor(get("wordColorLearning"), current.wordColorLearning),
wordColorKnown: sanitizeAccentColor(get("wordColorKnown"), current.wordColorKnown),
wordColorDue: sanitizeAccentColor(get("wordColorDue"), current.wordColorDue),
wordColorFailed: sanitizeAccentColor(get("wordColorFailed"), current.wordColorFailed),
wordColorIgnored: sanitizeAccentColor(get("wordColorIgnored"), current.wordColorIgnored),
pitchColorHeiban: sanitizeAccentColor(get("pitchColorHeiban"), current.pitchColorHeiban),
pitchColorAtamadaka: sanitizeAccentColor(get("pitchColorAtamadaka"), current.pitchColorAtamadaka),
pitchColorNakadaka: sanitizeAccentColor(get("pitchColorNakadaka"), current.pitchColorNakadaka),
pitchColorOdaka: sanitizeAccentColor(get("pitchColorOdaka"), current.pitchColorOdaka),
pitchColorKifuku: sanitizeAccentColor(get("pitchColorKifuku"), current.pitchColorKifuku),
pitchColorUnknown: sanitizeAccentColor(get("pitchColorUnknown"), current.pitchColorUnknown),
wordHighlightColorSource: colorSource("wordHighlightColorSource", current.wordHighlightColorSource),
wordUnderlineColorSource: colorSource("wordUnderlineColorSource", current.wordUnderlineColorSource),
wordTextColorSource: colorSource("wordTextColorSource", current.wordTextColorSource),
subtitleHighlightColorSource: colorSource("subtitleHighlightColorSource", current.subtitleHighlightColorSource),
subtitleUnderlineColorSource: colorSource("subtitleUnderlineColorSource", current.subtitleUnderlineColorSource),
subtitleTextColorSource: colorSource("subtitleTextColorSource", current.subtitleTextColorSource)
};
}
function readLookupBehaviorFormSettings(reader, current) {
const { has, number } = reader;
return {
parseSelection: has("parseSelection"),
lookupOnClick: has("lookupOnClick"),
lookupOnHover: has("lookupOnHover"),
lookupOnMiddleMouse: has("lookupOnMiddleMouse"),
hoverOpenDelayMs: Math.max(0, Math.min(1500, number("hoverOpenDelayMs", current.hoverOpenDelayMs))),
hoverCloseDelayMs: Math.max(0, Math.min(3e3, number("hoverCloseDelayMs", current.hoverCloseDelayMs))),
popupActivationMode: current.popupActivationMode,
scanModifierKey: current.scanModifierKey,
autoScanJapanese: has("autoScanJapanese"),
scanVisiblePage: has("scanVisiblePage"),
showFloatingButton: has("showFloatingButton")
};
}
function readNewTabFormSettings(reader, current) {
const { get, has, number } = reader;
return {
newTabEnabled: has("newTabEnabled"),
newTabSource: readOption(get("newTabSource"), ["auto", "jpdb", "anki", "dictionary"], current.newTabSource),
newTabJpdbDeck: get("newTabJpdbDeck").trim() || current.newTabJpdbDeck,
newTabJpdbReviewMode: readOption(get("newTabJpdbReviewMode"), ["auto", "api-vocabulary", "live-review"], current.newTabJpdbReviewMode),
corsProxyUrl: get("corsProxyUrl").trim(),
newTabKanjiKeywordSource: readOption(get("newTabKanjiKeywordSource"), ["auto", "rtk", "jpdb", "local"], current.newTabKanjiKeywordSource),
newTabParsingEnabled: has("newTabParsingEnabled"),
newTabFrontSentenceEnabled: has("newTabFrontSentenceEnabled"),
newTabOfflineEnabled: has("newTabOfflineEnabled"),
newTabOfflineLimit: Math.max(0, Math.min(500, number("newTabOfflineLimit", current.newTabOfflineLimit))),
newTabKanjiAutogradeEnabled: has("newTabKanjiAutogradeEnabled"),
newTabKanjiAutoSubmit: has("newTabKanjiAutoSubmit")
};
}
function readReadingDisplayFormSettings(reader, furiganaMode) {
const { has } = reader;
return {
showFurigana: furiganaMode !== "off",
furiganaMode,
showPitchAccent: has("showPitchAccent"),
hideKnownFurigana: furiganaMode === "known-status"
};
}
function readLocalDictionaryFormSettings(reader, current, kanjiPreferences) {
const { has, number } = reader;
return {
localDictionariesEnabled: has("localDictionariesEnabled"),
localDictionaryShowKanji: has("kanjiDictionaries.enabled") || kanjiPreferences.some((preference) => preference.enabled),
kanjiDictionariesPriority: Math.max(0, Math.min(999, number("kanjiDictionaries.priority", current.kanjiDictionariesPriority))),
dictionarySourcesInitiallyExpanded: has("dictionarySourcesInitiallyExpanded"),
localDictionaryMaxResults: Math.max(1, Math.min(64, number("localDictionaryMaxResults", current.localDictionaryMaxResults)))
};
}
function readAnkiFormSettings(reader, current) {
const { get, has } = reader;
return {
ankiEnabled: has("ankiEnabled"),
ankiConnectUrl: get("ankiConnectUrl").trim() || current.ankiConnectUrl,
ankiDeck: get("ankiDeck").trim() || current.ankiDeck,
ankiModel: get("ankiModel").trim() || current.ankiModel,
ankiTemplateMode: readOption(get("ankiTemplateMode"), ["recognition", "context"], current.ankiTemplateMode),
ankiFrontReading: has("ankiFrontReading"),
ankiFrontSentence: has("ankiFrontSentence"),
ankiFrontImage: has("ankiFrontImage"),
ankiTags: get("ankiTags").trim(),
ankiMineWithJpdb: has("ankiMineWithJpdb"),
ankiCaptureScreenshot: has("ankiCaptureScreenshot"),
ankiMobileHandoff: has("ankiMobileHandoff")
};
}
function readStudyToolFormSettings(reader, current) {
const { has, number } = reader;
return {
studyTranslationEnabled: has("studyTranslation.enabled"),
studyTranslationPriority: Math.max(0, Math.min(999, number("studyTranslation.priority", current.studyTranslationPriority))),
studyGrammarEnabled: has("studyGrammar.enabled"),
studyGrammarPriority: Math.max(0, Math.min(999, number("studyGrammar.priority", current.studyGrammarPriority)))
};
}
function readPopupFormSettings(reader, current) {
const { get, has, number } = reader;
const popupMode = readOption(get("popupMode"), ["auto", "sheet", "popover"], current.popupMode);
return {
theme: readOption(get("theme"), ["auto", "dark", "light"], current.theme),
popupMode,
stickyBottomSheet: popupMode !== "popover" && has("stickyBottomSheet"),
popoverWidth: Math.max(280, Math.min(900, number("popoverWidth", current.popoverWidth))),
popoverHeight: Math.max(220, Math.min(900, number("popoverHeight", current.popoverHeight))),
popoverHeightMode: readOption(get("popoverHeightMode"), ["available", "fixed"], current.popoverHeightMode)
};
}
function readMiningFormSettings(reader) {
const { get, has } = reader;
return {
jpdbMiningEnabled: has("jpdbMiningEnabled"),
miningDeck: get("miningDeck").trim() || "forq",
neverForgetDeck: get("neverForgetDeck").trim() || "never-forget",
blacklistDeck: get("blacklistDeck").trim() || "blacklist",
addToForq: has("addToForq"),
enableReviews: has("enableReviews"),
twoButtonReviews: get("twoButtonReviews") === "true"
};
}
function readOcrFormSettings(reader, current) {
const { get, has, number } = reader;
return {
ocrEnabled: has("ocrEnabled"),
ocrAutoScanImages: has("ocrAutoScanImages"),
ocrShowTextOverlay: has("ocrShowTextOverlay"),
ocrProvider: normalizeOcrProvider(get("ocrProvider")),
ocrEndpointUrl: get("ocrEndpointUrl").trim(),
ocrEngine: get("ocrEngine").trim() || "auto",
ocrCloudVisionApiKey: get("ocrCloudVisionApiKey").trim(),
ocrLanguage: get("ocrLanguage").trim() || "ja-JP",
ocrMaxImagePixels: Math.max(16e4, Math.min(28e5, number("ocrMaxImagePixels", current.ocrMaxImagePixels))),
ocrMinImageArea: Math.max(1e4, Math.min(8e5, number("ocrMinImageArea", current.ocrMinImageArea))),
ocrMaxImagesPerPage: Math.max(1, Math.min(30, number("ocrMaxImagesPerPage", current.ocrMaxImagesPerPage))),
ocrPrefetchMargin: Math.max(0, Math.min(3e3, number("ocrPrefetchMargin", current.ocrPrefetchMargin))),
ocrTextColor: sanitizeAccentColor(get("ocrTextColor"), current.ocrTextColor),
ocrOutlineColor: sanitizeAccentColor(get("ocrOutlineColor"), current.ocrOutlineColor),
ocrBackgroundColor: sanitizeAccentColor(get("ocrBackgroundColor"), current.ocrBackgroundColor),
ocrBackgroundOpacity: Math.max(0, Math.min(1, number("ocrBackgroundOpacity", current.ocrBackgroundOpacity))),
ocrFontScale: Math.max(0.7, Math.min(1.8, number("ocrFontScale", current.ocrFontScale)))
};
}
function readSubtitleFormSettings(reader, current) {
const { get, has, number } = reader;
return {
subtitlePlayerEnabled: has("subtitlePlayerEnabled"),
subtitleAutoDetect: has("subtitleAutoDetect"),
subtitleOverlayVisible: has("subtitleOverlayVisible"),
subtitleSecondaryVisible: has("subtitleSecondaryVisible"),
subtitleNativeBlurred: has("subtitleNativeBlurred"),
subtitleKaraokeMode: has("subtitleKaraokeMode"),
subtitleTranscriptVisible: has("subtitleTranscriptVisible"),
subtitleTranscriptPlacement: readOption(get("subtitleTranscriptPlacement"), ["right", "left", "bottom"], current.subtitleTranscriptPlacement),
subtitleTranscriptAutoScroll: has("subtitleTranscriptAutoScroll"),
subtitleAutoCopyLine: has("subtitleAutoCopyLine"),
subtitleControlsMode: readOption(get("subtitleControlsMode"), ["auto", "always", "hidden"], current.subtitleControlsMode),
subtitleFontSize: Math.max(16, Math.min(64, number("subtitleFontSize", current.subtitleFontSize))),
subtitleBottomOffset: Math.max(2, Math.min(40, number("subtitleBottomOffset", current.subtitleBottomOffset))),
subtitleTextColor: sanitizeAccentColor(get("subtitleTextColor"), current.subtitleTextColor),
subtitleOutlineColor: sanitizeAccentColor(get("subtitleOutlineColor"), current.subtitleOutlineColor),
subtitleBackgroundColor: sanitizeAccentColor(get("subtitleBackgroundColor"), current.subtitleBackgroundColor),
subtitleBackgroundOpacity: Math.max(0, Math.min(1, number("subtitleBackgroundOpacity", current.subtitleBackgroundOpacity))),
subtitleFontFamily: get("subtitleFontFamily").trim() || current.subtitleFontFamily,
subtitleFontWeight: Math.max(100, Math.min(900, number("subtitleFontWeight", current.subtitleFontWeight))),
subtitleMiningPause: has("subtitleMiningPause"),
subtitleSeekPadding: Math.max(-2, Math.min(2, number("subtitleSeekPadding", current.subtitleSeekPadding)))
};
}
function readImmersionKitFormSettings(reader, current) {
const { get, has, number } = reader;
const mediaEnabled = has("immersionKitEnabled");
const sourceRowPresent = Boolean(get("immersionKit.name") || get("immersionKit.priority"));
const sourceEnabled = sourceRowPresent ? has("immersionKit.enabled") : true;
return {
immersionKitEnabled: mediaEnabled && sourceEnabled,
immersionKitExampleSource: readOption(get("immersionKitExampleSource"), ["immersion-kit", "nadeshiko", "combined"], current.immersionKitExampleSource),
nadeshikoApiKey: get("nadeshikoApiKey").trim(),
immersionKitPriority: Math.max(0, Math.min(999, number("immersionKit.priority", current.immersionKitPriority))),
immersionKitLimitEnabled: get("immersionKitLimitEnabled") === "on",
immersionKitLimit: Math.max(1, Math.min(12, number("immersionKitLimit", current.immersionKitLimit))),
immersionKitMinLength: Math.max(0, Math.min(120, number("immersionKitMinLength", current.immersionKitMinLength))),
immersionKitMaxLength: Math.max(0, Math.min(240, number("immersionKitMaxLength", current.immersionKitMaxLength))),
immersionKitCategory: readOption(get("immersionKitCategory"), ["all", "anime", "drama", "games"], current.immersionKitCategory),
immersionKitSort: readOption(get("immersionKitSort"), ["sentence_length:asc", "sentence_length:desc"], current.immersionKitSort),
immersionKitExactMatch: has("immersionKitExactMatch"),
immersionKitShowTranslation: has("immersionKitShowTranslation"),
immersionKitRevealTranslationOnClick: has("immersionKitShowTranslation") && has("immersionKitRevealTranslationOnClick"),
immersionKitShowImages: has("immersionKitShowImages"),
immersionKitAutoPlayAudio: has("immersionKitAutoPlayAudio"),
immersionKitPlayOnHover: has("immersionKitPlayOnHover"),
immersionKitPlayOnImageClick: has("immersionKitPlayOnImageClick"),
immersionKitPlaybackRate: Math.max(0.5, Math.min(2, number("immersionKitPlaybackRate", current.immersionKitPlaybackRate)))
};
}
function readShortcutFormSettings(reader) {
const { get } = reader;
return {
scanPage: get("shortcuts.scanPage"),
hoverLookup: get("shortcuts.hoverLookup"),
openSettings: get("shortcuts.openSettings"),
playAudio: get("shortcuts.playAudio"),
closePopup: get("shortcuts.closePopup"),
previousSubtitle: get("shortcuts.previousSubtitle"),
nextSubtitle: get("shortcuts.nextSubtitle"),
copySubtitle: get("shortcuts.copySubtitle"),
toggleOcr: get("shortcuts.toggleOcr"),
scanImages: get("shortcuts.scanImages"),
gradeNothing: get("shortcuts.gradeNothing"),
gradeSomething: get("shortcuts.gradeSomething"),
gradeHard: get("shortcuts.gradeHard"),
gradeOkay: get("shortcuts.gradeOkay"),
gradeEasy: get("shortcuts.gradeEasy"),
gradeFail: get("shortcuts.gradeFail"),
gradePass: get("shortcuts.gradePass")
};
}
function readNumber(value, fallback) {
if (!value.trim()) return fallback;
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function readOption(value, allowed, fallback) {
return allowed.includes(value) ? value : fallback;
}
function readDictionaryPreferences(data, current) {
const get = (key) => String(data.get(key) ?? "");
const count = Math.max(0, Number(get("dictionaryPreferenceCount")) || 0);
if (!count) return current;
return Array.from({ length: count }, (_, index) => ({
name: get(`dictionaryPreferences.${index}.name`).trim(),
alias: get(`dictionaryPreferences.${index}.alias`).trim() || get(`dictionaryPreferences.${index}.name`).trim(),
enabled: data.has(`dictionaryPreferences.${index}.enabled`),
priority: readNumber(get(`dictionaryPreferences.${index}.priority`), index),
type: readDictionaryType(get(`dictionaryPreferences.${index}.type`))
})).filter((item) => item.name).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name));
}
function readDictionaryType(value) {
return value === "kanji" || value === "frequency" || value === "metadata" ? value : "terms";
}
function readAudioSources(data) {
const get = (key) => String(data.get(key) ?? "");
const count = Math.max(0, Number(get("audioSourceCount")) || 0);
const sources = [];
const builtInTypes = new Set(DEFAULT_AUDIO_SOURCES.map((source) => source.type));
for (let index = 0; index < count; index++) {
const source = readAudioSourceRow(data, get, index);
if (!source || shouldSkipAudioSourceRow(source, builtInTypes)) continue;
sources.push(source);
}
return sources;
}
function readAudioSourceRow(data, get, index) {
return normalizeAudioSource({
type: get(`audioSources.${index}.type`),
url: get(`audioSources.${index}.url`).trim(),
voice: get(`audioSources.${index}.voice`).trim(),
enabled: data.has(`audioSources.${index}.enabled`)
});
}
function shouldSkipAudioSourceRow(source, builtInTypes) {
return !source.enabled && !source.url && !source.voice && !builtInTypes.has(source.type);
}
function getReaderSettingsExport(value) {
const record = readerSettingsExportRecord(value);
return record && isReaderSettingsExport(record) ? record.settings : null;
}
function readerSettingsExportRecord(value) {
return value && typeof value === "object" ? value : null;
}
function isReaderSettingsExport(record) {
return isReaderSettingsExportFormat(record.formatName) && Boolean(record.settings) && typeof record.settings === "object";
}
function isReaderSettingsExportFormat(formatName) {
return formatName === "yomu-reader-settings" || formatName === "jpdb-popup-reader-settings";
}
function pickFile(root, type) {
const inputEl = root.querySelector(`input[data-file="${type}"]`);
if (!inputEl) {
log$6.warn("File picker input missing", { type });
return Promise.resolve(null);
}
return new Promise((resolve) => {
inputEl.onchange = () => {
const file = inputEl.files?.[0] ?? null;
inputEl.value = "";
log$6.info("File picker completed", { type, name: file?.name ?? "", size: file?.size ?? 0 });
resolve(file);
};
inputEl.click();
});
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1e3);
log$6.info("Downloaded blob", { filename, size: blob.size, type: blob.type });
}
function dateStamp() {
return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
}
const log$5 = Logger.scope("SettingsDialog");
const JPDB_SETTINGS_URL = "https://jpdb.io/settings";
function settingsStatusSetter(status) {
return (message) => {
if (status) status.textContent = message;
};
}
function focusPreviewAudioSource(form, button2, previewSettings) {
const row = button2?.closest("[data-audio-source-row]");
if (!row) return;
const source = previewSettings.audioSources[sourceRowIndex(form, row)];
if (!source) return;
previewSettings.audioSources = [{ ...source, enabled: true }];
previewSettings.audioEnableDefaultSources = false;
}
function sourceRowIndex(form, row) {
return Array.from(form.querySelectorAll("[data-audio-source-row]")).indexOf(row);
}
function recommendedDictionaryForControl(control) {
const dictionary = control?.dataset.dictionaryId ? findRecommendedDictionary(control.dataset.dictionaryId) : void 0;
if (!dictionary) throw new Error("Recommended dictionary not found.");
return dictionary;
}
function recommendedDictionaryDownloadStatus(control, dictionaryName) {
return `${control?.dataset.installed === "true" ? "Updating" : "Downloading"} ${dictionaryName}...`;
}
function settingsActionButton(control) {
return control instanceof HTMLButtonElement ? control : control?.closest("button") ?? null;
}
function selectedSettingsPanel(control) {
return control?.dataset.panel ?? "basics";
}
function handleSettingsActionError(action, control, setStatus, error) {
log$5.warn("Settings action failed", { action }, error);
if (shouldReenableSettingsAction(action)) control?.removeAttribute("disabled");
const message = errorMessage(error, "Import failed.");
setStatus(message);
return message;
}
function shouldReenableSettingsAction(action) {
return action === "download-recommended-dictionary" || action === "delete-yomitan-dictionary";
}
function dictionaryStatusElements(form) {
return {
status: form.querySelector("[data-dictionary-status]"),
priorities: form.querySelector(".jpdb-reader-dictionary-priorities"),
recommended: form.querySelector("[data-recommended-dictionaries]")
};
}
function renderDictionaryStatusElements(elements, summary, settings) {
if (elements.status) elements.status.textContent = dictionaryStatusText(summary);
if (elements.priorities) setInnerHtml(elements.priorities, renderDictionarySourceRows(settings));
if (elements.recommended) setInnerHtml(elements.recommended, renderRecommendedDictionaries(summary.dictionaries));
}
function dictionaryStatusText(summary) {
return summary.dictionaries.length ? `${summary.dictionaries.length} dictionaries, ${summary.terms.toLocaleString()} terms, ${summary.kanji.toLocaleString()} kanji, ${summary.termMeta.toLocaleString()} metadata rows.` : "No local dictionaries imported yet.";
}
function setDictionaryStatusError(status, error) {
if (status) status.textContent = errorMessage(error, "Dictionary status unavailable.");
}
function errorMessage(error, fallback) {
if (error instanceof Error && error.message.trim()) return error.message;
if (typeof error === "string" && error.trim()) return error;
return fallback;
}
class SettingsDialogController {
constructor(dependencies) {
this.dependencies = dependencies;
}
dictionaryOperationQueue = Promise.resolve();
pendingDictionaryOperations = 0;
recommendedDictionaryOperations = /* @__PURE__ */ new Map();
open(panel) {
log$5.info("Opening settings", { panel: panel ?? "default" });
const form = this.createSettingsForm(panel);
const backdrop = this.dependencies.createBackdrop();
this.bindFormSubmit(form);
this.bindLivePreview(form);
this.bindEditorControls(form);
this.dependencies.mountDialog(backdrop, form);
installSettingsDrawerHandle(form);
this.dependencies.beginSettingsPreview(this.settings.accentColor, this.settings.interfaceLanguage, this.settings.theme);
this.syncRecommendedDictionaryInstallControls(form);
this.syncDictionaryOperationState(form);
void this.refreshDictionaryStatus(form);
void this.refreshDeckControls(form);
}
get settings() {
return this.dependencies.getSettings();
}
set settings(settings) {
this.dependencies.setSettings(settings);
}
createSettingsForm(panel) {
const form = document.createElement("form");
form.className = "jpdb-reader-settings";
form.dataset.jpdbReaderRoot = "true";
form.setAttribute("role", "dialog");
form.setAttribute("aria-modal", "true");
form.setAttribute("aria-label", SETTINGS_TITLE);
form.tabIndex = -1;
setInnerHtml(form, renderSettingsForm(this.settings, JPDB_SETTINGS_URL));
localizeSettingsForm(form, this.settings.interfaceLanguage);
if (panel) activateSettingsPanel(form, panel);
return form;
}
bindFormSubmit(form) {
form.addEventListener("submit", (event) => {
event.preventDefault();
if (this.pendingDictionaryOperations > 0) {
this.showDictionarySaveBlocked(form);
return;
}
const previousInitialOpen = this.settings.dictionarySourcesInitiallyExpanded;
this.settings = readFormSettings(new FormData(form), this.settings);
configureLogger({ forceEnabled: this.settings.enableLogging });
if (this.settings.dictionarySourcesInitiallyExpanded !== previousInitialOpen) {
this.dependencies.clearDictionarySourceOpenOverrides();
}
void saveSettings(this.settings).then(() => this.afterSettingsSaved()).catch((error) => {
log$5.error("Settings save failed", error);
this.dependencies.toast(error instanceof Error ? error.message : "Settings save failed.");
});
});
form.querySelector('[data-action="cancel"]')?.addEventListener("click", () => this.dependencies.dismiss());
form.addEventListener("keydown", (event) => {
if (event.key !== "Escape" || event.isComposing) return;
event.preventDefault();
event.stopPropagation();
this.dependencies.dismiss();
});
}
async afterSettingsSaved() {
log$5.info("Settings saved", loggingSettingsSummary(this.settings));
this.dependencies.jpdb.clear();
this.dependencies.applyTheme();
await this.dependencies.refreshDictionaryStyles();
this.dependencies.installFab();
this.dependencies.subtitles.refresh();
this.dependencies.ocr.refresh();
this.dependencies.clearSettingsPreview();
this.dependencies.dismiss();
this.dependencies.scheduleDictionaryRescan();
this.dependencies.refreshNewTabIfCurrent();
this.dependencies.toast("Settings saved.");
}
bindLivePreview(form) {
const applyThemePreview = () => this.dependencies.applyTheme(readFormSettings(new FormData(form), this.settings));
form.querySelector('input[name="accentColor"]')?.addEventListener("input", (event) => {
this.dependencies.applyAccentColor(event.currentTarget.value);
});
form.querySelectorAll('input[name^="wordColor"], input[name^="pitchColor"]').forEach((input2) => {
input2.addEventListener("input", () => this.dependencies.applyWordColors(readFormSettings(new FormData(form), this.settings)));
});
this.syncThemeSwitch(form);
form.querySelector("[data-theme-switch]")?.addEventListener("click", (event) => {
event.preventDefault();
const input2 = form.querySelector("[data-theme-value]");
const current = this.effectiveTheme(input2?.value);
const next = current === "dark" ? "light" : "dark";
if (input2) input2.value = next;
this.settings.theme = next;
applyThemePreview();
this.syncThemeSwitch(form);
});
syncSubtitlePreview(form);
form.addEventListener("input", (event) => {
if (this.isSubtitleControl(event.target)) syncSubtitlePreview(form);
});
form.addEventListener("change", (event) => {
if (this.isSubtitleControl(event.target)) syncSubtitlePreview(form);
if (this.isColorSourceControl(event.target) || this.isReaderDisplayControl(event.target)) applyThemePreview();
});
form.querySelector('select[name="popupMode"]')?.addEventListener("change", () => syncStickyBottomSheetAvailability(form));
syncStickyBottomSheetAvailability(form);
const syncImmersionTranslationReveal = () => {
const translations = form.querySelector('input[name="immersionKitShowTranslation"]');
const reveal = form.querySelector('input[name="immersionKitRevealTranslationOnClick"]');
if (!translations || !reveal) return;
reveal.disabled = !translations.checked;
if (!translations.checked) reveal.checked = false;
};
form.querySelector('input[name="immersionKitShowTranslation"]')?.addEventListener("change", syncImmersionTranslationReveal);
syncImmersionTranslationReveal();
const syncImmersionEnabled = (source) => {
form.querySelectorAll('input[name="immersionKitEnabled"], input[name="immersionKit.enabled"]').forEach((input2) => {
if (input2 !== source) input2.checked = source.checked;
});
};
form.querySelectorAll('input[name="immersionKitEnabled"], input[name="immersionKit.enabled"]').forEach((input2) => {
input2.addEventListener("change", () => syncImmersionEnabled(input2));
});
const syncNadeshikoKeyField = () => {
const source = form.querySelector('select[name="immersionKitExampleSource"]')?.value;
const usesNadeshiko = source === "nadeshiko" || source === "combined";
form.querySelectorAll("[data-nadeshiko-api-key-field]").forEach((field) => {
field.hidden = !usesNadeshiko;
});
};
form.querySelector('select[name="immersionKitExampleSource"]')?.addEventListener("change", syncNadeshikoKeyField);
syncNadeshikoKeyField();
const syncImmersionLimit = () => {
const enabled = form.querySelector('input[name="immersionKitLimitEnabled"][value="on"]')?.checked ?? false;
const limit = form.querySelector('input[name="immersionKitLimit"]');
if (limit) limit.disabled = !enabled;
};
form.querySelectorAll('input[name="immersionKitLimitEnabled"]').forEach((input2) => {
input2.addEventListener("change", syncImmersionLimit);
});
syncImmersionLimit();
form.querySelector('select[name="interfaceLanguage"]')?.addEventListener("change", (event) => {
const value = event.currentTarget.value;
if (value !== "auto" && value !== "en" && value !== "ja") return;
this.settings.interfaceLanguage = value;
localizeSettingsForm(form, value);
this.syncRecommendedDictionaryInstallControls(form);
this.syncDictionaryOperationState(form);
syncSubtitlePreview(form);
this.dependencies.installFab();
});
form.querySelector('select[name="ocrProvider"]')?.addEventListener("change", (event) => {
const value = event.currentTarget.value;
form.querySelectorAll("[data-local-ocr]").forEach((node) => {
node.hidden = value !== "local-service";
});
form.querySelectorAll("[data-cloud-ocr]").forEach((node) => {
node.hidden = value !== "cloud-vision";
});
});
}
bindEditorControls(form) {
syncBrowserTtsVoiceOptions(form);
if ("speechSynthesis" in window) {
window.speechSynthesis.addEventListener("voiceschanged", () => syncBrowserTtsVoiceOptions(form), { once: true });
}
form.querySelector('input[name="enableReviews"]')?.addEventListener("change", () => syncReviewSettingsVisibility(form));
form.querySelector('select[name="twoButtonReviews"]')?.addEventListener("change", () => syncReviewSettingsVisibility(form));
form.querySelector('input[name="jpdbMiningEnabled"]')?.addEventListener("change", () => syncJpdbMiningDependentSettings(form));
syncJpdbMiningDependentSettings(form);
form.querySelector('input[name="apiKey"]')?.addEventListener("change", () => void this.refreshDeckControls(form));
form.addEventListener("change", (event) => this.handleSettingsFormChange(form, event));
installShortcutCapture(form);
installSourceRowDrag(form);
form.addEventListener("click", (event) => {
if (this.handleSettingsPreviewLookup(event)) return;
const control = event.target.closest("[data-action]");
const action = control?.dataset.action;
if (!action || action === "cancel") return;
event.preventDefault();
event.stopPropagation();
void this.handleSettingsAction(form, action, control);
});
form.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
if (this.handleSettingsPreviewLookup(event)) event.preventDefault();
});
}
handleSettingsPreviewLookup(event) {
const target = event.target instanceof HTMLElement ? event.target : null;
const word = target?.closest("[data-settings-preview-lookup]");
if (!word || !this.dependencies.lookupText) return false;
const expression = word.dataset.settingsPreviewLookup?.trim() || word.textContent?.trim() || "";
if (!expression) return false;
event.preventDefault();
event.stopPropagation();
void this.dependencies.lookupText(expression, word.dataset.sentence || expression, word);
return true;
}
handleSettingsFormChange(form, event) {
const sourceSelect = event.target.closest('select[name^="audioSources."][name$=".type"]');
if (sourceSelect) {
syncAudioSourceRow(sourceSelect.closest("[data-audio-source-row]"), sourceSelect.value);
syncBrowserTtsVoiceOptions(form);
}
const templateSelect = event.target.closest('select[name="ankiTemplateMode"]');
if (!templateSelect) return;
const preview = form.querySelector("[data-anki-template-preview]");
if (preview) setInnerHtml(preview, renderAnkiTemplatePreview(readFormSettings(new FormData(form), this.settings)));
}
syncThemeSwitch(form) {
const input2 = form.querySelector("[data-theme-value]");
const button2 = form.querySelector("[data-theme-switch]");
if (!button2) return;
const theme = this.effectiveTheme(input2?.value);
const label = theme === "dark" ? "Switch to light theme" : "Switch to dark theme";
button2.setAttribute("aria-checked", String(theme === "light"));
button2.setAttribute("aria-label", label);
button2.title = label;
}
effectiveTheme(value) {
if (value === "dark" || value === "light") return value;
return globalThis.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
isSubtitleControl(target) {
const name = target?.name ?? "";
return name.startsWith("subtitle");
}
isColorSourceControl(target) {
const name = target?.name ?? "";
return [
"wordHighlightColorSource",
"wordUnderlineColorSource",
"wordTextColorSource",
"subtitleHighlightColorSource",
"subtitleUnderlineColorSource",
"subtitleTextColorSource"
].includes(name);
}
isReaderDisplayControl(target) {
const name = target?.name ?? "";
return name === "furiganaMode" || name === "theme";
}
async refreshDeckControls(form) {
const container = form.querySelector("[data-jpdb-decks]");
if (!container) return;
const apiKey = form.querySelector('input[name="apiKey"]')?.value.trim() ?? this.settings.apiKey.trim();
if (!apiKey) {
setInnerHtml(container, renderDeckControls(this.settings, [], false));
localizeSettingsForm(form, getFormInterfaceLanguage(form, this.settings.interfaceLanguage));
return;
}
const originalKey = this.settings.apiKey;
this.settings.apiKey = apiKey;
try {
const decks = await this.dependencies.jpdb.listDecks();
setInnerHtml(container, renderDeckControls(readFormSettings(new FormData(form), this.settings), decks, true));
} catch (error) {
log$5.warn("Deck controls failed to load", error);
setInnerHtml(container, renderDeckControls(readFormSettings(new FormData(form), this.settings), [], true));
} finally {
this.settings.apiKey = originalKey;
localizeSettingsForm(form, getFormInterfaceLanguage(form, this.settings.interfaceLanguage));
}
}
async refreshDictionaryStatus(form) {
const elements = dictionaryStatusElements(form);
try {
const summary = await this.dependencies.dictionaries.summary();
await this.applyDictionaryStatus(form, elements, summary);
} catch (error) {
log$5.warn("Dictionary status unavailable", error);
setDictionaryStatusError(elements.status, error);
}
}
async applyDictionaryStatus(form, elements, summary) {
await this.mergeDictionaryPreferencesFromSummary(summary);
await this.dependencies.refreshDictionaryStyles();
renderDictionaryStatusElements(elements, summary, this.settings);
localizeSettingsForm(form, getFormInterfaceLanguage(form, this.settings.interfaceLanguage));
this.syncRecommendedDictionaryInstallControls(form);
this.syncDictionaryOperationState(form);
}
async mergeDictionaryPreferencesFromSummary(summary) {
const names = summary.dictionaries.map((item) => item.title);
const types = Object.fromEntries(summary.dictionaries.map((item) => [item.title, item.type]));
const merged = mergeDictionaryPreferences(this.settings.dictionaryPreferences, names, types);
if (merged.length === this.settings.dictionaryPreferences.length) return;
this.settings.dictionaryPreferences = merged;
await saveSettings(this.settings);
}
async enqueueDictionaryOperation(form, task) {
this.pendingDictionaryOperations++;
this.syncDictionaryOperationState(form);
const operation = this.dictionaryOperationQueue.then(task);
this.dictionaryOperationQueue = operation.then(() => void 0, () => void 0);
try {
return await operation;
} finally {
this.pendingDictionaryOperations = Math.max(0, this.pendingDictionaryOperations - 1);
this.syncDictionaryOperationState(form);
}
}
syncDictionaryOperationState(form) {
const save = form.querySelector('button[type="submit"]');
const status = form.querySelector("[data-settings-save-status]");
const busy = this.pendingDictionaryOperations > 0;
const message = busy ? formatUiTemplate(uiText(this.settings.interfaceLanguage, "dictionaryImportQueueStatus"), {
count: this.pendingDictionaryOperations.toLocaleString(),
plural: this.pendingDictionaryOperations === 1 ? "" : "s"
}) : "";
if (save) {
save.setAttribute("aria-disabled", String(busy));
save.disabled = busy;
if (busy) {
save.dataset.saveBlocked = "dictionary-import";
save.replaceChildren(uiText(this.settings.interfaceLanguage, "saveAfterInstall"));
save.title = message;
save.setAttribute("aria-label", message);
} else {
delete save.dataset.saveBlocked;
save.replaceChildren(uiText(this.settings.interfaceLanguage, "save"));
save.title = uiText(this.settings.interfaceLanguage, "save");
save.setAttribute("aria-label", uiText(this.settings.interfaceLanguage, "save"));
}
}
if (!status) return;
status.hidden = !busy;
status.textContent = message;
}
showDictionarySaveBlocked(form) {
this.syncDictionaryOperationState(form);
const message = uiText(this.settings.interfaceLanguage, "dictionaryInstallSaveBlocked");
const status = form.querySelector("[data-settings-save-status]");
if (status) {
status.hidden = false;
status.textContent = message;
}
this.dependencies.toast(message);
}
setRecommendedDictionaryInstallState(form, dictionaryId, state, message) {
this.recommendedDictionaryOperations.set(dictionaryId, { state, message });
this.syncRecommendedDictionaryInstallControls(form);
}
clearRecommendedDictionaryInstallState(form, dictionaryId) {
this.recommendedDictionaryOperations.delete(dictionaryId);
this.syncRecommendedDictionaryInstallControls(form);
}
syncRecommendedDictionaryInstallControls(form) {
form.querySelectorAll('[data-action="download-recommended-dictionary"]').forEach((button2) => {
const dictionaryId = button2.dataset.dictionaryId ?? "";
const operation = this.recommendedDictionaryOperations.get(dictionaryId);
const status = button2.closest(".jpdb-reader-recommended-item")?.querySelector("[data-recommended-dictionary-status]");
if (!operation) {
delete button2.dataset.importState;
delete button2.dataset.importMessage;
button2.disabled = false;
button2.removeAttribute("disabled");
if (status) {
status.hidden = true;
status.textContent = "";
delete status.dataset.importState;
}
const installed = button2.dataset.installed === "true";
const label2 = installed ? uiText(this.settings.interfaceLanguage, "update") : uiText(this.settings.interfaceLanguage, "install");
button2.replaceChildren(label2);
button2.title = label2;
button2.setAttribute("aria-label", label2);
return;
}
const label = uiText(this.settings.interfaceLanguage, operation.state === "installing" ? "installing" : "queued");
button2.disabled = true;
button2.dataset.importState = operation.state;
button2.dataset.importMessage = operation.message;
button2.replaceChildren(label);
button2.title = operation.message;
button2.setAttribute("aria-label", operation.message);
if (status) {
status.hidden = false;
status.dataset.importState = operation.state;
status.textContent = operation.message;
}
});
}
async handleSettingsAction(form, action, control) {
const status = form.querySelector("[data-import-status]");
const setStatus = settingsStatusSetter(status);
try {
await this.runSettingsAction(form, action, control, setStatus);
} catch (error) {
const message = handleSettingsActionError(action, control, setStatus, error);
this.dependencies.toast(message);
}
}
async runSettingsAction(form, action, control, setStatus) {
const handled = this.handleSettingsEditorAction(form, action, control) || await this.handleSettingsAudioAction(form, action, control) || await this.handleSettingsDictionaryAction(form, action, control, setStatus) || await this.handleSettingsImportExportAction(form, action, setStatus);
if (!handled) await this.handleSettingsConnectionOrSupportAction(form, action, control, setStatus);
}
async handleSettingsConnectionOrSupportAction(form, action, control, setStatus) {
if (await this.handleSettingsConnectionAction(form, action, control)) return true;
return await this.handleSettingsSupportAction(action, control, setStatus);
}
handleSettingsEditorAction(form, action, control) {
if (action === "settings-panel") {
const panel = selectedSettingsPanel(control);
activateSettingsPanel(form, panel);
return true;
}
if (isDictionarySourceOrderAction(action)) {
updateSourceRowEditor(action, control);
return true;
}
if (isAudioSourceEditorAction(action)) {
updateAudioSourceEditor(form, action, control);
localizeSettingsForm(form, getFormInterfaceLanguage(form, this.settings.interfaceLanguage));
syncBrowserTtsVoiceOptions(form);
return true;
}
if (isLookupLinkEditorAction(action)) {
updateDictionaryLookupLinkEditor(form, action, control);
localizeSettingsForm(form, getFormInterfaceLanguage(form, this.settings.interfaceLanguage));
return true;
}
return false;
}
async handleSettingsAudioAction(form, action, control) {
if (action !== "preview-audio") return false;
const button2 = settingsActionButton(control);
const previous = this.settings;
const previewSettings = readFormSettings(new FormData(form), this.settings);
focusPreviewAudioSource(form, button2, previewSettings);
this.settings = { ...previewSettings, audioEnabled: true, audioViaBlob: true };
button2?.setAttribute("disabled", "true");
try {
this.dependencies.toast("Playing よむ...");
await this.dependencies.audio.play(createAudioPreviewCard(), { userGesture: true });
log$5.info("Audio settings preview started");
} catch (error) {
log$5.warn("Audio settings preview failed", error);
this.dependencies.toast(error instanceof Error ? error.message : "Audio preview failed.");
} finally {
this.settings = previous;
button2?.removeAttribute("disabled");
}
return true;
}
async handleSettingsDictionaryAction(form, action, control, setStatus) {
if (action === "delete-yomitan-dictionary") {
await this.deleteDictionaryFromSettings(form, control, setStatus);
return true;
}
if (action === "import-yomitan-dictionary") {
await this.importDictionaryFromSettings(form, setStatus);
return true;
}
if (action === "download-recommended-dictionary") {
await this.downloadRecommendedDictionaryFromSettings(form, control, setStatus);
return true;
}
if (action === "export-yomitan-dictionary") {
const blob = await this.dependencies.dictionaries.exportJson();
downloadBlob(blob, `yomu-dictionaries-${dateStamp()}.json`);
setStatus("Dictionaries exported.");
log$5.info("Dictionaries exported");
return true;
}
return false;
}
async handleSettingsImportExportAction(form, action, setStatus) {
if (action === "import-yomitan-settings") {
await this.importReaderSettingsFromFile(form, setStatus);
return true;
}
if (action === "export-reader-settings") {
const dictionaries = await this.exportReaderDictionaryBackup();
downloadBlob(new Blob([JSON.stringify({
formatName: "yomu-reader-settings",
formatVersion: 3,
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
settings: this.settings,
storage: await exportManagedStoredValues(),
...dictionaries ? { dictionaries } : {}
}, null, 2)], { type: "application/json" }), `yomu-settings-${dateStamp()}.json`);
setStatus("Settings exported.");
log$5.info("Settings exported");
return true;
}
return false;
}
async exportReaderDictionaryBackup() {
const summary = await this.dependencies.dictionaries.summary().catch(() => ({ dictionaries: [] }));
if (!summary.dictionaries.length) return void 0;
const blob = await this.dependencies.dictionaries.exportJson();
const json = JSON.parse(await blob.text());
return readerDictionaryExportHasData(json) ? json : void 0;
}
async handleSettingsConnectionAction(form, action, control) {
if (action !== "test-anki") return false;
const ankiStatus = form.querySelector("[data-anki-status]");
const language = getFormInterfaceLanguage(form, this.settings.interfaceLanguage);
const button2 = settingsActionButton(control);
const setAnkiStatus = (message, tone) => {
if (!ankiStatus) return;
ankiStatus.textContent = message;
ankiStatus.dataset.statusTone = tone;
};
const previous = this.settings;
this.settings = readFormSettings(new FormData(form), this.settings);
button2?.setAttribute("disabled", "true");
setAnkiStatus(uiText(language, "ankiTesting"), "pending");
try {
if (canUseMobileAnkiHandoff(this.settings)) {
setAnkiStatus(uiText(language, "mobileAnkiReady"), "success");
return true;
}
const connected = await this.dependencies.anki.isConnected();
if (!connected) throw new Error(this.ankiUnreachableMessage(language));
await this.dependencies.anki.ensureDeckAndModel();
setAnkiStatus(this.ankiReadyMessage(language), "success");
log$5.info("Anki settings test succeeded", { deck: this.settings.ankiDeck, model: this.settings.ankiModel });
} catch (error) {
const message = this.ankiConnectionErrorMessage(error, language);
log$5.warn("Anki settings test failed", error);
setAnkiStatus(message, "error");
this.dependencies.toast(message);
} finally {
this.settings = previous;
button2?.removeAttribute("disabled");
}
return true;
}
ankiReadyMessage(language) {
return formatUiTemplate(uiText(language, "ankiConnectedReady"), {
deck: this.settings.ankiDeck,
model: this.settings.ankiModel
});
}
ankiUnreachableMessage(language) {
const message = uiText(language, "ankiUnreachable");
if (!needsHostedAnkiConnectSetupHint(this.settings.ankiConnectUrl)) return message;
return `${message} ${uiText(language, "ankiHostedCorsHint")}`;
}
ankiConnectionErrorMessage(error, language) {
return error instanceof Error ? error.message : uiText(language, "ankiUnreachable");
}
async handleSettingsSupportAction(action, control, setStatus) {
if (action === "copy-newtab-url") {
await copyText(NEW_TAB_PAGE_URL);
this.dependencies.toast("New tab address copied.");
return true;
}
if (action === "factory-reset") {
const button2 = settingsActionButton(control);
button2?.setAttribute("disabled", "true");
try {
await this.dependencies.resetAllData();
} finally {
button2?.removeAttribute("disabled");
}
return true;
}
setStatus("");
return false;
}
async deleteDictionaryFromSettings(form, control, setStatus) {
const dictionary = control?.dataset.dictionaryName;
if (!dictionary) throw new Error("Dictionary not found.");
if (!window.confirm(`Remove "${dictionary}" and all of its imported entries?`)) return;
control?.setAttribute("disabled", "true");
setStatus(`Removing ${dictionary}...`);
await this.dependencies.dictionaries.deleteDictionary(dictionary);
await clearNewTabOfflineCache().catch(() => void 0);
this.settings.dictionaryPreferences = this.settings.dictionaryPreferences.filter((item) => item.name !== dictionary);
await saveSettings(this.settings);
await this.dependencies.refreshDictionaryStyles();
this.dependencies.scheduleDictionaryRescan();
await this.refreshDictionaryStatus(form);
this.dependencies.refreshNewTabIfCurrent();
setStatus(formatUiTemplate(uiText(this.settings.interfaceLanguage, "dictionaryRemoved"), { dictionary }));
log$5.info("Dictionary removed", { dictionary });
}
async importDictionaryFromSettings(form, setStatus) {
const file = await pickFile(form, "dictionary");
if (!file) return;
await this.enqueueDictionaryOperation(form, async () => {
const summary = await this.dependencies.dictionaries.importFile(file, (message) => setStatus(message));
await this.persistDictionaryImport(summary);
setStatus(formatUiTemplate(uiText(this.settings.interfaceLanguage, "dictionaryImportComplete"), {
records: summary.entries.toLocaleString(),
sources: summary.dictionaries.length.toLocaleString(),
plural: summary.dictionaries.length === 1 ? "" : "s"
}));
log$5.info("Dictionary file imported", summary);
await this.refreshDictionaryStatus(form);
this.dependencies.refreshNewTabIfCurrent();
});
}
async downloadRecommendedDictionaryFromSettings(form, control, setStatus) {
const dictionary = recommendedDictionaryForControl(control);
if (this.recommendedDictionaryOperations.has(dictionary.id)) return;
const queuedMessage = formatUiTemplate(uiText(this.settings.interfaceLanguage, "dictionaryInstallQueued"), { dictionary: dictionary.name });
this.setRecommendedDictionaryInstallState(form, dictionary.id, "queued", queuedMessage);
setStatus(queuedMessage);
await this.enqueueDictionaryOperation(form, async () => {
try {
const startedMessage = recommendedDictionaryDownloadStatus(control, dictionary.name);
this.setRecommendedDictionaryInstallState(form, dictionary.id, "installing", startedMessage);
setStatus(startedMessage);
log$5.info("Downloading selected dictionary", { dictionary: dictionary.name });
const summary = await this.downloadRecommendedDictionary(dictionary, control, (message) => {
setStatus(message);
this.setRecommendedDictionaryInstallState(form, dictionary.id, "installing", `${dictionary.name}: ${message}`);
});
if (!summary) return;
await this.persistDictionaryImport(summary);
setStatus(formatUiTemplate(uiText(this.settings.interfaceLanguage, "dictionaryRecordsImported"), {
dictionary: dictionary.name,
records: summary.entries.toLocaleString()
}));
await this.refreshDictionaryStatus(form);
this.dependencies.refreshNewTabIfCurrent();
log$5.info("Selected dictionary downloaded", { dictionary: dictionary.name, entries: summary.entries });
} finally {
this.clearRecommendedDictionaryInstallState(form, dictionary.id);
}
});
}
async persistDictionaryImport(summary) {
this.settings.dictionaryPreferences = mergeDictionaryPreferences(this.settings.dictionaryPreferences, summary.dictionaries, summary.dictionaryTypes ?? {});
this.settings.localDictionariesEnabled = true;
await saveSettings(this.settings);
await this.dependencies.refreshDictionaryStyles();
this.dependencies.scheduleDictionaryRescan();
}
async downloadRecommendedDictionary(dictionary, control, setStatus) {
try {
return await this.dependencies.dictionaries.importFromUrl(dictionary.downloadUrl, recommendedDictionaryFilename(dictionary), (message) => setStatus(message));
} catch (error) {
return this.handleRecommendedDictionaryDownloadError(dictionary, control, setStatus, error);
}
}
handleRecommendedDictionaryDownloadError(dictionary, control, setStatus, error) {
const message = errorMessage(error, uiText(this.settings.interfaceLanguage, "dictionaryDownloadFailed"));
control?.removeAttribute("disabled");
if (!this.shouldPromptManualDictionaryDownload(error, dictionary.downloadUrl)) throw error;
const status = `${message} ${uiText(this.settings.interfaceLanguage, "dictionaryManualDownloadHint")}`;
setStatus(status);
this.dependencies.toast(status);
log$5.warn("Dictionary auto-download unavailable", { dictionary: dictionary.name, message });
return null;
}
shouldPromptManualDictionaryDownload(error, downloadUrl) {
const message = String(error?.message ?? "").toLowerCase();
const manualDownloadHints = [
"blocked in this browser",
"cross-site",
"request bridge",
"request bridge is unavailable",
"userscript bridge",
"needs the yomu userscript",
"needs yomu userscript",
"need the yomu userscript",
"needs the userscript",
"user script request",
"userscript request",
"ブロック",
"リクエストブリッジ",
"ユーザースクリプト"
];
return Boolean(downloadUrl.startsWith("http://") || downloadUrl.startsWith("https://")) && manualDownloadHints.some((hint) => message.includes(hint));
}
async importReaderSettingsFromFile(form, setStatus) {
const file = await pickFile(form, "settings");
if (!file) return;
const json = JSON.parse(await file.text());
const readerSettings = getReaderSettingsExport(json);
this.settings = readerSettings ? normalizeReaderSettings({ ...this.settings, ...readerSettings, shortcuts: { ...this.settings.shortcuts, ...readerSettings.shortcuts } }) : importedYomitanSettings(json, this.settings);
const restoredValues = await importStoredValues(getReaderStorageExport(json));
const dictionarySummary = await this.importReaderDictionaryBackup(json, setStatus);
await this.mergeImportedDictionaryPreferences();
await saveSettings(this.settings);
setStatus(importSettingsStatus(restoredValues, dictionarySummary, this.settings.interfaceLanguage));
this.dependencies.applyTheme();
void this.dependencies.refreshDictionaryStyles();
this.dependencies.scheduleDictionaryRescan();
this.dependencies.installFab();
this.dependencies.subtitles.refresh();
this.dependencies.clearSettingsPreview();
log$5.info("Settings imported", loggingSettingsSummary(this.settings));
this.open();
}
async importReaderDictionaryBackup(json, setStatus) {
const dictionaryExport = getReaderDictionaryExport(json);
if (!readerDictionaryExportHasData(dictionaryExport)) return null;
setStatus(uiText(this.settings.interfaceLanguage, "importingBundledDictionaries"));
const file = new File([JSON.stringify(dictionaryExport)], "yomu-dictionaries-from-settings.json", { type: "application/json" });
const summary = await this.dependencies.dictionaries.importFile(file, (message) => setStatus(message));
await this.persistDictionaryImport(summary);
return summary;
}
async mergeImportedDictionaryPreferences() {
const importedSummary = await this.dependencies.dictionaries.summary().catch(() => ({ dictionaries: [] }));
const importedNames = importedSummary.dictionaries.map((item) => item.title);
const importedTypes = Object.fromEntries(importedSummary.dictionaries.map((item) => [item.title, item.type]));
this.settings.dictionaryPreferences = mergeDictionaryPreferences(this.settings.dictionaryPreferences, importedNames, importedTypes);
}
}
function isDictionarySourceOrderAction(action) {
return action === "dictionary-source-up" || action === "dictionary-source-down";
}
function isAudioSourceEditorAction(action) {
return action === "audio-source-add" || action === "audio-source-remove" || action === "audio-source-up" || action === "audio-source-down";
}
function isLookupLinkEditorAction(action) {
return action === "lookup-link-add" || action === "lookup-link-remove" || action === "lookup-link-up" || action === "lookup-link-down";
}
function getReaderStorageExport(value) {
if (!value || typeof value !== "object") return null;
const record = value;
return record.formatName === "yomu-reader-settings" || record.formatName === "jpdb-popup-reader-settings" ? record.storage : null;
}
function getReaderDictionaryExport(value) {
if (!value || typeof value !== "object") return null;
const record = value;
if (record.formatName !== "yomu-reader-settings" && record.formatName !== "jpdb-popup-reader-settings") return null;
return isReaderDictionaryExport(record.dictionaries) ? record.dictionaries : record.dictionaryData;
}
function readerDictionaryExportHasData(value) {
if (!isReaderDictionaryExport(value)) return false;
const record = value;
return arrayHasItems(record.dictionaries) || arrayHasItems(record.terms) || arrayHasItems(record.kanji) || arrayHasItems(record.termMeta) || arrayHasItems(record.kanjiMeta);
}
function isReaderDictionaryExport(value) {
return Boolean(value && typeof value === "object" && value.formatName === "yomu-yomitan-dictionaries");
}
function arrayHasItems(value) {
return Array.isArray(value) && value.length > 0;
}
function importSettingsStatus(restoredValues, dictionarySummary, language) {
const details = [];
if (restoredValues) {
details.push(formatUiTemplate(uiText(language, "restoredStoredChoices"), {
count: restoredValues.toLocaleString(),
plural: restoredValues === 1 ? "" : "s"
}));
}
if (dictionarySummary) {
details.push(formatUiTemplate(uiText(language, "importedDictionaryRecordCount"), {
count: dictionarySummary.entries.toLocaleString(),
plural: dictionarySummary.entries === 1 ? "" : "s"
}));
}
return details.length ? formatUiTemplate(uiText(language, "settingsImportedWithDetails"), { details: details.join("; ") }) : uiText(language, "settingsImported");
}
function formatUiTemplate(template, values) {
return template.replace(/\{([a-z]+)\}/gi, (_, key) => values[key] ?? "");
}
function importedYomitanSettings(json, current) {
const imported = parseYomitanSettingsExport(json, current.interfaceLanguage);
return normalizeReaderSettings({
...current,
...imported.settings,
shortcuts: {
...current.shortcuts,
...imported.settings.shortcuts ?? {}
}
});
}
const COMMON_EXCLUDE = [
'[role="dialog"]',
'[aria-modal="true"]',
"[data-jpdb-reader-root]",
".jpdb-reader-word"
].join(",");
const ASBPLAYER_ROOT_SELECTOR = ".asbplayer-offscreen, .asbplayer-subtitles-container-bottom";
const DEFAULT_SCAN_TARGET_LIMIT = 2e3;
const GENERIC_PROSE_ROOTS = [
"article",
"main article",
'[role="main"] article',
".article",
".post",
".entry",
".story",
".prose",
".content",
".article-body",
".article-content",
".entry-content",
".post-content",
".story-body",
'[itemprop="articleBody"]'
].join(",");
const GENERIC_PROSE_EXCLUDE = [
COMMON_EXCLUDE,
"nav",
"header",
"footer",
"aside",
"button",
'a[role="button"]',
'[role="complementary"]',
'[class*="audio" i]',
'[class*="aside" i]',
'[class*="banner" i]',
'[class*="breadcrumb" i]',
'[class*="btn" i]',
'[class*="button" i]',
'[class*="card" i]',
'[class*="comment" i]',
'[class*="footer" i]',
'[class*="header" i]',
'[class*="menu" i]',
'[class*="meta" i]',
'[class*="nav" i]',
'[class*="new-article" i]',
'[class*="pager" i]',
'[class*="popular" i]',
'[class*="promo" i]',
'[class*="rank" i]',
'[class*="recommend" i]',
'[class*="related" i]',
'[class*="share" i]',
'[class*="sidebar" i]',
'[class*="sound" i]',
'[class*="speaker" i]',
'[class*="teaser" i]',
'[class*="voice" i]',
'[aria-label*="聞"]',
'[aria-label*="音声"]',
"time"
].join(",");
const SITE_PARSER_PROFILES = [
{
id: "jpdb-parser",
name: "JPDB",
description: "JPDB dictionary, review, and search result Japanese text.",
roots: [
".result.vocabulary",
".result.kanji",
".results .result",
".subsection-meanings",
".subsection-usages",
".subsection-examples",
".subsection-pitch-accent",
".subsection-spelling",
".primary-spelling",
".review-card",
".answer",
".sentence"
],
exclude: [
COMMON_EXCLUDE,
".nav",
".subsection-label",
".vocabulary-audio",
".icon-link",
"[data-audio]"
].join(","),
allowUiText: true,
minLength: 1,
matches: (url) => url.hostname === "jpdb.io" || url.hostname.endsWith(".jpdb.io")
},
{
id: "jisho-parser",
name: "Jisho",
description: "Jisho word, kanji, and example sentence result text.",
roots: [
".concept_light-representation .text",
".concept_light-readings .text",
".japanese_sentence",
".sentence_content",
".kanji_light_content",
".kanji-details__main-readings",
".kanji-details__main-meanings"
],
exclude: [
COMMON_EXCLUDE,
".furigana",
".english",
".debug",
".concept_light-status",
".concept_light-tag",
".concept_light-tags",
".concept_light-common",
".concept_light-readings .furigana",
".meaning-tags",
".meaning-wrapper",
".links",
".result_count"
].join(","),
allowUiText: true,
minLength: 1,
matches: (url) => url.hostname === "jisho.org" || url.hostname.endsWith(".jisho.org")
},
{
id: "luna-translator-parser",
name: "Luna Translator",
description: "Local LunaTranslator transcript windows.",
roots: [".lunatranslator_clickword", ".lunatranslator_text_all", ".origin"],
matches: (url) => url.protocol === "file:" && /LunaTranslator.*(?:mainui|transhist)\.html/i.test(decodeURIComponent(url.pathname))
},
{
id: "texthooker-parser",
name: "Texthooker",
description: "Hooked game text from common texthooker pages.",
roots: ["#textlog", "main", ".textline", ".line_box", ".my-2.cursor-pointer", "p"],
matches: (url) => /^(anacreondjt\.gitlab\.io|learnjapanese\.moe)$/.test(url.hostname) || url.hostname === "renji-xd.github.io" || /\/texthooker\/?$/.test(url.pathname)
},
{
id: "exstatic-parser",
name: "ExStatic",
description: "ExStatic sentence tracker entries.",
roots: [".sentence-entry", "#entry_holder"],
matches: (url) => url.hostname === "kamwithk.github.io" && url.pathname.endsWith("/exSTATic/tracker.html")
},
{
id: "readwok-parser",
name: "Readwok",
description: "Readwok reader paragraphs.",
roots: ['div[class*="styles_paragraph_"]', 'div[class*="styles_reader_"]'],
matches: (url) => url.hostname === "app.readwok.com"
},
{
id: "ttsu-parser",
name: "Ttsu",
description: "Ttsu book reader content.",
roots: ["div.book-content", "div.book-content-container", "#book-content"],
matches: (url) => url.hostname === "reader.ttsu.app"
},
{
id: "youtube-comments-parser",
name: "YouTube watch text",
description: "Japanese video metadata and comments in YouTube watch views.",
roots: [
"ytd-watch-metadata h1",
"ytd-watch-metadata #title",
"ytd-watch-metadata #description",
"ytd-watch-metadata #description-inline-expander",
"ytd-watch-metadata ytd-text-inline-expander",
"ytd-watch-metadata #attributed-snippet-text",
"ytd-comment-view-model",
"#content-text"
],
allowUiText: true,
includeGenericPageText: true,
scanLimit: 80,
matches: (url) => url.hostname === "youtube.com" || url.hostname.endsWith(".youtube.com") || url.hostname === "youtu.be"
},
{
id: "cijapanese-transcript-parser",
name: "Comprehensible Japanese",
description: "Comprehensible Japanese video transcripts with native furigana.",
roots: [
".transcript",
'[data-tab-type="transcript"]'
],
exclude: [
COMMON_EXCLUDE,
".cue-button",
".btn",
"svg"
].join(","),
allowUiText: true,
minLength: 1,
matches: (url) => url.hostname === "cijapanese.com" || url.hostname.endsWith(".cijapanese.com")
},
{
id: "mokuro-parser",
name: "Mokuro",
description: "Mokuro manga text boxes.",
roots: [".textBox", "#manga-panel .textBox", "#pagesContainer .textBox"],
matches: (url) => url.hostname === "reader.mokuro.app" || url.protocol === "file:" && /mokuro/i.test(decodeURIComponent(url.pathname))
},
{
id: "wikipedia-parser",
name: "Japanese Wikipedia",
description: "Japanese Wikipedia article text and previews.",
roots: ["#firstHeading", "#mw-content-text .mw-parser-output > *", ".mwe-popups-extract > *"],
exclude: [
COMMON_EXCLUDE,
".p-lang-btn",
".vector-menu-heading-label",
".vector-toc-toggle",
".vector-page-toolbar",
".mw-editsection",
"sup.reference"
].join(","),
matches: (url) => url.hostname === "ja.wikipedia.org" || url.hostname === "ja.m.wikipedia.org"
},
{
id: "satori-reader-parser",
name: "Satori Reader",
description: "Satori Reader article text.",
roots: ["#article-content"],
exclude: [COMMON_EXCLUDE, ".play-button-container", ".notes-button-container", ".fg", ".wpr"].join(","),
matches: (url) => url.hostname.endsWith(".satorireader.com") && url.pathname.includes("/articles/")
},
{
id: "nhk-parser",
name: "NHK Easy",
description: "NHK Easy visible page text with native ruby.",
roots: [
"body"
],
exclude: [
"#loading",
".article-top-tool",
".article-share"
].join(","),
allowUiText: true,
minLength: 1,
includeUiChrome: true,
fallbackToWholePage: true,
matches: (url) => url.hostname === "news.web.nhk" && /\/news\/easy\//.test(url.pathname) || url.protocol === "file:" && /NHK.*(?:やさしいことば|NEWS WEB EASY)|(?:やさしいことば|NEWS WEB EASY).*NHK/i.test(decodeURIComponent(url.pathname)) || /NHKやさしいことばニュース|NEWS WEB EASY/i.test(document.title)
},
{
id: "nhk-news-parser",
name: "NHK",
description: "NHK article text with native ruby.",
roots: [
"#main article",
"#main",
'[data-testid*="article"]'
],
exclude: [
COMMON_EXCLUDE,
'[class*="related" i]',
'[class*="recommend" i]',
'[class*="ranking" i]',
".soundButton",
".js-sound",
".js-play",
".player",
"[onclick]",
"[data-audio]"
].join(","),
fallbackToWholePage: true,
matches: (url) => (url.hostname === "news.web.nhk" || url.hostname.endsWith(".nhk.or.jp")) && /\/news\/html\//.test(url.pathname)
},
{
id: "bunpro-parser",
name: "Bunpro",
description: "Bunpro graded reader and study sections.",
roots: ["article", "div.mx-auto", '[id^="study-question-"]'],
matches: (url) => url.hostname === "bunpro.jp" || url.hostname.endsWith(".bunpro.jp")
},
{
id: "asbplayer-parser",
name: "asbplayer",
description: "asbplayer subtitle overlays.",
roots: [".asbplayer-offscreen", ".asbplayer-subtitles-container-bottom"],
matches: () => Boolean(document.querySelector(ASBPLAYER_ROOT_SELECTOR))
}
];
function getMatchingSiteParsers(href = window.location.href) {
const url = new URL(href, window.location.href);
return SITE_PARSER_PROFILES.filter((profile) => profile.matches(url));
}
function collectSiteScanTargets(limit = 40, href = window.location.href) {
const profiles = getMatchingSiteParsers(href);
if (!profiles.length) return null;
const context = createSiteScanContext(profiles, limit);
for (const profile of profiles) collectProfileScanTargets(profile, context);
return siteScanResult(profiles, context.targets);
}
function createSiteScanContext(profiles, limit) {
return {
effectiveLimit: effectiveScanTargetLimit(profiles, limit),
targets: [],
seen: /* @__PURE__ */ new Set()
};
}
function collectProfileScanTargets(profile, context) {
for (const root of queryParserRoots(profile)) {
if (!siteScanHasRoom(context)) break;
collectRootScanTargets(profile, root, context);
}
}
function collectRootScanTargets(profile, root, context) {
const collected = collectFragmentTextTargetsIn(root, siteScanRemaining(context), true, profile.exclude ?? COMMON_EXCLUDE, {
allowUiText: profile.allowUiText,
minLength: profile.minLength,
includeUiChrome: profile.includeUiChrome
});
for (const target of collected) {
if (!addUniqueSiteScanTarget(profile, target, context)) continue;
if (!siteScanHasRoom(context)) break;
}
}
function addUniqueSiteScanTarget(profile, target, context) {
const firstNode = target.fragments[0]?.node;
if (!firstNode || context.seen.has(firstNode)) return false;
context.seen.add(firstNode);
context.targets.push({ ...target, parserId: profile.id });
return true;
}
function siteScanRemaining(context) {
return context.effectiveLimit - context.targets.length;
}
function siteScanHasRoom(context) {
return siteScanRemaining(context) > 0;
}
function siteScanResult(profiles, targets) {
if (targets.length) return targets;
return profiles.some((profile) => profile.id !== "asbplayer-parser") ? [] : null;
}
function collectScanTargets(limit = DEFAULT_SCAN_TARGET_LIMIT, href = window.location.href) {
const matchingProfiles = getMatchingSiteParsers(href);
const effectiveLimit = matchingProfiles.length ? effectiveScanTargetLimit(matchingProfiles, limit) : limit;
const siteTargets = completeSiteScanTargets(matchingProfiles, effectiveLimit, href);
if (siteTargets) return siteTargets;
const genericTargets = collectGenericProseTargets(effectiveLimit);
if (genericTargets.length) return genericTargets;
const broadTargets = collectWholePageScanTargets(effectiveLimit);
return broadTargets.length ? broadTargets : collectVisibleTextTargets(effectiveLimit);
}
function completeSiteScanTargets(profiles, limit, href) {
if (!profiles.length) return null;
const siteTargets = collectSiteScanTargets(limit, href) ?? [];
if (siteTargets.length) return siteTargets;
if (hasWholePageFallback(profiles)) {
const broadTargets = collectWholePageScanTargets(limit);
if (broadTargets.length) return broadTargets;
}
return hasGenericPageTextFallback(profiles) ? null : siteTargets;
}
function hasGenericPageTextFallback(profiles) {
return profiles.some((profile) => profile.includeGenericPageText);
}
function hasWholePageFallback(profiles) {
return profiles.some((profile) => profile.fallbackToWholePage);
}
function effectiveScanTargetLimit(profiles, requestedLimit) {
const profileLimit = profiles.reduce((limit, profile) => Math.min(limit, profile.scanLimit ?? limit), requestedLimit);
return Math.max(1, profileLimit);
}
function collectWholePageScanTargets(limit) {
const targets = collectFragmentTextTargetsIn(document.body, limit, true, "", {
allowUiText: true,
includeUiChrome: true,
minLength: 1
});
return targets.map((target) => ({ ...target, parserId: target.parserId ?? "whole-page-parser" }));
}
function collectGenericProseTargets(limit) {
const roots = genericProseRoots();
const collection = { targets: [], seen: /* @__PURE__ */ new Set(), limit };
for (const root of roots) {
collectGenericProseTargetsFromRoot(root, collection);
if (genericProseCollectionFull(collection)) break;
}
return collection.targets;
}
function genericProseRoots() {
return Array.from(document.querySelectorAll(GENERIC_PROSE_ROOTS)).filter((root) => isUsefulGenericProseRoot(root));
}
function collectGenericProseTargetsFromRoot(root, collection) {
const collected = collectFragmentTextTargetsIn(root, genericProseRemaining(collection), true, GENERIC_PROSE_EXCLUDE, { minLength: 2 });
for (const target of collected) {
appendGenericProseTarget(collection.targets, collection.seen, target);
if (genericProseCollectionFull(collection)) break;
}
}
function genericProseRemaining(collection) {
return Math.max(0, collection.limit - collection.targets.length);
}
function genericProseCollectionFull(collection) {
return genericProseRemaining(collection) <= 0;
}
function appendGenericProseTarget(targets, seen, target) {
const firstNode = target.fragments[0]?.node;
if (!firstNode) return false;
if (seen.has(firstNode)) return false;
seen.add(firstNode);
targets.push({ ...target, parserId: "generic-prose-parser" });
return true;
}
function isUsefulGenericProseRoot(root) {
if (root.closest(GENERIC_PROSE_EXCLUDE)) return false;
const text2 = root.textContent?.replace(/\s+/g, "").trim() ?? "";
if (text2.length < 12) return false;
return /[\u3040-\u30ff\u3400-\u9fff]/u.test(text2);
}
function queryParserRoots(profile) {
const roots = [];
for (const selector of profile.roots) {
roots.push(...Array.from(document.querySelectorAll(selector)));
}
const result = uniqueVisibleRoots(roots);
return result;
}
function uniqueVisibleRoots(roots) {
const unique2 = [];
for (const root of roots) {
if (unique2.some((existing) => existing === root || existing.contains(root))) continue;
unique2.push(root);
}
return unique2;
}
const readerCss = `
:root {
--jpdb-reader-bg: #181b20;
--jpdb-reader-surface: #20242b;
--jpdb-reader-surface-2: #282e37;
--jpdb-reader-text: #f2f4f8;
--jpdb-reader-muted: #aab2c0;
--jpdb-reader-faint: #6f7a89;
--jpdb-reader-border: rgba(255,255,255,.12);
--jpdb-reader-accent: #5ea780;
--jpdb-reader-accent-readable: #76bd99;
--jpdb-reader-accent-soft: rgba(94,167,128,.18);
--jpdb-reader-hover: rgba(255,255,255,.08);
--jpdb-reader-font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
@media (prefers-color-scheme: light) {
:root {
--jpdb-reader-bg: #ffffff;
--jpdb-reader-surface: #f7f8fa;
--jpdb-reader-surface-2: #eef1f4;
--jpdb-reader-text: #171a1f;
--jpdb-reader-muted: #596272;
--jpdb-reader-faint: #7b8493;
--jpdb-reader-border: rgba(20,30,45,.16);
--jpdb-reader-accent-readable: #25573d;
--jpdb-reader-hover: rgba(20,30,45,.07);
}
}
.jpdb-reader-theme-dark {
--jpdb-reader-bg: #181b20;
--jpdb-reader-surface: #20242b;
--jpdb-reader-surface-2: #282e37;
--jpdb-reader-text: #f2f4f8;
--jpdb-reader-muted: #aab2c0;
--jpdb-reader-faint: #6f7a89;
--jpdb-reader-border: rgba(255,255,255,.12);
--jpdb-reader-accent-readable: #76bd99;
--jpdb-reader-hover: rgba(255,255,255,.08);
}
.jpdb-reader-theme-light {
--jpdb-reader-bg: #ffffff;
--jpdb-reader-surface: #f7f8fa;
--jpdb-reader-surface-2: #eef1f4;
--jpdb-reader-text: #171a1f;
--jpdb-reader-muted: #596272;
--jpdb-reader-faint: #7b8493;
--jpdb-reader-border: rgba(20,30,45,.16);
--jpdb-reader-accent-readable: #25573d;
--jpdb-reader-hover: rgba(20,30,45,.07);
}
.jpdb-reader-middle-scan-active {
overscroll-behavior: none;
cursor: crosshair;
}
.jpdb-reader-middle-scan-active * {
cursor: crosshair !important;
}
[data-jpdb-reader-root],
[data-jpdb-reader-root] * {
box-sizing: border-box;
}
[data-jpdb-reader-root] :where(a) {
border-bottom: 0;
color: inherit;
outline: revert;
text-decoration: none;
}
[data-jpdb-reader-root] :where(button) {
appearance: none;
-webkit-appearance: none;
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
color: inherit;
cursor: pointer;
display: inline-block;
font: inherit;
height: auto;
line-height: normal;
margin: 0;
padding: 0;
text-align: inherit;
text-decoration: none;
transform: none;
transition: none;
white-space: normal;
}
[data-jpdb-reader-root] :where(input, select, textarea) {
appearance: auto;
-webkit-appearance: auto;
background: revert;
border: revert;
border-radius: revert;
box-shadow: none;
color: inherit;
font: inherit;
height: auto;
margin: 0;
padding: revert;
transform: none;
transition: none;
width: auto;
}
[data-jpdb-reader-root] :where(fieldset, legend, p, ul, ol, li, dl, dt, dd, blockquote, figure, form, table, th, td, hr, h1, h2, h3, h4, h5, h6) {
letter-spacing: normal;
line-height: revert;
margin: revert;
padding: revert;
}
[data-jpdb-reader-root] :where(table) {
border-collapse: revert;
border-spacing: revert;
}
[data-jpdb-reader-root] :where(th, td) {
border: revert;
text-align: revert;
}
[data-jpdb-reader-root] :where(rt) {
font-size: revert;
opacity: revert;
}
[data-jpdb-reader-root] button,
[data-jpdb-reader-root] input,
[data-jpdb-reader-root] select,
[data-jpdb-reader-root] textarea {
font-family: var(--jpdb-reader-font);
letter-spacing: 0;
text-transform: none;
}
.jpdb-reader-word {
--jpdb-reader-word-underline: transparent;
--jpdb-reader-source-status-color: var(--jpdb-reader-status-color, currentColor);
--jpdb-reader-source-status-decoration: var(--jpdb-reader-status-color, transparent);
--jpdb-reader-source-status-soft: var(--jpdb-reader-status-soft, transparent);
--jpdb-reader-source-jpdb-color: var(--jpdb-reader-jpdb-color, currentColor);
--jpdb-reader-source-jpdb-decoration: var(--jpdb-reader-jpdb-color, transparent);
--jpdb-reader-source-jpdb-soft: var(--jpdb-reader-jpdb-soft, transparent);
--jpdb-reader-source-anki-color: var(--jpdb-reader-anki-color, currentColor);
--jpdb-reader-source-anki-decoration: var(--jpdb-reader-anki-color, transparent);
--jpdb-reader-source-anki-soft: var(--jpdb-reader-anki-soft, transparent);
--jpdb-reader-source-pitch-color: var(--jpdb-reader-pitch-color, currentColor);
--jpdb-reader-source-pitch-decoration: var(--jpdb-reader-pitch-color, var(--jpdb-reader-pitch-unknown, #94a3b8));
--jpdb-reader-source-pitch-soft: var(--jpdb-reader-pitch-soft, var(--jpdb-reader-pitch-unknown-soft, transparent));
position: static;
display: inline;
white-space: nowrap;
word-break: keep-all;
overflow-wrap: normal !important;
border-radius: 3px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
text-decoration-line: underline !important;
text-decoration-style: solid !important;
text-decoration-color: var(
--jpdb-reader-word-underline,
transparent
) !important;
text-decoration-thickness: 2px !important;
text-underline-offset: 0.16em !important;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
.jpdb-reader-word::after {
content: none;
}
.jpdb-reader-word:hover,
.jpdb-reader-word:focus {
background: var(--jpdb-reader-hover) !important;
outline: none;
}
.jpdb-reader-word:is(.jpdb-new, .jpdb-suspended, .jpdb-not-in-deck) {
--jpdb-reader-jpdb-color: var(--jpdb-reader-state-new, #58a6ff);
--jpdb-reader-jpdb-soft: var(--jpdb-reader-state-new-soft, rgba(88, 166, 255, 0.16));
}
.jpdb-reader-word.jpdb-learning {
--jpdb-reader-jpdb-color: var(--jpdb-reader-state-learning, #ffd166);
--jpdb-reader-jpdb-soft: var(--jpdb-reader-state-learning-soft, rgba(255, 209, 102, 0.16));
}
.jpdb-reader-word:is(.jpdb-known, .jpdb-never-forget, .jpdb-redundant) {
--jpdb-reader-jpdb-color: var(--jpdb-reader-state-known, #7bd88f);
--jpdb-reader-jpdb-soft: var(--jpdb-reader-state-known-soft, rgba(123, 216, 143, 0.16));
}
.jpdb-reader-word.jpdb-due {
--jpdb-reader-jpdb-color: var(--jpdb-reader-state-due, #5fb3b3);
--jpdb-reader-jpdb-soft: var(--jpdb-reader-state-due-soft, rgba(95, 179, 179, 0.16));
}
.jpdb-reader-word.jpdb-failed {
--jpdb-reader-jpdb-color: var(--jpdb-reader-state-failed, #ff6b6b);
--jpdb-reader-jpdb-soft: var(--jpdb-reader-state-failed-soft, rgba(255, 107, 107, 0.16));
}
.jpdb-reader-word:is(.jpdb-blacklisted, .jpdb-locked) {
--jpdb-reader-jpdb-color: var(--jpdb-reader-state-ignored, #b8a7ff);
--jpdb-reader-jpdb-soft: var(--jpdb-reader-state-ignored-soft, rgba(184, 167, 255, 0.16));
--jpdb-reader-status-color: var(--jpdb-reader-state-ignored, #b8a7ff);
--jpdb-reader-status-soft: var(--jpdb-reader-state-ignored-soft, rgba(184, 167, 255, 0.16));
opacity: 0.82 !important;
}
.jpdb-reader-word:is(.anki-new, .anki-suspended) {
--jpdb-reader-anki-color: var(--jpdb-reader-state-new, #58a6ff);
--jpdb-reader-anki-soft: var(--jpdb-reader-state-new-soft, rgba(88, 166, 255, 0.16));
}
.jpdb-reader-word.anki-learning {
--jpdb-reader-anki-color: var(--jpdb-reader-state-learning, #ffd166);
--jpdb-reader-anki-soft: var(--jpdb-reader-state-learning-soft, rgba(255, 209, 102, 0.16));
}
.jpdb-reader-word.anki-known {
--jpdb-reader-anki-color: var(--jpdb-reader-state-known, #7bd88f);
--jpdb-reader-anki-soft: var(--jpdb-reader-state-known-soft, rgba(123, 216, 143, 0.16));
}
.jpdb-reader-word.anki-due {
--jpdb-reader-anki-color: var(--jpdb-reader-state-due, #5fb3b3);
--jpdb-reader-anki-soft: var(--jpdb-reader-state-due-soft, rgba(95, 179, 179, 0.16));
}
.jpdb-reader-word.anki-failed {
--jpdb-reader-anki-color: var(--jpdb-reader-state-failed, #ff6b6b);
--jpdb-reader-anki-soft: var(--jpdb-reader-state-failed-soft, rgba(255, 107, 107, 0.16));
}
.jpdb-reader-word:is(.jpdb-new, .jpdb-suspended, .jpdb-not-in-deck, .anki-new, .anki-suspended) {
--jpdb-reader-status-color: var(--jpdb-reader-state-new, #58a6ff);
--jpdb-reader-status-soft: var(--jpdb-reader-state-new-soft, rgba(88, 166, 255, 0.16));
}
.jpdb-reader-word:is(.jpdb-learning, .anki-learning) {
--jpdb-reader-status-color: var(--jpdb-reader-state-learning, #ffd166);
--jpdb-reader-status-soft: var(--jpdb-reader-state-learning-soft, rgba(255, 209, 102, 0.16));
}
.jpdb-reader-word:is(.jpdb-known, .jpdb-never-forget, .jpdb-redundant, .anki-known) {
--jpdb-reader-status-color: var(--jpdb-reader-state-known, #7bd88f);
--jpdb-reader-status-soft: var(--jpdb-reader-state-known-soft, rgba(123, 216, 143, 0.16));
}
.jpdb-reader-word:is(.jpdb-due, .anki-due) {
--jpdb-reader-status-color: var(--jpdb-reader-state-due, #5fb3b3);
--jpdb-reader-status-soft: var(--jpdb-reader-state-due-soft, rgba(95, 179, 179, 0.16));
}
.jpdb-reader-word:is(.jpdb-failed, .anki-failed) {
--jpdb-reader-status-color: var(--jpdb-reader-state-failed, #ff6b6b);
--jpdb-reader-status-soft: var(--jpdb-reader-state-failed-soft, rgba(255, 107, 107, 0.16));
}
.jpdb-reader-word.jpdb-pitch-heiban {
--jpdb-reader-pitch-color: var(--jpdb-reader-pitch-heiban, #359eff);
--jpdb-reader-pitch-soft: var(--jpdb-reader-pitch-heiban-soft, rgba(53, 158, 255, 0.14));
}
.jpdb-reader-word.jpdb-pitch-atamadaka {
--jpdb-reader-pitch-color: var(--jpdb-reader-pitch-atamadaka, #fe4b74);
--jpdb-reader-pitch-soft: var(--jpdb-reader-pitch-atamadaka-soft, rgba(254, 75, 116, 0.14));
}
.jpdb-reader-word.jpdb-pitch-nakadaka {
--jpdb-reader-pitch-color: var(--jpdb-reader-pitch-nakadaka, #fba840);
--jpdb-reader-pitch-soft: var(--jpdb-reader-pitch-nakadaka-soft, rgba(251, 168, 64, 0.16));
}
.jpdb-reader-word.jpdb-pitch-odaka {
--jpdb-reader-pitch-color: var(--jpdb-reader-pitch-odaka, #57ccb7);
--jpdb-reader-pitch-soft: var(--jpdb-reader-pitch-odaka-soft, rgba(87, 204, 183, 0.14));
}
.jpdb-reader-word.jpdb-pitch-kifuku {
--jpdb-reader-pitch-color: var(--jpdb-reader-pitch-kifuku, #9050f6);
--jpdb-reader-pitch-soft: var(--jpdb-reader-pitch-kifuku-soft, rgba(144, 80, 246, 0.14));
}
.jpdb-reader-word-highlight-status { --jpdb-reader-word-highlight: var(--jpdb-reader-source-status-soft, transparent); }
.jpdb-reader-word-highlight-jpdb { --jpdb-reader-word-highlight: var(--jpdb-reader-source-jpdb-soft, transparent); }
.jpdb-reader-word-highlight-anki { --jpdb-reader-word-highlight: var(--jpdb-reader-source-anki-soft, transparent); }
.jpdb-reader-word-highlight-pitch { --jpdb-reader-word-highlight: var(--jpdb-reader-source-pitch-soft, transparent); }
.jpdb-reader-word-underline-status { --jpdb-reader-word-decoration: var(--jpdb-reader-source-status-decoration, transparent); }
.jpdb-reader-word-underline-jpdb { --jpdb-reader-word-decoration: var(--jpdb-reader-source-jpdb-decoration, transparent); }
.jpdb-reader-word-underline-anki { --jpdb-reader-word-decoration: var(--jpdb-reader-source-anki-decoration, transparent); }
.jpdb-reader-word-underline-pitch { --jpdb-reader-word-decoration: var(--jpdb-reader-source-pitch-decoration, transparent); }
.jpdb-reader-word-text-status { --jpdb-reader-word-color: var(--jpdb-reader-source-status-color, currentColor); }
.jpdb-reader-word-text-jpdb { --jpdb-reader-word-color: var(--jpdb-reader-source-jpdb-color, currentColor); }
.jpdb-reader-word-text-anki { --jpdb-reader-word-color: var(--jpdb-reader-source-anki-color, currentColor); }
.jpdb-reader-word-text-pitch { --jpdb-reader-word-color: var(--jpdb-reader-source-pitch-color, currentColor); }
.jpdb-reader-word-highlight-status .jpdb-reader-word { background: var(--jpdb-reader-source-status-soft, transparent) !important; }
.jpdb-reader-word-highlight-jpdb .jpdb-reader-word { background: var(--jpdb-reader-source-jpdb-soft, transparent) !important; }
.jpdb-reader-word-highlight-anki .jpdb-reader-word { background: var(--jpdb-reader-source-anki-soft, transparent) !important; }
.jpdb-reader-word-highlight-pitch .jpdb-reader-word { background: var(--jpdb-reader-source-pitch-soft, transparent) !important; }
.jpdb-reader-word-underline-status .jpdb-reader-word { --jpdb-reader-word-underline: var(--jpdb-reader-source-status-decoration, transparent); }
.jpdb-reader-word-underline-jpdb .jpdb-reader-word { --jpdb-reader-word-underline: var(--jpdb-reader-source-jpdb-decoration, transparent); }
.jpdb-reader-word-underline-anki .jpdb-reader-word { --jpdb-reader-word-underline: var(--jpdb-reader-source-anki-decoration, transparent); }
.jpdb-reader-word-underline-pitch .jpdb-reader-word { --jpdb-reader-word-underline: var(--jpdb-reader-source-pitch-decoration, transparent); }
.jpdb-reader-word-text-status .jpdb-reader-word { color: var(--jpdb-reader-source-status-color, currentColor) !important; }
.jpdb-reader-word-text-jpdb .jpdb-reader-word { color: var(--jpdb-reader-source-jpdb-color, currentColor) !important; }
.jpdb-reader-word-text-anki .jpdb-reader-word { color: var(--jpdb-reader-source-anki-color, currentColor) !important; }
.jpdb-reader-word-text-pitch .jpdb-reader-word { color: var(--jpdb-reader-source-pitch-color, currentColor) !important; }
:is(.jpdb-reader-word-highlight-pitch, .jpdb-reader-word-underline-pitch, .jpdb-reader-word-text-pitch) .jpdb-reader-word {
opacity: 1 !important;
}
.jpdb-reader-word.jpdb-reader-has-furi {
line-height: 1.85;
}
.jpdb-reader-furi {
font-size: 0.52em;
color: var(--jpdb-reader-muted);
line-height: 1;
user-select: none;
}
.jpdb-reader-word ruby {
position: static;
display: ruby;
ruby-align: center;
ruby-position: over;
line-height: 1;
vertical-align: baseline;
text-decoration-line: inherit !important;
text-decoration-style: inherit !important;
text-decoration-color: inherit !important;
text-decoration-thickness: inherit !important;
text-underline-offset: inherit !important;
}
.jpdb-reader-word rp {
display: none;
}
.jpdb-reader-word rt.jpdb-reader-furi {
position: static;
left: auto;
bottom: auto;
display: ruby-text;
transform: none;
white-space: nowrap;
pointer-events: none;
text-decoration: none !important;
ruby-align: center;
line-height: 1;
text-align: center;
}
.jpdb-reader-hide-known
.jpdb-reader-word:is(.jpdb-known, .jpdb-due, .jpdb-never-forget)
.jpdb-reader-furi {
display: none;
}
.jpdb-ocr-layer {
position: fixed;
z-index: 2147483643;
pointer-events: none;
box-sizing: border-box;
contain: layout style;
}
.jpdb-ocr-status,
.jpdb-ocr-line {
pointer-events: auto;
box-sizing: border-box;
font-family: var(--jpdb-reader-font);
-webkit-tap-highlight-color: transparent;
}
.jpdb-ocr-status {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(24, 27, 32, 0.82);
color: rgba(255, 255, 255, 0.88);
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.24);
font-size: 11px;
font-weight: 700;
}
.jpdb-ocr-line {
position: absolute;
display: flex;
align-items: flex-end;
justify-content: center;
overflow: visible;
min-width: 0;
min-height: 0;
padding: var(--jpdb-ocr-pad-top, 3px) var(--jpdb-ocr-pad-x, 5px)
var(--jpdb-ocr-pad-bottom, 3px);
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: transparent;
text-shadow: none;
font-weight: 800;
line-height: 1;
white-space: pre-wrap;
overflow-wrap: anywhere;
box-shadow: none;
opacity: 1;
user-select: text;
cursor: text;
}
.jpdb-ocr-line-text {
display: inline-flex;
flex: none;
justify-content: center;
align-items: flex-end;
align-content: flex-end;
flex-wrap: nowrap;
white-space: nowrap;
max-width: none;
overflow-wrap: normal;
}
.jpdb-ocr-line[data-vertical="true"] {
align-items: center;
justify-content: center;
letter-spacing: 0;
}
.jpdb-ocr-line[data-vertical="true"] .jpdb-ocr-line-text {
white-space: normal;
flex-wrap: wrap;
}
.jpdb-ocr-line-visible {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(24, 27, 32, 0.14);
box-shadow:
0 6px 16px rgba(0, 0, 0, 0.2),
inset 0 0 0 1px rgba(255, 255, 255, 0.04);
}
.jpdb-ocr-line:hover,
.jpdb-ocr-line:focus,
.jpdb-ocr-line.jpdb-ocr-line-active {
color: var(--jpdb-ocr-text-color, #fff);
text-shadow:
0 2px 2px var(--jpdb-ocr-outline-color, #000),
0 0 3px var(--jpdb-ocr-outline-color, #000),
0 0 10px var(--jpdb-ocr-outline-color, #000);
background: var(--jpdb-ocr-background-active-rgba, rgba(24, 27, 32, 0.48));
border-color: rgba(255, 255, 255, 0.18);
box-shadow:
0 10px 24px rgba(0, 0, 0, 0.24),
inset 0 0 0 1px rgba(255, 255, 255, 0.05);
outline: none;
z-index: 2;
}
.jpdb-ocr-line .jpdb-reader-word {
background: transparent !important;
--jpdb-reader-word-underline: transparent;
text-decoration: none !important;
color: inherit !important;
pointer-events: none;
cursor: pointer;
line-height: 1 !important;
}
.jpdb-ocr-line .jpdb-reader-word.jpdb-reader-has-furi {
line-height: 1 !important;
}
.jpdb-ocr-line[data-has-furi="true"] .jpdb-reader-word {
display: inline-flex;
align-items: flex-end;
}
.jpdb-ocr-line .jpdb-ocr-plain {
display: inline-flex;
align-items: flex-end;
line-height: 1;
white-space: pre;
}
.jpdb-ocr-line:hover .jpdb-reader-word,
.jpdb-ocr-line:focus .jpdb-reader-word,
.jpdb-ocr-line.jpdb-ocr-line-active .jpdb-reader-word {
pointer-events: auto;
}
.jpdb-ocr-ruby {
position: relative;
display: inline-flex;
align-items: flex-end;
justify-content: center;
padding-top: 0.5em;
line-height: 1;
vertical-align: baseline;
}
.jpdb-ocr-ruby-base {
display: inline-flex;
align-items: flex-end;
line-height: 1;
}
.jpdb-ocr-furi {
position: absolute;
top: 0;
left: 50%;
color: currentColor;
font-size: 0.42em;
line-height: 1;
opacity: 0;
pointer-events: none;
text-align: center;
text-shadow: none;
transform: translateX(-50%);
white-space: nowrap;
}
.jpdb-ocr-line:is(:hover, :focus, .jpdb-ocr-line-active)
.jpdb-reader-word.jpdb-new,
.jpdb-ocr-line:is(:hover, :focus, .jpdb-ocr-line-active)
.jpdb-reader-word.jpdb-suspended,
.jpdb-ocr-line:is(:hover, :focus, .jpdb-ocr-line-active)
.jpdb-reader-word.jpdb-not-in-deck {
color: var(--jpdb-reader-state-new, #58a6ff) !important;
}
.jpdb-ocr-line:is(:hover, :focus, .jpdb-ocr-line-active)
.jpdb-reader-word.jpdb-learning {
color: var(--jpdb-reader-state-learning, #ffd166) !important;
}
.jpdb-ocr-line:is(:hover, :focus, .jpdb-ocr-line-active)
.jpdb-reader-word.jpdb-known,
.jpdb-ocr-line:is(:hover, :focus, .jpdb-ocr-line-active)
.jpdb-reader-word.jpdb-never-forget,
.jpdb-ocr-line:is(:hover, :focus, .jpdb-ocr-line-active)
.jpdb-reader-word.jpdb-redundant {
color: var(--jpdb-reader-state-known, #7bd88f) !important;
}
.jpdb-ocr-line:is(:hover, :focus, .jpdb-ocr-line-active)
.jpdb-reader-word.jpdb-due {
color: var(--jpdb-reader-state-due, #5fb3b3) !important;
}
.jpdb-ocr-line:is(:hover, :focus, .jpdb-ocr-line-active)
.jpdb-reader-word.jpdb-failed {
color: var(--jpdb-reader-state-failed, #ff6b6b) !important;
}
.jpdb-ocr-line:is(:hover, :focus, .jpdb-ocr-line-active)
.jpdb-reader-word.jpdb-blacklisted,
.jpdb-ocr-line:is(:hover, :focus, .jpdb-ocr-line-active)
.jpdb-reader-word.jpdb-locked {
color: var(--jpdb-reader-state-ignored, #b8a7ff) !important;
}
.jpdb-ocr-line:is(:hover, :focus, .jpdb-ocr-line-active) .jpdb-ocr-furi {
opacity: 0.9;
text-shadow:
0 1px 1px var(--jpdb-ocr-outline-color, #000),
0 0 5px var(--jpdb-ocr-outline-color, #000);
}
.asbplayer-subtitles-container-bottom {
z-index: 2147483644 !important;
}
.asbplayer-subtitles-container-bottom .jpdb-reader-word {
--jpdb-reader-subtitle-fallback: currentColor;
background: transparent !important;
--jpdb-reader-word-underline: transparent;
text-decoration-line: underline !important;
text-decoration-style: solid !important;
text-decoration-color: var(
--jpdb-reader-word-underline,
transparent
) !important;
text-decoration-thickness: 0.08em !important;
text-underline-offset: 0.15em !important;
color: inherit !important;
}
.jpdb-reader-fab {
position: fixed;
right: max(14px, env(safe-area-inset-right));
bottom: max(14px, env(safe-area-inset-bottom));
z-index: 2147483645;
min-width: 52px;
width: auto;
height: 52px;
padding: 0 13px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 50%;
background: var(--jpdb-reader-surface);
color: var(--jpdb-reader-text);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.25);
opacity: 0.78;
font: 700 14px/1 var(--jpdb-reader-font);
cursor: pointer;
touch-action: none;
transform: translate3d(0, 0, 0);
}
.jpdb-reader-fab:hover,
.jpdb-reader-fab:focus-visible {
border-color: var(--jpdb-reader-accent);
color: var(--jpdb-reader-accent);
opacity: 1;
outline: none;
}
.jpdb-reader-fab-over-video:not(:hover):not(:focus-visible) {
opacity: 0.28;
transform: translate3d(0, 6px, 0) scale(0.92);
}
.jpdb-reader-backdrop {
position: fixed;
inset: 0;
z-index: 2147483646;
background: rgba(12, 16, 22, 0.74);
}
.jpdb-reader-popover,
.jpdb-reader-settings {
position: fixed;
z-index: 2147483647;
box-sizing: border-box;
background: var(--jpdb-reader-bg);
border: 1px solid var(--jpdb-reader-border);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.34);
color: var(--jpdb-reader-text);
color-scheme: light dark;
font: 14px/1.45 var(--jpdb-reader-font);
}
.jpdb-reader-popover {
width: min(520px, calc(100vw - 16px));
max-height: min(580px, calc(100vh - 16px));
overflow: auto;
overflow-anchor: none;
padding: 14px;
container-type: inline-size;
scrollbar-gutter: stable;
}
.jpdb-reader-popover.jpdb-reader-sheet {
left: 0 !important;
right: 0 !important;
top: auto !important;
bottom: 0 !important;
width: 100%;
height: var(--jpdb-reader-sheet-height, var(--jpdb-reader-sheet-collapsed-height, 70dvh));
min-height: var(--jpdb-reader-sheet-min-height, 180px);
max-height: var(--jpdb-reader-sheet-viewport-height, 100dvh);
border-radius: 16px 16px 0 0;
padding: 14px 16px calc(18px + env(safe-area-inset-bottom));
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.jpdb-reader-popover.jpdb-reader-sheet.jpdb-reader-sheet-resizing {
user-select: none;
}
.jpdb-reader-popover.jpdb-reader-sheet:has(.jpdb-reader-popover-body) {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
padding: 0;
}
.jpdb-reader-popover.jpdb-reader-sheet:has(.jpdb-reader-popover-body) .jpdb-reader-popover-body {
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
padding: 14px 16px;
scrollbar-gutter: stable;
-webkit-overflow-scrolling: touch;
}
.jpdb-reader-popover.jpdb-reader-sheet:has(.jpdb-reader-popover-body) .jpdb-reader-actions {
padding: 6px 16px calc(18px + env(safe-area-inset-bottom));
}
.jpdb-reader-popover.jpdb-reader-sheet:has(.jpdb-reader-popover-body) .jpdb-reader-actions.jpdb-reader-actions-has-mining {
padding-top: 37px;
}
.jpdb-reader-popover.jpdb-reader-sheet.jpdb-reader-sheet-expanded {
border-radius: 0;
padding-top: max(8px, env(safe-area-inset-top));
}
.jpdb-reader-sheet .jpdb-reader-sheet-handle {
display: block;
}
.jpdb-reader-popover.jpdb-reader-sheet > .jpdb-reader-sheet-handle {
width: 100%;
margin: 0;
}
.jpdb-reader-sheet-close {
position: absolute;
top: 8px;
right: max(12px, env(safe-area-inset-right));
z-index: 2;
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 999px;
background: color-mix(in srgb, var(--jpdb-reader-surface, #20242b) 88%, transparent);
color: var(--jpdb-reader-text);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.24);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.jpdb-reader-sheet-close:hover,
.jpdb-reader-sheet-close:focus-visible {
border-color: var(--jpdb-reader-accent);
color: var(--jpdb-reader-accent);
outline: none;
}
.jpdb-reader-sheet-close svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
}
:root.jpdb-reader-theme-dark .jpdb-reader-popover,
:root.jpdb-reader-theme-dark .jpdb-reader-settings,
:root.jpdb-reader-theme-dark .jpdb-reader-onboarding {
color-scheme: dark;
}
:root.jpdb-reader-theme-light .jpdb-reader-popover,
:root.jpdb-reader-theme-light .jpdb-reader-settings,
:root.jpdb-reader-theme-light .jpdb-reader-onboarding {
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not(.jpdb-reader-theme-light) .jpdb-reader-popover,
:root:not(.jpdb-reader-theme-light) .jpdb-reader-settings,
:root:not(.jpdb-reader-theme-light) .jpdb-reader-onboarding {
color-scheme: dark;
}
}
@media (prefers-color-scheme: light) {
:root:not(.jpdb-reader-theme-dark) .jpdb-reader-popover,
:root:not(.jpdb-reader-theme-dark) .jpdb-reader-settings,
:root:not(.jpdb-reader-theme-dark) .jpdb-reader-onboarding {
color-scheme: light;
}
}
.jpdb-reader-sheet-handle {
display: none;
position: relative;
width: calc(100% + 32px);
height: 46px;
border-bottom: 1px solid var(--jpdb-reader-border);
border-radius: 16px 16px 0 0;
background: color-mix(
in srgb,
var(--jpdb-reader-surface, #20242b) 72%,
transparent
);
margin: -14px -16px 10px;
cursor: grab;
touch-action: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.jpdb-reader-sheet-handle::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 46px;
height: 5px;
border-radius: 999px;
background: var(--jpdb-reader-faint);
transform: translate(-50%, -50%);
}
.jpdb-reader-sheet-handle:active {
cursor: grabbing;
}
.jpdb-reader-sheet-handle:hover::before,
.jpdb-reader-sheet-handle:focus-visible::before {
background: var(--jpdb-reader-accent);
}
.jpdb-reader-header {
display: flex;
align-items: flex-start;
gap: 10px;
}
.jpdb-reader-heading {
min-width: 0;
flex: 1 1 auto;
}
.jpdb-reader-card-tools {
display: flex;
align-items: flex-start;
gap: 8px;
margin-left: auto;
}
.jpdb-reader-icon-btn {
position: relative;
display: inline-grid;
place-items: center;
width: 36px !important;
min-width: 36px !important;
max-width: 36px !important;
height: 36px !important;
min-height: 36px !important;
max-height: 36px !important;
flex: 0 0 auto;
padding: 0 !important;
border: 1px solid var(--jpdb-reader-border);
border-radius: 50%;
background: var(--jpdb-reader-surface);
color: var(--jpdb-reader-text);
cursor: pointer;
overflow: hidden;
transform: translateY(-0.01rem);
-webkit-tap-highlight-color: transparent;
}
.jpdb-reader-icon-btn:hover,
.jpdb-reader-icon-btn:focus-visible {
border-color: var(--jpdb-reader-accent);
box-shadow: 0 8px 18px
color-mix(in srgb, var(--jpdb-reader-accent) 26%, transparent);
color: var(--jpdb-reader-accent);
transform: translateY(-0.25rem);
outline: none;
}
.jpdb-reader-icon-btn:active {
transform: scale(0.98);
}
.jpdb-reader-onboarding {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 2147483647;
box-sizing: border-box;
width: min(760px, calc(100vw - 24px));
max-height: min(760px, calc(100vh - 24px));
overflow: auto;
padding: 54px 32px 32px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 16px;
background:
radial-gradient(
circle at 18% 0%,
var(--jpdb-reader-accent-soft),
transparent 34%
),
var(--jpdb-reader-bg);
color: var(--jpdb-reader-text);
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.4);
color-scheme: light dark;
font: 15px/1.5 var(--jpdb-reader-font);
}
.jpdb-reader-onboarding-close {
position: absolute;
top: 14px;
right: 14px;
}
.jpdb-reader-onboarding h2 {
margin: 4px 0 10px;
color: var(--jpdb-reader-text);
font-size: clamp(38px, 8vw, 72px);
line-height: 0.95;
letter-spacing: 0;
}
.jpdb-reader-onboarding p {
max-width: 620px;
margin: 0;
color: var(--jpdb-reader-muted);
}
.jpdb-reader-onboarding-eyebrow {
color: var(--jpdb-reader-accent);
font-size: 11px;
font-weight: 850;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.jpdb-reader-onboarding-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin: 24px 0;
}
.jpdb-reader-onboarding-grid div {
display: grid;
gap: 5px;
min-height: 96px;
padding: 14px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 8px;
background: var(--jpdb-reader-surface);
}
.jpdb-reader-onboarding-grid strong {
color: var(--jpdb-reader-text);
font-size: 16px;
}
.jpdb-reader-onboarding-grid span {
color: var(--jpdb-reader-muted);
font-size: 13px;
}
.jpdb-reader-onboarding-language {
display: grid;
gap: 6px;
max-width: 280px;
margin: 0 0 16px;
color: var(--jpdb-reader-muted);
font-weight: 750;
font-size: 13px;
}
.jpdb-reader-onboarding-language select {
width: 100%;
box-sizing: border-box;
min-height: 42px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 8px;
background: var(--jpdb-reader-surface);
color: var(--jpdb-reader-text);
padding: 8px 10px;
font: 750 14px/1.2 var(--jpdb-reader-font);
}
.jpdb-reader-onboarding-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
.jpdb-reader-onboarding-actions .jpdb-reader-btn {
min-width: 150px;
min-height: 46px;
}
.jpdb-reader-icon-btn svg {
width: 20px !important;
height: 20px !important;
max-width: 20px !important;
max-height: 20px !important;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
@keyframes jpdb-reader-audio-loading-a {
0% {
left: 0;
}
25% {
left: 0;
}
75% {
left: 100%;
}
100% {
left: 100%;
}
}
@keyframes jpdb-reader-audio-loading-b {
0% {
right: 100%;
}
50% {
right: 0;
}
100% {
right: 0;
}
}
.jpdb-reader-popover[data-audio-loading="true"] .jpdb-reader-audio-control {
border-color: color-mix(
in srgb,
var(--jpdb-reader-accent) 62%,
var(--jpdb-reader-border)
);
color: var(--jpdb-reader-accent);
cursor: progress;
}
.jpdb-reader-popover[data-audio-loading="true"]
.jpdb-reader-audio-control::before,
.jpdb-reader-popover[data-audio-loading="true"]
.jpdb-reader-audio-control::after {
content: "";
position: absolute;
left: 0;
right: 100%;
bottom: 0;
height: 3px;
background: var(--jpdb-reader-accent);
pointer-events: none;
}
.jpdb-reader-popover[data-audio-loading="true"]
.jpdb-reader-audio-control::before {
animation:
jpdb-reader-audio-loading-a 1.65s infinite ease-in-out,
jpdb-reader-audio-loading-b 1.65s infinite ease-in-out;
}
.jpdb-reader-popover[data-audio-loading="true"]
.jpdb-reader-audio-control::after {
animation:
jpdb-reader-audio-loading-a 1.65s infinite ease-in-out 0.62s,
jpdb-reader-audio-loading-b 1.65s infinite ease-in-out 0.62s;
}
.jpdb-reader-spelling {
color: var(--jpdb-reader-text);
font-size: 24px;
font-weight: 750;
line-height: 1.16;
text-decoration: none;
word-break: keep-all;
}
.jpdb-reader-title-row {
display: flex;
align-items: baseline;
gap: 9px;
flex-wrap: wrap;
width: 100%;
min-width: 0;
}
.jpdb-reader-kanji-inline {
display: inline !important;
width: auto !important;
min-width: 0 !important;
height: auto !important;
min-height: 0 !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
border-bottom: 2px solid transparent !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
color: inherit !important;
cursor: pointer;
font: inherit !important;
line-height: inherit !important;
text-align: inherit !important;
vertical-align: baseline !important;
-webkit-tap-highlight-color: transparent;
}
.jpdb-reader-kanji-inline:hover,
.jpdb-reader-kanji-inline:focus-visible {
border-bottom-color: currentColor !important;
outline: none !important;
}
.jpdb-reader-pill {
appearance: none;
-webkit-appearance: none;
display: inline-flex !important;
align-items: center;
align-self: center;
gap: 4px;
box-sizing: border-box;
width: auto !important;
min-width: 0 !important;
max-width: max-content !important;
height: auto !important;
min-height: 24px !important;
padding: 3px 8px !important;
margin: 0 !important;
border: 1px solid var(--chip-border, var(--jpdb-reader-border)) !important;
border-radius: 999px !important;
color: var(--chip-text, var(--jpdb-reader-text)) !important;
background: var(--chip-bg, var(--jpdb-reader-surface-2)) !important;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.18) !important;
font-size: 11px !important;
font-weight: 700 !important;
line-height: 1 !important;
text-align: center !important;
text-decoration: none !important;
vertical-align: middle !important;
white-space: nowrap !important;
flex: 0 0 auto !important;
}
a.jpdb-reader-action-pill,
button.jpdb-reader-action-pill {
--chip-bg: color-mix(
in srgb,
var(--jpdb-reader-surface) 92%,
var(--jpdb-reader-accent) 8%
);
--chip-border: color-mix(
in srgb,
var(--jpdb-reader-accent) 38%,
var(--jpdb-reader-border)
);
--chip-text: var(--jpdb-reader-text);
cursor: pointer;
font-family: inherit;
transform: translateY(-0.01rem) !important;
-webkit-tap-highlight-color: transparent;
}
.jpdb-reader-action-pill:hover,
.jpdb-reader-action-pill:focus-visible {
color: var(--chip-text, var(--jpdb-reader-text)) !important;
background: var(--chip-bg, var(--jpdb-reader-surface-2)) !important;
transform: translateY(-1px) !important;
box-shadow: 0 3px 8px
color-mix(
in srgb,
var(--chip-border, var(--jpdb-reader-accent)) 34%,
transparent
) !important;
outline: 2px solid
color-mix(
in srgb,
var(--chip-border, var(--jpdb-reader-accent)) 55%,
transparent
) !important;
outline-offset: 1px;
}
.jpdb-reader-jpdb-pill {
--chip-bg: color-mix(
in srgb,
var(--jpdb-reader-accent) 15%,
var(--jpdb-reader-surface)
);
--chip-border: color-mix(
in srgb,
var(--jpdb-reader-accent) 58%,
var(--jpdb-reader-border)
);
--chip-text: var(--jpdb-reader-accent-readable);
}
.jpdb-reader-action-pill:active {
transform: scale(0.98) !important;
}
.jpdb-reader-pill svg {
width: 12px !important;
height: 12px !important;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
.jpdb-reader-word-pills {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
width: 100%;
margin-top: 6px;
}
@container (max-width: 340px) {
.jpdb-reader-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0 8px;
}
.jpdb-reader-heading {
display: contents;
}
.jpdb-reader-title-row {
grid-column: 1;
grid-row: 1;
}
.jpdb-reader-card-tools {
display: contents;
}
.jpdb-reader-pitch {
grid-column: 1 / -1;
grid-row: 2;
margin-top: -3px;
}
.jpdb-reader-audio-control {
grid-column: 2;
grid-row: 1;
margin-left: auto;
}
.jpdb-reader-word-pills {
grid-column: 1 / -1;
grid-row: 3;
width: auto;
gap: 4px;
margin-top: 5px;
}
.jpdb-reader-pill {
padding: 3px 7px !important;
font-size: 10.5px !important;
}
}
.jpdb-reader-reading,
.jpdb-reader-pos,
.jpdb-reader-meta,
.jpdb-reader-help {
color: var(--jpdb-reader-muted);
}
.jpdb-reader-reading {
margin-top: 2px;
font-size: 15px;
}
.jpdb-reader-title-row .jpdb-reader-reading {
margin-top: 0;
line-height: 1.2;
}
.jpdb-reader-pos {
margin-top: 7px;
font-size: 11px;
line-height: 1.35;
}
.jpdb-reader-status-line {
min-height: 22px;
}
.jpdb-reader-status-line[data-status-tone] {
margin-top: 8px;
padding: 8px 10px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 8px;
background: var(--jpdb-reader-surface-2);
color: var(--jpdb-reader-text);
}
.jpdb-reader-status-line[data-status-tone="pending"] {
color: var(--jpdb-reader-muted);
}
.jpdb-reader-status-line[data-status-tone="success"] {
border-color: color-mix(
in srgb,
var(--jpdb-reader-accent) 52%,
var(--jpdb-reader-border)
);
background: color-mix(
in srgb,
var(--jpdb-reader-accent) 11%,
var(--jpdb-reader-surface-2)
);
color: var(--jpdb-reader-accent);
}
.jpdb-reader-status-line[data-status-tone="error"] {
border-color: color-mix(in srgb, #e55353 52%, var(--jpdb-reader-border));
background: color-mix(in srgb, #e55353 11%, var(--jpdb-reader-surface-2));
color: #ff8c8c;
}
.jpdb-reader-meanings {
margin: 9px 0;
display: grid;
gap: 5px;
}
.jpdb-reader-meaning {
color: var(--jpdb-reader-text);
font-size: 13px;
line-height: 1.32;
}
.jpdb-reader-jpdb-extras {
display: grid;
gap: 10px;
margin: 10px 0 0;
}
.jpdb-reader-jpdb-extras .jpdb-reader-jpdb-examples-group,
.jpdb-reader-jpdb-extras .jpdb-reader-jpdb-used-in-group {
border: 0;
border-radius: 0;
background: transparent;
padding: 0;
}
.jpdb-reader-jpdb-extras
:is(.jpdb-reader-jpdb-examples-group, .jpdb-reader-jpdb-used-in-group)
> .jpdb-reader-local-glossary {
padding: 0 8px 8px;
}
.jpdb-reader-jpdb-extra {
display: grid;
gap: 6px;
}
.jpdb-reader-jpdb-extra h6 {
margin: 0;
color: var(--jpdb-reader-muted);
font-size: 11px;
font-weight: 850;
text-transform: uppercase;
}
.jpdb-reader-jpdb-compounds,
.jpdb-reader-jpdb-used-in,
.jpdb-reader-jpdb-examples {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.jpdb-reader-jpdb-example {
padding: 2px 0;
color: var(--jpdb-reader-text);
line-height: 1.3;
}
.jpdb-reader-jpdb-example-row.has-audio {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
align-items: start;
gap: 8px;
}
.jpdb-reader-jpdb-example-text {
min-width: 0;
}
button.jpdb-reader-jpdb-example-audio.jpdb-reader-icon-mini {
width: 28px !important;
min-width: 28px !important;
max-width: 28px !important;
height: 28px !important;
min-height: 28px !important;
max-height: 28px !important;
margin-top: 2px;
border-radius: 999px;
background: color-mix(in srgb, var(--jpdb-reader-surface-2) 88%, transparent);
}
button.jpdb-reader-jpdb-example-audio.jpdb-reader-icon-mini svg {
width: 14px;
height: 14px;
stroke-width: 2.6;
}
.jpdb-reader-jpdb-compounds li,
.jpdb-reader-jpdb-used-in li {
display: grid;
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
align-items: baseline;
gap: 8px;
min-width: 0;
}
.jpdb-reader-jpdb-compound,
.jpdb-reader-jpdb-used-in-link {
display: inline-flex;
align-items: flex-start;
min-width: 0;
color: inherit !important;
text-decoration: none;
}
.jpdb-reader-jpdb-compound:hover,
.jpdb-reader-jpdb-compound:focus-visible,
.jpdb-reader-jpdb-used-in-link:hover,
.jpdb-reader-jpdb-used-in-link:focus-visible {
color: inherit !important;
outline: none;
}
.jpdb-reader-jpdb-compound-head {
display: grid;
gap: 2px;
max-width: 100%;
}
.jpdb-reader-jpdb-compound-term {
display: inline-block;
width: max-content;
max-width: 100%;
font-size: 17px;
line-height: 1.1;
font-weight: 850;
white-space: nowrap;
}
.jpdb-reader-jpdb-used-in-term {
font-size: 14.5px;
font-weight: 760;
line-height: 1.25;
}
.jpdb-reader-jpdb-used-in-source-item {
grid-template-columns: minmax(0, 1fr) !important;
align-items: start !important;
gap: 2px !important;
}
.jpdb-reader-jpdb-used-in-source-link {
display: block;
}
.jpdb-reader-jpdb-used-in-source-title {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 760;
line-height: 1.25;
}
.jpdb-reader-jpdb-compound-reading,
.jpdb-reader-jpdb-compounds small,
.jpdb-reader-jpdb-used-in small,
.jpdb-reader-jpdb-example .jpdb-reader-example-translation {
color: var(--jpdb-reader-muted);
font-size: 11px;
line-height: 1.25;
}
.jpdb-reader-jpdb-compound-reading {
white-space: nowrap;
}
.jpdb-reader-jpdb-compound:has(.jpdb-reader-word) .jpdb-reader-jpdb-compound-reading {
display: none;
}
.jpdb-reader-example-summary .jpdb-reader-example-count {
flex: 0 0 auto;
margin-left: auto;
text-align: right;
white-space: nowrap;
}
.jpdb-reader-jpdb-example .jpdb-reader-example-sentence,
.jpdb-reader-jpdb-example .jpdb-reader-example-translation {
justify-self: stretch;
width: 100%;
text-align: left;
text-wrap: pretty;
}
.jpdb-reader-jpdb-example .jpdb-reader-example-sentence {
line-height: 1.35;
}
.jpdb-reader-jpdb-example .jpdb-reader-example-translation {
font-size: 10.5px;
line-height: 1.3;
}
.jpdb-reader-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
font-size: 11px;
}
.jpdb-reader-state-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
background: var(--jpdb-reader-faint);
margin-right: 4px;
}
.jpdb-reader-state-dot.jpdb-new,
.jpdb-reader-state-dot.jpdb-suspended,
.jpdb-reader-state-dot.jpdb-not-in-deck {
background: var(--jpdb-reader-state-new, #58a6ff);
}
.jpdb-reader-state-dot.jpdb-learning {
background: var(--jpdb-reader-state-learning, #ffd166);
}
.jpdb-reader-state-dot.jpdb-known,
.jpdb-reader-state-dot.jpdb-never-forget,
.jpdb-reader-state-dot.jpdb-redundant {
background: var(--jpdb-reader-state-known, #7bd88f);
}
.jpdb-reader-state-dot.jpdb-due {
background: var(--jpdb-reader-state-due, #5fb3b3);
}
.jpdb-reader-state-dot.jpdb-failed {
background: var(--jpdb-reader-state-failed, #ff6b6b);
}
.jpdb-reader-state-dot.jpdb-blacklisted,
.jpdb-reader-state-dot.jpdb-locked {
background: var(--jpdb-reader-state-ignored, #b8a7ff);
}
.jpdb-reader-local {
border-top: 1px solid var(--jpdb-reader-border);
margin-top: 12px;
padding-top: 12px;
display: grid;
gap: 8px;
font-size: 13px;
line-height: 1.45;
}
.jpdb-reader-definition-stack {
display: grid;
gap: 0;
margin-top: 12px;
}
.jpdb-reader-definition-stack .jpdb-reader-local {
margin-top: 0;
}
.jpdb-reader-source-card,
.jpdb-reader-study-source {
gap: 0;
padding-top: 0;
position: relative;
}
.jpdb-reader-source-card .jpdb-reader-meanings {
margin: 0;
}
.jpdb-reader-source-card > summary.jpdb-reader-local-title,
.jpdb-reader-study-source > summary.jpdb-reader-local-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 34px;
padding: 6px 0;
cursor: pointer;
list-style: none;
}
.jpdb-reader-source-card
> summary.jpdb-reader-local-title::-webkit-details-marker,
.jpdb-reader-study-source
> summary.jpdb-reader-local-title::-webkit-details-marker {
display: none;
}
.jpdb-reader-source-card > summary.jpdb-reader-local-title::after,
.jpdb-reader-study-source > summary.jpdb-reader-local-title::after {
content: "+";
flex: 0 0 auto;
margin-left: 0;
color: var(--jpdb-reader-muted);
font-size: 15px;
line-height: 1;
}
.jpdb-reader-source-card[open] > summary.jpdb-reader-local-title::after,
.jpdb-reader-study-source[open] > summary.jpdb-reader-local-title::after {
content: "-";
}
.jpdb-reader-source-card[data-immersion-empty="true"]
> summary.jpdb-reader-local-title {
cursor: default;
}
.jpdb-reader-source-card[data-immersion-empty="true"]
> summary.jpdb-reader-local-title::after {
content: "";
}
.jpdb-reader-source-card > :not(summary) {
margin-left: 0;
margin-right: 0;
}
.jpdb-reader-source-card[open] > :last-child {
margin-bottom: 7px;
}
.jpdb-reader-dictionary-source-list,
.jpdb-reader-dictionaries-section .jpdb-reader-local-terms {
gap: 0;
margin-top: 0;
padding: 0;
}
.jpdb-reader-dictionary-source-list {
display: grid;
}
.jpdb-reader-dictionaries-section .jpdb-reader-dictionary-group {
border-top: 1px solid var(--jpdb-reader-border);
padding: 0;
overflow: visible;
}
.jpdb-reader-dictionaries-section .jpdb-reader-dictionary-group:first-child {
border-top: 1px solid var(--jpdb-reader-border);
}
.jpdb-reader-dictionaries-section .jpdb-reader-dictionary-source-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 30px;
padding: 6px 0;
color: var(--jpdb-reader-muted);
cursor: pointer;
list-style: none;
}
.jpdb-reader-dictionaries-section
.jpdb-reader-dictionary-source-title::-webkit-details-marker {
display: none;
}
.jpdb-reader-dictionaries-section .jpdb-reader-dictionary-source-title::after {
content: "+";
flex: 0 0 auto;
color: var(--jpdb-reader-muted);
font-size: 14px;
line-height: 1;
}
.jpdb-reader-dictionaries-section
.jpdb-reader-dictionary-group[open]
> .jpdb-reader-dictionary-source-title::after {
content: "-";
}
.jpdb-reader-dictionaries-section .jpdb-reader-local-term {
border: 0;
border-radius: 0;
background: transparent;
padding: 7px 0 8px;
}
.jpdb-reader-dictionaries-section
.jpdb-reader-local-term
+ .jpdb-reader-local-term,
.jpdb-reader-dictionaries-section
.jpdb-reader-dictionary-source-title
+ .jpdb-reader-local-terms {
border-top: 1px solid var(--jpdb-reader-border);
}
.jpdb-reader-study-source > .jpdb-reader-study-panel {
margin-top: 4px;
margin-bottom: 12px;
}
.jpdb-reader-local-title {
color: var(--jpdb-reader-muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.jpdb-reader-source-status {
margin-left: auto;
color: var(--jpdb-reader-faint);
font-size: 11px;
font-weight: 700;
text-transform: none;
}
.jpdb-reader-local-entry {
border: 1px solid var(--jpdb-reader-border);
border-radius: 7px;
background: var(--jpdb-reader-surface);
padding: 6px;
}
.jpdb-reader-source-card > .jpdb-reader-local-entry {
display: grid;
gap: 10px;
padding: 8px 0 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.jpdb-reader-kanji > .jpdb-reader-local-entry + .jpdb-reader-local-entry {
margin-top: 6px;
}
.jpdb-reader-local-terms {
display: grid;
gap: 5px;
}
.jpdb-reader-local-term {
padding: 7px 8px;
padding-right: 32px;
background: color-mix(
in srgb,
var(--jpdb-reader-surface) 88%,
var(--jpdb-reader-surface-2)
);
}
.jpdb-reader-source-card .jpdb-reader-local-term {
padding: 7px 0 8px;
padding-right: 0;
border-top: 1px solid
color-mix(in srgb, var(--jpdb-reader-border) 52%, transparent);
border-radius: 0;
background: transparent;
}
.jpdb-reader-source-card .jpdb-reader-local-term:first-child {
border-top: 0;
}
.jpdb-reader-local-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
font-weight: 700;
}
.jpdb-reader-local-expression {
min-width: 0;
color: var(--jpdb-reader-text);
font-size: 16px;
line-height: 1.25;
font-weight: 850;
}
.jpdb-reader-local-reading,
.jpdb-reader-local-dict {
color: var(--jpdb-reader-muted);
font-size: 11px;
font-weight: 650;
}
.jpdb-reader-local-dict {
margin-left: auto;
}
.jpdb-reader-local-frequency {
margin-left: auto;
color: var(--jpdb-reader-accent-readable);
font-size: 11px;
font-weight: 800;
}
.jpdb-reader-local-senses {
display: grid;
gap: 2px;
margin-top: 4px;
}
.jpdb-reader-local-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 0 0 4px;
}
.jpdb-reader-local-head + .jpdb-reader-local-tags {
margin-top: 5px;
}
.jpdb-reader-local-tags:empty {
display: none;
}
.jpdb-reader-dict-tag {
display: inline-flex;
align-items: center;
min-height: 1.35em;
padding: 0 0.38em;
border: 1px solid
color-mix(in srgb, var(--jpdb-reader-accent) 55%, var(--jpdb-reader-border));
border-radius: 4px;
background: color-mix(
in srgb,
var(--jpdb-reader-accent) 24%,
var(--jpdb-reader-surface-2)
);
color: var(--jpdb-reader-accent-readable);
font-size: 10px;
font-weight: 800;
line-height: 1.2;
}
.jpdb-reader-source-tag {
border-color: var(--jpdb-reader-border);
background: var(--jpdb-reader-surface-2);
color: var(--jpdb-reader-muted);
}
.jpdb-reader-local-sense,
.jpdb-reader-local-glossary-entry {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.jpdb-reader-local-sense {
color: var(--jpdb-reader-text);
font-size: 13px;
line-height: 1.3;
}
.jpdb-reader-local-sense > span:last-child,
.jpdb-reader-local-glossary-entry > div {
min-width: 0;
flex: 1 1 auto;
}
.jpdb-reader-local-sense-index {
flex: 0 0 16px;
color: var(--jpdb-reader-faint);
font-size: 11px;
font-weight: 800;
text-align: right;
}
.jpdb-reader-local-glossary {
margin-top: 3px;
color: var(--jpdb-reader-text);
font-size: 13px;
line-height: 1.45;
white-space: normal;
display: grid;
gap: 2px;
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
--font-size-no-units: 13;
--line-height: 1.45;
--list-padding1: 1.1em;
--list-padding2: 1.45em;
--compact-list-separator: " / ";
}
.jpdb-reader-local-glossary-entry.no-index {
display: block;
}
.jpdb-reader-local-glossary-entry.no-index > div {
min-width: 0;
}
.jpdb-reader-local-glossary-entry.no-index
> div
> div:first-child
> :first-child {
margin-top: 0 !important;
}
.jpdb-reader-local-glossary-entry.no-index
> div
> div:last-child
> :last-child {
margin-bottom: 0 !important;
}
.jpdb-reader-local-glossary-entry.no-index .gloss-sc-div,
.jpdb-reader-local-glossary-entry.no-index .gloss-sc-ul,
.jpdb-reader-local-glossary-entry.no-index .gloss-sc-ol {
margin-top: 0.12em !important;
margin-bottom: 0.12em !important;
}
.jpdb-reader-local-glossary ul,
.jpdb-reader-local-glossary ol {
padding: 0;
}
.jpdb-reader-local-glossary li {
margin: 1px 0;
}
.jpdb-reader-local-glossary table {
border-collapse: collapse;
width: 100%;
white-space: normal;
margin: 4px 0;
}
.jpdb-reader-local-glossary td,
.jpdb-reader-local-glossary th {
border: 1px solid var(--jpdb-reader-border);
padding: 8px 6px 6px;
line-height: 1.45;
}
.jpdb-reader-dictionary-group {
padding: 0;
overflow: hidden;
}
.jpdb-reader-dictionary-group > summary.jpdb-reader-local-head,
.jpdb-reader-dictionary-group > summary.jpdb-reader-local-title.jpdb-reader-example-summary {
align-items: center;
cursor: pointer;
display: flex;
gap: 8px;
justify-content: space-between;
list-style: none;
min-height: 38px;
padding: 8px 0;
}
.jpdb-reader-dictionary-group
> summary.jpdb-reader-local-head::-webkit-details-marker,
.jpdb-reader-dictionary-group
> summary.jpdb-reader-local-title.jpdb-reader-example-summary::-webkit-details-marker {
display: none;
}
.jpdb-reader-dictionary-group > summary.jpdb-reader-local-head::after,
.jpdb-reader-dictionary-group
> summary.jpdb-reader-local-title.jpdb-reader-example-summary::after {
content: "+";
margin-left: 4px;
color: var(--jpdb-reader-muted);
font-size: 16px;
line-height: 1;
}
.jpdb-reader-dictionary-group[open] > summary.jpdb-reader-local-head::after,
.jpdb-reader-dictionary-group[open]
> summary.jpdb-reader-local-title.jpdb-reader-example-summary::after {
content: "-";
}
.jpdb-reader-dictionary-group > .jpdb-reader-local-glossary {
padding: 0 8px 8px;
}
.jpdb-reader-local-glossary .structured-content {
display: inline;
min-width: 0;
max-width: 100%;
white-space: normal;
line-height: var(--line-height);
}
.jpdb-reader-local-glossary ruby {
display: ruby;
ruby-align: center;
ruby-position: over;
max-width: 100%;
margin: 0 0.03em;
color: inherit;
line-height: 1;
text-align: center;
vertical-align: baseline;
white-space: nowrap;
}
.jpdb-reader-local-glossary rt {
display: ruby-text;
color: var(--jpdb-reader-muted);
font-size: 0.52em;
font-weight: 600;
line-height: 1;
text-align: center;
white-space: nowrap;
}
.jpdb-reader-local-glossary rp {
display: none;
}
.jpdb-reader-local-glossary
:is(
.gloss-sc-div,
.gloss-sc-ul,
.gloss-sc-ol,
.gloss-sc-li,
.gloss-sc-details
) {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
white-space: normal;
}
.jpdb-reader-local-glossary [data-sc-class="extra-box"] {
box-sizing: border-box;
max-width: 100%;
}
.jpdb-reader-local-glossary .gloss-sc-ul,
.jpdb-reader-local-glossary .gloss-sc-ol {
margin: 0.25em 0 0.25em var(--list-padding1);
padding: 0;
}
.jpdb-reader-local-glossary .gloss-sc-ul[data-sc-content="glossary"],
.jpdb-reader-local-glossary .gloss-sc-ol[data-sc-content="glossary"] {
display: grid;
gap: 0.25em;
}
.jpdb-reader-local-glossary .gloss-sc-li {
margin: 0;
padding-left: 0.1em;
}
.jpdb-reader-local-glossary .gloss-sc-li > .gloss-sc-ul,
.jpdb-reader-local-glossary .gloss-sc-li > .gloss-sc-ol {
margin-left: var(--list-padding2);
}
.jpdb-reader-local-glossary .gloss-sc-details {
margin: 0.35em 0;
border: 1px solid var(--jpdb-reader-border);
border-radius: 6px;
background: color-mix(in srgb, var(--jpdb-reader-surface-2) 72%, transparent);
}
.jpdb-reader-local-glossary .gloss-sc-summary {
padding: 0.35em 0.55em;
cursor: pointer;
font-weight: 700;
}
.jpdb-reader-local-glossary .gloss-sc-details > :not(.gloss-sc-summary) {
padding: 0 0.55em 0.45em;
}
.jpdb-reader-local-glossary .gloss-sc-table-container {
display: block;
max-width: 100%;
margin: 0.35em 0;
overflow-x: auto;
white-space: normal;
}
.jpdb-reader-local-glossary .gloss-sc-table {
width: auto;
min-width: min(100%, 24rem);
border-collapse: collapse;
table-layout: auto;
}
.jpdb-reader-local-glossary .gloss-sc-th,
.jpdb-reader-local-glossary .gloss-sc-td {
border: 1px solid var(--jpdb-reader-border);
padding: 0.35em 0.45em;
vertical-align: top;
}
.jpdb-reader-local-glossary .gloss-sc-th {
background: var(--jpdb-reader-surface-2);
font-weight: 800;
}
.jpdb-reader-local-glossary .gloss-sc-td[data-sc-class="form-valid"] {
text-align: center;
vertical-align: middle;
}
.jpdb-reader-local-glossary [data-sc-class="form-valid"] {
color: var(--jpdb-reader-accent-readable);
font-weight: 800;
}
.jpdb-reader-local-glossary .gloss-link {
color: var(--jpdb-reader-accent);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
.jpdb-reader-local-glossary .gloss-link-external-icon {
display: none;
}
.jpdb-reader-local-glossary [data-sc-content="part-of-speech-info"],
.jpdb-reader-local-glossary [data-sc-class="tag"],
.jpdb-reader-local-glossary [data-sc-class="pitch-accent-position"] {
display: inline-flex;
align-items: center;
min-height: 1.4em;
margin: 0 0.25em 0.15em 0;
padding: 0.05em 0.35em;
border: 1px solid var(--jpdb-reader-border);
border-radius: 999px;
background: var(--jpdb-reader-surface-2);
color: var(--jpdb-reader-muted);
font-size: 0.88em;
font-weight: 700;
white-space: nowrap;
}
.jpdb-reader-local-glossary .gloss-image-link {
display: inline-flex;
align-items: center;
gap: 0.35em;
max-width: 100%;
margin: 0.15em 0;
color: var(--jpdb-reader-muted);
vertical-align: middle;
}
.jpdb-reader-local-glossary .gloss-image-container {
position: relative;
display: inline-block;
max-width: min(100%, 20rem);
min-width: 3rem;
overflow: hidden;
border: 1px solid var(--jpdb-reader-border);
border-radius: 6px;
background: var(--jpdb-reader-surface-2);
vertical-align: middle;
}
.jpdb-reader-local-glossary .gloss-image-sizer {
display: block;
}
.jpdb-reader-local-glossary .gloss-image-background,
.jpdb-reader-local-glossary .gloss-image-container-overlay {
position: absolute;
inset: 0;
}
.jpdb-reader-local-glossary .gloss-image-background {
background:
linear-gradient(
45deg,
rgba(255, 255, 255, 0.06) 25%,
transparent 25% 75%,
rgba(255, 255, 255, 0.06) 75%
),
linear-gradient(
45deg,
rgba(255, 255, 255, 0.06) 25%,
transparent 25% 75%,
rgba(255, 255, 255, 0.06) 75%
);
background-position:
0 0,
6px 6px;
background-size: 12px 12px;
}
.jpdb-reader-local-glossary .gloss-image-link-text,
.jpdb-reader-local-glossary .gloss-image-description {
color: var(--jpdb-reader-muted);
font-size: 0.9em;
}
.jpdb-reader-anki-existing {
margin-top: 12px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 8px;
background: var(--jpdb-reader-surface);
overflow: hidden;
}
.jpdb-reader-anki-existing summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 10px;
cursor: pointer;
color: var(--jpdb-reader-text);
font-size: 11px;
font-weight: 800;
list-style: none;
}
.jpdb-reader-anki-existing summary::-webkit-details-marker {
display: none;
}
.jpdb-reader-anki-existing summary small {
color: var(--jpdb-reader-muted);
font-weight: 600;
text-align: right;
}
.jpdb-reader-anki-card-preview {
border-top: 1px solid var(--jpdb-reader-border);
padding: 9px 10px 10px;
display: grid;
gap: 8px;
color: var(--jpdb-reader-muted);
font-size: 11px;
line-height: 1.35;
}
.jpdb-reader-anki-field,
.jpdb-reader-anki-context {
display: grid;
gap: 2px;
}
.jpdb-reader-anki-field > strong,
.jpdb-reader-anki-context > strong,
.jpdb-reader-anki-rendered-side > strong {
color: var(--jpdb-reader-text);
font-size: 11px;
text-transform: uppercase;
}
.jpdb-reader-anki-field > span,
.jpdb-reader-anki-context > span,
.jpdb-reader-anki-context > small {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.jpdb-reader-anki-rendered-card,
.jpdb-reader-anki-fields {
display: grid;
gap: 8px;
}
.jpdb-reader-anki-rendered-side {
display: grid;
gap: 5px;
min-width: 0;
}
.jpdb-reader-anki-rendered-side-body {
max-height: 220px;
overflow: auto;
overscroll-behavior: contain;
contain: content;
border: 1px solid var(--jpdb-reader-border);
border-radius: 6px;
padding: 8px;
background: rgba(0, 0, 0, 0.14);
color: var(--jpdb-reader-text);
font-size: 13px;
line-height: 1.45;
}
.jpdb-reader-anki-rendered-side-body * {
max-width: 100%;
}
.jpdb-reader-anki-rendered-side-body img,
.jpdb-reader-anki-rendered-side-body video,
.jpdb-reader-anki-rendered-side-body audio {
max-width: 100%;
height: auto;
}
.jpdb-reader-anki-sound {
display: inline-block;
line-height: 1.3;
width: fit-content;
max-width: 100%;
margin-top: 2px;
cursor: pointer;
padding: 2px 6px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 999px;
color: var(--jpdb-reader-text);
background: rgba(94, 167, 128, 0.16);
font-size: 10px;
font-weight: 700;
}
.jpdb-reader-anki-sound:disabled {
cursor: default;
opacity: 0.65;
}
.jpdb-reader-anki-note-actions {
display: grid;
gap: 8px;
}
.jpdb-reader-anki-note-action-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.jpdb-reader-anki-audio-merge {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
min-width: 0;
}
.jpdb-reader-anki-audio-merge span {
color: var(--jpdb-reader-text);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.jpdb-reader-anki-audio-merge select {
min-width: 0;
width: 100%;
border: 1px solid var(--jpdb-reader-border);
border-radius: 6px;
padding: 5px 8px;
color: var(--jpdb-reader-text);
background: var(--jpdb-reader-bg);
font: inherit;
}
.jpdb-reader-settings-subsection {
display: grid;
gap: 8px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--jpdb-reader-border);
}
.jpdb-reader-immersion {
container-type: inline-size;
--jpdb-reader-example-media-max-height: clamp(150px, calc(100vh - 300px), 260px);
}
.jpdb-reader-example-card {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
align-items: start;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.jpdb-reader-example-card.has-image {
grid-template-columns: minmax(0, 1fr);
}
.jpdb-reader-example-image {
display: block;
max-width: 100%;
height: auto;
max-height: var(--jpdb-reader-example-media-max-height);
object-fit: contain;
}
.jpdb-reader-example-media {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 0;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.jpdb-reader-example-card.has-image .jpdb-reader-example-media {
overflow: hidden;
}
.jpdb-reader-example-body {
display: grid;
align-content: start;
gap: 9px;
min-width: 0;
}
.jpdb-reader-example-source {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jpdb-reader-example-summary {
display: flex !important;
align-items: center !important;
justify-content: space-between;
gap: 8px !important;
min-height: 38px;
padding: 8px 0;
width: 100%;
}
.jpdb-reader-example-summary .jpdb-reader-example-source {
color: var(--jpdb-reader-muted);
font-size: 11px;
font-weight: 700;
line-height: 1.2;
text-transform: uppercase;
}
.jpdb-reader-example-summary .jpdb-reader-local-dict {
display: block;
max-width: 100%;
flex: 1 1 auto;
margin-left: 0;
min-width: 0;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.jpdb-reader-example-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 0;
width: 100%;
margin: 3px 0 7px;
}
.jpdb-reader-example-meta {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: baseline;
column-gap: 7px;
row-gap: 2px;
}
.jpdb-reader-example-meta .jpdb-reader-example-source {
grid-column: 1 / -1;
flex: none;
max-width: 100%;
color: var(--jpdb-reader-muted);
font-size: 10.5px;
font-weight: 850;
line-height: 1.15;
text-transform: uppercase;
}
.jpdb-reader-example-title {
min-width: 0;
color: var(--jpdb-reader-text);
font-size: 11px;
font-weight: 780;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jpdb-reader-example-count {
color: var(--jpdb-reader-faint);
font-size: 10.5px;
font-weight: 850;
line-height: 1.2;
white-space: nowrap;
}
.jpdb-reader-example-sentence {
justify-self: center;
min-width: 0;
width: min(100%, 38em);
color: var(--jpdb-reader-text);
font-size: 13px;
line-height: 1.72;
text-align: center;
text-wrap: balance;
overflow-wrap: anywhere;
word-break: normal;
}
.jpdb-reader-example-card.has-image .jpdb-reader-example-sentence {
position: absolute;
left: 50%;
bottom: clamp(8px, 5%, 18px);
z-index: 1;
width: min(calc(100% - 28px), 34em);
padding: 2px 6px;
transform: translateX(-50%);
color: #fff;
font-size: 13px;
font-weight: 750;
line-height: 1.45;
text-shadow:
0 1px 2px rgb(0 0 0 / 95%),
0 0 5px rgb(0 0 0 / 90%),
0 0 9px rgb(0 0 0 / 78%);
pointer-events: auto;
}
.jpdb-reader-example-sentence:has(rt.jpdb-reader-furi) {
padding-top: 0.25em;
line-height: 1.95;
}
.jpdb-reader-example-card.has-image .jpdb-reader-example-sentence:has(rt.jpdb-reader-furi) {
padding-top: 2px;
line-height: 1.75;
}
.jpdb-reader-example-sentence .jpdb-reader-word {
display: inline;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
.jpdb-reader-example-sentence .jpdb-reader-word.jpdb-reader-has-furi {
line-height: 2;
}
.jpdb-reader-example-target {
padding: 0 0.08em;
border-radius: 0.22em;
background: color-mix(
in srgb,
var(--jpdb-reader-accent-readable) 15%,
transparent
);
box-shadow: inset 0 -0.1em 0
color-mix(in srgb, var(--jpdb-reader-accent-readable) 58%, transparent);
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
mark.jpdb-reader-example-target {
color: inherit;
}
.jpdb-reader-example-sentence .jpdb-reader-word.jpdb-reader-example-target {
background: color-mix(
in srgb,
var(--jpdb-reader-accent-readable) 15%,
transparent
) !important;
}
.jpdb-reader-example-card
.jpdb-reader-example-sentence
.jpdb-reader-word.jpdb-reader-example-target {
box-shadow: inset 0 -0.1em 0
color-mix(in srgb, var(--jpdb-reader-accent-readable) 58%, transparent) !important;
}
.jpdb-reader-example-card.has-image
.jpdb-reader-example-sentence
.jpdb-reader-word.jpdb-reader-example-target {
background: transparent !important;
box-shadow:
0 0.08em 0 color-mix(in srgb, #000 58%, transparent),
inset 0 -0.12em 0 color-mix(in srgb, var(--jpdb-reader-accent-readable) 74%, transparent) !important;
}
.jpdb-reader-example-translation {
justify-self: center;
width: min(100%, 38em);
color: var(--jpdb-reader-muted);
font-size: 11px;
line-height: 1.45;
text-align: center;
text-wrap: balance;
}
.jpdb-reader-example-translation[data-immersion-translation-blurred="true"],
.jpdb-reader-example-translation[data-yomu-immersion-translation-blurred="true"] {
filter: blur(4px);
user-select: none;
cursor: pointer;
}
.jpdb-reader-example-translation[data-immersion-translation-blurred="true"]:hover,
.jpdb-reader-example-translation[data-yomu-immersion-translation-blurred="true"]:hover,
.jpdb-reader-example-translation[data-immersion-translation-blurred="true"]:focus-visible,
.jpdb-reader-example-translation[data-yomu-immersion-translation-blurred="true"]:focus-visible {
filter: blur(3px);
outline: none;
}
.jpdb-reader-example-actions {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-self: end;
gap: 3px;
margin-top: 0;
padding: 0;
border: 0;
background: transparent;
}
.jpdb-reader-example-toolbar .jpdb-reader-example-actions {
margin-left: auto;
}
.jpdb-reader-immersion > .jpdb-reader-example-actions {
display: flex;
justify-content: flex-end;
margin: -2px 0 6px;
}
.jpdb-reader-example-actions .jpdb-reader-icon-mini {
width: 30px !important;
min-width: 30px !important;
max-width: 30px !important;
height: 30px !important;
min-height: 30px !important;
max-height: 30px !important;
border-radius: 5px;
background: rgba(255, 255, 255, 0.025);
font-size: 13px;
}
.jpdb-reader-example-actions svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 2.35;
stroke-linecap: round;
stroke-linejoin: round;
}
@container (min-width: 560px) {
.jpdb-reader-example-card.has-image {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 520px) {
.jpdb-reader-example-card.has-image {
grid-template-columns: minmax(0, 1fr);
}
.jpdb-reader-example-media {
--jpdb-reader-example-media-max-height: clamp(130px, calc(100vh - 300px), 230px);
max-height: var(--jpdb-reader-example-media-max-height);
}
.jpdb-reader-example-image {
max-height: var(--jpdb-reader-example-media-max-height);
}
.jpdb-reader-example-actions {
justify-self: start;
}
.jpdb-reader-example-toolbar {
flex-wrap: wrap;
}
.jpdb-reader-example-meta {
flex-basis: 100%;
}
.jpdb-reader-example-toolbar .jpdb-reader-example-actions {
margin-left: 0;
}
.jpdb-reader-example-source {
flex-basis: 100%;
}
}
@media (pointer: coarse) {
.jpdb-reader-example-actions .jpdb-reader-icon-mini {
width: 34px !important;
min-width: 34px !important;
max-width: 34px !important;
height: 34px !important;
min-height: 34px !important;
max-height: 34px !important;
}
}
.jpdb-reader-study-tools {
display: grid;
gap: 8px;
margin: 10px 0;
}
.jpdb-reader-study-panel {
display: grid;
gap: 10px;
background: transparent;
padding: 0;
color: var(--jpdb-reader-text);
font-size: 11px;
line-height: 1.4;
}
.jpdb-reader-study-translation-panel {
gap: 12px;
}
.jpdb-reader-study-block {
display: grid;
gap: 4px;
min-width: 0;
}
.jpdb-reader-study-sentence-block {
gap: 8px;
}
.jpdb-reader-study-block + .jpdb-reader-study-block {
border-top: 1px solid var(--jpdb-reader-border);
padding-top: 10px;
}
.jpdb-reader-study-label {
color: var(--jpdb-reader-muted);
font-size: 10px;
font-weight: 800;
letter-spacing: 0;
line-height: 1.2;
text-transform: uppercase;
}
.jpdb-reader-study-label-row,
.jpdb-reader-study-item-head,
.jpdb-reader-grammar-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.jpdb-reader-study-label-row .jpdb-reader-icon-mini {
width: 28px !important;
min-width: 28px !important;
max-width: 28px !important;
height: 28px !important;
min-height: 28px !important;
max-height: 28px !important;
}
.jpdb-reader-study-original {
margin: 0;
font-size: 13px;
line-height: 1.7;
}
.jpdb-reader-study-original rt {
color: var(--jpdb-reader-muted);
font-size: 0.58em;
line-height: 1;
}
.jpdb-reader-study-translation {
color: var(--jpdb-reader-text);
font-size: 13px;
line-height: 1.45;
}
.jpdb-reader-study-name {
color: var(--jpdb-reader-text);
font-weight: 800;
}
.jpdb-reader-study-list {
border-top: 1px solid var(--jpdb-reader-border);
display: grid;
gap: 0;
list-style: none;
margin: 0;
padding: 6px 0 0;
}
.jpdb-reader-study-item {
display: grid;
grid-template-columns: minmax(54px, max-content) minmax(0, 1fr);
gap: 10px;
align-items: start;
border-top: 1px solid var(--jpdb-reader-border);
margin: 0;
padding: 8px 0;
}
.jpdb-reader-study-item.known {
opacity: 0.72;
}
.jpdb-reader-study-item:first-child {
border-top: 0;
padding-top: 2px;
}
.jpdb-reader-study-name {
display: grid;
gap: 4px;
font-size: 15px;
line-height: 1.25;
}
.jpdb-reader-study-body {
display: grid;
gap: 4px;
min-width: 0;
}
.jpdb-reader-study-kind {
color: var(--jpdb-reader-muted);
font-size: 11px;
font-weight: 700;
min-width: 0;
}
.jpdb-reader-study-short,
.jpdb-reader-study-empty {
color: var(--jpdb-reader-text);
}
.jpdb-reader-study-detail,
.jpdb-reader-study-match {
color: var(--jpdb-reader-muted);
}
.jpdb-reader-study-detail {
font-size: 11px;
line-height: 1.35;
}
.jpdb-reader-study-match {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: baseline;
font-size: 11px;
}
.jpdb-reader-study-match span {
color: var(--jpdb-reader-muted);
}
.jpdb-reader-study-guide {
display: inline-flex;
align-items: center;
min-height: 24px;
border-radius: 3px;
color: color-mix(
in srgb,
var(--jpdb-reader-accent-readable) 82%,
var(--jpdb-reader-text)
);
font-size: 11px;
font-weight: 800;
text-decoration: underline;
text-decoration-color: color-mix(
in srgb,
var(--jpdb-reader-accent-readable) 64%,
transparent
);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
width: max-content;
}
.jpdb-reader-study-guide:hover {
color: var(--jpdb-reader-text);
text-decoration-color: var(--jpdb-reader-accent-readable);
}
.jpdb-reader-study-guide:focus-visible {
color: var(--jpdb-reader-text);
outline: 2px solid
color-mix(in srgb, var(--jpdb-reader-accent-readable) 58%, transparent);
outline-offset: 2px;
text-decoration-color: var(--jpdb-reader-accent-readable);
}
.jpdb-reader-grammar-summary {
min-width: 0;
color: var(--jpdb-reader-muted);
font-size: 11px;
font-weight: 700;
}
.jpdb-reader-grammar-level {
justify-self: start;
border: 1px solid
color-mix(in srgb, var(--jpdb-reader-accent) 42%, var(--jpdb-reader-border));
border-radius: 999px;
padding: 1px 5px;
color: var(--jpdb-reader-accent-readable);
font-size: 10px;
font-weight: 850;
line-height: 1.2;
}
.jpdb-reader-grammar-known,
.jpdb-reader-grammar-toggle {
border: 1px solid var(--jpdb-reader-border);
border-radius: 5px;
background: rgba(255, 255, 255, 0.025);
color: var(--jpdb-reader-muted);
cursor: pointer;
font: inherit;
font-size: 11px;
font-weight: 800;
line-height: 1.1;
min-height: 24px;
padding: 0 7px;
white-space: nowrap;
}
.jpdb-reader-grammar-known:hover,
.jpdb-reader-grammar-known:focus-visible,
.jpdb-reader-grammar-toggle:hover,
.jpdb-reader-grammar-toggle:focus-visible {
border-color: color-mix(
in srgb,
var(--jpdb-reader-accent) 52%,
var(--jpdb-reader-border)
);
color: var(--jpdb-reader-accent-readable);
outline: none;
}
.jpdb-reader-grammar-known[aria-pressed="true"],
.jpdb-reader-grammar-toggle[aria-pressed="true"] {
border-color: color-mix(
in srgb,
var(--jpdb-reader-accent) 46%,
var(--jpdb-reader-border)
);
color: var(--jpdb-reader-accent-readable);
}
.jpdb-reader-grammar-more {
display: grid;
gap: 4px;
}
.jpdb-reader-grammar-more[open] {
row-gap: 6px;
}
.jpdb-reader-grammar-more > summary {
width: max-content;
color: var(--jpdb-reader-muted);
cursor: pointer;
font-size: 11px;
font-weight: 750;
list-style: none;
}
.jpdb-reader-grammar-more > summary::-webkit-details-marker {
display: none;
}
.jpdb-reader-grammar-more > summary::after {
content: "+";
margin-left: 5px;
}
.jpdb-reader-grammar-more[open] > summary::after {
content: "-";
}
.jpdb-reader-grammar-examples {
display: grid;
row-gap: 6px;
margin-top: 4px;
}
.jpdb-reader-grammar-examples > span {
color: var(--jpdb-reader-text);
font-size: 11px;
font-weight: 750;
line-height: 1.25;
}
.jpdb-reader-grammar-example {
display: grid;
row-gap: 2px;
}
@media (max-width: 420px) {
.jpdb-reader-study-item {
grid-template-columns: minmax(0, 1fr);
gap: 4px;
}
.jpdb-reader-study-name {
font-size: 14px;
}
}
.jpdb-reader-template-preview {
border: 1px solid var(--jpdb-reader-border);
border-radius: 8px;
background: var(--jpdb-reader-surface);
padding: 10px;
margin-top: 10px;
}
.jpdb-reader-template-preview-title {
color: var(--jpdb-reader-text);
font-size: 13px;
font-weight: 800;
margin-bottom: 8px;
}
.jpdb-reader-template-preview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.jpdb-reader-template-preview-grid > div {
border: 1px solid var(--jpdb-reader-border);
border-radius: 8px;
background: var(--jpdb-reader-surface-2);
padding: 10px;
min-height: 118px;
}
.jpdb-reader-template-preview strong,
.jpdb-reader-template-preview small {
display: block;
color: var(--jpdb-reader-muted);
}
.jpdb-reader-template-expression {
color: var(--jpdb-reader-text);
font-size: 24px;
font-weight: 850;
line-height: 1.1;
margin-top: 8px;
}
.jpdb-reader-template-reading,
.jpdb-reader-template-meaning {
color: var(--jpdb-reader-muted);
margin-top: 4px;
}
.jpdb-reader-template-sentence {
color: var(--jpdb-reader-text);
font-size: 18px;
line-height: 1.35;
margin: 10px 0 8px;
}
.jpdb-reader-template-sentence span {
color: var(--jpdb-reader-accent);
font-weight: 850;
}
.jpdb-reader-frequency-pill {
border: 1px solid var(--chip-border, var(--jpdb-reader-border));
background: var(--chip-bg, var(--jpdb-reader-surface-2));
color: var(--chip-text, var(--jpdb-reader-text));
}
.jpdb-reader-kanji-char {
font-size: 20px;
}
.jpdb-reader-kanji-readings {
display: flex;
flex-wrap: wrap;
gap: 6px;
color: var(--jpdb-reader-muted);
font-size: 11px;
margin-top: 6px;
}
.jpdb-reader-modal-nav {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 8px;
color: var(--jpdb-reader-muted);
font-size: 11px;
font-weight: 750;
}
.jpdb-reader-modal-nav span {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jpdb-reader-kanji-display {
color: var(--jpdb-reader-text);
font-size: 46px;
font-weight: 850;
line-height: 1;
}
.jpdb-reader-kanji-title-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px 12px;
width: 100%;
}
.jpdb-reader-kanji-title-row [data-kanji-keyword-mount] {
min-width: 0;
}
.jpdb-reader-kanji-title-row .jpdb-reader-word-pills {
grid-column: 2 / -1;
justify-self: start;
align-self: start;
margin-top: 1px;
}
.jpdb-reader-kanji-title-row .jpdb-reader-action-pill {
--chip-bg: var(--jpdb-reader-surface);
--chip-border: var(--jpdb-reader-border);
--chip-text: var(--jpdb-reader-muted);
}
.jpdb-reader-kanji-keywords {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
min-width: 0;
}
.jpdb-reader-kanji-keyword {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
min-width: 0;
max-width: 100%;
min-height: 24px;
padding: 3px 9px;
border: 1px solid
color-mix(in srgb, var(--jpdb-reader-accent) 34%, var(--jpdb-reader-border));
border-radius: 999px;
background: color-mix(in srgb, var(--jpdb-reader-surface-2) 88%, var(--jpdb-reader-accent) 12%);
color: var(--jpdb-reader-text);
box-shadow: none;
font-size: 12px;
font-weight: 780;
line-height: 1.08;
overflow: hidden;
white-space: nowrap;
}
.jpdb-reader-kanji-keyword small {
display: inline;
flex: 0 0 auto;
height: auto;
padding: 0;
border-radius: 0;
background: transparent;
color: var(--jpdb-reader-muted);
font-size: 9px;
font-weight: 800;
line-height: 1;
letter-spacing: 0;
text-transform: uppercase;
}
.jpdb-reader-kanji-keyword span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.jpdb-reader-kanji-facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 148px), 1fr));
gap: 8px;
overflow: visible;
padding-bottom: 0;
}
.jpdb-reader-kanji-facts span {
display: grid;
gap: 3px;
min-width: 0;
align-content: center;
min-height: 44px;
padding: 8px 10px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 8px;
background: var(--jpdb-reader-surface-2);
color: var(--jpdb-reader-text);
font-size: 13px;
font-weight: 820;
line-height: 1.2;
overflow-wrap: anywhere;
}
.jpdb-reader-kanji-facts :is(strong, small) {
display: block;
color: var(--jpdb-reader-muted);
font-size: 10px;
font-weight: 850;
line-height: 1.1;
text-transform: uppercase;
}
.jpdb-reader-origin-detail {
display: grid;
gap: 8px;
}
.jpdb-reader-kanji-facts + .jpdb-reader-origin-detail {
margin-top: 8px;
}
.jpdb-reader-origin-detail p {
margin: 0;
color: var(--jpdb-reader-text);
font-size: 13px;
line-height: 1.35;
}
.jpdb-reader-origin-detail p span {
color: var(--jpdb-reader-muted);
}
.jpdb-reader-radical-card {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 8px 0;
border-top: 1px solid
color-mix(in srgb, var(--jpdb-reader-border) 52%, transparent);
background: transparent;
}
.jpdb-reader-radical-card:first-child {
border-top: 0;
}
.jpdb-reader-radical-glyph {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 8px;
background: var(--jpdb-reader-bg);
font-size: 28px !important;
line-height: 1;
}
.jpdb-reader-radical-card img {
width: 48px;
height: 48px;
object-fit: contain;
border-radius: 6px;
background: color-mix(in srgb, var(--jpdb-reader-text) 92%, white);
}
.jpdb-reader-radical-frames {
display: flex !important;
flex-flow: row wrap;
gap: 5px !important;
margin-top: 5px;
}
.jpdb-reader-radical-card div {
display: grid;
gap: 2px;
min-width: 0;
}
.jpdb-reader-radical-card strong {
color: var(--jpdb-reader-text);
font-size: 15px;
}
.jpdb-reader-radical-card span {
color: var(--jpdb-reader-muted);
font-size: 11px;
line-height: 1.3;
}
.jpdb-reader-origin-graph-wrap {
--jpdb-reader-origin-graph-node-size: 60px;
--jpdb-reader-origin-graph-bg: color-mix(
in srgb,
var(--jpdb-reader-surface, #20242b) 88%,
var(--jpdb-reader-bg, #181b20)
);
--jpdb-reader-origin-graph-line: color-mix(
in srgb,
var(--jpdb-reader-text, #f2f4f8) 72%,
transparent
);
--jpdb-reader-origin-graph-outbound-line: color-mix(
in srgb,
var(--jpdb-reader-accent, #5ea780) 76%,
var(--jpdb-reader-text, #f2f4f8)
);
--jpdb-reader-origin-graph-subcomponent-line: color-mix(
in srgb,
var(--jpdb-reader-accent, #5ea780) 48%,
var(--jpdb-reader-text, #f2f4f8)
);
--jpdb-reader-origin-graph-frame: color-mix(
in srgb,
var(--jpdb-reader-bg, #181b20) 84%,
var(--jpdb-reader-text, #f2f4f8)
);
--jpdb-reader-origin-graph-node-fill: color-mix(
in srgb,
var(--jpdb-reader-accent, #5ea780) 72%,
var(--jpdb-reader-surface-2, #282e37)
);
--jpdb-reader-origin-graph-related-fill: color-mix(
in srgb,
var(--jpdb-reader-accent, #5ea780) 18%,
var(--jpdb-reader-surface, #20242b)
);
--jpdb-reader-origin-graph-accent-text: color-mix(
in srgb,
var(--jpdb-reader-accent, #5ea780) 12%,
#000
);
position: relative;
height: min(240px, 58vw);
min-height: 190px;
margin-top: 8px;
border: 1px solid
color-mix(in srgb, var(--jpdb-reader-border) 82%, var(--jpdb-reader-text, #f2f4f8));
border-radius: 8px;
background: var(--jpdb-reader-origin-graph-bg);
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--jpdb-reader-text, #f2f4f8) 8%, transparent),
inset 0 -18px 44px color-mix(in srgb, var(--jpdb-reader-bg, #181b20) 18%, transparent);
isolation: isolate;
overflow: hidden;
}
.jpdb-reader-origin-graph-wrap[data-origin-has-subcomponents="true"] {
height: min(340px, 72vw);
min-height: 240px;
}
.jpdb-reader-origin-graph-lines {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
opacity: 0.35;
transition: opacity 0.12s ease;
}
.jpdb-reader-origin-graph-wrap[data-graph-ready="true"] .jpdb-reader-origin-graph-lines {
opacity: 1;
}
.jpdb-reader-origin-graph-lines .jpdb-reader-origin-edge {
fill: none;
stroke: var(--jpdb-reader-origin-graph-line);
stroke-width: 1.35;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.jpdb-reader-origin-graph-lines .jpdb-reader-origin-edge-arrow {
fill: var(--jpdb-reader-origin-graph-line);
}
.jpdb-reader-origin-graph-lines
.jpdb-reader-origin-edge-arrow-outbound
.jpdb-reader-origin-edge-arrow {
fill: var(--jpdb-reader-origin-graph-outbound-line);
}
.jpdb-reader-origin-graph-lines
.jpdb-reader-origin-edge-arrow-subcomponent
.jpdb-reader-origin-edge-arrow {
fill: var(--jpdb-reader-origin-graph-subcomponent-line);
}
.jpdb-reader-origin-graph-lines .jpdb-reader-origin-edge-group.outbound .jpdb-reader-origin-edge {
stroke: var(--jpdb-reader-origin-graph-outbound-line);
stroke-dasharray: 2.6 3.2;
}
.jpdb-reader-origin-graph-lines .jpdb-reader-origin-edge-group.subcomponent .jpdb-reader-origin-edge {
stroke: var(--jpdb-reader-origin-graph-subcomponent-line);
stroke-width: 1.15;
stroke-dasharray: 1.6 4;
}
.jpdb-reader-origin-graph-wrap:has([data-origin-outbound-toggle]:not(:checked))
[data-origin-outbound="true"] {
display: none;
}
.jpdb-reader-origin-graph-wrap:has([data-origin-subcomponent-toggle]:not(:checked))
[data-origin-subcomponent="true"] {
display: none;
}
.jpdb-reader-origin-graph-toggles {
position: absolute;
top: 7px;
right: 7px;
z-index: 4;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 5px;
max-width: calc(100% - 14px);
}
.jpdb-reader-origin-graph-toggle {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 100%;
padding: 4px 7px;
border: 1px solid var(--jpdb-reader-border, rgba(255, 255, 255, 0.12));
border-radius: 999px;
background: color-mix(in srgb, var(--jpdb-reader-surface, #20242b) 88%, transparent);
color: var(--jpdb-reader-text, #f2f4f8);
font-size: 11px;
font-weight: 850;
line-height: 1.1;
cursor: pointer;
user-select: none;
box-shadow: 0 8px 20px color-mix(in srgb, var(--jpdb-reader-bg, #181b20) 24%, transparent);
}
.jpdb-reader-origin-graph-toggle input {
width: 13px;
height: 13px;
margin: 0;
accent-color: var(--jpdb-reader-accent, #5ea780);
}
.jpdb-reader-origin-graph-node {
position: absolute;
transform: translate(-50%, -50%);
display: grid;
place-items: center;
width: var(--jpdb-reader-origin-graph-node-size);
min-width: var(--jpdb-reader-origin-graph-node-size);
height: var(--jpdb-reader-origin-graph-node-size);
padding: 0;
border: 4px solid var(--jpdb-reader-origin-graph-frame);
border-radius: 999px;
background: var(--jpdb-reader-origin-graph-node-fill);
color: var(--jpdb-reader-text, #f2f4f8);
font-family: "Hiragino Sans", "Yu Gothic", var(--jpdb-reader-font);
font-size: 33px;
font-weight: 850;
line-height: 1;
box-shadow: 0 9px 26px color-mix(in srgb, var(--jpdb-reader-bg, #181b20) 34%, transparent);
cursor: grab;
touch-action: none;
user-select: none;
z-index: 2;
transition:
border-color 0.14s ease,
box-shadow 0.14s ease,
transform 0.14s ease;
}
.jpdb-reader-origin-graph-node.current {
border-color: var(--jpdb-reader-origin-graph-frame);
background: var(--jpdb-reader-accent, #5ea780);
color: var(--jpdb-reader-origin-graph-accent-text);
font-size: 35px;
box-shadow: 0 12px 32px
color-mix(in srgb, var(--jpdb-reader-bg, #181b20) 40%, transparent);
}
.jpdb-reader-origin-graph-node.component {
border-color: var(--jpdb-reader-origin-graph-frame);
}
.jpdb-reader-origin-graph-node.related {
background: var(--jpdb-reader-origin-graph-related-fill);
color: var(--jpdb-reader-text, #f2f4f8);
font-size: 29px;
}
.jpdb-reader-origin-graph-node[data-label-length="2"] {
font-size: 27px;
}
.jpdb-reader-origin-graph-node[data-label-length="many"] {
font-size: 22px;
}
.jpdb-reader-origin-graph-node:hover,
.jpdb-reader-origin-graph-node:focus-visible {
border-color: var(--jpdb-reader-origin-graph-frame);
box-shadow:
0 14px 34px color-mix(in srgb, var(--jpdb-reader-bg, #181b20) 44%, transparent),
0 0 0 3px color-mix(in srgb, var(--jpdb-reader-accent, #5ea780) 24%, transparent);
outline: none;
transform: translate(-50%, -50%) scale(1.04);
}
.jpdb-reader-origin-graph-node.dragging {
cursor: grabbing;
z-index: 3;
transform: translate(-50%, -50%) scale(1.08);
box-shadow:
0 18px 40px color-mix(in srgb, var(--jpdb-reader-bg, #181b20) 50%, transparent),
0 0 0 4px color-mix(in srgb, var(--jpdb-reader-accent, #5ea780) 28%, transparent);
}
.jpdb-reader-rtk-head {
display: flex;
align-items: baseline;
gap: 8px;
color: var(--jpdb-reader-text);
}
.jpdb-reader-rtk-head span {
color: var(--jpdb-reader-muted);
font-size: 11px;
}
.jpdb-reader-rtk details,
.jpdb-reader-jpdb-kanji details {
margin-top: 8px;
color: var(--jpdb-reader-muted);
}
.jpdb-reader-rtk summary,
.jpdb-reader-jpdb-kanji summary {
cursor: pointer;
color: var(--jpdb-reader-text);
font-weight: 750;
}
.jpdb-reader-rtk p,
.jpdb-reader-jpdb-kanji p {
margin: 6px 0 0;
color: var(--jpdb-reader-muted);
line-height: 1.45;
}
.jpdb-reader-rtk-elements {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.jpdb-reader-rtk-elements > span,
.jpdb-reader-rtk-elements > button {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 24px;
padding: 2px 8px;
border: 1px solid transparent;
border-radius: 999px;
background: var(--jpdb-reader-surface-2);
color: var(--jpdb-reader-muted);
font: inherit;
font-size: 11px;
font-weight: 750;
}
.jpdb-reader-rtk-elements > span strong,
.jpdb-reader-rtk-elements > button strong {
color: var(--jpdb-reader-text);
font-size: 14px;
line-height: 1;
}
.jpdb-reader-rtk-elements > span span,
.jpdb-reader-rtk-elements > button span {
all: unset;
color: var(--jpdb-reader-muted);
}
.jpdb-reader-rtk-elements > button {
cursor: pointer;
}
.jpdb-reader-rtk-elements > button:hover,
.jpdb-reader-rtk-elements > button:focus-visible {
border-color: var(--jpdb-reader-accent);
color: var(--jpdb-reader-text);
outline: none;
}
.jpdb-reader-component-grid,
.jpdb-reader-similar-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(136px, 1fr));
gap: 6px;
margin-top: 8px;
}
.jpdb-reader-similar-grid {
grid-template-columns: repeat(auto-fit, minmax(158px, 1fr));
gap: 8px;
}
.jpdb-reader-component-card,
.jpdb-reader-component-button,
.jpdb-reader-similar-word {
min-width: 0;
border: 1px solid
color-mix(in srgb, var(--jpdb-reader-border) 70%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--jpdb-reader-surface-2) 62%, transparent);
color: var(--jpdb-reader-text);
padding: 7px;
}
.jpdb-reader-component-card,
.jpdb-reader-component-button {
display: grid;
gap: 2px;
text-align: left;
cursor: pointer;
font: inherit;
}
.jpdb-reader-component-card strong,
.jpdb-reader-component-button strong {
font-size: 18px;
}
.jpdb-reader-component-card span,
.jpdb-reader-component-card small,
.jpdb-reader-component-button span,
.jpdb-reader-component-button small,
.jpdb-reader-similar-word small,
.jpdb-reader-similar-word em {
color: var(--jpdb-reader-muted);
font-size: 11px;
font-style: normal;
}
.jpdb-reader-similar-word {
display: grid;
gap: 3px;
align-content: start;
min-height: 86px;
text-align: left;
cursor: pointer;
font: inherit;
}
.jpdb-reader-similar-word-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.jpdb-reader-similar-word-head > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jpdb-reader-similar-reading,
.jpdb-reader-similar-meaning {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
}
.jpdb-reader-similar-reading {
-webkit-line-clamp: 1;
line-clamp: 1;
}
.jpdb-reader-similar-meaning {
-webkit-line-clamp: 2;
line-clamp: 2;
}
.jpdb-reader-similar-word:hover,
.jpdb-reader-similar-word:focus-visible,
.jpdb-reader-component-card:hover,
.jpdb-reader-component-card:focus-visible,
.jpdb-reader-component-button:hover,
.jpdb-reader-component-button:focus-visible {
border-color: var(--jpdb-reader-accent);
outline: none;
}
.jpdb-reader-doodle-stage {
position: relative;
width: min(100%, 240px);
aspect-ratio: 1 / 1;
justify-self: center;
margin: 8px auto 0;
--jpdb-reader-doodle-ink: #141820;
border: 1px solid var(--jpdb-reader-border);
border-radius: 8px;
overflow: hidden;
background:
linear-gradient(90deg, rgba(0, 0, 0, 0.08) 1px, transparent 1px),
linear-gradient(0deg, rgba(0, 0, 0, 0.08) 1px, transparent 1px), #f8f9fb;
background-size: 27.25px 27.25px;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
.jpdb-reader-kanjivg > .jpdb-reader-doodle-stage {
margin-inline: auto;
}
.jpdb-reader-doodle-ghost,
.jpdb-reader-doodle-canvas {
position: absolute;
inset: 0;
}
.jpdb-reader-doodle-ghost {
display: grid;
place-items: center;
opacity: 0.3;
pointer-events: none;
}
.jpdb-reader-doodle-stage.trace-hidden .jpdb-reader-doodle-ghost,
.jpdb-reader-doodle-ghost[hidden] {
display: none !important;
}
.jpdb-reader-doodle-canvas {
width: 100%;
height: 100%;
cursor: crosshair;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
.jpdb-reader-doodle-tools,
.jpdb-reader-doodle-tools *,
.jpdb-reader-btn.jpdb-reader-doodle-control {
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
html.jpdb-reader-doodle-active,
html.jpdb-reader-doodle-active body,
html.jpdb-reader-doodle-active * {
user-select: none !important;
-webkit-user-select: none !important;
-webkit-touch-callout: none !important;
-webkit-tap-highlight-color: transparent !important;
}
.jpdb-reader-kanjivg-svg {
width: 90%;
max-height: 90%;
}
.jpdb-reader-kanjivg-strokes path {
fill: none;
stroke: #141820;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.jpdb-reader-kanjivg-numbers {
fill: #6b7280;
font-size: 8px;
font-family: var(--jpdb-reader-font);
}
.jpdb-reader-kanjivg .jpdb-reader-help {
color: #3d4654;
}
.jpdb-reader-doodle-text-ghost {
color: #141820;
font-family:
"Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", Meiryo,
sans-serif;
font-size: 180px;
font-weight: 500;
line-height: 1;
}
.jpdb-reader-doodle-tools {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
margin-top: 7px;
}
.jpdb-reader-kanjivg > .jpdb-reader-doodle-tools {
margin-bottom: 12px;
}
.jpdb-reader-kanjivg > .jpdb-reader-newtab-doodle-result {
margin: 0 auto 12px;
}
.jpdb-reader-kanjivg.jpdb-reader-doodle-pass .jpdb-reader-newtab-doodle-result {
border-color: color-mix(
in srgb,
var(--jpdb-reader-state-known, #7bd88f) 54%,
var(--jpdb-reader-border, rgba(255, 255, 255, 0.12))
);
background: color-mix(
in srgb,
var(--jpdb-reader-state-known, #7bd88f) 16%,
var(--jpdb-reader-surface, #20242b)
);
}
.jpdb-reader-kanjivg.jpdb-reader-doodle-fail .jpdb-reader-newtab-doodle-result {
border-color: color-mix(
in srgb,
var(--jpdb-reader-state-failed, #ff6b6b) 54%,
var(--jpdb-reader-border, rgba(255, 255, 255, 0.12))
);
background: color-mix(
in srgb,
var(--jpdb-reader-state-failed, #ff6b6b) 16%,
var(--jpdb-reader-surface, #20242b)
);
}
.jpdb-reader-btn.jpdb-reader-doodle-control {
min-height: 28px;
max-height: 30px;
padding: 4px 9px;
border-radius: 7px;
font: 800 12px/1 var(--jpdb-reader-font);
}
.jpdb-reader-popover:has(.jpdb-reader-popover-body) {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
overflow: hidden;
padding: 0;
}
.jpdb-reader-popover-body {
min-height: 0;
overflow: auto;
overflow-anchor: none;
padding: 14px;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.jpdb-reader-actions {
position: relative;
z-index: 4;
container-type: inline-size;
border-top: 1px solid var(--jpdb-reader-border);
margin: 0;
padding: 6px 14px 15px;
display: grid;
gap: 6px;
--jpdb-reader-actions-bg: color-mix(in srgb, var(--jpdb-reader-bg) 90%, black 10%);
background: var(--jpdb-reader-actions-bg);
box-shadow: 0 -1px 0 var(--jpdb-reader-actions-bg);
}
.jpdb-reader-row {
display: grid;
grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr));
gap: 10px;
}
.jpdb-reader-mining-details {
position: relative;
display: grid;
gap: 0;
min-width: 0;
}
.jpdb-reader-mining-panel {
display: grid;
gap: 6px;
min-width: 0;
}
.jpdb-reader-mining-title {
position: static;
justify-content: center;
padding-inline: 10px;
}
.jpdb-reader-mining-title:hover,
.jpdb-reader-mining-title:focus-visible {
color: var(--jpdb-reader-state-new, #5aa9ff);
}
.jpdb-reader-actions-has-mining {
position: relative;
padding-top: 31px;
}
.jpdb-reader-actions-gutter {
position: absolute;
top: 1px;
right: 10px;
left: 10px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
cursor: grab;
pointer-events: auto;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.jpdb-reader-actions-gutter[hidden] {
display: none !important;
}
.jpdb-reader-actions-gutter:active,
.jpdb-reader-mining-drawer-dragging .jpdb-reader-actions-gutter {
cursor: grabbing;
}
.jpdb-reader-mining-collapse {
display: inline-flex;
align-items: center;
justify-content: center;
width: 72px;
height: 30px;
min-width: 72px;
min-height: 30px;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--jpdb-reader-muted);
cursor: pointer;
padding: 0;
pointer-events: auto;
-webkit-tap-highlight-color: transparent;
}
.jpdb-reader-mining-collapse::before {
content: "";
display: block;
width: 42px;
height: 5px;
border-radius: 999px;
background: var(--jpdb-reader-faint);
}
.jpdb-reader-mining-collapse:hover,
.jpdb-reader-mining-collapse:focus-visible {
outline: none;
}
.jpdb-reader-mining-collapse:hover::before,
.jpdb-reader-mining-collapse:focus-visible::before {
background: var(--jpdb-reader-accent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--jpdb-reader-accent) 12%, transparent);
}
.jpdb-reader-actions:not(.jpdb-reader-actions-mining-collapsed) .jpdb-reader-mining-collapse::before {
width: 48px;
background: color-mix(in srgb, var(--jpdb-reader-accent) 74%, var(--jpdb-reader-faint));
}
.jpdb-reader-actions-mining-collapsed .jpdb-reader-mining-panel,
.jpdb-reader-actions-mining-collapsed .jpdb-reader-mining-details {
display: none;
}
.jpdb-reader-mining-action-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: stretch;
gap: 12px;
padding: 0;
}
.jpdb-reader-kanji-mining-row {
grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr));
}
.jpdb-reader-add-deck-select {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.jpdb-reader-add-deck-select-open {
position: static;
width: 100%;
min-width: 0;
height: 34px;
margin-top: 6px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 7px;
background: var(--jpdb-reader-surface);
color: var(--jpdb-reader-text);
opacity: 1;
pointer-events: auto;
font: 750 12px/1 var(--jpdb-reader-font);
}
.jpdb-reader-grades {
grid-template-columns: repeat(5, minmax(24px, 1fr));
}
.jpdb-reader-mining-action-row .jpdb-reader-btn {
min-width: 0;
min-height: 48px;
padding-inline: clamp(6px, 1.8cqi, 10px);
font-size: clamp(10.5px, 3cqi, 13px);
letter-spacing: 0;
line-height: 1.05;
white-space: nowrap;
overflow-wrap: normal;
}
.jpdb-reader-grades .jpdb-reader-btn {
min-width: 0;
min-height: 48px;
padding-inline: clamp(2px, 1cqi, 6px);
font-size: clamp(9.3px, 2.55cqi, 11px);
letter-spacing: 0;
line-height: 1.05;
white-space: nowrap;
overflow-wrap: normal;
}
.jpdb-reader-btn {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-height: 42px;
padding: 0 12px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 10px;
background: color-mix(
in srgb,
var(--jpdb-reader-surface) 90%,
currentColor 7%
);
color: var(--jpdb-reader-text);
cursor: pointer;
font: 750 13px/1 var(--jpdb-reader-font);
text-align: center;
text-decoration: none;
white-space: nowrap;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
transform: none;
transition:
background 0.14s ease,
border-color 0.14s ease,
box-shadow 0.14s ease,
transform 0.12s ease;
-webkit-tap-highlight-color: transparent;
}
.jpdb-reader-btn:focus-visible:not(:disabled) {
border-color: color-mix(
in srgb,
var(--button-accent, var(--jpdb-reader-accent)) 70%,
var(--jpdb-reader-border)
);
outline: 2px solid
color-mix(in srgb, var(--button-accent, var(--jpdb-reader-accent)) 44%, transparent);
outline-offset: 2px;
}
@media (hover: hover) and (pointer: fine) {
.jpdb-reader-btn:hover:not(:disabled) {
background: color-mix(
in srgb,
var(--jpdb-reader-surface) 84%,
var(--button-accent, currentColor) 16%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 6px 14px rgba(0, 0, 0, 0.16);
transform: translateY(-0.12rem);
}
}
.jpdb-reader-btn:active:not(:disabled) {
transform: scale(0.98);
}
.jpdb-reader-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.jpdb-reader-btn.primary {
color: var(--jpdb-reader-accent-readable);
border-color: var(--jpdb-reader-accent);
background: color-mix(
in srgb,
var(--jpdb-reader-surface) 86%,
var(--jpdb-reader-accent) 14%
);
}
.jpdb-reader-btn.add {
--button-accent: var(--jpdb-reader-accent);
color: var(--jpdb-reader-accent-readable) !important;
}
.jpdb-reader-btn.nf {
--button-accent: var(--jpdb-reader-state-known, #7bd88f);
}
.jpdb-reader-btn.nf.danger {
--button-accent: var(--jpdb-reader-state-failed, #ff6b6b);
}
.jpdb-reader-btn.blacklist {
--button-accent: var(--jpdb-reader-state-ignored, #b8a7ff);
}
.jpdb-reader-btn.anki {
--button-accent: var(--jpdb-reader-state-new, #5aa9ff);
}
.jpdb-reader-btn.nothing,
.jpdb-reader-btn.fail {
--button-accent: var(--jpdb-reader-state-failed, #ff6b6b);
}
.jpdb-reader-btn.something {
--button-accent: var(--jpdb-reader-state-failed, #ff6b6b);
}
.jpdb-reader-btn.hard {
--button-accent: var(--jpdb-reader-state-due, #ffb454);
}
.jpdb-reader-btn.okay,
.jpdb-reader-btn.pass {
--button-accent: var(--jpdb-reader-state-known, #7bd88f);
}
.jpdb-reader-btn.easy {
--button-accent: var(--jpdb-reader-state-new, #5aa9ff);
}
.jpdb-reader-btn:is(
.add,
.nf,
.blacklist,
.anki,
.nothing,
.something,
.hard,
.okay,
.easy,
.fail,
.pass
) {
color: var(--jpdb-reader-text);
border-color: color-mix(
in srgb,
var(--button-accent, var(--jpdb-reader-border)) 54%,
var(--jpdb-reader-border)
);
background: color-mix(
in srgb,
var(--jpdb-reader-surface) 94%,
var(--button-accent, var(--jpdb-reader-border)) 6%
);
}
@container (max-width: 430px) {
.jpdb-reader-mining-action-row {
gap: 6px;
}
.jpdb-reader-mining-action-row .jpdb-reader-btn,
.jpdb-reader-grades .jpdb-reader-btn {
min-height: 44px;
}
.jpdb-reader-mining-action-row .jpdb-reader-btn {
font-size: clamp(10px, 2.85cqi, 11.5px);
}
.jpdb-reader-grades .jpdb-reader-btn {
padding-inline: clamp(1px, 0.75cqi, 4px);
font-size: clamp(8.8px, 2.4cqi, 10.4px);
}
}
@container (max-width: 340px) {
.jpdb-reader-actions {
padding: 6px 10px 15px;
gap: 6px;
}
.jpdb-reader-actions-has-mining {
padding-top: 29px;
}
.jpdb-reader-actions-gutter {
top: 1px;
right: 8px;
left: 8px;
height: 28px;
}
.jpdb-reader-mining-collapse {
width: 64px;
height: 28px;
min-width: 64px;
min-height: 28px;
}
.jpdb-reader-mining-collapse::before {
width: 38px;
}
.jpdb-reader-actions:not(.jpdb-reader-actions-mining-collapsed) .jpdb-reader-mining-collapse::before {
width: 44px;
}
.jpdb-reader-row,
.jpdb-reader-mining-action-row {
gap: 3px;
}
.jpdb-reader-mining-action-row .jpdb-reader-btn {
min-height: 38px;
padding-inline: 3px;
border-radius: 8px;
font-size: 9.5px;
}
.jpdb-reader-grades .jpdb-reader-btn {
min-height: 38px;
padding-inline: 1px;
border-radius: 8px;
font-size: 8.8px;
}
}
@container (max-width: 300px) {
.jpdb-reader-actions {
padding-inline: 8px;
}
.jpdb-reader-row,
.jpdb-reader-mining-action-row {
gap: 2px;
}
.jpdb-reader-grades {
grid-template-columns: repeat(3, minmax(24px, 1fr));
}
.jpdb-reader-mining-action-row .jpdb-reader-btn {
font-size: 8.8px;
}
.jpdb-reader-grades .jpdb-reader-btn {
padding-inline: 0;
font-size: 8.2px;
}
}
.jpdb-reader-pitch svg {
display: block;
height: 42px;
max-width: 128px;
}
.jpdb-reader-pitch text {
fill: var(--jpdb-reader-text);
font-size: 11px;
}
.jpdb-reader-pitch polyline {
fill: none;
stroke: currentColor;
stroke-width: 2;
}
.jpdb-reader-pitch circle {
fill: currentColor;
}
.jpdb-reader-pitch .heiban {
color: var(--jpdb-reader-pitch-heiban, #359eff);
}
.jpdb-reader-pitch .atamadaka {
color: var(--jpdb-reader-pitch-atamadaka, #fe4b74);
}
.jpdb-reader-pitch .nakadaka {
color: var(--jpdb-reader-pitch-nakadaka, #fba840);
}
.jpdb-reader-pitch .odaka {
color: var(--jpdb-reader-pitch-odaka, #57ccb7);
}
.jpdb-reader-pitch .kifuku {
color: var(--jpdb-reader-pitch-kifuku, #9050f6);
}
.yomu-jpdb-uchisen-source[open] > :last-child {
display: grid;
gap: 10px;
}
.yomu-jpdb-uchisen-body {
display: grid;
gap: 10px;
justify-items: center;
text-align: center;
}
.yomu-jpdb-source-meta,
.yomu-jpdb-counter {
color: var(--jpdb-reader-muted);
font-size: 12px;
font-weight: 750;
}
.yomu-jpdb-uchisen-summary-main,
.yomu-jpdb-uchisen-summary-controls,
.yomu-jpdb-uchisen-summary-link {
display: inline-flex;
align-items: center;
}
.yomu-jpdb-uchisen-summary-main {
gap: 8px;
min-width: 0;
}
.yomu-jpdb-uchisen-summary-controls {
gap: 7px;
margin-left: auto;
}
.yomu-jpdb-uchisen-summary-controls .jpdb-reader-icon-mini {
width: 28px !important;
min-width: 28px !important;
max-width: 28px !important;
height: 28px !important;
min-height: 28px !important;
max-height: 28px !important;
}
.yomu-jpdb-uchisen-summary-link {
gap: 4px;
min-height: 28px;
padding-inline: 2px;
color: var(--jpdb-reader-accent-readable);
font-size: 11px;
font-weight: 750;
line-height: 1.2;
text-decoration: none;
text-transform: none;
}
.yomu-jpdb-uchisen-summary-link svg {
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
.yomu-jpdb-uchisen-summary-link:hover,
.yomu-jpdb-uchisen-summary-link:focus-visible {
color: var(--jpdb-reader-accent);
outline: none;
}
.yomu-jpdb-component-breakdown {
display: grid;
gap: 8px;
width: 100%;
text-align: left;
}
.yomu-jpdb-component-group {
display: grid;
gap: 5px;
}
.yomu-jpdb-component-group-label {
color: var(--jpdb-reader-muted);
font-size: 10px;
font-weight: 850;
line-height: 1.1;
text-transform: uppercase;
}
.yomu-jpdb-component-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.yomu-jpdb-component-chip {
display: inline-flex;
align-items: baseline;
gap: 6px;
min-width: 0;
padding: 5px 7px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 7px;
background: color-mix(in srgb, var(--jpdb-reader-surface-2) 72%, transparent);
color: var(--jpdb-reader-text);
font-size: 11px;
font-weight: 760;
line-height: 1.2;
text-decoration: none;
}
.yomu-jpdb-component-chip strong {
font-size: 16px;
line-height: 1;
}
.yomu-jpdb-component-chip span {
color: var(--jpdb-reader-muted);
}
.yomu-jpdb-component-chip:hover,
.yomu-jpdb-component-chip:focus-visible {
border-color: var(--jpdb-reader-accent);
color: var(--jpdb-reader-text);
outline: none;
}
.yomu-jpdb-image-shell {
display: grid;
place-items: center;
justify-self: center;
width: min(100%, 540px);
min-height: 120px;
border-radius: 8px;
background: color-mix(in srgb, var(--jpdb-reader-bg) 72%, transparent);
overflow: hidden;
}
.yomu-jpdb-image-shell img {
display: block;
max-width: 100%;
max-height: min(260px, 50vh);
object-fit: contain;
}
.yomu-jpdb-story {
color: var(--jpdb-reader-text);
font-size: 13px;
line-height: 1.5;
}
.jpdb-reader-toast {
position: fixed;
left: 50%;
bottom: max(18px, env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: 2147483647;
max-width: min(520px, calc(100vw - 24px));
padding: 10px 12px;
border-radius: 10px;
background: var(--jpdb-reader-surface);
color: var(--jpdb-reader-text);
border: 1px solid var(--jpdb-reader-border);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.25);
font: 13px/1.35 var(--jpdb-reader-font);
}
.jpdb-reader-settings {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(1180px, calc(100vw - 48px));
max-height: min(820px, calc(100vh - 20px));
max-height: min(820px, calc(100dvh - 20px));
overflow: hidden;
padding: 0;
display: flex;
flex-direction: column;
}
.jpdb-reader-settings button,
.jpdb-reader-settings input,
.jpdb-reader-settings select,
.jpdb-reader-settings textarea,
.jpdb-reader-settings fieldset,
.jpdb-reader-settings legend {
margin: 0;
letter-spacing: 0;
}
.jpdb-reader-settings input,
.jpdb-reader-settings select,
.jpdb-reader-settings textarea {
box-shadow: none;
transform: none;
}
.jpdb-reader-settings input:hover,
.jpdb-reader-settings input:focus,
.jpdb-reader-settings input:active {
transform: none;
}
.jpdb-reader-settings input[type="checkbox"]::before,
.jpdb-reader-settings input[type="radio"]::before {
content: none !important;
}
.jpdb-reader-settings-head {
flex: 0 0 auto;
padding: 18px 18px 0;
}
.jpdb-reader-settings-drag-handle {
display: none;
width: min(160px, 44vw);
height: 34px;
border-radius: 999px;
margin: -6px auto 2px;
cursor: grab;
touch-action: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.jpdb-reader-settings-drag-handle::before {
content: "";
display: block;
width: 48px;
height: 5px;
border-radius: 999px;
margin: 14px auto 0;
background: var(--jpdb-reader-faint);
}
.jpdb-reader-settings-drag-handle:active {
cursor: grabbing;
}
.jpdb-reader-settings-drag-handle:focus-visible::before {
background: var(--jpdb-reader-accent);
}
.jpdb-reader-settings-tabs {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
gap: 6px;
overflow: visible;
padding: 0 18px 8px;
}
.jpdb-reader-settings-tab {
min-height: 34px;
padding: 0 11px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 999px;
background: var(--jpdb-reader-surface);
color: var(--jpdb-reader-muted);
font: 800 12px/1 var(--jpdb-reader-font);
cursor: pointer;
white-space: nowrap;
transform: translateY(-0.01rem);
}
.jpdb-reader-settings-tab[aria-selected="true"] {
border-color: var(--jpdb-reader-accent);
color: var(--jpdb-reader-accent-readable);
background: var(--jpdb-reader-accent-soft);
}
.jpdb-reader-settings-tab:focus-visible {
border-color: var(--jpdb-reader-accent);
outline: 2px solid var(--jpdb-reader-accent-soft);
outline-offset: 2px;
}
@media (hover: hover) and (pointer: fine) {
.jpdb-reader-settings-tab:hover {
border-color: var(--jpdb-reader-accent);
box-shadow: 0 8px 18px
color-mix(in srgb, var(--jpdb-reader-accent) 22%, transparent);
color: var(--jpdb-reader-accent-readable);
transform: translateY(-0.16rem);
}
}
@media (hover: none), (pointer: coarse) {
.jpdb-reader-settings-tab:not([aria-selected="true"]):hover {
border-color: var(--jpdb-reader-border);
background: var(--jpdb-reader-surface);
color: var(--jpdb-reader-muted);
box-shadow: none;
transform: none;
}
}
.jpdb-reader-settings-tab:active {
transform: scale(0.98);
}
.jpdb-reader-settings-scroll {
min-height: 0;
overflow: auto;
padding: 0 18px 96px;
-webkit-overflow-scrolling: touch;
}
.jpdb-reader-settings h2 {
margin: 0 0 12px;
font-size: 20px;
line-height: 1.45;
color: var(--jpdb-reader-text) !important;
}
.jpdb-reader-settings fieldset {
border: 1px solid var(--jpdb-reader-border);
border-radius: 8px;
margin: 12px 0;
padding: 12px;
}
.jpdb-reader-settings legend {
color: var(--jpdb-reader-muted);
padding: 0 6px;
}
.jpdb-reader-settings label {
display: grid;
gap: 5px;
margin: 10px 0;
min-width: 0;
color: var(--jpdb-reader-muted) !important;
font-size: 11px;
line-height: 1.35;
}
.jpdb-reader-settings input,
.jpdb-reader-settings select,
.jpdb-reader-field-display {
width: 100%;
box-sizing: border-box;
min-height: 38px;
border-radius: 7px;
border: 1px solid var(--jpdb-reader-border);
background: var(--jpdb-reader-surface);
color: var(--jpdb-reader-text);
font: 700 12px/1.2 var(--jpdb-reader-font);
height: 38px;
min-width: 0;
padding: 8px;
}
.jpdb-reader-btn {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-height: 38px;
padding: 0 16px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 8px;
background: var(--jpdb-reader-surface);
color: var(--jpdb-reader-text);
font: 800 12px/1 var(--jpdb-reader-font);
cursor: pointer;
text-align: center;
text-decoration: none !important;
}
.jpdb-reader-btn:where(:link, :visited) {
color: var(--jpdb-reader-text) !important;
}
.jpdb-reader-btn:hover,
.jpdb-reader-btn:focus-visible {
border-color: var(--button-accent, var(--jpdb-reader-accent));
background: color-mix(
in srgb,
var(--jpdb-reader-surface) 82%,
var(--button-accent, var(--jpdb-reader-accent)) 18%
);
outline: none;
}
.jpdb-reader-btn:active {
transform: scale(0.98);
}
.jpdb-reader-btn:disabled {
cursor: not-allowed;
opacity: 0.62;
}
.jpdb-reader-btn[data-save-blocked] {
cursor: wait;
color: var(--jpdb-reader-accent-readable);
border-color: color-mix(in srgb, var(--jpdb-reader-accent) 55%, var(--jpdb-reader-border));
background: var(--jpdb-reader-accent-soft);
}
.jpdb-reader-btn[data-import-state] {
gap: 7px;
cursor: progress;
opacity: 1;
}
.jpdb-reader-btn[data-import-state]::before {
content: "";
display: inline-block;
flex: 0 0 auto;
}
.jpdb-reader-btn[data-import-state="installing"]::before {
width: 13px;
height: 13px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 999px;
animation: jpdb-reader-spin 0.8s linear infinite;
}
.jpdb-reader-btn[data-import-state="queued"]::before {
width: 8px;
height: 8px;
border-radius: 999px;
background: currentColor;
opacity: 0.72;
}
.jpdb-reader-btn.add {
color: var(--jpdb-reader-accent-readable) !important;
border-color: var(--jpdb-reader-accent) !important;
background: color-mix(
in srgb,
var(--jpdb-reader-surface) 82%,
var(--jpdb-reader-accent) 18%
) !important;
}
.jpdb-reader-btn.add:hover,
.jpdb-reader-btn.add:focus-visible {
background: color-mix(
in srgb,
var(--jpdb-reader-surface) 74%,
var(--jpdb-reader-accent) 26%
) !important;
box-shadow: 0 4px 12px
color-mix(in srgb, var(--jpdb-reader-accent) 20%, transparent);
}
@keyframes jpdb-reader-spin {
to {
transform: rotate(1turn);
}
}
@media (prefers-reduced-motion: reduce) {
.jpdb-reader-btn[data-import-state="installing"]::before {
animation-duration: 1.8s;
}
}
.jpdb-reader-field-display {
min-width: 0;
display: flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
pointer-events: none;
}
.jpdb-reader-settings input[type="color"] {
padding: 3px;
cursor: pointer;
}
.jpdb-reader-settings input[type="checkbox"],
.jpdb-reader-settings input[type="radio"] {
appearance: none;
-webkit-appearance: none;
width: 24px;
height: 24px;
min-width: 24px;
min-height: 24px;
display: grid;
place-content: center;
margin: 0;
padding: 0;
border: 1.5px solid var(--jpdb-reader-border);
background: var(--jpdb-reader-surface-2);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
}
.jpdb-reader-settings input[type="checkbox"] {
border-radius: 7px;
}
.jpdb-reader-settings input[type="radio"] {
border-radius: 999px;
}
.jpdb-reader-settings input[type="checkbox"]:checked,
.jpdb-reader-settings input[type="radio"]:checked {
border-color: var(--jpdb-reader-accent);
background: var(--jpdb-reader-accent);
box-shadow: 0 0 0 3px var(--jpdb-reader-accent-soft);
}
.jpdb-reader-settings input[type="checkbox"]:checked::after {
content: "";
width: 12px;
height: 7px;
border-left: 2.5px solid #11161d;
border-bottom: 2.5px solid #11161d;
transform: rotate(-45deg) translate(1px, -1px);
}
.jpdb-reader-settings input[type="radio"]:checked::after {
content: "";
width: 10px;
height: 10px;
border-radius: 999px;
background: #11161d;
}
.jpdb-reader-settings input[type="checkbox"]:focus-visible,
.jpdb-reader-settings input[type="radio"]:focus-visible {
outline: 2px solid var(--jpdb-reader-accent);
outline-offset: 3px;
}
.jpdb-reader-settings label:has(input:disabled),
.jpdb-reader-settings label:has(select:disabled),
.jpdb-reader-settings label:has(textarea:disabled) {
opacity: 0.55;
}
.jpdb-reader-settings input:disabled,
.jpdb-reader-settings select:disabled,
.jpdb-reader-settings textarea:disabled {
cursor: not-allowed;
}
.jpdb-reader-settings input[type="file"][data-file] {
display: none !important;
}
.jpdb-reader-settings [hidden] {
display: none !important;
}
.jpdb-reader-settings .inline {
display: flex;
align-items: center;
gap: 12px;
min-height: 32px;
}
.jpdb-reader-settings .grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px 14px;
}
.jpdb-reader-settings .jpdb-reader-radio-group {
align-self: end;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
margin: 10px 0;
padding: 0;
border: 0;
min-width: 0;
}
.jpdb-reader-settings .jpdb-reader-radio-group legend {
grid-column: 1 / -1;
padding: 0;
color: var(--jpdb-reader-muted);
font-size: 11px;
line-height: 1.35;
}
.jpdb-reader-settings .jpdb-reader-radio-group label {
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
margin: 0;
min-height: 38px;
}
.jpdb-reader-settings-subsection {
margin: 16px 0 0;
padding-top: 12px;
border-top: 1px solid color-mix(in srgb, var(--jpdb-reader-border) 72%, transparent);
}
.jpdb-reader-theme-field {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
min-height: 38px;
gap: 10px;
margin: 10px 0;
color: var(--jpdb-reader-muted) !important;
font-size: 11px;
}
.jpdb-reader-theme-title {
min-width: 0;
font: 750 12px/1.2 var(--jpdb-reader-font);
}
.jpdb-reader-settings .jpdb-reader-theme-appearance {
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 48px;
height: 22px;
margin: 0;
}
.jpdb-reader-settings .jpdb-reader-theme-switch {
position: relative;
display: block;
width: 40px;
min-width: 40px;
height: 22px !important;
min-height: 22px !important;
padding: 0;
border: 1px solid var(--jpdb-reader-border);
border-radius: 999px;
background: color-mix(in srgb, var(--jpdb-reader-surface) 86%, transparent);
cursor: pointer;
transform: translateY(-1px);
}
.jpdb-reader-settings .jpdb-reader-theme-switch .check {
position: absolute;
top: 1px;
left: 1px;
display: grid;
place-items: center;
width: 18px;
height: 18px;
border-radius: 999px;
background: var(--jpdb-reader-bg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
}
.jpdb-reader-settings .jpdb-reader-theme-switch[aria-checked="true"] .check {
transform: translateX(18px);
}
.jpdb-reader-settings .jpdb-reader-theme-switch .icon {
position: relative;
display: block;
width: 14px;
height: 14px;
color: var(--jpdb-reader-muted);
}
.jpdb-reader-settings .jpdb-reader-theme-switch .sun,
.jpdb-reader-settings .jpdb-reader-theme-switch .moon {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 11px;
line-height: 1;
}
.jpdb-reader-settings .jpdb-reader-theme-switch .sun::before {
content: "☀";
}
.jpdb-reader-settings .jpdb-reader-theme-switch .moon::before {
content: "☾";
}
.jpdb-reader-settings .jpdb-reader-theme-switch[aria-checked="false"] .sun,
.jpdb-reader-settings .jpdb-reader-theme-switch[aria-checked="true"] .moon {
opacity: 0;
}
.jpdb-reader-settings .jpdb-reader-theme-switch[aria-checked="true"] .sun,
.jpdb-reader-settings .jpdb-reader-theme-switch[aria-checked="false"] .moon {
opacity: 1;
}
.jpdb-reader-settings .jpdb-reader-theme-switch:hover,
.jpdb-reader-settings .jpdb-reader-theme-switch:focus-visible {
border-color: var(--jpdb-reader-accent);
outline: 2px solid var(--jpdb-reader-accent-soft);
outline-offset: 3px;
}
.jpdb-reader-shortcut-group {
display: contents;
}
.jpdb-reader-settings .footer {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin: 0;
background: var(--jpdb-reader-bg);
border-top: 1px solid var(--jpdb-reader-border);
padding: 12px 18px calc(12px + env(safe-area-inset-bottom));
box-shadow: 0 -10px 24px rgba(0, 0, 0, 0.18);
}
.jpdb-reader-settings-save-status {
flex: 1 1 100%;
color: var(--jpdb-reader-accent-readable);
font: 760 11px/1.35 var(--jpdb-reader-font);
text-align: right;
}
.jpdb-reader-settings .footer .jpdb-reader-btn {
min-width: 92px;
padding-inline: 18px;
font-size: 13px;
}
.jpdb-reader-settings .footer .jpdb-reader-btn[data-action="cancel"] {
color: var(--jpdb-reader-text);
border-color: var(--jpdb-reader-border);
background: color-mix(
in srgb,
var(--jpdb-reader-surface) 92%,
var(--jpdb-reader-text) 5%
);
}
.jpdb-reader-settings a:not(.jpdb-reader-btn) {
color: var(--jpdb-reader-accent-readable) !important;
text-decoration: underline;
text-underline-offset: 3px;
}
.jpdb-reader-settings-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 10px 0;
}
.jpdb-reader-settings-actions-single {
display: flex;
justify-content: center;
}
.jpdb-reader-settings-actions .jpdb-reader-btn {
display: inline-flex;
min-width: 0;
}
.jpdb-reader-settings-actions .jpdb-reader-btn[data-newtab-url-link] {
border-color: color-mix(
in srgb,
var(--jpdb-reader-accent) 45%,
var(--jpdb-reader-border)
);
background: color-mix(
in srgb,
var(--jpdb-reader-surface) 86%,
var(--jpdb-reader-accent) 14%
);
color: var(--jpdb-reader-text) !important;
}
.jpdb-reader-settings-actions .jpdb-reader-btn[data-newtab-url-link]:hover,
.jpdb-reader-settings-actions .jpdb-reader-btn[data-newtab-url-link]:focus-visible {
border-color: var(--jpdb-reader-accent);
background: color-mix(
in srgb,
var(--jpdb-reader-surface) 78%,
var(--jpdb-reader-accent) 22%
);
box-shadow: 0 6px 16px
color-mix(in srgb, var(--jpdb-reader-accent) 18%, transparent);
}
.jpdb-reader-help-card {
display: grid;
gap: 12px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 8px;
background: var(--jpdb-reader-surface);
padding: 14px;
}
.jpdb-reader-help-title {
color: var(--jpdb-reader-text);
font-size: 15px;
font-weight: 850;
}
.jpdb-reader-help-card p {
margin: 8px 0 0;
color: var(--jpdb-reader-muted);
font-size: 13px;
line-height: 1.45;
}
.jpdb-reader-help-actions {
display: flex;
flex-wrap: nowrap;
gap: 8px;
min-width: 0;
}
.jpdb-reader-help-actions .jpdb-reader-btn {
flex: 1 1 auto;
min-width: 0;
min-height: 42px;
padding-inline: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jpdb-reader-help-actions .jpdb-reader-help-donate {
border-color: var(--jpdb-reader-accent);
background: var(--jpdb-reader-accent);
color: var(--jpdb-reader-accent-text, #11161d) !important;
box-shadow: 0 6px 16px
color-mix(in srgb, var(--jpdb-reader-accent) 22%, transparent);
}
.jpdb-reader-help-actions .jpdb-reader-help-donate:hover,
.jpdb-reader-help-actions .jpdb-reader-help-donate:focus-visible {
border-color: color-mix(in srgb, var(--jpdb-reader-accent) 86%, white);
background: color-mix(in srgb, var(--jpdb-reader-accent) 90%, white);
color: var(--jpdb-reader-accent-text, #11161d) !important;
box-shadow: 0 8px 20px
color-mix(in srgb, var(--jpdb-reader-accent) 28%, transparent);
}
.jpdb-reader-help-actions .jpdb-reader-help-reset {
border-color: color-mix(in srgb, #ef4444 70%, var(--jpdb-reader-border));
color: color-mix(in srgb, #ef4444 78%, var(--jpdb-reader-text)) !important;
}
.jpdb-reader-help-actions .jpdb-reader-help-reset:hover,
.jpdb-reader-help-actions .jpdb-reader-help-reset:focus-visible {
background: color-mix(in srgb, #7f1d1d 42%, var(--jpdb-reader-surface));
border-color: #ef4444;
color: color-mix(in srgb, #ef4444 62%, var(--jpdb-reader-text)) !important;
}
.jpdb-reader-help-support {
display: grid;
gap: 10px;
padding-top: 12px;
border-top: 1px solid color-mix(in srgb, var(--jpdb-reader-border) 72%, transparent);
}
.jpdb-reader-help-discord {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-height: 42px;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 8px;
color: var(--jpdb-reader-text);
background: color-mix(in srgb, var(--jpdb-reader-surface) 88%, var(--jpdb-reader-accent) 12%);
font: 800 12px/1.2 var(--jpdb-reader-font);
text-align: center;
overflow-wrap: anywhere;
}
.jpdb-reader-dictionary-status {
margin: 10px 0;
color: var(--jpdb-reader-muted);
font-size: 11px;
}
.jpdb-reader-dictionary-priorities,
.jpdb-reader-kanji-priorities,
.jpdb-reader-audio-sources,
.jpdb-reader-lookup-links {
display: grid;
gap: 9px;
margin: 12px 0;
}
.jpdb-reader-recommended-dictionaries {
display: grid;
gap: 10px;
margin: 12px 0;
}
.jpdb-reader-recommended-title {
color: var(--jpdb-reader-text);
font-weight: 800;
font-size: 13px;
}
.jpdb-reader-recommended-note {
margin: -4px 0 2px;
}
.jpdb-reader-recommended-group {
display: grid;
gap: 7px;
}
.jpdb-reader-recommended-group-title {
color: var(--jpdb-reader-faint);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.jpdb-reader-recommended-item {
display: grid;
grid-template-columns: minmax(0, 1fr) 112px;
gap: 10px;
align-items: center;
border: 1px solid var(--jpdb-reader-border);
border-radius: 8px;
background: var(--jpdb-reader-surface);
padding: 10px;
}
.jpdb-reader-recommended-item .jpdb-reader-btn {
width: 100%;
min-height: 42px;
padding-inline: 10px;
}
.jpdb-reader-recommended-name {
display: flex;
gap: 10px;
align-items: baseline;
flex-wrap: wrap;
color: var(--jpdb-reader-text);
font-weight: 800;
font-size: 13px;
}
.jpdb-reader-recommended-name a {
font-size: 11px;
font-weight: 700;
}
.jpdb-reader-recommended-status {
margin-top: 7px;
color: var(--jpdb-reader-accent-readable);
font: 760 11px/1.35 var(--jpdb-reader-font);
overflow-wrap: anywhere;
}
.jpdb-reader-recommended-status[data-import-state="queued"] {
color: var(--jpdb-reader-muted);
}
.jpdb-reader-order-head,
.jpdb-reader-order-row {
display: grid;
gap: 10px;
align-items: center;
box-sizing: border-box;
min-width: 0;
}
.jpdb-reader-dictionary-head,
.jpdb-reader-dictionary-row,
.jpdb-reader-audio-source-head,
.jpdb-reader-audio-source-row,
.jpdb-reader-lookup-link-head,
.jpdb-reader-lookup-link-row {
grid-template-columns: 50px minmax(0, 1fr) minmax(0, 0.9fr) max-content 42px;
}
.jpdb-reader-dictionary-head.no-remove,
.jpdb-reader-dictionary-row.no-remove {
grid-template-columns: 50px minmax(0, 1fr) minmax(0, 0.9fr) max-content;
}
.jpdb-reader-dictionary-head.compact,
.jpdb-reader-dictionary-row.compact {
grid-template-columns: 50px minmax(0, 1fr) max-content 42px;
}
.jpdb-reader-dictionary-head.compact.no-remove,
.jpdb-reader-dictionary-row.compact.no-remove {
grid-template-columns: 50px minmax(0, 1fr) max-content;
}
.jpdb-reader-order-head {
padding: 0 10px;
color: var(--jpdb-reader-faint);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.jpdb-reader-order-row {
position: relative;
border: 1px solid var(--jpdb-reader-border);
border-radius: 8px;
background: var(--jpdb-reader-surface);
padding: 10px;
min-height: 56px;
width: 100%;
}
.jpdb-reader-dictionary-row-help {
grid-column: 2 / -1;
color: var(--jpdb-reader-muted);
font-size: 11px;
line-height: 1.35;
}
.jpdb-reader-settings .jpdb-reader-dictionary-toggle {
margin: 0;
justify-content: center;
color: var(--jpdb-reader-text);
}
.jpdb-reader-audio-source-choice select,
.jpdb-reader-audio-source-fields input,
.jpdb-reader-audio-source-fields select {
min-width: 0;
width: 100%;
}
.jpdb-reader-lookup-link-row input {
min-width: 0;
}
.jpdb-reader-lookup-link-note {
color: var(--jpdb-reader-muted);
min-height: 38px;
display: flex;
align-items: center;
}
.jpdb-reader-lookup-link-fixed {
width: 34px;
height: 34px;
}
.jpdb-reader-lookup-link-actions {
display: flex;
justify-content: flex-start;
margin-top: 3px;
}
.jpdb-reader-lookup-link-actions .jpdb-reader-btn {
width: auto !important;
min-width: 86px !important;
padding-inline: 16px !important;
}
.jpdb-reader-settings .jpdb-reader-audio-index {
margin: 0;
min-height: 38px;
justify-content: center;
color: var(--jpdb-reader-text);
}
.jpdb-reader-audio-source-choice {
display: grid;
grid-template-columns: minmax(0, 1fr) 34px;
gap: 6px;
align-items: center;
min-width: 0;
}
.jpdb-reader-audio-source-fields {
display: grid;
gap: 6px;
}
.jpdb-reader-row-tools {
display: flex;
gap: 5px;
justify-content: flex-end;
flex-wrap: nowrap;
min-width: max-content;
}
.jpdb-reader-drag-handle {
cursor: grab;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.jpdb-reader-drag-handle:active,
.jpdb-reader-order-row-dragging .jpdb-reader-drag-handle {
cursor: grabbing;
}
.jpdb-reader-order-row-drag-pending,
.jpdb-reader-order-row-dragging {
user-select: none;
-webkit-user-select: none;
}
.jpdb-reader-order-row-dragging {
border-color: var(--jpdb-reader-accent);
box-shadow: 0 14px 30px color-mix(in srgb, var(--jpdb-reader-accent) 18%, transparent);
z-index: 2;
}
.jpdb-reader-icon-mini {
display: inline-grid !important;
place-items: center !important;
width: 34px !important;
min-width: 34px !important;
max-width: 34px !important;
height: 34px !important;
min-height: 34px !important;
max-height: 34px !important;
padding: 0 !important;
border: 1px solid var(--jpdb-reader-border);
border-radius: 7px;
background: transparent;
color: var(--jpdb-reader-text);
cursor: pointer;
font: 800 13px/1 var(--jpdb-reader-font);
transform: translateY(-0.01rem);
}
.jpdb-reader-icon-mini svg {
display: block;
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.jpdb-reader-icon-mini:hover,
.jpdb-reader-icon-mini:focus-visible {
border-color: var(--jpdb-reader-accent);
box-shadow: 0 8px 18px
color-mix(in srgb, var(--jpdb-reader-accent) 22%, transparent);
color: var(--jpdb-reader-accent);
transform: translateY(-0.25rem);
outline: none;
}
.jpdb-reader-icon-mini:active {
transform: scale(0.98);
}
@media (min-width: 700px) and (max-width: 1180px) and (hover: hover) and (pointer: fine) {
.jpdb-reader-settings {
width: min(860px, calc(100vw - 44px));
max-height: min(820px, calc(100vh - 44px));
max-height: min(820px, calc(100dvh - 44px));
}
.jpdb-reader-settings-head {
padding: 20px 22px 0;
}
.jpdb-reader-settings-tabs {
padding-inline: 22px;
}
.jpdb-reader-settings-scroll {
padding: 0 22px 104px;
}
.jpdb-reader-dictionary-head,
.jpdb-reader-dictionary-row,
.jpdb-reader-audio-source-head,
.jpdb-reader-audio-source-row,
.jpdb-reader-lookup-link-head,
.jpdb-reader-lookup-link-row {
grid-template-columns: 46px minmax(0, 0.95fr) minmax(0, 1.1fr) max-content 42px;
}
.jpdb-reader-dictionary-head.no-remove,
.jpdb-reader-dictionary-row.no-remove {
grid-template-columns: 46px minmax(0, 0.95fr) minmax(0, 1.1fr) max-content;
}
.jpdb-reader-dictionary-head.compact,
.jpdb-reader-dictionary-row.compact {
grid-template-columns: 46px minmax(0, 1fr) max-content 42px;
}
.jpdb-reader-dictionary-head.compact.no-remove,
.jpdb-reader-dictionary-row.compact.no-remove {
grid-template-columns: 46px minmax(0, 1fr) max-content;
}
.jpdb-reader-recommended-item {
grid-template-columns: minmax(0, 1fr) minmax(112px, 140px);
}
}
@media (pointer: coarse) and (min-width: 700px) {
.jpdb-reader-settings {
inset: auto 0 0 0;
left: 0;
right: 0;
top: auto;
bottom: 0;
transform: none;
width: 100%;
height: var(--jpdb-reader-settings-drawer-height, 88svh);
min-height: var(--jpdb-reader-settings-drawer-min-height, 280px);
max-height: var(--jpdb-reader-settings-drawer-viewport-height, 100svh);
border-radius: 18px 18px 0 0;
}
.jpdb-reader-settings.jpdb-reader-settings-drawer-resizing {
user-select: none;
}
.jpdb-reader-settings.jpdb-reader-settings-drawer-expanded {
border-radius: 0;
}
.jpdb-reader-settings-drag-handle {
display: block;
}
.jpdb-reader-settings-head {
padding: 8px 24px 0;
}
.jpdb-reader-settings-tabs {
padding: 0 24px 10px;
}
.jpdb-reader-settings-scroll {
padding: 0 24px 108px;
}
}
@media (max-width: 699px) {
.jpdb-reader-settings {
inset: auto 0 0 0;
left: 0;
right: 0;
top: auto;
bottom: 0;
transform: none;
width: 100%;
height: var(--jpdb-reader-settings-drawer-height, 88svh);
min-height: var(--jpdb-reader-settings-drawer-min-height, 280px);
max-height: var(--jpdb-reader-settings-drawer-viewport-height, 100svh);
border-radius: 16px 16px 0 0;
}
.jpdb-reader-settings.jpdb-reader-settings-drawer-resizing {
user-select: none;
}
.jpdb-reader-settings.jpdb-reader-settings-drawer-expanded {
border-radius: 0;
}
.jpdb-reader-settings-drag-handle {
display: block;
}
.jpdb-reader-settings-head {
padding: 8px 14px 0;
}
.jpdb-reader-settings-tabs {
padding: 0 14px 8px;
}
.jpdb-reader-settings-scroll {
padding: 0 14px 100px;
}
.jpdb-reader-settings fieldset {
margin: 10px 0;
padding: 11px;
}
.jpdb-reader-settings .grid,
.jpdb-reader-settings-actions,
.jpdb-reader-template-preview-grid,
.jpdb-reader-recommended-item {
grid-template-columns: 1fr;
}
.jpdb-reader-help-actions {
gap: 6px;
}
.jpdb-reader-help-actions .jpdb-reader-btn {
min-height: 40px;
padding-inline: 6px;
font-size: 11px;
}
.jpdb-reader-order-head {
display: none;
}
.jpdb-reader-dictionary-row,
.jpdb-reader-dictionary-row.compact,
.jpdb-reader-audio-source-row,
.jpdb-reader-lookup-link-row {
grid-template-columns: 42px minmax(0, 1fr) 42px;
align-items: center;
gap: 8px;
}
.jpdb-reader-dictionary-row > .jpdb-reader-order-toggle,
.jpdb-reader-audio-source-row > .jpdb-reader-order-toggle,
.jpdb-reader-lookup-link-row > .jpdb-reader-order-toggle {
grid-column: 1;
grid-row: 1;
}
.jpdb-reader-dictionary-row > .jpdb-reader-field-display,
.jpdb-reader-audio-source-choice,
.jpdb-reader-lookup-link-row > input[name$=".label"] {
grid-column: 2 / -1;
grid-row: 1;
}
.jpdb-reader-dictionary-row > input[name$=".alias"],
.jpdb-reader-dictionary-row > .jpdb-reader-field-display[aria-label="Display name"],
.jpdb-reader-audio-source-fields,
.jpdb-reader-lookup-link-row > input[name$=".urlTemplate"],
.jpdb-reader-lookup-link-note {
grid-column: 2 / -1;
grid-row: 2;
}
.jpdb-reader-row-order-tools {
grid-column: 2;
grid-row: 3;
justify-content: flex-start;
min-width: 0;
}
.jpdb-reader-row-remove-tools {
grid-column: 3;
grid-row: 3;
align-self: start;
justify-content: flex-end;
}
.jpdb-reader-audio-source-row .jpdb-reader-row-tools {
grid-column: auto;
justify-content: flex-start;
flex-wrap: nowrap;
min-width: 0;
}
.jpdb-reader-audio-source-row .jpdb-reader-row-order-tools,
.jpdb-reader-lookup-link-row .jpdb-reader-row-order-tools {
grid-column: 2;
grid-row: 3;
}
.jpdb-reader-audio-source-row .jpdb-reader-row-remove-tools,
.jpdb-reader-lookup-link-row .jpdb-reader-row-remove-tools {
grid-column: 3;
grid-row: 3;
}
.jpdb-reader-audio-source-row .jpdb-reader-icon-mini {
width: 36px !important;
min-width: 36px !important;
max-width: 36px !important;
height: 36px !important;
min-height: 36px !important;
max-height: 36px !important;
}
.jpdb-reader-lookup-link-row .jpdb-reader-row-tools {
justify-content: flex-start;
}
.jpdb-reader-dictionary-row-help {
grid-column: 1 / -1;
}
.jpdb-reader-settings .footer {
justify-content: stretch;
gap: 12px;
padding: 12px 14px calc(14px + env(safe-area-inset-bottom));
}
.jpdb-reader-settings .footer .jpdb-reader-btn {
flex: 1 1 0;
min-width: 0;
}
}
.jpdb-reader-icon-mini[data-immersion-action="previous"],
.jpdb-reader-icon-mini[data-immersion-action="next"],
.jpdb-reader-icon-mini[data-yomu-immersion-action="previous"],
.jpdb-reader-icon-mini[data-yomu-immersion-action="next"],
.jpdb-reader-icon-mini[data-uchisen-action="previous"],
.jpdb-reader-icon-mini[data-uchisen-action="next"],
.jpdb-reader-icon-mini[data-action="kanji-prev"],
.jpdb-reader-icon-mini[data-action="kanji-next"] {
font-size: 19px;
line-height: 0;
padding-bottom: 2px !important;
}
.jpdb-subtitle-player {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 2147483644;
pointer-events: none;
--subtitle-font-size-target: 28px;
--subtitle-font-size: var(--subtitle-font-size-target);
--subtitle-bottom: 12%;
--subtitle-color: #fff;
--subtitle-outline: #000;
--subtitle-background-rgba: transparent;
--subtitle-family: var(--jpdb-reader-font);
--subtitle-weight: 760;
}
.jpdb-subtitle-text {
position: absolute;
left: 14px;
right: 14px;
bottom: var(--subtitle-bottom);
color: var(--subtitle-color);
text-align: center;
font: var(--subtitle-weight) var(--subtitle-font-size)/1.36 var(--subtitle-family);
text-shadow:
0 1px 2px var(--subtitle-outline),
0 0 3px var(--subtitle-outline),
0 0 8px rgba(0,0,0,.92),
0 3px 12px rgba(0,0,0,.72);
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: keep-all;
max-height: min(45%, calc(100% - 24px));
overflow: hidden;
pointer-events: auto;
-webkit-tap-highlight-color: transparent;
}
.jpdb-subtitle-status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.jpdb-subtitle-primary {
display: inline;
padding: 0 .06em;
border-radius: 3px;
background: var(--subtitle-background-rgba);
box-shadow: none;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
line-height: 1.58;
overflow-wrap: normal;
word-break: keep-all;
-webkit-text-stroke: .02em color-mix(in srgb, var(--subtitle-outline) 78%, transparent);
paint-order: stroke fill;
}
.jpdb-subtitle-secondary {
display: block;
width: fit-content;
max-width: 100%;
margin-top: 8px;
margin-left: auto;
margin-right: auto;
padding: 0;
border: 0 !important;
background: transparent !important;
color: rgba(255,255,255,.82);
font-size: .62em;
font-family: inherit;
font-weight: 650;
line-height: 1.25;
min-height: 24px;
text-shadow: 0 2px 2px #000, 0 0 7px rgba(0,0,0,.86);
cursor: pointer;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.jpdb-subtitle-secondary-blurred {
filter: blur(5px);
opacity: .8;
}
.jpdb-subtitle-secondary-blurred:hover,
.jpdb-subtitle-secondary-blurred:focus-visible {
filter: none;
opacity: 1;
}
.jpdb-subtitle-karaoke-word {
display: inline;
border-radius: 3px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.jpdb-subtitle-word-pending {
opacity: .42;
}
.jpdb-subtitle-word-spoken,
.jpdb-subtitle-word-current {
opacity: 1;
color: var(--subtitle-color);
}
.jpdb-subtitle-word-spoken {
color: color-mix(in srgb, var(--jpdb-reader-accent, #5ea780) 58%, var(--subtitle-color));
text-decoration-line: underline;
text-decoration-color: var(--jpdb-reader-accent, #5ea780);
text-decoration-thickness: .09em;
text-underline-offset: .16em;
}
.jpdb-subtitle-word-current {
text-decoration-line: underline;
text-decoration-color: var(--subtitle-color);
text-decoration-thickness: .1em;
text-underline-offset: .16em;
}
.jpdb-subtitle-primary .jpdb-reader-word {
--jpdb-reader-subtitle-fallback: var(--subtitle-color);
background: transparent !important;
color: var(--subtitle-color) !important;
--jpdb-reader-word-underline: transparent;
text-decoration-line: none !important;
text-decoration-style: solid !important;
text-decoration-color: var(--jpdb-reader-word-underline, transparent) !important;
text-decoration-thickness: .08em !important;
text-underline-offset: .15em !important;
white-space: nowrap;
text-shadow:
0 1px 2px var(--subtitle-outline),
0 0 3px var(--subtitle-outline),
0 0 8px rgba(0,0,0,.92),
0 3px 12px rgba(0,0,0,.72);
-webkit-text-stroke: .02em color-mix(in srgb, var(--subtitle-outline) 78%, transparent);
paint-order: stroke fill;
}
.jpdb-subtitle-primary .jpdb-reader-word:hover,
.jpdb-subtitle-primary .jpdb-reader-word:focus-visible {
background: rgba(255,255,255,.14) !important;
}
.jpdb-subtitle-primary .jpdb-reader-word.jpdb-reader-has-furi {
line-height: 1.48;
}
.jpdb-reader-subtitle-highlight-status { --jpdb-reader-subtitle-highlight: var(--jpdb-reader-source-status-soft, transparent); }
.jpdb-reader-subtitle-highlight-jpdb { --jpdb-reader-subtitle-highlight: var(--jpdb-reader-source-jpdb-soft, transparent); }
.jpdb-reader-subtitle-highlight-anki { --jpdb-reader-subtitle-highlight: var(--jpdb-reader-source-anki-soft, transparent); }
.jpdb-reader-subtitle-highlight-pitch { --jpdb-reader-subtitle-highlight: var(--jpdb-reader-source-pitch-soft, transparent); }
.jpdb-reader-subtitle-underline-status { --jpdb-reader-subtitle-decoration: var(--jpdb-reader-source-status-decoration, transparent); }
.jpdb-reader-subtitle-underline-jpdb { --jpdb-reader-subtitle-decoration: var(--jpdb-reader-source-jpdb-decoration, transparent); }
.jpdb-reader-subtitle-underline-anki { --jpdb-reader-subtitle-decoration: var(--jpdb-reader-source-anki-decoration, transparent); }
.jpdb-reader-subtitle-underline-pitch { --jpdb-reader-subtitle-decoration: var(--jpdb-reader-source-pitch-decoration, transparent); }
.jpdb-reader-subtitle-text-status { --jpdb-reader-subtitle-text: var(--jpdb-reader-source-status-color, var(--jpdb-reader-subtitle-fallback, currentColor)); }
.jpdb-reader-subtitle-text-jpdb { --jpdb-reader-subtitle-text: var(--jpdb-reader-source-jpdb-color, var(--jpdb-reader-subtitle-fallback, currentColor)); }
.jpdb-reader-subtitle-text-anki { --jpdb-reader-subtitle-text: var(--jpdb-reader-source-anki-color, var(--jpdb-reader-subtitle-fallback, currentColor)); }
.jpdb-reader-subtitle-text-pitch { --jpdb-reader-subtitle-text: var(--jpdb-reader-source-pitch-color, var(--jpdb-reader-subtitle-fallback, currentColor)); }
:is(.jpdb-reader-subtitle-highlight-status, .jpdb-reader-subtitle-highlight-jpdb, .jpdb-reader-subtitle-highlight-anki, .jpdb-reader-subtitle-highlight-pitch) :is(.jpdb-subtitle-primary, .jpdb-subtitle-row-text, .asbplayer-subtitles-container-bottom) .jpdb-reader-word {
background: var(--jpdb-reader-subtitle-highlight, transparent) !important;
}
:is(.jpdb-reader-subtitle-underline-status, .jpdb-reader-subtitle-underline-jpdb, .jpdb-reader-subtitle-underline-anki, .jpdb-reader-subtitle-underline-pitch) :is(.jpdb-subtitle-primary, .jpdb-subtitle-row-text, .asbplayer-subtitles-container-bottom) .jpdb-reader-word {
--jpdb-reader-word-underline: var(--jpdb-reader-subtitle-decoration, transparent);
text-decoration-line: underline !important;
}
:is(.jpdb-reader-subtitle-text-status, .jpdb-reader-subtitle-text-jpdb, .jpdb-reader-subtitle-text-anki, .jpdb-reader-subtitle-text-pitch) :is(.jpdb-subtitle-primary, .jpdb-subtitle-row-text, .asbplayer-subtitles-container-bottom) .jpdb-reader-word {
color: var(--jpdb-reader-subtitle-text, var(--jpdb-reader-subtitle-fallback, currentColor)) !important;
}
.jpdb-reader-subtitle-highlight-status :is(.jpdb-subtitle-primary, .jpdb-subtitle-row-text, .asbplayer-subtitles-container-bottom) .jpdb-reader-word {
background: var(--jpdb-reader-source-status-soft, transparent) !important;
}
.jpdb-reader-subtitle-highlight-jpdb :is(.jpdb-subtitle-primary, .jpdb-subtitle-row-text, .asbplayer-subtitles-container-bottom) .jpdb-reader-word {
background: var(--jpdb-reader-source-jpdb-soft, transparent) !important;
}
.jpdb-reader-subtitle-highlight-anki :is(.jpdb-subtitle-primary, .jpdb-subtitle-row-text, .asbplayer-subtitles-container-bottom) .jpdb-reader-word {
background: var(--jpdb-reader-source-anki-soft, transparent) !important;
}
.jpdb-reader-subtitle-highlight-pitch :is(.jpdb-subtitle-primary, .jpdb-subtitle-row-text, .asbplayer-subtitles-container-bottom) .jpdb-reader-word {
background: var(--jpdb-reader-source-pitch-soft, transparent) !important;
}
.jpdb-reader-subtitle-underline-status :is(.jpdb-subtitle-primary, .jpdb-subtitle-row-text, .asbplayer-subtitles-container-bottom) .jpdb-reader-word {
--jpdb-reader-word-underline: var(--jpdb-reader-source-status-decoration, transparent);
text-decoration-line: underline !important;
}
.jpdb-reader-subtitle-underline-jpdb :is(.jpdb-subtitle-primary, .jpdb-subtitle-row-text, .asbplayer-subtitles-container-bottom) .jpdb-reader-word {
--jpdb-reader-word-underline: var(--jpdb-reader-source-jpdb-decoration, transparent);
text-decoration-line: underline !important;
}
.jpdb-reader-subtitle-underline-anki :is(.jpdb-subtitle-primary, .jpdb-subtitle-row-text, .asbplayer-subtitles-container-bottom) .jpdb-reader-word {
--jpdb-reader-word-underline: var(--jpdb-reader-source-anki-decoration, transparent);
text-decoration-line: underline !important;
}
.jpdb-reader-subtitle-underline-pitch :is(.jpdb-subtitle-primary, .jpdb-subtitle-row-text, .asbplayer-subtitles-container-bottom) .jpdb-reader-word {
--jpdb-reader-word-underline: var(--jpdb-reader-source-pitch-decoration, transparent);
text-decoration-line: underline !important;
}
.jpdb-reader-subtitle-text-status :is(.jpdb-subtitle-primary, .jpdb-subtitle-row-text, .asbplayer-subtitles-container-bottom) .jpdb-reader-word {
color: var(--jpdb-reader-source-status-color, var(--jpdb-reader-subtitle-fallback, currentColor)) !important;
}
.jpdb-reader-subtitle-text-jpdb :is(.jpdb-subtitle-primary, .jpdb-subtitle-row-text, .asbplayer-subtitles-container-bottom) .jpdb-reader-word {
color: var(--jpdb-reader-source-jpdb-color, var(--jpdb-reader-subtitle-fallback, currentColor)) !important;
}
.jpdb-reader-subtitle-text-anki :is(.jpdb-subtitle-primary, .jpdb-subtitle-row-text, .asbplayer-subtitles-container-bottom) .jpdb-reader-word {
color: var(--jpdb-reader-source-anki-color, var(--jpdb-reader-subtitle-fallback, currentColor)) !important;
}
.jpdb-reader-subtitle-text-pitch :is(.jpdb-subtitle-primary, .jpdb-subtitle-row-text, .asbplayer-subtitles-container-bottom) .jpdb-reader-word {
color: var(--jpdb-reader-source-pitch-color, var(--jpdb-reader-subtitle-fallback, currentColor)) !important;
}
.jpdb-subtitle-primary .jpdb-reader-word.jpdb-subtitle-word-pending { opacity: .42; }
.jpdb-subtitle-primary .jpdb-reader-word.jpdb-subtitle-word-spoken {
opacity: 1;
--jpdb-reader-word-underline: var(--jpdb-reader-source-pitch-decoration, var(--jpdb-reader-accent, #5ea780));
text-decoration-line: underline !important;
text-decoration-color: var(--jpdb-reader-word-underline, var(--jpdb-reader-accent, #5ea780)) !important;
}
.jpdb-subtitle-primary .jpdb-reader-word.jpdb-subtitle-word-current {
opacity: 1;
text-decoration-line: underline !important;
text-decoration-color: var(--subtitle-color) !important;
}
.jpdb-subtitle-primary .jpdb-reader-word.jpdb-subtitle-word-pending {
filter: saturate(.72);
}
.jpdb-subtitle-primary .jpdb-reader-furi {
color: currentColor;
opacity: .82;
text-shadow:
0 1px 1px var(--subtitle-outline),
0 0 3px var(--subtitle-outline),
0 0 7px rgba(0,0,0,.86);
}
.jpdb-subtitle-primary-loading {
opacity: .78;
color: currentColor;
}
.jpdb-reader-subtitle-preview {
min-height: 94px;
padding: 18px 10px;
border: 1px solid var(--jpdb-reader-border);
border-radius: 10px;
background:
linear-gradient(135deg, rgba(255,255,255,.10) 25%, transparent 25% 50%, rgba(255,255,255,.10) 50% 75%, transparent 75%) 0 0 / 28px 28px,
#1c222b;
display: grid;
place-items: center;
text-align: center;
overflow: hidden;
color: var(--subtitle-color);
font: var(--subtitle-weight) var(--subtitle-font-size)/1.36 var(--subtitle-family);
text-shadow:
0 1px 2px var(--subtitle-outline),
0 0 3px var(--subtitle-outline),
0 0 8px rgba(0,0,0,.86);
}
.jpdb-reader-subtitle-preview .jpdb-subtitle-primary {
display: inline;
}
.jpdb-reader-subtitle-preview .jpdb-reader-word {
--jpdb-reader-source-status-color: var(--jpdb-reader-status-color, var(--subtitle-color));
--jpdb-reader-source-status-decoration: var(--jpdb-reader-status-color, transparent);
--jpdb-reader-source-jpdb-color: var(--jpdb-reader-jpdb-color, var(--subtitle-color));
--jpdb-reader-source-jpdb-decoration: var(--jpdb-reader-jpdb-color, transparent);
--jpdb-reader-source-pitch-color: var(--jpdb-reader-pitch-color, var(--subtitle-color));
--jpdb-reader-source-pitch-decoration: var(--jpdb-reader-pitch-color, transparent);
}
.jpdb-reader-subtitle-preview .jpdb-reader-word:is(.jpdb-new, .jpdb-suspended, .jpdb-not-in-deck) {
--jpdb-reader-jpdb-color: var(--jpdb-reader-state-new, #58a6ff);
--jpdb-reader-status-color: var(--jpdb-reader-state-new, #58a6ff);
}
.jpdb-reader-subtitle-preview .jpdb-reader-word.jpdb-learning {
--jpdb-reader-jpdb-color: var(--jpdb-reader-state-learning, #ffd166);
--jpdb-reader-status-color: var(--jpdb-reader-state-learning, #ffd166);
}
.jpdb-reader-subtitle-preview .jpdb-reader-word:is(.jpdb-known, .jpdb-never-forget, .jpdb-redundant) {
--jpdb-reader-jpdb-color: var(--jpdb-reader-state-known, #7bd88f);
--jpdb-reader-status-color: var(--jpdb-reader-state-known, #7bd88f);
}
.jpdb-reader-subtitle-preview .jpdb-reader-word.jpdb-due {
--jpdb-reader-jpdb-color: var(--jpdb-reader-state-due, #5fb3b3);
--jpdb-reader-status-color: var(--jpdb-reader-state-due, #5fb3b3);
}
.jpdb-reader-subtitle-preview .jpdb-reader-word.jpdb-pitch-heiban {
--jpdb-reader-pitch-color: var(--jpdb-reader-pitch-heiban, #359eff);
}
.jpdb-reader-subtitle-preview .jpdb-reader-word.jpdb-pitch-atamadaka {
--jpdb-reader-pitch-color: var(--jpdb-reader-pitch-atamadaka, #fe4b74);
}
.jpdb-reader-subtitle-preview .jpdb-reader-word.jpdb-pitch-nakadaka {
--jpdb-reader-pitch-color: var(--jpdb-reader-pitch-nakadaka, #fba840);
}
.jpdb-reader-subtitle-preview .jpdb-reader-word.jpdb-pitch-odaka {
--jpdb-reader-pitch-color: var(--jpdb-reader-pitch-odaka, #57ccb7);
}
.jpdb-reader-subtitle-preview.jpdb-reader-subtitle-text-status .jpdb-subtitle-primary .jpdb-reader-word {
color: var(--jpdb-reader-source-status-color, var(--subtitle-color)) !important;
}
.jpdb-reader-subtitle-preview.jpdb-reader-subtitle-text-jpdb .jpdb-subtitle-primary .jpdb-reader-word {
color: var(--jpdb-reader-source-jpdb-color, var(--subtitle-color)) !important;
}
.jpdb-reader-subtitle-preview.jpdb-reader-subtitle-text-pitch .jpdb-subtitle-primary .jpdb-reader-word {
color: var(--jpdb-reader-source-pitch-color, var(--subtitle-color)) !important;
}
.jpdb-reader-subtitle-preview.jpdb-reader-subtitle-underline-status .jpdb-subtitle-primary .jpdb-reader-word {
--jpdb-reader-word-underline: var(--jpdb-reader-source-status-decoration, transparent);
text-decoration-line: underline !important;
text-decoration-color: var(--jpdb-reader-word-underline, transparent) !important;
}
.jpdb-reader-subtitle-preview.jpdb-reader-subtitle-underline-jpdb .jpdb-subtitle-primary .jpdb-reader-word {
--jpdb-reader-word-underline: var(--jpdb-reader-source-jpdb-decoration, transparent);
text-decoration-line: underline !important;
text-decoration-color: var(--jpdb-reader-word-underline, transparent) !important;
}
.jpdb-reader-subtitle-preview.jpdb-reader-subtitle-underline-pitch .jpdb-subtitle-primary .jpdb-reader-word {
--jpdb-reader-word-underline: var(--jpdb-reader-source-pitch-decoration, transparent);
text-decoration-line: underline !important;
text-decoration-color: var(--jpdb-reader-word-underline, transparent) !important;
}
.jpdb-subtitle-rail {
position: absolute;
right: max(10px, env(safe-area-inset-right));
top: 10px;
display: flex;
align-items: center;
gap: 4px;
max-width: calc(100% - 20px);
padding: 4px;
border: 1px solid var(--jpdb-reader-border, rgba(255,255,255,.14));
border-radius: 8px;
background: color-mix(in srgb, var(--jpdb-reader-surface, #20242b) 82%, transparent);
box-shadow: 0 12px 30px rgba(0,0,0,.22);
backdrop-filter: blur(14px);
pointer-events: auto;
opacity: .78;
}
.jpdb-subtitle-controls-hidden .jpdb-subtitle-menu,
.jpdb-subtitle-controls-hidden .jpdb-subtitle-list {
display: none !important;
}
.jpdb-subtitle-controls-hidden .jpdb-subtitle-rail {
opacity: 0;
pointer-events: none;
}
.jpdb-subtitle-controls-hidden .jpdb-subtitle-rail:hover,
.jpdb-subtitle-controls-hidden .jpdb-subtitle-rail:focus-within {
opacity: 1;
}
.jpdb-subtitle-controls-auto .jpdb-subtitle-rail:not(:hover):not(:focus-within) { opacity: .72; }
.jpdb-subtitle-controls-auto.jpdb-subtitle-controls-idle:not(.jpdb-subtitle-menu-open):not(.jpdb-subtitle-panel-open) .jpdb-subtitle-rail:not(:hover):not(:focus-within) {
opacity: 0;
pointer-events: none;
}
.jpdb-subtitle-controls-always .jpdb-subtitle-rail {
opacity: 1;
}
.jpdb-subtitle-rail:hover,
.jpdb-subtitle-menu-open .jpdb-subtitle-rail,
.jpdb-subtitle-panel-open .jpdb-subtitle-rail {
opacity: 1;
}
.jpdb-subtitle-rail button,
.jpdb-subtitle-menu button,
.jpdb-subtitle-list button {
border: 1px solid var(--jpdb-reader-border, rgba(255,255,255,.16));
border-radius: 7px;
background: color-mix(in srgb, var(--jpdb-reader-surface, #20242b) 86%, transparent);
color: var(--jpdb-reader-text, #fff);
box-shadow: none;
font: 700 12px/1 var(--jpdb-reader-font);
pointer-events: auto;
}
.jpdb-subtitle-rail button {
display: inline-grid;
place-items: center;
min-width: 34px;
width: 34px;
max-width: 34px;
min-height: 34px;
height: 34px;
max-height: 34px;
padding: 0;
flex: 0 0 auto;
white-space: nowrap;
}
.jpdb-subtitle-rail button[data-action="previous"],
.jpdb-subtitle-rail button[data-action="next"] {
font-size: 19px;
line-height: 0;
padding-bottom: 2px;
}
.jpdb-subtitle-panel-toggle {
border-color: color-mix(in srgb, var(--jpdb-reader-accent) 50%, var(--jpdb-reader-border, rgba(255,255,255,.22))) !important;
background: color-mix(in srgb, var(--jpdb-reader-accent) 18%, var(--jpdb-reader-surface, #20242b)) !important;
}
.jpdb-subtitle-icon {
width: 17px;
height: 17px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.jpdb-subtitle-panel-toggle[aria-pressed="false"] {
color: var(--jpdb-reader-muted, rgba(255,255,255,.72));
border-color: var(--jpdb-reader-border, rgba(255,255,255,.18)) !important;
background: color-mix(in srgb, var(--jpdb-reader-surface, #20242b) 86%, transparent) !important;
}
.jpdb-subtitle-rail button[hidden],
.jpdb-subtitle-menu button[hidden] {
display: none !important;
}
.jpdb-subtitle-rail button:disabled {
opacity: .45;
}
.jpdb-subtitle-compact-video .jpdb-subtitle-rail {
top: 8px;
right: 8px;
gap: 3px;
padding: 3px;
}
.jpdb-subtitle-compact-video .jpdb-subtitle-rail button {
min-width: 32px;
width: 32px;
max-width: 32px;
min-height: 32px;
height: 32px;
max-height: 32px;
}
.jpdb-subtitle-menu {
position: absolute;
right: max(10px, env(safe-area-inset-right));
top: 50px;
display: grid;
gap: 6px;
width: min(230px, calc(100vw - 24px));
padding: 8px;
border: 1px solid var(--jpdb-reader-border, rgba(255,255,255,.18));
border-radius: 8px;
background: color-mix(in srgb, var(--jpdb-reader-bg, #181b20) 92%, transparent);
box-shadow: 0 14px 34px rgba(0,0,0,.28);
pointer-events: auto;
}
.jpdb-subtitle-menu[hidden],
.jpdb-subtitle-list[hidden] { display: none; }
.jpdb-subtitle-menu-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 0 0 2px;
color: var(--jpdb-reader-muted, rgba(255,255,255,.78));
font: 800 12px/1 var(--jpdb-reader-font);
}
.jpdb-subtitle-menu button {
min-height: 36px;
text-align: left;
padding: 0 10px;
box-shadow: none;
}
.jpdb-subtitle-menu button[aria-pressed="true"],
.jpdb-subtitle-track-row button[aria-pressed="true"] {
border-color: var(--jpdb-reader-accent);
color: var(--jpdb-reader-text, #fff);
background: color-mix(in srgb, var(--jpdb-reader-accent) 18%, var(--jpdb-reader-surface, #20242b));
}
.jpdb-subtitle-list {
position: fixed;
right: max(12px, env(safe-area-inset-right));
top: 72px;
width: min(460px, calc(100vw - 24px));
height: min(78vh, 860px);
max-height: calc(100vh - 24px);
overflow: hidden;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border: 1px solid color-mix(in srgb, var(--jpdb-reader-border, rgba(255,255,255,.18)) 88%, rgba(255,255,255,.10));
border-radius: 22px;
background: color-mix(in srgb, var(--jpdb-reader-bg, #181b20) 94%, rgba(255,255,255,.06));
color: var(--jpdb-reader-text, #fff);
box-shadow: 0 24px 56px rgba(0,0,0,.32);
pointer-events: auto;
backdrop-filter: blur(18px) saturate(1.08);
-webkit-backdrop-filter: blur(18px) saturate(1.08);
transform: translateZ(0);
}
html.jpdb-subtitle-yomu-captions-active .ytp-caption-window-container,
html.jpdb-subtitle-yomu-captions-active .caption-window,
html.jpdb-subtitle-yomu-captions-active .ytp-caption-segment {
display: none !important;
}
html.jpdb-subtitle-fullscreen .jpdb-subtitle-list,
html.jpdb-subtitle-fullscreen .jpdb-reader-fab {
display: none !important;
}
.jpdb-subtitle-resize {
position: absolute;
z-index: 2;
min-width: 24px;
min-height: 24px;
padding: 0 !important;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
touch-action: none;
}
.jpdb-subtitle-resize::after {
content: "";
position: absolute;
border-radius: 999px;
background: rgba(255,255,255,.22);
opacity: 0;
}
.jpdb-subtitle-resize:hover::after,
.jpdb-subtitle-resize:focus-visible::after {
background: var(--jpdb-reader-accent);
opacity: .88;
}
.jpdb-subtitle-transcript-right .jpdb-subtitle-resize {
left: -12px;
top: 0;
bottom: 0;
width: 24px;
cursor: ew-resize;
}
.jpdb-subtitle-transcript-left .jpdb-subtitle-resize {
right: -12px;
top: 0;
bottom: 0;
width: 24px;
cursor: ew-resize;
}
.jpdb-subtitle-transcript-right .jpdb-subtitle-resize::after,
.jpdb-subtitle-transcript-left .jpdb-subtitle-resize::after {
top: 16px;
bottom: 16px;
left: 11px;
width: 2px;
}
.jpdb-subtitle-transcript-bottom .jpdb-subtitle-resize {
left: 0;
right: 0;
top: -12px;
height: 24px;
cursor: ns-resize;
}
.jpdb-subtitle-transcript-bottom .jpdb-subtitle-resize::after {
left: 16px;
right: 16px;
top: 11px;
height: 2px;
}
.jpdb-subtitle-drawer-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 12px;
padding: 14px 14px 12px;
border-bottom: 1px solid var(--jpdb-reader-border, rgba(255,255,255,.12));
background: color-mix(in srgb, var(--jpdb-reader-surface, #20242b) 76%, transparent);
}
.jpdb-subtitle-drawer-brand {
display: grid;
gap: 4px;
min-width: 0;
}
.jpdb-subtitle-drawer-title {
min-width: 0;
color: var(--jpdb-reader-text, #fff);
font: 800 15px/1.2 var(--jpdb-reader-font);
letter-spacing: .01em;
}
.jpdb-subtitle-drawer-meta {
min-width: 0;
color: var(--jpdb-reader-muted, rgba(255,255,255,.78));
font: 700 11px/1.35 var(--jpdb-reader-font);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.jpdb-subtitle-drawer-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.jpdb-subtitle-drawer-close {
display: inline-grid;
place-items: center;
min-width: 34px;
width: 34px;
min-height: 34px;
height: 34px;
padding: 0 !important;
border-radius: 12px !important;
}
.jpdb-subtitle-drawer-close svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.jpdb-subtitle-panel-mode,
.jpdb-subtitle-panel-nav {
justify-self: start;
width: max-content;
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 2px;
padding: 2px;
border: 1px solid var(--jpdb-reader-border, rgba(255,255,255,.14));
border-radius: 12px;
background: color-mix(in srgb, var(--jpdb-reader-surface, #20242b) 82%, transparent);
}
.jpdb-subtitle-panel-mode button {
min-height: 30px;
padding: 0 11px;
border: 0;
border-radius: 10px;
background: transparent;
text-align: center;
box-shadow: none;
font-size: 11px;
font-weight: 700;
}
.jpdb-subtitle-panel-mode button[aria-pressed="true"] {
color: var(--jpdb-reader-text, #fff);
background: color-mix(in srgb, var(--jpdb-reader-accent) 18%, var(--jpdb-reader-surface, #20242b));
}
.jpdb-subtitle-panel-mode button:disabled {
opacity: .42;
}
.jpdb-subtitle-panel-nav button {
display: inline-grid;
place-items: center;
min-width: 32px;
width: 32px;
min-height: 32px;
height: 32px;
padding: 0 0 2px;
border: 0;
border-radius: 10px;
background: transparent;
text-align: center;
box-shadow: none;
font-size: 19px;
line-height: 0;
}
.jpdb-subtitle-panel-nav button:disabled {
opacity: .38;
}
.jpdb-subtitle-list-scroll {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
display: grid;
align-content: start;
grid-auto-rows: max-content;
gap: 4px;
padding: 8px 10px 12px;
overscroll-behavior: contain;
}
.jpdb-subtitle-list-row {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: start;
gap: 10px;
min-height: 76px;
padding: 16px 18px;
border: 0;
border-bottom: 1px solid var(--jpdb-reader-border, rgba(255,255,255,.15));
border-radius: 0;
background: transparent;
cursor: pointer;
}
.jpdb-subtitle-row-body {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: 6px;
width: 100%;
min-height: 42px;
padding: 0;
text-align: left;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.jpdb-subtitle-row-tools {
align-self: start;
display: grid;
grid-template-columns: 30px auto;
align-items: center;
gap: 12px;
color: var(--jpdb-reader-muted, rgba(255,255,255,.82));
}
.jpdb-subtitle-row-copy {
display: inline-grid;
place-items: center;
min-width: 30px;
width: 30px;
min-height: 30px;
height: 30px;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
color: var(--jpdb-reader-muted, rgba(255,255,255,.82)) !important;
}
.jpdb-subtitle-list-row.active {
border-color: var(--jpdb-reader-border, rgba(255,255,255,.16));
background: color-mix(in srgb, var(--jpdb-reader-accent) 14%, transparent);
box-shadow: inset 2px 0 0 var(--jpdb-reader-accent);
}
.jpdb-subtitle-list .jpdb-reader-word {
color: inherit !important;
}
.jpdb-subtitle-row-time {
color: var(--jpdb-reader-muted, rgba(255,255,255,.82));
font-size: 16px;
line-height: 1.2;
font-weight: 760;
white-space: nowrap;
}
.jpdb-subtitle-row-text {
min-width: 0;
max-width: 100%;
grid-column: 1;
color: var(--jpdb-reader-text, #fff);
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
font-weight: 600;
line-height: 1.45;
font-size: 22px;
letter-spacing: 0;
text-shadow: 0 1px 1px rgba(0,0,0,.38);
}
.jpdb-subtitle-row-text .jpdb-reader-word {
--jpdb-reader-subtitle-fallback: var(--jpdb-reader-text, #fff);
color: inherit;
background: transparent;
text-decoration-color: var(--jpdb-reader-word-underline, currentColor);
text-decoration-thickness: .08em;
text-underline-offset: .16em;
border-radius: 4px;
padding: 0 1px;
white-space: normal !important;
overflow-wrap: anywhere;
word-break: break-word;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.jpdb-subtitle-list .jpdb-subtitle-row-text .jpdb-reader-word,
.jpdb-subtitle-list .jpdb-subtitle-row-text .jpdb-reader-word ruby {
color: inherit !important;
}
.jpdb-subtitle-row-text ruby,
.jpdb-subtitle-row-text rt,
.jpdb-subtitle-row-text .jpdb-reader-furi {
white-space: normal !important;
}
.jpdb-subtitle-row-text .jpdb-reader-word:hover,
.jpdb-subtitle-row-text .jpdb-reader-word:focus-visible {
background: var(--jpdb-reader-hover, rgba(255,255,255,.14));
outline: 1px solid var(--jpdb-reader-border, rgba(255,255,255,.28));
}
.jpdb-subtitle-row-translation {
grid-column: 1;
min-width: 0;
margin-top: 3px;
color: var(--jpdb-reader-muted, rgba(255,255,255,.68));
overflow-wrap: anywhere;
font-style: normal;
font-weight: 650;
line-height: 1.4;
}
.jpdb-subtitle-tracks-panel .jpdb-subtitle-list-scroll {
align-content: start;
}
.jpdb-subtitle-track-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 3px 8px;
padding: 7px 8px;
border: 1px solid var(--jpdb-reader-border, rgba(255,255,255,.14));
border-radius: 8px;
background: transparent;
}
.jpdb-subtitle-track-row.active {
border-color: color-mix(in srgb, var(--jpdb-reader-accent) 68%, var(--jpdb-reader-border, rgba(255,255,255,.14)));
background: color-mix(in srgb, var(--jpdb-reader-accent) 14%, transparent);
}
.jpdb-subtitle-track-row strong {
min-width: 0;
overflow-wrap: anywhere;
font-size: 11px;
line-height: 1.2;
}
.jpdb-subtitle-track-title {
grid-column: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
}
.jpdb-subtitle-track-title span {
padding: 1px 5px;
border-radius: 6px;
background: color-mix(in srgb, var(--jpdb-reader-surface, #20242b) 86%, transparent);
color: var(--jpdb-reader-muted, rgba(255,255,255,.72));
font-size: 8px;
text-transform: uppercase;
}
.jpdb-subtitle-track-row span {
grid-column: 1;
color: var(--jpdb-reader-faint, rgba(255,255,255,.62));
font-size: 9px;
font-weight: 700;
}
.jpdb-subtitle-track-actions {
grid-column: 2;
grid-row: 1 / span 2;
align-self: center;
display: flex;
gap: 5px;
}
.jpdb-subtitle-track-row button {
min-height: 26px;
padding-inline: 9px;
text-align: center;
box-shadow: none;
font-size: 10px;
}
.jpdb-subtitle-track-tools {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(142px, 1fr));
gap: 8px;
margin-bottom: 4px;
}
.jpdb-subtitle-track-tools button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 36px;
padding: 0 10px;
text-align: center;
line-height: 1.2;
white-space: normal;
overflow-wrap: anywhere;
box-shadow: none;
}
.jpdb-subtitle-track-summary {
margin: 0 0 3px;
padding: 5px 7px;
border: 1px dashed var(--jpdb-reader-border, rgba(255,255,255,.16));
border-radius: 8px;
color: var(--jpdb-reader-muted, rgba(255,255,255,.72));
background: transparent;
font: 750 11px/1.3 var(--jpdb-reader-font);
}
.jpdb-subtitle-list-empty {
padding: 12px;
color: var(--jpdb-reader-muted, rgba(255,255,255,.72));
font: 700 12px/1.35 var(--jpdb-reader-font);
}
.jpdb-subtitle-hidden .jpdb-subtitle-text { display: none; }
@media (max-width: 768px), (pointer: coarse) {
.jpdb-reader-btn { min-height: 44px; font-size: 13px; }
.jpdb-reader-onboarding {
inset: auto 0 0 0;
transform: none;
width: 100%;
max-height: 88vh;
max-height: 88svh;
border-radius: 16px 16px 0 0;
padding: 54px 20px calc(24px + env(safe-area-inset-bottom));
}
.jpdb-reader-onboarding-close {
top: 12px;
right: 16px;
}
.jpdb-reader-onboarding-grid { grid-template-columns: 1fr; }
.jpdb-reader-onboarding-actions { display: grid; grid-template-columns: 1fr; }
.jpdb-ocr-line { min-width: 0; min-height: 0; border-radius: 8px; }
.jpdb-subtitle-text { left: 8px; right: 8px; font-size: min(var(--subtitle-font-size), 8vw); }
.jpdb-subtitle-rail {
top: max(8px, env(safe-area-inset-top));
right: max(8px, env(safe-area-inset-right));
bottom: auto;
gap: 3px;
}
.jpdb-subtitle-rail button {
height: 34px;
min-height: 34px;
max-height: 34px;
min-width: 34px;
width: 34px;
max-width: 34px;
padding: 0;
font-size: 11px;
}
.jpdb-subtitle-menu {
top: calc(52px + env(safe-area-inset-top));
right: 8px;
bottom: auto;
}
.jpdb-subtitle-transcript-bottom .jpdb-subtitle-list {
left: 10px !important;
right: auto !important;
width: calc(100vw - 20px) !important;
border-radius: 20px 20px 16px 16px;
}
}
@media (max-width: 519px) {
.jpdb-subtitle-list {
left: 10px !important;
right: auto !important;
width: calc(100vw - 20px) !important;
border-radius: 20px 20px 16px 16px;
}
}
`;
const READER_CSS = readerCss;
const log$4 = Logger.scope("StudySources");
class StudySourceController {
constructor(dependencies) {
this.dependencies = dependencies;
}
renderTranslationSource(sentence) {
const settings = this.settings();
if (!sentence || !settings.studyTranslationEnabled) return "";
return `
${escapeHtml$1(uiText(settings.interfaceLanguage, "translation"))}
${this.renderTranslationPanel(sentence)}
`;
}
renderGrammarSource(sentence) {
const settings = this.settings();
if (!sentence || !settings.studyGrammarEnabled) return "";
return `
${escapeHtml$1(uiText(settings.interfaceLanguage, "grammar"))}
${this.renderGrammarPanel()}
`;
}
installLoaders(popover, sentence) {
this.installTranslationLoader(popover, sentence);
this.installGrammarLoader(popover, sentence);
}
async detectGrammarHints(sentence) {
return detectGrammarHints(sentence);
}
renderTranslationPanel(sentence) {
const language = this.settings().interfaceLanguage;
const readSentence = uiText(language, "readSentenceAloud");
return `
${escapeHtml$1(uiText(language, "japaneseLabel"))}
${speakerIcon()}
${escapeHtml$1(sentence)}
${escapeHtml$1(uiText(language, "meaning"))}
${escapeHtml$1(uiText(language, "openSectionToTranslate"))}
`;
}
renderGrammarPanel() {
const language = this.settings().interfaceLanguage;
return `
${escapeHtml$1(uiText(language, "findingGrammar"))}
`;
}
installGrammarLoader(popover, sentence) {
const containers = Array.from(popover.querySelectorAll("[data-study-grammar]"));
if (!containers.length || !sentence) return;
for (const container of containers) {
const load = () => {
if (!isStudyDetailsOpen(container) || container.dataset.loaded === "true" || container.dataset.loading === "true") return;
container.dataset.loading = "true";
void this.loadGrammar(popover, sentence, container).finally(() => {
if (!container.isConnected) return;
delete container.dataset.loading;
container.dataset.loaded = "true";
});
};
container.addEventListener("toggle", load);
container.parentElement?.closest("details")?.addEventListener("toggle", load);
load();
}
}
async loadGrammar(popover, sentence, container) {
const panel = container.querySelector("[data-study-grammar-panel]");
if (!panel) return;
try {
const hints = await this.detectGrammarHints(sentence);
if (!this.canRenderGrammar(popover, container)) return;
if (!hints.length) {
container.remove();
return;
}
setInnerHtml(panel, await renderGrammarHints(hints, sentence, void 0, this.settings().interfaceLanguage));
delete popover.dataset.jpdbReaderParseKey;
delete popover.dataset.jpdbReaderParseLoadingKey;
void this.dependencies.parsePopoverJapanese(popover);
} catch (error) {
log$4.warn("Automatic grammar lookup failed", { sentenceLength: sentence.length }, error);
}
}
canRenderGrammar(popover, container) {
return this.dependencies.isCurrentPopoverRoot(popover) && container.isConnected;
}
installTranslationLoader(popover, sentence) {
const containers = Array.from(popover.querySelectorAll("[data-study-translation]"));
if (!containers.length || !sentence) return;
for (const container of containers) {
const load = () => {
if (!isStudyDetailsOpen(container) || container.dataset.loaded === "true" || container.dataset.loading === "true") return;
container.dataset.loading = "true";
const result = container.querySelector("[data-study-translation-result]");
if (result) result.textContent = uiText(this.settings().interfaceLanguage, "translating");
void this.loadTranslation(popover, sentence, container).finally(() => {
if (!container.isConnected) return;
delete container.dataset.loading;
container.dataset.loaded = "true";
});
};
container.addEventListener("toggle", load);
container.parentElement?.closest("details")?.addEventListener("toggle", load);
load();
}
}
async loadTranslation(popover, sentence, container) {
if (!sentence) return;
try {
const translation = await this.loadTranslationContent(sentence);
if (!this.canApplyTranslation(popover, container)) return;
this.applyTranslation(popover, sentence, container, translation);
} catch (error) {
this.renderTranslationError(sentence, container, error);
}
}
canApplyTranslation(popover, container) {
return this.dependencies.isCurrentPopoverRoot(popover) && container.isConnected;
}
async loadTranslationContent(sentence) {
const [tokens, translated] = await Promise.all([
this.dependencies.parseJapanese([sentence]).then(([parsed]) => parsed ?? []),
translateJapaneseSentence(sentence, this.settings().interfaceLanguage)
]);
return { tokens, translated };
}
applyTranslation(popover, sentence, container, translation) {
const original = container.querySelector("[data-study-original-render]");
if (original) setInnerHtml(original, renderTokensToHtml(sentence, translation.tokens, this.settings()));
const result = container.querySelector("[data-study-translation-result]");
if (result) result.textContent = translation.translated;
void this.dependencies.parsePopoverJapanese(popover);
void this.dependencies.enrichAnkiWords(translation.tokens);
}
renderTranslationError(sentence, container, error) {
log$4.warn("Automatic sentence translation failed", { sentenceLength: sentence.length }, error);
if (!container.isConnected) return;
const result = container.querySelector("[data-study-translation-result]");
if (result) result.textContent = uiText(this.settings().interfaceLanguage, "translationUnavailable");
}
sourceAttributes(sourceId) {
return this.dependencies.dictionarySourceAttributes(definitionSourceStateKey(sourceId));
}
settings() {
return this.dependencies.getSettings();
}
}
function isStudyDetailsOpen(container) {
if (!container.open) return false;
let ancestor = container.parentElement?.closest("details");
while (ancestor) {
if (!ancestor.open) return false;
ancestor = ancestor.parentElement?.closest("details");
}
return true;
}
function normalizeSubtitleCues(cues, options = {}) {
const normalized = [];
cues.forEach((cue) => {
const text2 = normalizeCaptionText(cue.text);
if (!text2 || !Number.isFinite(cue.start) || !Number.isFinite(cue.end)) return;
const words = exactSubtitleWords(cue, cue.start, Math.max(cue.end, cue.start + 0.12));
const base = {
...cue,
text: text2,
start: cue.start,
end: Math.max(cue.end, cue.start + 0.12),
originalText: cue.originalText ?? text2,
words,
wordTimingsExact: Boolean(words?.length),
transcriptEligible: options.transcriptEligible ?? cue.transcriptEligible ?? true
};
const sentenceParts = splitCueDisplayText(text2);
if (sentenceParts.length <= 1) {
normalized.push({ ...base, transcriptEligible: base.transcriptEligible });
return;
}
const timedParts = distributeCueParts(base, sentenceParts);
for (const part of timedParts) {
const partWords = sliceCueWords(base, part.start, part.end);
normalized.push({
start: part.start,
end: part.end,
text: part.text,
originalText: base.originalText,
words: partWords,
wordTimingsExact: Boolean(partWords?.length),
transcriptEligible: base.transcriptEligible
});
}
if (!timedParts.length) normalized.push({ ...base, transcriptEligible: base.transcriptEligible });
});
return normalized.sort((a, b) => a.start - b.start);
}
function splitCueDisplayText(text2) {
const normalized = normalizeCaptionText(text2);
if (!normalized) return [];
const sentenceParts = splitSentencesByPunctuation(normalized);
if (sentenceParts.length > 1) return sentenceParts;
if (displayTextWeight(normalized) <= 38) return [normalized];
return splitOverlongCue(normalized);
}
function splitSentencesByPunctuation(text2) {
const parts = [];
let start = 0;
let offset = 0;
for (const char of Array.from(text2)) {
offset += char.length;
const end = subtitleSentenceBoundaryEnd(text2, char, offset);
if (end === null) continue;
offset = end;
pushSubtitleSentencePart(parts, text2, start, offset);
start = offset;
}
pushSubtitleSentencePart(parts, text2, start, text2.length);
return parts.length ? parts : [text2];
}
function subtitleSentenceBoundaryEnd(text2, char, offset) {
if (!isSubtitleSentencePunctuation(char)) return null;
return consumeClosingSubtitlePunctuation(text2, offset);
}
function isSubtitleSentencePunctuation(char) {
return /[。!?!?]/u.test(char);
}
function consumeClosingSubtitlePunctuation(text2, offset) {
let end = offset;
while (end < text2.length && isSubtitleSentenceCloser(text2[end])) end++;
return end;
}
function isSubtitleSentenceCloser(char) {
return /["'」』)\]]/u.test(char);
}
function pushSubtitleSentencePart(parts, text2, start, end) {
const part = text2.slice(start, end).trim();
if (part) parts.push(part);
}
function splitOverlongCue(text2) {
const parts = [];
const tokens = overlongCueTokens(text2);
let current = "";
for (const token of tokens) {
if (shouldFlushOverlongCuePart(current, token)) {
parts.push(current.trim());
current = token.trimStart();
} else {
current += token;
}
}
if (current.trim()) parts.push(current.trim());
return splitCuePartsOrOriginal(parts, text2);
}
function overlongCueTokens(text2) {
return text2.includes(" ") ? text2.split(/(\s+)/u).filter(Boolean) : Array.from(text2);
}
function shouldFlushOverlongCuePart(current, token) {
return displayTextWeight(current + token) > 32 && Boolean(current.trim());
}
function splitCuePartsOrOriginal(parts, text2) {
return parts.length > 1 ? parts : [text2];
}
function distributeCueParts(cue, parts) {
const duration = Math.max(0.12, cue.end - cue.start);
const weights = parts.map((part) => Math.max(1, displayTextWeight(part)));
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) || parts.length;
let cursor = cue.start;
return parts.map((part, index) => {
const partDuration = index === parts.length - 1 ? cue.end - cursor : duration * (weights[index] / totalWeight);
const start = cursor;
const end = index === parts.length - 1 ? cue.end : Math.min(cue.end, cursor + Math.max(0.12, partDuration));
cursor = end;
return { text: part, start, end: Math.max(end, start + 0.12) };
});
}
function exactSubtitleWords(cue, start, end) {
if (!cue.wordTimingsExact || !cue.words?.length) return void 0;
const words = cue.words.filter((word) => word.text.trim() && Number.isFinite(word.start) && Number.isFinite(word.end) && word.end > start && word.start < end).map((word) => ({ ...word, start: clampNumber$2(word.start, start, end), end: clampNumber$2(word.end, start, end) })).filter((word) => word.end > word.start);
return words.length ? words : void 0;
}
function sliceCueWords(cue, start, end) {
return exactSubtitleWords(cue, start, end);
}
function renderKaraokeTextParts(text2, progress) {
const chars = Array.from(text2);
const split = clampNumber$2(Math.round(progress), 0, chars.length);
const past = chars.slice(0, split).join("");
const current = chars.slice(split, split + 1).join("");
const upcoming = chars.slice(split + 1).join("");
return [
past ? `${escapeHtml$1(past)} ` : "",
current ? `${escapeHtml$1(current)} ` : "",
upcoming ? `${escapeHtml$1(upcoming)} ` : ""
].join("");
}
function karaokeCharacterProgress(cue, words, time) {
const total = compactTextLength(cue.text);
const edgeProgress = karaokeEdgeProgress(cue, time, total);
if (edgeProgress !== null) return edgeProgress;
return karaokeTimedWordProgress(sortedSubtitleWords(words), total, time);
}
function karaokeEdgeProgress(cue, time, total) {
if (!total) return 0;
if (time <= cue.start) return 0;
if (time >= cue.end) return total;
return null;
}
function sortedSubtitleWords(words) {
return [...words].filter(hasUsableSubtitleWordTiming).sort((a, b) => a.start - b.start);
}
function hasUsableSubtitleWordTiming(word) {
return Boolean(word.text.trim() && Number.isFinite(word.start) && Number.isFinite(word.end));
}
function karaokeTimedWordProgress(words, total, time) {
let cursor = 0;
for (const word of words) {
const length = compactTextLength(word.text);
if (!length) continue;
if (time >= word.end) {
cursor += length;
continue;
}
if (time <= word.start) return Math.min(total, cursor);
return karaokeProgressInsideWord(total, cursor, length, word, time);
}
return Math.min(total, cursor);
}
function karaokeProgressInsideWord(total, cursor, length, word, time) {
const ratio = clampNumber$2((time - word.start) / Math.max(0.04, word.end - word.start), 0, 1);
return Math.min(total, cursor + Math.max(1, Math.floor(length * ratio)));
}
function compactTextLength(text2) {
return Array.from(text2.replace(/\s+/gu, "")).length;
}
function displayTextWeight(text2) {
return Array.from(text2.replace(/\s+/gu, "")).length;
}
function clampNumber$2(value, min, max2) {
return Math.min(Math.max(value, min), Math.max(min, max2));
}
function parseVttCuePayload(raw, cueStart, cueEnd) {
const timestampPattern = /<((?:(?:\d+:)?\d{2}:)?\d{2}[,.]\d{3})>/g;
const markers = vttTimestampMarkers(raw, timestampPattern);
const text2 = vttCueTextWithoutMarkers(raw, timestampPattern);
if (!markers.length) return { text: text2 };
return vttCuePayloadWithMarkers(raw, timestampPattern, markers, text2, cueStart, cueEnd);
}
function vttCuePayloadWithMarkers(raw, timestampPattern, markers, text2, cueStart, cueEnd) {
const words = [];
for (let index = 0; index < markers.length; index++) {
const markerWord = vttMarkerWord(raw, timestampPattern, markers, index);
if (!markerWord) continue;
if (/\s/u.test(markerWord.text)) return { text: text2 };
words.push(vttWordTiming(markerWord, cueStart, cueEnd));
}
return { text: text2, words: words.length ? words : void 0, wordTimingsExact: Boolean(words.length) };
}
function vttTimestampMarkers(raw, timestampPattern) {
const markers = [];
raw.replace(timestampPattern, (match, rawTime, index) => {
appendVttTimestampMarker(markers, match, rawTime, index);
return match;
});
return markers;
}
function appendVttTimestampMarker(markers, match, rawTime, index) {
const time = parseSubtitleTime(rawTime.includes(":") ? rawTime : `00:${rawTime}`);
if (Number.isFinite(time)) markers.push({ time, index, endIndex: index + match.length });
}
function vttCueTextWithoutMarkers(raw, timestampPattern) {
return raw.replace(timestampPattern, "").replace(/<[^>]+>/g, "").trim();
}
function vttMarkerWord(raw, timestampPattern, markers, index) {
const marker = markers[index];
const next = markers[index + 1];
const segmentRaw = raw.slice(marker.endIndex, next?.index ?? raw.length).replace(timestampPattern, "").replace(/<[^>]+>/g, "");
const segmentText = segmentRaw.trim();
return segmentText ? { text: segmentText, start: marker.time, end: next?.time ?? Number.POSITIVE_INFINITY } : null;
}
function vttWordTiming(markerWord, cueStart, cueEnd) {
const start = clampNumber$2(markerWord.start, cueStart, cueEnd);
const end = clampNumber$2(markerWord.end, cueStart, cueEnd);
return { text: markerWord.text, start, end: Math.max(start + 0.04, end) };
}
function parseSubtitleText(text2, options = {}) {
const normalizedText = text2.replace(/^\uFEFF/, "");
return parseKnownSubtitleText(normalizedText, options) ?? parseVttSubtitleText(normalizedText, options);
}
function parseKnownSubtitleText(text2, options) {
const youtubeJson = parseYouTubeJson3SubtitleText(text2, options);
if (youtubeJson.length) return youtubeJson;
const youtubeXml = parseYouTubeXmlSubtitleText(text2, options);
if (youtubeXml.length) return youtubeXml;
return looksLikeAssSubtitleText(text2) ? parseAssSubtitleText(text2) : void 0;
}
function looksLikeAssSubtitleText(text2) {
return /^\s*\[Script Info\]/im.test(text2) || /^\s*Dialogue:/im.test(text2);
}
function parseVttSubtitleText(text2, options) {
const cues = text2.replace(/\r/g, "").replace(/^WEBVTT.*?\n\n/s, "").split(/\n{2,}/).map((block) => block.trim()).filter(Boolean).map(readVttCueBlock).filter((cue) => Boolean(cue));
const sorted = cues.sort((a, b) => a.start - b.start);
return options.smoothYouTubeFragments ? smoothFragmentedYouTubeCues(sorted) : sorted;
}
function readVttCueBlock(block) {
const lines = block.split("\n").filter(Boolean);
const timeIndex = lines.findIndex((line) => line.includes("-->"));
if (timeIndex < 0) return null;
const [startRaw, endRaw] = lines[timeIndex].split("-->").map((part) => part.trim().split(/\s+/)[0]);
const start = parseSubtitleTime(startRaw);
const end = parseSubtitleTime(endRaw);
const payload = parseVttCuePayload(lines.slice(timeIndex + 1).join("\n"), start, end);
return Number.isFinite(start) && Number.isFinite(end) && payload.text ? { start, end, text: payload.text, words: payload.words, wordTimingsExact: payload.wordTimingsExact } : null;
}
function parseYouTubeJson3SubtitleText(text2, options = {}) {
if (!/^\s*\{/.test(text2)) return [];
try {
const parsed = JSON.parse(text2);
const cues = (parsed.events ?? []).map((event) => {
const start = Number(event.tStartMs ?? Number.NaN) / 1e3;
const duration = Number(event.dDurationMs ?? 0) / 1e3;
const cueText = (event.segs ?? []).map((seg) => seg.utf8 ?? "").join("").replace(/\s+/g, " ").trim();
const end = start + Math.max(duration, 0.75);
const words = options.youtubeAutoGenerated ? void 0 : youtubeJson3WordTimings(event.segs ?? [], start, end);
return { start, end, text: cueText, words, wordTimingsExact: Boolean(words?.length) };
}).filter((cue) => Number.isFinite(cue.start) && Number.isFinite(cue.end) && cue.text).sort((a, b) => a.start - b.start);
return smoothFragmentedYouTubeCues(normalizeYouTubeAutoCaptionTiming(cues, options.youtubeAutoGenerated === true));
} catch {
return [];
}
}
function youtubeJson3WordTimings(segs, cueStart, cueEnd) {
const visible = segs.map((seg) => ({ text: seg.utf8 ?? "", offset: Number(seg.tOffsetMs) })).filter((seg) => seg.text.trim());
const timed = visible.filter((seg) => Number.isFinite(seg.offset) && !/\s/u.test(seg.text.trim()));
if (!timed.length || timed.length !== visible.length) return void 0;
return timed.map((seg, index) => {
const nextOffset = timed[index + 1]?.offset;
const start = cueStart + seg.offset / 1e3;
const end = nextOffset === void 0 ? cueEnd : cueStart + nextOffset / 1e3;
return { text: seg.text, start: clampNumber$2(start, cueStart, cueEnd), end: clampNumber$2(end, cueStart, cueEnd) };
}).filter((word) => word.end > word.start);
}
function parseYouTubeXmlSubtitleText(text2, options = {}) {
if (!looksLikeYouTubeXmlSubtitleText(text2)) return [];
try {
const document2 = parseXmlDocument(text2, "text/xml");
const srv3 = parseYouTubeSrv3Rows(document2, options);
const cues = [
...parseYouTubeTimedTextElements(document2),
...parseYouTubeTtmlParagraphs(document2),
...srv3.cues
];
const sorted = cues.sort((a, b) => a.start - b.start);
const autoGenerated = isYouTubeXmlAutoGenerated(options, srv3, sorted);
const normalized = normalizeYouTubeAutoCaptionTiming(sorted, autoGenerated);
return autoGenerated ? normalized : smoothFragmentedYouTubeCues(normalized);
} catch {
return [];
}
}
function looksLikeYouTubeXmlSubtitleText(text2) {
return /^\s* {
const start = Number(element2.getAttribute("start"));
const duration = Number(element2.getAttribute("dur") ?? 0);
const cueText = normalizeCaptionText(element2.textContent ?? "");
return Number.isFinite(start) && cueText ? { start, end: start + Math.max(duration, 0.75), text: cueText } : null;
}).filter((cue) => Boolean(cue));
}
function parseYouTubeTtmlParagraphs(document2) {
return Array.from(document2.querySelectorAll("p[begin]")).map((element2) => {
const start = parseSubtitleClockValue(element2.getAttribute("begin") ?? "");
const end = parseSubtitleClockValue(element2.getAttribute("end") ?? "");
const cueText = normalizeCaptionText(element2.textContent ?? "");
return Number.isFinite(start) && Number.isFinite(end) && cueText ? { start, end, text: cueText } : null;
}).filter((cue) => Boolean(cue));
}
function parseYouTubeSrv3Rows(document2, options) {
const rows = Array.from(document2.querySelectorAll("p[t], p[_t]"));
let sawLineBoundary = false;
const cues = rows.map((element2, index) => {
const startMs = Number(element2.getAttribute("t") ?? element2.getAttribute("_t"));
const durationMs = Number(element2.getAttribute("d") ?? element2.getAttribute("_d") ?? 0);
const start = startMs / 1e3;
const nextLineBoundary = youtubeSrv3LineBoundaryTime(rows[index + 1]);
sawLineBoundary ||= Number.isFinite(nextLineBoundary);
const rawEnd = start + Math.max(durationMs / 1e3, 0.75);
const end = Number.isFinite(nextLineBoundary) && nextLineBoundary > start ? Math.min(rawEnd, nextLineBoundary) : rawEnd;
const words = !options.youtubeAutoGenerated && !Number.isFinite(nextLineBoundary) ? parseYouTubeSrv3WordNodes(element2, start, end) : [];
const cueText = normalizeCaptionText(words.length ? words.map((word) => word.text).join("") : element2.textContent ?? "");
if (!Number.isFinite(start) || !cueText) return null;
const cue = { start, end, text: cueText, words: words.length ? words : void 0, wordTimingsExact: Boolean(words.length) };
return cue;
}).filter((cue) => Boolean(cue));
return { cues, sawLineBoundary };
}
function youtubeSrv3LineBoundaryTime(element2) {
if (!element2) return Number.NaN;
if (!isYouTubeSrv3LineBoundaryText(element2.textContent ?? "")) return Number.NaN;
const startMs = Number(youtubeSrv3StartAttribute(element2));
return Number.isFinite(startMs) ? startMs / 1e3 : Number.NaN;
}
function isYouTubeSrv3LineBoundaryText(text2) {
return text2 === "\n" || !text2.trim();
}
function youtubeSrv3StartAttribute(element2) {
return element2.getAttribute("t") ?? element2.getAttribute("_t");
}
function normalizeYouTubeAutoCaptionTiming(cues, knownAutoGenerated) {
if (!cues.length) return cues;
const probablyAutoGenerated = knownAutoGenerated || looksLikeOverlappingAutoGeneratedCues(cues);
if (!probablyAutoGenerated) return cues;
return cues.map((cue, index) => {
const next = cues[index + 1];
const nextStart = next?.start;
const end = Number.isFinite(nextStart) && nextStart > cue.start ? Math.max(cue.start, Math.min(cue.end, nextStart - 1e-3)) : cue.end;
return {
...cue,
end,
words: void 0,
wordTimingsExact: false
};
});
}
function looksLikeOverlappingAutoGeneratedCues(cues) {
const sampled = cues.slice(0, 80);
if (sampled.length < 3) return false;
let overlapping = 0;
for (let index = 1; index < sampled.length; index++) {
if (sampled[index - 1].end > sampled[index].start + 0.05) overlapping += 1;
}
return overlapping / sampled.length > 0.5;
}
function smoothFragmentedYouTubeCues(cues) {
if (!shouldSmoothFragmentedYouTubeCues(cues)) return cues;
const merged = [];
let current;
for (const cue of cues) {
current = mergeYouTubeFragmentIntoGroup(merged, current, cue);
}
if (current) merged.push(current);
return merged;
}
function shouldSmoothFragmentedYouTubeCues(cues) {
return cues.length >= 3 && looksLikeFragmentedYouTubeCues(cues);
}
function mergeYouTubeFragmentIntoGroup(merged, current, cue) {
const normalized = normalizeYouTubeCueFragment(cue);
if (!normalized.text) return current;
if (!current) {
return pushCurrentYouTubeCueGroup(merged, current, normalized);
}
if (shouldBreakYouTubeLine(current, normalized)) return pushCurrentYouTubeCueGroup(merged, current, normalized);
return mergeYouTubeCueFragments(current, normalized);
}
function normalizeYouTubeCueFragment(cue) {
const hasExactWords = cueHasExactWordTimings(cue);
return {
...cue,
text: normalizeCaptionText(cue.text),
words: hasExactWords ? cue.words : void 0,
wordTimingsExact: hasExactWords
};
}
function pushCurrentYouTubeCueGroup(merged, current, next) {
if (current) merged.push(current);
return next;
}
function looksLikeFragmentedYouTubeCues(cues) {
const sampled = cues.slice(0, 80);
const fragments = sampled.filter((cue) => displayTextWeight(cue.text) <= 14 || cue.end - cue.start <= 1.35).length;
return fragments / sampled.length >= 0.42;
}
function shouldBreakYouTubeLine(current, next) {
const gap = next.start - current.end;
if (isYouTubeContinuationFragment(current, next)) return false;
return gap > 2.6 || gap < -0.2 && !isProgressiveYouTubeCaption(current.text, next.text) || hasYouTubeLineBreakText(current);
}
function hasYouTubeLineBreakText(cue) {
return /[。!?!?]$/u.test(cue.text.trim()) || cue.end - cue.start >= 12 || displayTextWeight(cue.text) >= 68;
}
function isYouTubeContinuationFragment(current, next) {
return isTrailingPunctuationFragment(next.text) || isShortYouTubeContinuationFragment(next.text) || hasYouTubeCaptionTextOverlap(current.text, next.text);
}
function mergeYouTubeCueFragments(current, next) {
const progressive = isProgressiveYouTubeCaption(current.text, next.text);
const overlap = progressive ? 0 : youtubeCaptionTextOverlapLength(current.text, next.text);
const text2 = progressive ? next.text : mergeYouTubeCaptionFragmentText(current.text, next.text);
const words = mergedYouTubeCueWords(current, next, { progressive, overlap });
return {
...current,
end: Math.max(current.end, next.end),
text: text2,
originalText: text2,
words,
wordTimingsExact: Boolean(words?.length)
};
}
function mergedYouTubeCueWords(current, next, merge) {
const words = youtubeCueMergeWords(current, next);
if (merge.progressive) return words.next;
if (!canMergeYouTubeCueWords(words.current, words.next, next.text)) return void 0;
return [...words.current, ...trimmedNextYouTubeCueWords(words.next, merge.overlap)];
}
function youtubeCueMergeWords(current, next) {
return {
current: cueHasExactWordTimings(current) ? current.words : void 0,
next: cueHasExactWordTimings(next) ? next.words : void 0
};
}
function trimmedNextYouTubeCueWords(words, overlap) {
return words ? subtitleWordsAfterCompactOffset(words, overlap) : [];
}
function canMergeYouTubeCueWords(currentWords, nextWords, nextText) {
return Boolean(currentWords && (nextWords || isTrailingPunctuationFragment(nextText)));
}
function mergeYouTubeCaptionFragmentText(left, right) {
const a = left.trim();
const b = right.trim();
const overlap = youtubeCaptionTextOverlapLength(a, b);
if (overlap > 0) {
const tail = sliceByCompactOffset(b, overlap);
return tail ? joinYouTubeCaptionFragments(a, tail) : a;
}
return joinYouTubeCaptionFragments(a, b);
}
function isProgressiveYouTubeCaption(current, next) {
const compactCurrent = current.replace(/\s+/gu, "");
const compactNext = next.replace(/\s+/gu, "");
return compactCurrent.length >= 2 && compactNext.length > compactCurrent.length && compactNext.startsWith(compactCurrent);
}
function joinYouTubeCaptionFragments(left, right) {
const a = left.trim();
const b = right.trim();
const emptyJoin = emptyYouTubeCaptionFragmentJoin(a, b);
if (emptyJoin !== null) return emptyJoin;
return `${a}${youtubeCaptionFragmentSeparator(a, b)}${b}`;
}
function emptyYouTubeCaptionFragmentJoin(left, right) {
if (!left) return right;
return right ? null : left;
}
function youtubeCaptionFragmentSeparator(left, right) {
if (shouldJoinYouTubeCaptionFragmentsDirectly(left, right)) return "";
return shouldSpaceYouTubeCaptionFragments(left, right) ? " " : "";
}
function shouldJoinYouTubeCaptionFragmentsDirectly(left, right) {
return /^[、。,.!?!?))」』\]}]/u.test(right) || /[\s「『(([{]$/u.test(left);
}
function shouldSpaceYouTubeCaptionFragments(left, right) {
return /[A-Za-z0-9]$/u.test(left) && /^[A-Za-z0-9]/u.test(right);
}
function isTrailingPunctuationFragment(text2) {
return /^[、。,.!?!?…・]+$/u.test(text2.trim());
}
function isShortYouTubeContinuationFragment(text2) {
const compact = compactCaptionText(text2);
return compact.length <= 3 && /^[っッゃゅょぁぃぅぇぉャュョァィゥェォー〜、。,.!?!?…・んン]+$/u.test(compact);
}
function hasYouTubeCaptionTextOverlap(left, right) {
return youtubeCaptionTextOverlapLength(left, right) >= Math.min(6, compactCaptionText(right).length);
}
function youtubeCaptionTextOverlapLength(left, right) {
const a = compactCaptionText(left);
const b = compactCaptionText(right);
const max2 = Math.min(a.length, b.length);
for (let length = max2; length >= 2; length--) {
if (a.endsWith(b.slice(0, length))) return length;
}
return 0;
}
function compactCaptionText(text2) {
return text2.replace(/\s+/gu, "");
}
function subtitleWordsAfterCompactOffset(words, compactOffset) {
if (compactOffset <= 0) return words;
let cursor = 0;
return words.filter((word) => {
const start = cursor;
cursor += compactTextLength(word.text);
return start >= compactOffset;
});
}
function sliceByCompactOffset(text2, compactOffset) {
if (compactOffset <= 0) return text2;
for (const step of compactTextOffsetSteps(text2)) {
if (step.seen >= compactOffset) return text2.slice(step.index);
}
return "";
}
function compactTextOffsetSteps(text2) {
const steps = [];
let index = 0;
let seen = 0;
for (const char of Array.from(text2)) {
index += char.length;
if (/\s/u.test(char)) continue;
steps.push({ index, seen: ++seen });
}
return steps;
}
function parseYouTubeSrv3WordNodes(element2, cueStart, cueEnd) {
const nodes = Array.from(element2.querySelectorAll("s"));
if (!nodes.length) return [];
if (nodes.some((node) => /\s/u.test((node.textContent ?? "").trim()))) return [];
const starts = nodes.map((node) => {
const raw = Number(node.getAttribute("t") ?? node.getAttribute("_t"));
return Number.isFinite(raw) ? cueStart + raw / 1e3 : Number.NaN;
});
return nodes.map((node, index) => {
const text2 = node.textContent ?? "";
if (!text2) return null;
const start = Number.isFinite(starts[index]) ? starts[index] : cueStart;
const nextStart = starts.slice(index + 1).find((value) => Number.isFinite(value));
const end = typeof nextStart === "number" && Number.isFinite(nextStart) ? nextStart : cueEnd;
return { text: text2, start: clampNumber$2(start, cueStart, cueEnd), end: clampNumber$2(end, cueStart, cueEnd) };
}).filter((word) => Boolean(word?.text.trim() && word.end > word.start));
}
function parseAssSubtitleText(text2) {
const state = createAssParseState();
for (const rawLine of text2.replace(/\r/g, "").split("\n")) {
readAssSubtitleLine(rawLine.trim(), state);
}
return state.cues.sort((a, b) => a.start - b.start);
}
function createAssParseState() {
return {
cues: [],
inEvents: false,
format: ["layer", "start", "end", "style", "name", "marginl", "marginr", "marginv", "effect", "text"]
};
}
function readAssSubtitleLine(line, state) {
if (!shouldParseAssCueLine(line, state)) return;
const cue = readAssDialogueCue(line, state.format);
if (cue) state.cues.push(cue);
}
function shouldParseAssCueLine(line, state) {
if (shouldIgnoreAssLine(line)) return false;
if (updateAssSectionState(line, state)) return false;
if (!shouldReadAssDialogueLine(line, state)) return false;
return !readAssFormatLine(line, state);
}
function shouldReadAssDialogueLine(line, state) {
return state.inEvents || /^Dialogue:/i.test(line);
}
function shouldIgnoreAssLine(line) {
return !line || line.startsWith(";");
}
function updateAssSectionState(line, state) {
if (/^\[Events\]/i.test(line)) {
state.inEvents = true;
return true;
}
if (/^\[.+\]/.test(line)) {
state.inEvents = false;
return true;
}
return false;
}
function readAssFormatLine(line, state) {
if (!/^Format:/i.test(line)) return false;
state.format = line.slice(line.indexOf(":") + 1).split(",").map((part) => part.trim().toLowerCase());
return true;
}
function readAssDialogueCue(line, format) {
if (!/^Dialogue:/i.test(line)) return null;
const values = splitAssDialogue(line.slice(line.indexOf(":") + 1), format.length);
const fields = assDialogueFields(values, format);
const start = parseSubtitleTime(fields.start);
const end = parseSubtitleTime(fields.end);
const cueText = cleanAssSubtitleText(fields.text);
return Number.isFinite(start) && Number.isFinite(end) && cueText ? { start, end, text: cueText } : null;
}
function assDialogueFields(values, format) {
const textIndex = format.indexOf("text");
return {
start: values[format.indexOf("start")] ?? "",
end: values[format.indexOf("end")] ?? "",
text: values.slice(textIndex >= 0 ? textIndex : values.length - 1).join(",")
};
}
function splitAssDialogue(value, fieldCount) {
const parts = [];
let start = 0;
const maxSplits = Math.max(0, fieldCount - 1);
for (let index = 0; index < value.length && parts.length < maxSplits; index++) {
if (value[index] !== ",") continue;
parts.push(value.slice(start, index).trim());
start = index + 1;
}
parts.push(value.slice(start).trim());
return parts;
}
function cleanAssSubtitleText(value) {
return value.replace(/\{[^}]*}/g, "").replace(/\\[Nn]/g, "\n").replace(/\\h/g, " ").replace(/<[^>]+>/g, "").split("\n").map((line) => line.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n");
}
function parseSubtitleTime(value) {
const match = value.trim().match(/(?:(\d+):)?(\d{1,2}):(\d{2})(?:[,.](\d{1,3}))?/);
if (!match) return Number.NaN;
const [, hours = "0", minutes, seconds, fraction = "0"] = match;
return Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds) + Number(fraction.padEnd(3, "0")) / 1e3;
}
function parseSubtitleClockValue(value) {
const trimmed = value.trim();
if (!trimmed) return Number.NaN;
if (/^\d+(?:\.\d+)?s$/i.test(trimmed)) return Number(trimmed.slice(0, -1));
if (/^\d+(?:\.\d+)?ms$/i.test(trimmed)) return Number(trimmed.slice(0, -2)) / 1e3;
if (/^\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
return parseSubtitleTime(trimmed);
}
function formatSubtitleTime(value) {
const minutes = Math.floor(value / 60);
const seconds = Math.floor(value % 60).toString().padStart(2, "0");
return `${minutes}:${seconds}`;
}
function findAlignedCue(cues, cue) {
return cues.map((item) => ({
item,
overlap: Math.max(0, Math.min(cue.end, item.end) - Math.max(cue.start, item.start)),
startDistance: Math.abs(cue.start - item.start)
})).filter((candidate) => candidate.overlap > 0 || candidate.startDistance <= 0.45).sort((a, b) => b.overlap - a.overlap || a.startDistance - b.startDistance)[0]?.item;
}
function findActiveSubtitleCue(cues, time) {
return cues.filter((cue) => time >= cue.start - 0.05 && time < cue.end + 0.12).sort((a, b) => b.start - a.start || b.end - a.end)[0];
}
function subtitleCueSignature(cue) {
return `${cue.start.toFixed(2)}:${cue.end.toFixed(2)}:${cue.text.trim()}`;
}
function cueHasExactWordTimings(cue) {
return Boolean(cue?.wordTimingsExact && cue.words?.length);
}
function normalizeCaptionText(value) {
return value.replace(/\u00a0/g, " ").split("\n").map((line) => line.replace(/\s+/g, " ").trim()).filter(Boolean).join(" ");
}
function escapeWithBreaks(value) {
return withBreaks(escapeHtml$1(value));
}
function withBreaks(value) {
return value.replace(/\n/g, " ");
}
const TRANSCRIPT_PANEL_MARGIN = 10;
const TRANSCRIPT_PANEL_SIZE_KEY = "jpdb-reader-transcript-panel-size";
function computeSubtitleDrawerLayout(options) {
const margin = TRANSCRIPT_PANEL_MARGIN;
const size = options.size ?? {};
const preferredPlacement = options.preferredPlacement ?? "right";
return options.compactPanel || preferredPlacement === "bottom" ? compactSubtitleDrawerLayout(options, size, margin) : sideSubtitleDrawerLayout(options, size, margin, preferredPlacement);
}
function compactSubtitleDrawerLayout(options, size, margin) {
const height = clampNumber$1(
size.bottomHeight ?? Math.min(420, options.viewportHeight * 0.46),
220,
Math.max(220, options.viewportHeight - margin * 3)
);
return {
placement: "bottom",
left: margin,
top: Math.max(margin, options.viewportHeight - height - margin),
width: options.viewportWidth - margin * 2,
height,
viewportWidth: options.viewportWidth,
viewportHeight: options.viewportHeight,
margin
};
}
function sideSubtitleDrawerLayout(options, size, margin, preferredPlacement) {
const top = clampNumber$1(options.anchorTop ?? 72, margin, Math.max(margin, options.viewportHeight - 280));
const width = clampNumber$1(
size.sideWidth ?? Math.min(460, options.viewportWidth * 0.32),
340,
Math.max(340, options.viewportWidth - margin * 3)
);
const placement = preferredPlacement === "left" ? "left" : "right";
return {
placement,
left: placement === "left" ? margin : Math.max(margin, options.viewportWidth - width - margin),
top,
width,
height: Math.max(260, options.viewportHeight - top - margin),
viewportWidth: options.viewportWidth,
viewportHeight: options.viewportHeight,
margin,
maxWidth: Math.max(340, options.viewportWidth - margin * 3)
};
}
function shouldUseCompactSubtitleDrawer(viewportWidth) {
return viewportWidth < 700;
}
function applyTranscriptPanelLayout(panel, layout) {
setStylePropertyIfChanged$2(panel, "left", `${Math.round(layout.left)}px`);
setStylePropertyIfChanged$2(panel, "top", `${Math.round(layout.top)}px`);
setStylePropertyIfChanged$2(panel, "right", "auto");
setStylePropertyIfChanged$2(panel, "bottom", "auto");
setStylePropertyIfChanged$2(panel, "width", `${Math.round(Math.max(260, Math.min(layout.width, layout.viewportWidth - layout.margin * 2)))}px`);
const minHeight = layout.placement === "bottom" ? 80 : 150;
const height = `${Math.round(Math.max(minHeight, layout.height))}px`;
setStylePropertyIfChanged$2(panel, "height", height);
setStylePropertyIfChanged$2(panel, "max-height", height);
}
function clampNumber$1(value, min, max2) {
return Math.min(Math.max(value, min), Math.max(min, max2));
}
function setStylePropertyIfChanged$2(element2, property, value) {
if (element2.style.getPropertyValue(property) === value) return;
element2.style.setProperty(property, value);
}
function loadTranscriptPanelSize() {
try {
const parsed = gmStorageGetSync(TRANSCRIPT_PANEL_SIZE_KEY, {});
return {
sideWidth: Number.isFinite(parsed.sideWidth) ? parsed.sideWidth : void 0,
bottomHeight: Number.isFinite(parsed.bottomHeight) ? parsed.bottomHeight : void 0
};
} catch {
return {};
}
}
function saveTranscriptPanelSize(size) {
try {
gmStorageSetSync(TRANSCRIPT_PANEL_SIZE_KEY, size);
} catch {
}
}
function collectPageSubtitleSources(root = document) {
const pageTitle = pageSubtitleTitle(root);
return dedupeSubtitleSources([
...collectTrackSubtitleSources(root, pageTitle),
...collectLinkSubtitleSources(root, pageTitle)
]);
}
function collectTrackSubtitleSources(root, pageTitle) {
return Array.from(root.querySelectorAll("track[src]")).map((track) => subtitleSourceFromTrack(track, pageTitle)).filter((source) => Boolean(source));
}
function subtitleSourceFromTrack(track, pageTitle) {
if (!isSubtitleTrackElement(track)) return null;
const url = subtitleTrackSourceUrl(track);
if (!url) return null;
const label = subtitleTrackSourceLabel(track, url, pageTitle);
return {
url,
label,
language: normalizeSubtitleLanguage(track.srclang || inferSubtitleLanguage(label, url)),
sourceKey: pageSubtitleSourceKey("track", url)
};
}
function isSubtitleTrackElement(track) {
return !track.kind || /subtitles|captions/i.test(track.kind);
}
function subtitleTrackSourceUrl(track) {
return subtitleSourceUrl(track.src || track.getAttribute("src") || "");
}
function subtitleTrackSourceLabel(track, url, pageTitle) {
return subtitleSourceLabel(track.label || track.srclang || track.getAttribute("aria-label") || "", url, {
pageTitle,
preferPageTitleForGeneric: true
});
}
function collectLinkSubtitleSources(root, pageTitle) {
return Array.from(root.querySelectorAll("a[href]")).map((link) => subtitleSourceFromLink(link, pageTitle)).filter((source) => Boolean(source));
}
function subtitleSourceFromLink(link, pageTitle) {
const url = subtitleSourceUrl(link.href || link.getAttribute("href") || "");
if (!url) return null;
const label = subtitleSourceLabel(linkSubtitleLabelText(link), url, { pageTitle });
return {
url,
label,
language: normalizeSubtitleLanguage(link.lang || inferSubtitleLanguage(label, url)),
sourceKey: pageSubtitleSourceKey("link", url)
};
}
function linkSubtitleLabelText(link) {
return link.getAttribute("download") || link.getAttribute("aria-label") || link.getAttribute("title") || link.textContent || "";
}
function subtitleSourceUrl(value) {
const url = resolveSubtitleSourceUrl(value);
return url && isSupportedSubtitleSourceUrl(url) ? url : "";
}
function dedupeSubtitleSources(sources) {
const seen = /* @__PURE__ */ new Set();
return sources.filter((source) => {
const key = source.sourceKey;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function pageSubtitleTitle(root) {
const doc = root instanceof Document ? root : root.ownerDocument ?? document;
return cleanSubtitleTitle(pageSubtitleTitleCandidate(doc));
}
function pageSubtitleTitleCandidate(doc) {
return openGraphSubtitleTitle(doc) || headingSubtitleTitle(doc) || doc.title || "";
}
function openGraphSubtitleTitle(doc) {
return doc.querySelector('meta[property="og:title"], meta[name="twitter:title"]')?.content ?? "";
}
function headingSubtitleTitle(doc) {
return doc.querySelector("h1")?.textContent ?? "";
}
function resolveSubtitleSourceUrl(value) {
try {
const url = new URL(value, document.baseURI);
if (!/^(https?|blob|data):$/i.test(url.protocol)) return "";
return url.href;
} catch {
return "";
}
}
function isSupportedSubtitleSourceUrl(value) {
try {
const url = new URL(value, document.baseURI);
const haystack = [
decodeURIComponent(url.pathname),
...Array.from(url.searchParams.values()).map((part) => decodeURIComponent(part))
].join(" ");
return /\.(vtt|srt|ass|ssa)(?:$|[?#\s])/i.test(`${haystack} `);
} catch {
return /\.(vtt|srt|ass|ssa)(?:$|[?#\s])/i.test(value);
}
}
function subtitleSourceLabel(value, url, options = {}) {
const cleaned = cleanSubtitleTitle(value);
const filename = subtitleSourceFilenameLabel(url);
const specific = specificSubtitleLabel(cleaned, filename);
if (specific) return specific;
return genericSubtitleLabel(cleaned, filename, options);
}
function genericSubtitleLabel(cleaned, filename, options) {
if (shouldUsePageTitleForGeneric(cleaned, options)) return options.pageTitle ?? "";
return cleaned || filename || "Subtitle file";
}
function shouldUsePageTitleForGeneric(cleaned, options) {
if (!options.pageTitle) return false;
return !cleaned || Boolean(options.preferPageTitleForGeneric && cleaned);
}
function specificSubtitleLabel(cleaned, filename) {
if (cleaned && !isGenericSubtitleLabel(cleaned)) return cleaned;
if (filename && !isGenericSubtitleLabel(filename)) return filename;
return "";
}
function subtitleSourceFilenameLabel(url) {
try {
const parsed = new URL(url, document.baseURI);
const filename = parsed.searchParams.get("filename") || parsed.pathname.split("/").pop() || "";
return cleanSubtitleTitle(decodeURIComponent(filename).replace(/[_-]+/g, " "));
} catch {
return "";
}
}
function cleanSubtitleTitle(value) {
return value.replace(/\.(vtt|srt|ass|ssa)$/i, "").replace(/\s+/g, " ").trim();
}
function isGenericSubtitleLabel(value) {
return /^(?:vtt|srt|ass|ssa|subtitles?|captions?|cc|closed captions?|日本語|英語|japanese|english|native|ja(?:panese)?|en(?:glish)?)$/i.test(value.trim());
}
function inferSubtitleLanguage(label, url) {
const text2 = `${label} ${url}`;
if (/(^|[\s._/-])(ja|jp|jpn|japanese|日本語)(?=$|[\s._/-])/i.test(text2) || /[\u3040-\u30ff\u3400-\u9fff]/u.test(label)) return "ja";
if (/(^|[\s._/-])(en|eng|english|native)(?=$|[\s._/-])/i.test(text2)) return "en";
return void 0;
}
function normalizeSubtitleLanguage(language) {
if (!language) return void 0;
if (/^(ja|jp|jpn)(?:-|$)/i.test(language)) return "ja";
if (/^(en|eng)(?:-|$)/i.test(language)) return "en";
return language;
}
function pageSubtitleSourceKey(kind, url) {
return `${kind}:${normalizedSubtitleUrl(url)}`;
}
function normalizedSubtitleUrl(value) {
try {
const url = new URL(value, document.baseURI);
url.searchParams.delete("v");
url.hash = "";
return url.href;
} catch {
return value;
}
}
function sameSubtitleUrl(a, b) {
return normalizedSubtitleUrl(a) === normalizedSubtitleUrl(b);
}
class SubtitleVideoInsetAdapter {
lastSignature = "";
apply(options) {
const metrics = videoInsetMetrics(options);
if (metrics.signature === this.lastSignature) return;
this.lastSignature = metrics.signature;
document.documentElement.classList.toggle("jpdb-subtitle-video-inset-left", options.side === "left");
document.documentElement.classList.toggle("jpdb-subtitle-video-inset-right", options.side === "right");
document.documentElement.classList.toggle("jpdb-subtitle-video-inset-bottom", options.side === "bottom");
document.documentElement.style.setProperty("--jpdb-subtitle-video-inset", metrics.inset);
applyYouTubePlayerInset(options.side, metrics.width, metrics.insetPixels, metrics.height);
applyGenericVideoInsetIfNeeded(options, metrics);
requestYouTubePlayerResize(metrics.width, metrics.height);
}
clear(video) {
if (!hasActiveVideoInset(this.lastSignature)) return;
this.lastSignature = "";
document.documentElement.classList.remove("jpdb-subtitle-video-inset-left", "jpdb-subtitle-video-inset-right", "jpdb-subtitle-video-inset-bottom");
document.documentElement.style.removeProperty("--jpdb-subtitle-video-inset");
const watchFlexy = document.querySelector("ytd-watch-flexy");
watchFlexy?.style.removeProperty("--ytd-watch-flexy-player-width");
watchFlexy?.style.removeProperty("--ytd-watch-flexy-player-height");
watchFlexy?.style.removeProperty("--ytd-watch-flexy-min-player-height");
for (const element2 of youtubePlayerContainers()) clearYouTubePlayerContainerInset(element2);
if (video) clearGenericVideoInset(video);
}
}
function hasActiveVideoInset(lastSignature) {
return Boolean(lastSignature) || document.documentElement.classList.contains("jpdb-subtitle-video-inset-left") || document.documentElement.classList.contains("jpdb-subtitle-video-inset-right") || document.documentElement.classList.contains("jpdb-subtitle-video-inset-bottom");
}
function videoInsetMetrics(options) {
const insetPixels = Math.max(0, Math.round(options.panelSize) + options.margin);
const width = videoInsetWidth(options);
const height = videoInsetHeight(options, width);
const inset = `${insetPixels}px`;
return {
insetPixels,
inset,
width,
height,
signature: `${options.side}:${inset}:${width}:${height}`
};
}
function videoInsetWidth(options) {
return options.side === "bottom" ? Math.max(320, Math.round(options.videoRect.width)) : Math.max(320, Math.round(options.playerSize));
}
function videoInsetHeight(options, width) {
if (options.side === "bottom") return Math.max(180, Math.round(options.playerSize));
const aspect = videoAspectRatio(options.video);
return Number.isFinite(aspect) && aspect > 0 ? Math.round(width * aspect) : 0;
}
function applyGenericVideoInsetIfNeeded(options, metrics) {
if (!isYouTubePage$1() && options.video) {
applyGenericVideoInset(options.video, options.side, options.side === "bottom" ? metrics.height : metrics.width, metrics.height);
}
}
function createSubtitleVideoInsetAdapter() {
return new SubtitleVideoInsetAdapter();
}
function subtitleVideoLayoutRect(video) {
if (isYouTubePage$1()) {
const rect = youtubeVisiblePlayerRect();
if (rect) return rect;
}
return video?.getBoundingClientRect() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight);
}
function transcriptAvoidanceTarget(video) {
const videoRect = video.getBoundingClientRect();
let best = genericVideoLayoutTarget(video);
for (let ancestor = video.parentElement; ancestor && ancestor !== document.body && ancestor !== document.documentElement; ancestor = ancestor.parentElement) {
if (isUsefulTranscriptAvoidanceTarget(ancestor, videoRect)) best = ancestor;
}
return best;
}
function isUsefulTranscriptAvoidanceTarget(element2, videoRect) {
const rect = element2.getBoundingClientRect();
return usableVideoRect(rect) && rectContainsRect(rect, videoRect, 2) && !isViewportSizedVideoRect(rect) && hasMeaningfulVideoInsetSpace(rect, videoRect);
}
function isViewportSizedVideoRect(rect) {
return rect.width > window.innerWidth * 0.92 || rect.height > window.innerHeight * 0.9;
}
function hasMeaningfulVideoInsetSpace(rect, videoRect) {
return rect.width - videoRect.width >= 180 || rect.height - videoRect.height >= 80;
}
function videoAspectRatio(video) {
if (!video) return 9 / 16;
if (video.videoWidth && video.videoHeight) return video.videoHeight / video.videoWidth;
if (!video.currentSrc && !video.src) return 9 / 16;
const rect = video.getBoundingClientRect();
return rect.height / Math.max(1, rect.width);
}
function applyYouTubePlayerInset(side, width, inset, height) {
const watchFlexy = document.querySelector("ytd-watch-flexy");
applyYouTubeWatchFlexyInset(watchFlexy, side, width, height);
for (const element2 of youtubePlayerContainers()) {
applyYouTubePlayerContainerInset(element2, side, width, inset, bottomInsetHeight(side, height));
}
}
function applyYouTubeWatchFlexyInset(watchFlexy, side, width, height) {
if (side !== "bottom") watchFlexy?.style.setProperty("--ytd-watch-flexy-player-width", `${width}px`);
if (height) watchFlexy?.style.setProperty("--ytd-watch-flexy-player-height", `${height}px`);
if (side === "bottom" && height) watchFlexy?.style.setProperty("--ytd-watch-flexy-min-player-height", `${height}px`);
}
function bottomInsetHeight(side, height) {
return side === "bottom" ? height : 0;
}
function youtubePlayerContainers() {
if (!isYouTubePage$1()) return [];
return [
document.querySelector("ytd-watch-flexy #primary"),
document.querySelector("ytd-watch-flexy #primary-inner")
].filter((element2) => Boolean(element2));
}
function applyYouTubePlayerContainerInset(element2, side, width, inset, height = 0) {
if (side === "bottom") {
applyBottomYouTubePlayerContainerInset(element2, height);
return;
}
applySideYouTubePlayerContainerInset(element2, side, width, inset);
}
function applyBottomYouTubePlayerContainerInset(element2, height) {
if (!height) return;
setStylePropertyIfChanged$1(element2, "height", `${height}px`);
setStylePropertyIfChanged$1(element2, "max-height", `${height}px`);
setStylePropertyIfChanged$1(element2, "min-height", "0px");
}
function applySideYouTubePlayerContainerInset(element2, side, width, inset) {
const widthValue = `${width}px`;
setStylePropertyIfChanged$1(element2, "width", widthValue);
setStylePropertyIfChanged$1(element2, "max-width", widthValue);
setStylePropertyIfChanged$1(element2, "min-width", "0px");
const margin = side === "left" ? `${Math.max(0, Math.round(inset - element2.getBoundingClientRect().left))}px` : `${Math.max(0, Math.round(element2.getBoundingClientRect().right - (window.innerWidth - inset)))}px`;
setStylePropertyIfChanged$1(element2, side === "left" ? "margin-left" : "margin-right", margin);
setStylePropertyIfChanged$1(element2, side === "left" ? "margin-right" : "margin-left", "0px");
}
function youtubeVisiblePlayerRect() {
for (const selector of [
"#movie_player",
"ytd-watch-flexy #player-container-inner",
"ytd-watch-flexy #player-container-outer",
"ytd-watch-flexy #player"
]) {
const rect = document.querySelector(selector)?.getBoundingClientRect();
if (usableVideoRect(rect)) return rect;
}
return void 0;
}
function requestYouTubePlayerResize(width, height) {
if (!isYouTubePage$1()) return;
const player = youtubeMoviePlayer();
try {
if (canResizeYouTubePlayer(player, width, height)) player.setSize(Math.round(width), Math.round(height));
} catch {
}
dispatchWindowEvent(createWindowEvent("resize"));
window.setTimeout(() => dispatchWindowEvent(createWindowEvent("resize")), 0);
}
function youtubeMoviePlayer() {
return document.querySelector("#movie_player");
}
function canResizeYouTubePlayer(player, width, height) {
return Boolean(player?.setSize && width > 0 && height > 0);
}
function clearYouTubePlayerContainerInset(element2) {
for (const property of ["width", "max-width", "min-width", "height", "max-height", "min-height", "margin-left", "margin-right"]) {
if (element2.style.getPropertyValue(property)) element2.style.removeProperty(property);
}
}
const genericVideoInsetStyles = /* @__PURE__ */ new WeakMap();
const genericVideoInsetTargets = /* @__PURE__ */ new WeakMap();
function applyGenericVideoInset(video, side, size, height = 0) {
const target = prepareGenericVideoInsetTarget(video);
if (side === "bottom") {
applyGenericBottomInset(target, size, video);
return;
}
applyGenericSideInset(target, side, size, height);
}
function prepareGenericVideoInsetTarget(video) {
const target = genericVideoLayoutTarget(video);
const previousTarget = genericVideoInsetTargets.get(video);
if (previousTarget && previousTarget !== target) clearGenericVideoInsetTarget(previousTarget);
genericVideoInsetTargets.set(video, target);
rememberGenericVideoInsetStyles(target);
return target;
}
function rememberGenericVideoInsetStyles(target) {
if (genericVideoInsetStyles.has(target)) return;
genericVideoInsetStyles.set(target, {
width: target.style.width,
height: target.style.height,
maxWidth: target.style.maxWidth,
maxHeight: target.style.maxHeight,
minWidth: target.style.minWidth,
minHeight: target.style.minHeight,
marginLeft: target.style.marginLeft,
marginRight: target.style.marginRight,
justifySelf: target.style.justifySelf
});
}
function applyGenericBottomInset(target, size, video) {
restoreGenericSideInsetStyles(target);
const height = genericBottomInsetHeight(target, size, video);
setStylePropertyIfChanged$1(target, "height", `${Math.round(height)}px`);
setStylePropertyIfChanged$1(target, "max-height", `${Math.round(height)}px`);
setStylePropertyIfChanged$1(target, "min-height", "0px");
}
function genericBottomInsetHeight(target, size, video) {
if (!target.matches("[data-yomu-video-frame]")) return size;
return Math.min(size, target.getBoundingClientRect().width * videoAspectRatio(video));
}
function applyGenericSideInset(target, side, size, height) {
restoreGenericBottomInsetStyles(target);
const rect = target.getBoundingClientRect();
const inset = Number.parseFloat(document.documentElement.style.getPropertyValue("--jpdb-subtitle-video-inset")) || 0;
const margin = side === "left" ? Math.max(0, Math.round(inset - rect.left)) : Math.max(0, Math.round(rect.right - (window.innerWidth - inset)));
setStylePropertyIfChanged$1(target, "width", `${Math.round(size)}px`);
setStylePropertyIfChanged$1(target, "max-width", `${Math.round(size)}px`);
setStylePropertyIfChanged$1(target, "min-width", "0px");
setStylePropertyIfChanged$1(target, "justify-self", side === "left" ? "end" : "start");
if (height > 0) {
setStylePropertyIfChanged$1(target, "height", `${Math.round(height)}px`);
setStylePropertyIfChanged$1(target, "max-height", `${Math.round(height)}px`);
setStylePropertyIfChanged$1(target, "min-height", "0px");
}
setStylePropertyIfChanged$1(target, side === "left" ? "margin-left" : "margin-right", `${margin}px`);
setStylePropertyIfChanged$1(target, side === "left" ? "margin-right" : "margin-left", "0px");
}
function restoreGenericSideInsetStyles(target) {
const previous = genericVideoInsetStyles.get(target);
if (!previous) return;
setRestoredStyleProperty(target, "width", previous.width);
setRestoredStyleProperty(target, "height", previous.height);
setRestoredStyleProperty(target, "max-width", previous.maxWidth);
setRestoredStyleProperty(target, "max-height", previous.maxHeight);
setRestoredStyleProperty(target, "min-width", previous.minWidth);
setRestoredStyleProperty(target, "min-height", previous.minHeight);
setRestoredStyleProperty(target, "margin-left", previous.marginLeft);
setRestoredStyleProperty(target, "margin-right", previous.marginRight);
setRestoredStyleProperty(target, "justify-self", previous.justifySelf);
}
function restoreGenericBottomInsetStyles(target) {
const previous = genericVideoInsetStyles.get(target);
if (!previous) return;
setRestoredStyleProperty(target, "height", previous.height);
setRestoredStyleProperty(target, "max-height", previous.maxHeight);
setRestoredStyleProperty(target, "min-height", previous.minHeight);
}
function clearGenericVideoInset(video) {
const target = genericVideoInsetTargets.get(video) ?? genericVideoLayoutTarget(video);
clearGenericVideoInsetTarget(target);
genericVideoInsetTargets.delete(video);
}
function clearGenericVideoInsetTarget(target) {
const previous = genericVideoInsetStyles.get(target);
if (!previous) return;
setRestoredStyleProperty(target, "width", previous.width);
setRestoredStyleProperty(target, "height", previous.height);
setRestoredStyleProperty(target, "max-width", previous.maxWidth);
setRestoredStyleProperty(target, "max-height", previous.maxHeight);
setRestoredStyleProperty(target, "min-width", previous.minWidth);
setRestoredStyleProperty(target, "min-height", previous.minHeight);
setRestoredStyleProperty(target, "margin-left", previous.marginLeft);
setRestoredStyleProperty(target, "margin-right", previous.marginRight);
setRestoredStyleProperty(target, "justify-self", previous.justifySelf);
genericVideoInsetStyles.delete(target);
}
function genericVideoLayoutTarget(video) {
const parent = video.parentElement;
if (!isGenericVideoLayoutParent(parent)) return video;
const parentRect = parent.getBoundingClientRect();
const videoRect = video.getBoundingClientRect();
return shouldUseGenericVideoParent(parent, parentRect, videoRect) ? parent : video;
}
function isGenericVideoLayoutParent(parent) {
return Boolean(parent && parent !== document.body && parent !== document.documentElement);
}
function shouldUseGenericVideoParent(parent, parentRect, videoRect) {
if (parent.matches("[data-yomu-video-frame]")) return true;
if (rectsHaveMatchingSize(parentRect, videoRect, 3)) return false;
return rectContainsRect(parentRect, videoRect);
}
function rectsHaveMatchingSize(a, b, tolerance) {
return Math.abs(a.width - b.width) <= tolerance && Math.abs(a.height - b.height) <= tolerance;
}
function rectContainsRect(container, child, tolerance = 0) {
return container.left <= child.left + tolerance && container.top <= child.top + tolerance && container.right >= child.right - tolerance && container.bottom >= child.bottom - tolerance;
}
function setRestoredStyleProperty(element2, property, value) {
if (value) {
element2.style.setProperty(property, value);
} else {
element2.style.removeProperty(property);
}
}
function setStylePropertyIfChanged$1(element2, property, value) {
if (element2.style.getPropertyValue(property) === value) return;
element2.style.setProperty(property, value);
}
function usableVideoRect(rect) {
return Boolean(rect && rect.width >= 120 && rect.height >= 80);
}
function isYouTubePage$1() {
return /(^|\.)youtube\.com$/i.test(location.hostname);
}
async function discoverYouTubeCaptionTracks() {
const pageTracks = getYouTubeCaptionTracks();
return pageTracks.length ? pageTracks : await getAndroidYouTubeCaptionTracks();
}
async function loadYouTubeTrackCues(track, options) {
if (!track.url) return [];
applyPreferredYouTubeCaptionCandidate(track);
const tried = /* @__PURE__ */ new Set();
const primary = await loadYouTubeCueUrls(track, youtubeSubtitleRequestUrls(track.url), options, tried);
if (primary.length) return primary;
for (const candidate of await fallbackYouTubeCaptionCandidates(track)) {
const cues = await loadYouTubeCueUrls(track, youtubeSubtitleRequestUrls(candidate.url), options, tried);
if (!cues.length) continue;
track.url = candidate.url;
track.youtubeTrack = candidate.raw;
return cues;
}
return [];
}
async function loadYouTubeCueUrls(track, urls, options, tried) {
for (const url of urls) {
if (tried.has(url)) continue;
tried.add(url);
try {
const text2 = await options.requestText(url);
if (!text2.trim()) throw new Error("YouTube timedtext response was empty.");
const cues = normalizeSubtitleCues(parseSubtitleText(text2, {
smoothYouTubeFragments: true,
youtubeAutoGenerated: isAutoGeneratedSubtitleTrack(track)
}));
if (cues.length) return cues;
} catch (error) {
options.onRequestError?.(track, url, error);
}
}
return [];
}
function applyPreferredYouTubeCaptionCandidate(track) {
const preferred = findPreferredYouTubeCaptionCandidate(track);
if (!preferred) return;
if (!track.url || !shouldPreferYouTubeTrackUrl(preferred.url, track.url)) return;
track.url = preferred.url;
track.youtubeTrack = preferred.raw;
}
async function loadFirstUsableYouTubeSibling(track, tracks, options) {
const siblings = tracks.filter((candidate) => candidate.kind === "youtube" && candidate !== track && candidate.language === track.language && candidate.url);
for (const sibling of siblings) {
const cues = sibling.cues?.length ? sibling.cues : await loadYouTubeTrackCues(sibling, options);
if (!cues.length) continue;
sibling.cues = cues;
return { track: sibling, cues };
}
return null;
}
function getYouTubeCaptionTracks() {
const playerTracks = getYouTubePlayerCaptionTracks();
const response = getYouTubePlayerResponse();
const rawTracks = response?.captions?.playerCaptionsTracklistRenderer?.captionTracks;
return uniqueYouTubeCaptionTracks([
...playerTracks,
...Array.isArray(rawTracks) ? rawTracks : []
]);
}
async function fallbackYouTubeCaptionCandidates(track) {
if (track.kind !== "youtube") return [];
const candidates = await getAndroidYouTubeCaptionTracks();
return candidates.filter((candidate) => youtubeCaptionCandidateMatchesTrack(candidate, track)).sort((a, b) => youtubeTrackUrlScore(b.url) - youtubeTrackUrlScore(a.url));
}
function youtubeCaptionCandidateMatchesTrack(candidate, track) {
return youtubeCaptionTrackIdentity(candidate) === youtubeCaptionTrackIdentity(track) || Boolean(candidate.language && track.language && candidate.language.toLowerCase() === track.language.toLowerCase());
}
function disableYouTubeNativeCaptions() {
if (!isYouTubePage()) return;
const player = document.querySelector("#movie_player");
try {
player?.setOption?.("captions", "track", {});
player?.unloadModule?.("captions");
} catch {
}
}
function activateYouTubeCaptionTrack(track) {
if (!isYouTubePage()) return;
const player = youtubeCaptionPlayer();
if (!player?.setOption) return;
try {
player.loadModule?.("captions");
setYouTubeCaptionTrack(player, findMatchingYouTubePlayerTrack(track, player) ?? track.youtubeTrack);
player.setOption("captions", "reload", true);
} catch {
}
}
function youtubeCaptionPlayer() {
return document.querySelector("#movie_player");
}
function setYouTubeCaptionTrack(player, candidate) {
if (candidate) player.setOption?.("captions", "track", candidate);
}
function getYouTubeVideoId() {
const url = new URL(location.href);
return url.searchParams.get("v") ?? url.pathname.match(/\/shorts\/([^/?]+)/)?.[1] ?? "";
}
function isYouTubePage() {
return /(^|\.)youtube\.com$/i.test(location.hostname);
}
function shouldPreferYouTubeTrackUrl(next, current) {
return youtubeTrackUrlScore(next) > youtubeTrackUrlScore(current);
}
function isAutoGeneratedSubtitleTrack(track) {
return Boolean(track.autoGenerated) || /asr|auto(?:matic)?|auto-generated|自動生成|自動字幕/i.test(`${track.label} ${track.language ?? ""}`);
}
function youtubeCaptionTrackIdentity(track) {
return `${track.language ?? ""}:${track.label.replace(/\s+·\s+auto-generated$/iu, "").replace(/\([^)]*\)\s*$/u, "").replace(/\s+/g, " ").trim().toLowerCase()}`;
}
function getYouTubePlayerCaptionTracks() {
const player = document.querySelector("#movie_player");
const videoId = getYouTubeVideoId();
const playerVideoId = player?.getVideoData?.()?.video_id;
const tracks = player?.getAudioTrack?.()?.captionTracks;
return (!playerVideoId || !videoId || playerVideoId === videoId) && Array.isArray(tracks) ? tracks : [];
}
function uniqueYouTubeCaptionTracks(rawTracks) {
const tracks = /* @__PURE__ */ new Map();
for (const track of rawTracks) {
const parsed = parseYouTubeCaptionTrack(track);
if (!parsed) continue;
const key = youtubeCaptionTrackIdentity(parsed);
const existing = tracks.get(key);
if (!existing || shouldPreferYouTubeTrackUrl(parsed.url, existing.url)) tracks.set(key, parsed);
}
return [...tracks.values()];
}
function parseYouTubeCaptionTrack(track) {
const record = track;
const url = normalizedYouTubeCaptionUrl(record);
if (!url) return null;
const language = record.languageCode;
const label = youtubeCaptionTrackLabel(record, language);
const autoGenerated = isAutoGeneratedYouTubeCaptionTrack(record, label);
const autoSuffix = youtubeCaptionAutoSuffix(autoGenerated, label);
return { label: `${label}${language ? ` (${language})` : ""}${autoSuffix}`, language, autoGenerated, url: url.toString(), raw: track };
}
function normalizedYouTubeCaptionUrl(record) {
const rawUrl = rawYouTubeCaptionUrl(record);
if (!rawUrl) return null;
const url = new URL(rawUrl, location.href);
url.searchParams.set("fmt", "srv3");
if (record.languageCode && !url.searchParams.has("lang")) url.searchParams.set("lang", record.languageCode);
applyYouTubeCaptionClientName(url, readYouTubeClientName());
return url;
}
function rawYouTubeCaptionUrl(record) {
return typeof record.url === "string" ? record.url : typeof record.baseUrl === "string" ? record.baseUrl : "";
}
function applyYouTubeCaptionClientName(url, clientName) {
if (clientName && !url.searchParams.has("c")) url.searchParams.set("c", clientName);
}
async function getAndroidYouTubeCaptionTracks() {
const videoId = getYouTubeVideoId();
const apiKey = readYouTubeConfigString("INNERTUBE_API_KEY");
if (!videoId || !apiKey) return [];
try {
const response = await fetch(`${location.origin}/youtubei/v1/player?key=${encodeURIComponent(apiKey)}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
context: {
client: {
clientName: "ANDROID",
clientVersion: "20.10.38",
hl: readYouTubeConfigString("HL") || "en"
}
},
videoId
})
});
if (!response.ok) return [];
const payload = await response.json();
if (!isMatchingYouTubePlayerResponse(payload, videoId)) return [];
const rawTracks = payload.captions?.playerCaptionsTracklistRenderer?.captionTracks;
return uniqueYouTubeCaptionTracks(Array.isArray(rawTracks) ? rawTracks : []);
} catch {
return [];
}
}
function youtubeCaptionTrackLabel(record, language) {
return firstYouTubeCaptionTrackLabel(record, language) || "YouTube subtitles";
}
function firstYouTubeCaptionTrackLabel(record, language) {
return [
simpleYouTubeCaptionName(record),
runYouTubeCaptionName(record),
record.displayName,
record.languageName,
language
].find((label) => Boolean(label)) ?? "";
}
function simpleYouTubeCaptionName(record) {
return record.name?.simpleText ?? "";
}
function runYouTubeCaptionName(record) {
return record.name?.runs?.map((run) => run.text ?? "").join("") ?? "";
}
function youtubeCaptionAutoSuffix(autoGenerated, label) {
return autoGenerated && !/asr|auto(?:matic)?|auto-generated|自動生成|自動字幕/i.test(label) ? " · auto-generated" : "";
}
function isAutoGeneratedYouTubeCaptionTrack(track, label = "") {
return youtubeAutoGeneratedSignals(track, label).some((signal) => signal.matches(signal.value));
}
function youtubeAutoGeneratedSignals(track, label) {
return [
{ value: track.kind ?? "", matches: (value) => /asr/i.test(value) },
{ value: track.vssId ?? "", matches: (value) => /^a\./i.test(value) },
{ value: `${label} ${track.languageCode ?? ""}`, matches: (value) => /asr|auto(?:matic)?|auto-generated|自動生成|自動字幕/i.test(value) }
];
}
function findMatchingYouTubePlayerTrack(track, player) {
const rawTracks = [
...extractYouTubeTrackArray(player.getAudioTrack?.()?.captionTracks),
...extractYouTubeTrackArray(player.getOption?.("captions", "tracklist"))
];
const targetIdentity = youtubeCaptionTrackIdentity(track);
const exact = rawTracks.find((raw) => {
const parsed = parseYouTubeCaptionTrack(raw);
return parsed && youtubeCaptionTrackIdentity(parsed) === targetIdentity;
});
if (exact) return exact;
return rawTracks.find((raw) => {
const parsed = parseYouTubeCaptionTrack(raw);
return parsed?.language && track.language && parsed.language.toLowerCase() === track.language.toLowerCase();
}) ?? null;
}
function findPreferredYouTubeCaptionCandidate(track) {
if (track.kind !== "youtube") return null;
const candidates = uniqueYouTubeCaptionTracks([
...getYouTubePlayerCaptionTracks(),
...getYouTubePlayerResponse()?.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? []
]);
const targetIdentity = youtubeCaptionTrackIdentity(track);
return candidates.filter((candidate) => youtubeCaptionTrackIdentity(candidate) === targetIdentity || Boolean(candidate.language && track.language && candidate.language.toLowerCase() === track.language.toLowerCase())).sort((a, b) => youtubeTrackUrlScore(b.url) - youtubeTrackUrlScore(a.url))[0] ?? null;
}
function extractYouTubeTrackArray(value) {
if (Array.isArray(value)) return value;
const record = value;
return Array.isArray(record?.captionTracks) ? record.captionTracks : [];
}
function getYouTubePlayerResponse() {
const videoId = getYouTubeVideoId();
const fromWindow = window.ytInitialPlayerResponse;
if (isMatchingYouTubePlayerResponse(fromWindow, videoId)) return fromWindow;
const fromConfig = readYouTubePlayerResponseFromConfig(videoId);
if (fromConfig) return fromConfig;
return readYouTubePlayerResponseFromScripts(videoId);
}
function readYouTubePlayerResponseFromScripts(videoId) {
for (const script of Array.from(document.scripts)) {
const parsed = readYouTubePlayerResponseFromScript(script.textContent ?? "", videoId);
if (parsed) return parsed;
}
return null;
}
function readYouTubePlayerResponseFromScript(text2, videoId) {
return readYouTubeInitialPlayerResponse(text2, videoId) ?? readEscapedYouTubePlayerResponse(text2, videoId);
}
function readYouTubeInitialPlayerResponse(text2, videoId) {
for (const marker of ["ytInitialPlayerResponse = ", "ytInitialPlayerResponse=", "var ytInitialPlayerResponse = "]) {
const parsed = parseYouTubePlayerResponseMarker(text2, marker, videoId);
if (parsed) return parsed;
}
return null;
}
function parseYouTubePlayerResponseMarker(text2, marker, videoId) {
const start = text2.indexOf(marker);
if (start < 0) return null;
const raw = extractJsonObject(text2, start + marker.length);
return raw ? parseMatchingYouTubePlayerResponse(raw, videoId) : null;
}
function readEscapedYouTubePlayerResponse(text2, videoId) {
const escaped = text2.match(/"playerResponse"\s*:\s*"((?:\\.|[^"\\])+)"/);
if (!escaped?.[1]) return null;
try {
return parseMatchingYouTubePlayerResponse(JSON.parse(`"${escaped[1]}"`), videoId);
} catch {
return null;
}
}
function parseMatchingYouTubePlayerResponse(raw, videoId) {
try {
const parsed = JSON.parse(raw);
return isMatchingYouTubePlayerResponse(parsed, videoId) ? parsed : null;
} catch {
return null;
}
}
function readYouTubePlayerResponseFromConfig(videoId) {
const ytcfg = window.ytcfg;
const candidates = [
ytcfg?.get?.("PLAYER_RESPONSE"),
ytcfg?.get?.("PLAYER_VARS"),
ytcfg?.data_?.PLAYER_RESPONSE,
ytcfg?.data_?.PLAYER_VARS
];
for (const candidate of candidates) {
const response = readYouTubePlayerResponseCandidate(candidate);
if (isMatchingYouTubePlayerResponse(response, videoId)) return response;
}
return null;
}
function readYouTubePlayerResponseCandidate(candidate) {
if (!candidate) return null;
if (typeof candidate === "string") return parseYouTubePlayerResponseJson(candidate);
if (typeof candidate === "object") return readYouTubePlayerResponseObject(candidate);
return null;
}
function parseYouTubePlayerResponseJson(candidate) {
try {
return JSON.parse(candidate);
} catch {
return null;
}
}
function readYouTubePlayerResponseObject(candidate) {
const record = candidate;
return readYouTubePlayerResponseCandidate(record.player_response ?? record.raw_player_response) ?? candidate;
}
function isMatchingYouTubePlayerResponse(value, videoId) {
const response = youtubePlayerResponseRecord(value);
return Boolean(response && hasYouTubeCaptionTracks(response) && youtubePlayerResponseMatchesVideo(response, videoId));
}
function youtubePlayerResponseRecord(value) {
return value && typeof value === "object" ? value : null;
}
function hasYouTubeCaptionTracks(response) {
return Boolean(response.captions?.playerCaptionsTracklistRenderer?.captionTracks);
}
function youtubePlayerResponseMatchesVideo(response, videoId) {
const responseVideoId = response.videoDetails?.videoId;
return !videoId || !responseVideoId || responseVideoId === videoId;
}
function extractJsonObject(text2, start) {
const objectStart = text2.indexOf("{", start);
if (objectStart < 0) return null;
const state = createJsonObjectScanState();
for (let index = objectStart; index < text2.length; index++) {
if (scanJsonObjectCharacter(state, text2[index])) return text2.slice(objectStart, index + 1);
}
return null;
}
function createJsonObjectScanState() {
return { depth: 0, inString: false, escaped: false };
}
function scanJsonObjectCharacter(state, char) {
if (state.inString) {
scanJsonStringCharacter(state, char);
return false;
}
if (char === '"') {
state.inString = true;
return false;
}
if (char === "{") state.depth += 1;
if (char !== "}") return false;
state.depth -= 1;
return state.depth === 0;
}
function scanJsonStringCharacter(state, char) {
if (state.escaped) {
state.escaped = false;
return;
}
if (char === "\\") state.escaped = true;
if (char === '"') state.inString = false;
}
function youtubeSubtitleRequestUrls(url) {
return uniqueStrings([
withYouTubeSubtitleFormat(url, "srv3"),
withYouTubeSubtitleFormat(url, "json3"),
withYouTubeSubtitleFormat(url, "vtt"),
url
]);
}
function withYouTubeSubtitleFormat(url, format) {
const parsed = new URL(url);
parsed.searchParams.set("fmt", format);
const clientName = readYouTubeClientName();
if (clientName && !parsed.searchParams.has("c")) parsed.searchParams.set("c", clientName);
return parsed.href;
}
function readYouTubeClientName() {
return readYouTubeConfigString("INNERTUBE_CLIENT_NAME");
}
function readYouTubeConfigString(key) {
const ytcfg = window.ytcfg;
const value = ytcfg?.get?.(key);
return typeof value === "string" && value ? value : "";
}
function youtubeTrackUrlScore(value) {
if (!value) return 0;
try {
const url = new URL(value, location.href);
return youtubeTrackSearchParamScore(url.searchParams);
} catch {
return 0;
}
}
function youtubeTrackSearchParamScore(params) {
return [
params.has("pot") ? 8 : 0,
params.has("potc") ? 4 : 0,
params.has("signature") ? 2 : 0,
params.has("kind") ? 1 : 0
].reduce((sum, item) => sum + item, 0);
}
function uniqueStrings(values) {
return [...new Set(values)];
}
async function loadSubtitleTrackCues(track, options) {
if (track.track) return loadNativeTrackCues(track);
if (isRemoteSubtitleTrack(track)) {
const cues = await loadRemoteTrackCues(track, options);
track.cues = cues;
return { track, cues };
}
if (isYouTubeSubtitleTrack(track)) return loadYouTubeTrackWithFallback(track, options);
return { track, cues: track.cues ?? [] };
}
function isRemoteSubtitleTrack(track) {
return track.kind === "remote" && Boolean(track.url);
}
function isYouTubeSubtitleTrack(track) {
return track.kind === "youtube" && Boolean(track.url);
}
async function loadNativeTrackCues(track) {
const nativeTrack = track.track;
if (!nativeTrack) return { track, cues: [] };
ensureTextTrackReadable(nativeTrack);
const cues = readTextTrackCues(nativeTrack);
return { track, cues: cues.length ? cues : await waitForTextTrackCues(nativeTrack) };
}
async function loadYouTubeTrackWithFallback(track, options) {
const youtubeOptions = {
requestText: options.requestText,
onRequestError: options.onYouTubeRequestError
};
const cues = await loadYouTubeTrackCues(track, youtubeOptions);
if (cues.length) {
track.cues = cues;
return { track, cues };
}
const fallback = await loadFirstUsableYouTubeSibling(track, options.tracks, youtubeOptions);
if (fallback) return fallback;
track.cues = [];
return { track, cues: [] };
}
function ensureTextTrackReadable(track) {
if (track.mode === "disabled") track.mode = "hidden";
}
function readTextTrackCues(track) {
return normalizeSubtitleCues(Array.from(track.cues ?? []).map((cue) => ({ start: cue.startTime, end: cue.endTime, text: getTextTrackCueText(cue).trim() })).filter((cue) => cue.text).sort((a, b) => a.start - b.start));
}
function waitForTextTrackCues(track, timeoutMs = 900) {
const startedAt = performance.now();
return new Promise((resolve) => {
const poll = () => {
const cues = readTextTrackCues(track);
if (cues.length || performance.now() - startedAt >= timeoutMs) {
resolve(cues);
return;
}
window.setTimeout(poll, 50);
};
poll();
});
}
function getTextTrackCueText(cue) {
if ("text" in cue && typeof cue.text === "string") return cue.text;
return "";
}
async function loadRemoteTrackCues(track, options) {
try {
const cues = normalizeSubtitleCues(parseSubtitleText(await options.requestText(track.url ?? "")), {
transcriptEligible: options.transcriptEligible
});
if (cues.length) return cues;
options.onRemoteEmpty?.(track);
} catch (error) {
options.onRemoteError?.(track, error);
}
return [];
}
function trackStatusText(track, language = "en") {
if (track.loadingState === "loading") return ` · ${uiText(language, "trackStatusLoading")}`;
if (track.loadingState === "waiting") return ` · ${uiText(language, "trackStatusWaiting")}`;
if (track.loadingState === "error") return ` · ${uiText(language, "trackStatusFailed")}`;
return "";
}
function formatTrackKind(kind, language = "en") {
if (kind === "native") return uiText(language, "trackKindPageTrack");
if (kind === "remote") return uiText(language, "trackKindPageFile");
if (kind === "youtube") return uiText(language, "trackKindYouTubeCaptions");
return uiText(language, "trackKindLoadedFile");
}
function compareSubtitleTrackOptions(a, b) {
return subtitleTrackRank(a) - subtitleTrackRank(b) || (a.language ?? "").localeCompare(b.language ?? "", void 0, { sensitivity: "base" }) || a.label.localeCompare(b.label, void 0, { sensitivity: "base" });
}
function isJapaneseSubtitleTrack(track) {
const language = track.language?.toLowerCase() ?? "";
const label = track.label.toLowerCase();
return language === "ja" || language.startsWith("ja-") || /日本語|japanese/.test(label);
}
function isEnglishSubtitleTrack(track) {
return /(^|\b)(en|eng|english)(\b|$)/i.test(`${track.label} ${track.language ?? ""}`);
}
function shouldReplaceWaitingNativeTrack(selected, replacement, cues) {
return isWaitingNativeTrack(selected, cues) && (hasSameSubtitleRole(selected, replacement) || hasSameNormalizedSubtitleLanguage(selected, replacement));
}
function isWaitingNativeTrack(selected, cues) {
return Boolean(selected && selected.kind === "native" && !cues.length);
}
function hasSameSubtitleRole(selected, replacement) {
return isJapaneseSubtitleTrack(selected) && isJapaneseSubtitleTrack(replacement) || isEnglishSubtitleTrack(selected) && isEnglishSubtitleTrack(replacement);
}
function hasSameNormalizedSubtitleLanguage(selected, replacement) {
const selectedLanguage = normalizeSubtitleLanguage(selected.language);
const replacementLanguage = normalizeSubtitleLanguage(replacement.language);
return Boolean(selectedLanguage && replacementLanguage && selectedLanguage === replacementLanguage);
}
function subtitleTrackRank(track) {
return SUBTITLE_TRACK_RANKS.find((rule) => rule.matches(track))?.rank ?? 5;
}
const SUBTITLE_TRACK_RANKS = [
{ rank: 0, matches: (track) => track.kind === "file" },
{ rank: 1, matches: isManualJapaneseSubtitleTrack },
{ rank: 2, matches: isJapaneseSubtitleTrack },
{ rank: 3, matches: isAutoGeneratedSubtitleTrack },
{ rank: 4, matches: isEnglishOrNativeSubtitleTrack }
];
function isManualJapaneseSubtitleTrack(track) {
return isJapaneseSubtitleTrack(track) && !isAutoGeneratedSubtitleTrack(track);
}
function isEnglishOrNativeSubtitleTrack(track) {
return isEnglishSubtitleTrack(track) || track.kind === "native";
}
function applySubtitleNativeTrackModes(state) {
const yomuCaptionsActive = Boolean(state.overlayVisible && (state.selectedTrackId || state.hasPrimaryCues || state.currentCueText));
if (!isYouTubePage()) return applyGenericNativeTrackModes(state);
return applyYouTubeNativeTrackModes(state, yomuCaptionsActive);
}
function applyGenericNativeTrackModes(state) {
for (const option of state.tracks) {
if (option.track && isSelectedSubtitleTrack(option, state)) ensureTextTrackReadable(option.track);
}
document.documentElement.classList.remove("jpdb-subtitle-yomu-captions-active");
return false;
}
function applyYouTubeNativeTrackModes(state, yomuCaptionsActive) {
applyYouTubeTextTrackModes(state);
const hideYouTubeNativeCaptions = yomuCaptionsActive && !needsYouTubeDomCaptionFallback(state);
document.documentElement.classList.toggle("jpdb-subtitle-yomu-captions-active", hideYouTubeNativeCaptions);
if (shouldDisableYouTubeNativeCaptions(state, hideYouTubeNativeCaptions)) disableYouTubeNativeCaptions();
if (shouldRestoreYouTubeNativeCaptions(state, hideYouTubeNativeCaptions)) restoreYouTubeNativeCaptionTrack(state);
return hideYouTubeNativeCaptions;
}
function applyYouTubeTextTrackModes(state) {
for (const option of state.tracks) {
if (option.track) option.track.mode = isSelectedSubtitleTrack(option, state) ? "hidden" : "disabled";
}
}
function shouldDisableYouTubeNativeCaptions(state, hideYouTubeNativeCaptions) {
return hideYouTubeNativeCaptions && !state.lastYomuCaptionsActive;
}
function shouldRestoreYouTubeNativeCaptions(state, hideYouTubeNativeCaptions) {
return !hideYouTubeNativeCaptions && state.lastYomuCaptionsActive;
}
function restoreYouTubeNativeCaptionTrack(state) {
const selected = state.tracks.find((track) => track.id === state.selectedTrackId && track.kind === "youtube");
if (selected) activateYouTubeCaptionTrack(selected);
}
function needsYouTubeDomCaptionFallback(state) {
return Boolean(state.youtubeDomCaptionFallbackTrackId && state.youtubeDomCaptionFallbackTrackId === state.selectedTrackId);
}
function isSelectedSubtitleTrack(option, state) {
return option.id === state.selectedTrackId || option.id === state.secondaryTrackId;
}
function renderSubtitlePrimary(input2) {
const activeCue = input2.cue;
const karaokeActive = input2.karaokeMode && cueHasExactWordTimings(activeCue);
const parsedHasReaderWords = input2.parsedHtml?.includes("jpdb-reader-word") ?? false;
const mode = subtitlePrimaryRenderMode(input2, karaokeActive, parsedHasReaderWords);
return {
html: renderSubtitlePrimaryHtml(input2, mode),
karaokeActive,
shouldRequestParse: input2.hasParser && !input2.parsedHtml,
nextRenderedPrimary: nextRenderedPrimaryCache(input2, karaokeActive)
};
}
function subtitlePrimaryRenderMode(input2, karaokeActive, parsedHasReaderWords) {
if (hasParsedKaraokeRender(karaokeActive, parsedHasReaderWords)) return "parsed-karaoke";
if (hasPlainKaraokeRender(input2, karaokeActive)) return "karaoke";
if (input2.parsedHtml) return "parsed";
if (hasReusablePrimaryParserCache(input2)) return "cached-parser";
return parserFallbackRenderMode(input2.hasParser);
}
function hasParsedKaraokeRender(karaokeActive, parsedHasReaderWords) {
return karaokeActive && parsedHasReaderWords;
}
function hasPlainKaraokeRender(input2, karaokeActive) {
return Boolean(karaokeActive && input2.cue);
}
function parserFallbackRenderMode(hasParser) {
return hasParser ? "loading-parser" : "plain";
}
function hasReusablePrimaryParserCache(input2) {
return Boolean(input2.hasParser && input2.lastRenderedText === input2.text && input2.lastRenderedHtml);
}
function renderSubtitlePrimaryHtml(input2, mode) {
return SUBTITLE_PRIMARY_RENDERERS[mode](input2);
}
const SUBTITLE_PRIMARY_RENDERERS = {
"parsed-karaoke": (input2) => input2.parsedHtml ?? "",
parsed: (input2) => input2.parsedHtml ?? "",
karaoke: (input2) => renderSubtitleKaraokeCue(input2.cue, input2.time),
"cached-parser": (input2) => input2.lastRenderedHtml,
"loading-parser": (input2) => `${escapeWithBreaks(input2.text)} `,
plain: (input2) => escapeWithBreaks(input2.text)
};
function nextRenderedPrimaryCache(input2, karaokeActive) {
if (input2.parsedHtml) return { text: input2.text, html: input2.parsedHtml };
return karaokeActive ? { text: input2.text, html: "" } : void 0;
}
function renderSubtitleSecondary(text2, nativeBlurred, language = "en") {
const blurClass = nativeBlurred ? "jpdb-subtitle-secondary-blurred" : "jpdb-subtitle-secondary-clear";
const label = uiText(language, "toggleNativeSubtitleBlur");
return `${escapeWithBreaks(text2)} `;
}
function renderSubtitleKaraokeCue(cue, time) {
if (!cue?.text.trim()) return "";
if (!cueHasExactWordTimings(cue)) return escapeWithBreaks(cue.text);
const words = cue.words;
if (!words.length) return "";
const progress = karaokeCharacterProgress(cue, words, time);
return renderKaraokeTextParts(cue.text, progress);
}
function planTranscriptHydrationIndexes(options) {
const indexes = /* @__PURE__ */ new Set();
addPreferredIndexes(indexes, options);
addVisibleIndexes(indexes, options);
const nextCursor = addBackgroundIndexes(indexes, options);
return { indexes: [...indexes].sort((a, b) => a - b), nextCursor };
}
function addPreferredIndexes(indexes, options) {
if (options.preferredIndex >= 0) {
for (const index of preferredHydrationRange(options)) {
addHydrationIndex(indexes, index, options);
if (indexes.size >= options.maxRows) break;
}
return;
}
for (let index = 0; index < fallbackHydrationRows(options); index++) indexes.add(index);
}
function preferredHydrationRange(options) {
const start = options.preferredIndex - options.activeBehind;
const end = options.preferredIndex + options.activeAhead;
return Array.from({ length: Math.max(0, end - start + 1) }, (_, offset) => start + offset);
}
function addHydrationIndex(indexes, index, options) {
if (index >= 0 && index < options.rowCount) indexes.add(index);
}
function fallbackHydrationRows(options) {
return Math.min(options.fallbackRows ?? 6, options.rowCount);
}
function addVisibleIndexes(indexes, options) {
const rows = visibleTranscriptRows(options);
if (!rows) return;
for (const row of rows.elements) {
addVisibleTranscriptRowIndex(indexes, row, rows.scrollerRect, options);
if (indexes.size >= options.maxRows) break;
}
}
function visibleTranscriptRows(options) {
const scrollerRect = options.scroller?.getBoundingClientRect();
return options.scroller && scrollerRect ? { elements: Array.from(options.scroller.querySelectorAll(".jpdb-subtitle-list-row")), scrollerRect } : null;
}
function addVisibleTranscriptRowIndex(indexes, row, scrollerRect, options) {
const index = visibleTranscriptRowIndex(row, scrollerRect, options.rowCount);
if (index !== null) indexes.add(index);
}
function visibleTranscriptRowIndex(row, scrollerRect, rowCount) {
const rect = row.getBoundingClientRect();
if (!isTranscriptRowVisible(rect, scrollerRect)) return null;
const index = Number(row.dataset.rowIndex);
return validTranscriptRowIndex(index, rowCount) ? index : null;
}
function isTranscriptRowVisible(rect, scrollerRect) {
return rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom;
}
function validTranscriptRowIndex(index, rowCount) {
return Number.isInteger(index) && index >= 0 && index < rowCount;
}
function addBackgroundIndexes(indexes, options) {
let nextCursor = options.cursor;
for (let count = 0; count < options.backgroundBatch && options.rowCount && indexes.size < options.maxRows; count++) {
const index = nextCursor % options.rowCount;
nextCursor = (nextCursor + 1) % options.rowCount;
indexes.add(index);
}
return nextCursor;
}
const CAPTION_SELECTOR_LIST = [
".caption-visual-line",
".captions-text",
'[data-purpose="captions-text"]',
".ytp-caption-segment"
];
const CAPTION_SELECTORS = CAPTION_SELECTOR_LIST.join(",");
const CAPTION_CONTAINER_SELECTORS = '.caption-visual-line,.captions-text,[data-purpose="captions-text"],.caption-window,.ytp-caption-segment';
function readPageCaptionText(video, readerRoot) {
const direct = readDirectPageCaptionText(video, readerRoot);
if (direct || !video) return direct;
return isYouTubePage() ? readHiddenYouTubeCaptionText(readerRoot) : readNearbyPageCaptionText(video, readerRoot);
}
function readDirectPageCaptionText(video, readerRoot) {
return collectCaptionTexts([...document.querySelectorAll(CAPTION_SELECTORS)], video, readerRoot, false);
}
function readNearbyPageCaptionText(video, readerRoot) {
return collectCaptionTexts(
[...document.querySelectorAll("span, p, div")],
video,
readerRoot,
true
);
}
function readHiddenYouTubeCaptionText(readerRoot) {
const lines = [];
const seen = /* @__PURE__ */ new Set();
for (const element2 of Array.from(document.querySelectorAll(".ytp-caption-segment, .caption-window"))) {
const text2 = hiddenYouTubeCaptionLine(element2, readerRoot);
if (!text2 || seen.has(text2)) continue;
seen.add(text2);
lines.push(text2);
if (lines.length >= 2) break;
}
return lines.join(" ").replace(/\s+/g, " ").trim();
}
function hiddenYouTubeCaptionLine(element2, readerRoot) {
if (isCaptionElementExcluded(element2, readerRoot)) return "";
const text2 = normalizeCaptionText(element2.innerText || element2.textContent || "");
return isJapaneseCaptionText(text2) ? text2 : "";
}
function collectCaptionTexts(elements, video, readerRoot, nearVideoOnly = false) {
const lines = [];
const seen = /* @__PURE__ */ new Set();
for (const element2 of elements) {
if (!isLikelyCaptionElement(element2, video, readerRoot, nearVideoOnly)) continue;
const text2 = unseenCaptionText(element2, seen);
if (!text2) continue;
seen.add(text2);
lines.push(text2);
if (lines.length >= 2) break;
}
return lines.join(" ").replace(/\s+/g, " ").trim();
}
function unseenCaptionText(element2, seen) {
const text2 = normalizeCaptionText(element2.innerText || element2.textContent || "");
return text2 && !seen.has(text2) ? text2 : "";
}
function isLikelyCaptionElement(element2, video, readerRoot, nearVideoOnly = false) {
if (!isCaptionCandidateElement(element2, readerRoot)) return false;
const rect = element2.getBoundingClientRect();
return isVisibleCaptionRect(element2, rect) && matchesCaptionVideoScope(rect, video, nearVideoOnly);
}
function isCaptionCandidateElement(element2, readerRoot) {
if (isCaptionElementExcluded(element2, readerRoot)) return false;
return isCaptionTextShape(element2, normalizeCaptionText(element2.innerText || element2.textContent || ""));
}
function matchesCaptionVideoScope(rect, video, nearVideoOnly = false) {
if (!video) return !nearVideoOnly;
const videoRect = video.getBoundingClientRect();
if (videoRect.width < 120 || videoRect.height < 80) return !nearVideoOnly;
return isCaptionNearVideo(rect, videoRect);
}
function isCaptionElementExcluded(element2, readerRoot) {
return !element2.isConnected || Boolean(readerRoot && (element2 === readerRoot || readerRoot.contains(element2))) || Boolean(element2.closest([
"[data-jpdb-reader-root]",
".asbplayer-offscreen",
".asbplayer-subtitles-container-bottom",
".asbplayer-subtitle",
".asbplayer-drag-zone",
".asbplayer-overlay-container",
"script",
"style",
"noscript",
"textarea",
"input",
"select",
"button"
].join(",")));
}
function isCaptionTextShape(element2, text2) {
const allowsChildText = element2.matches(CAPTION_CONTAINER_SELECTORS);
if (!hasCaptionTextLength(text2)) return false;
if (!isJapaneseCaptionText(text2)) return false;
if (text2.split("\n").length > 4) return false;
return allowsChildText || !hasJapaneseCaptionChildText(element2);
}
function hasCaptionTextLength(text2) {
return text2.length >= 2 && text2.length <= 180;
}
function isJapaneseCaptionText(text2) {
return Boolean(text2 && /[\u3040-\u30ff\u3400-\u9fff]/.test(text2));
}
function hasJapaneseCaptionChildText(element2) {
return [...element2.children].some((child) => /[\u3040-\u30ff\u3400-\u9fff]/.test(child.textContent ?? ""));
}
function isVisibleCaptionRect(element2, rect) {
if (!hasVisibleCaptionRectBounds(rect)) return false;
const style = getComputedStyle(element2);
return hasVisibleCaptionStyle(style);
}
function hasVisibleCaptionRectBounds(rect) {
return rect.width >= 24 && rect.height >= 10 && rect.bottom >= 0 && rect.top <= window.innerHeight;
}
function hasVisibleCaptionStyle(style) {
return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity || "1") > 0;
}
function isCaptionNearVideo(rect, videoRect) {
const horizontalOverlap = Math.max(0, Math.min(rect.right, videoRect.right) - Math.max(rect.left, videoRect.left));
const overlapRatio = horizontalOverlap / Math.max(1, Math.min(rect.width, videoRect.width));
const overlapsVideo = captionOverlapsVideo(rect, videoRect, overlapRatio);
const belowVideo = captionSitsBelowVideo(rect, videoRect, overlapRatio);
const tooLarge = rect.width * rect.height > videoRect.width * videoRect.height * 0.45;
return !tooLarge && (overlapsVideo || belowVideo);
}
function captionOverlapsVideo(rect, videoRect, overlapRatio) {
return rect.bottom >= videoRect.top && rect.top <= videoRect.bottom && overlapRatio > 0.25;
}
function captionSitsBelowVideo(rect, videoRect, overlapRatio) {
return rect.top >= videoRect.bottom && rect.top <= videoRect.bottom + 90 && overlapRatio > 0.25;
}
function updatePageSubtitleTrack(track, source) {
if (track.label === source.label && track.language === source.language && track.sourceKey === source.sourceKey) return false;
track.label = source.label;
track.language = source.language;
track.sourceKey = source.sourceKey;
return true;
}
function secondarySubtitleToggleLabel(settings) {
return uiText(settings.interfaceLanguage, settings.subtitleSecondaryVisible ? "nativeSubtitlesOn" : "nativeSubtitlesOff");
}
function canParseSubtitleTranscriptRows(settings) {
return hasSubtitleParserSource(settings);
}
function shouldApplyParsedTranscriptHtml(target, key) {
return target.dataset.parseKey === key && target.dataset.parsedKey !== key;
}
function hasSubtitleParserSource(settings) {
return Boolean(settings.apiKey.trim() || settings.localDictionariesEnabled);
}
function hasAttemptedTranscriptParse(target, key) {
return target.dataset.parsedKey === key || hasRecentTranscriptParseAttempt(target.dataset.parseEmptyKey, target.dataset.parseEmptyAt, key) || hasRecentTranscriptParseAttempt(target.dataset.parseFailedKey, target.dataset.parseFailedAt, key);
}
function hasRecentTranscriptParseAttempt(markerKey, markerAt, key) {
if (markerKey !== key) return false;
const markedAt = Number(markerAt || 0);
return Number.isFinite(markedAt) && Date.now() - markedAt < SUBTITLE_EMPTY_PARSE_RETRY_MS;
}
function parsedSubtitleHtmlHasReaderWords(html) {
return html.includes("jpdb-reader-word");
}
function subtitleParseSourceSignature(settings) {
return [
settings.apiKey.trim() ? "api:on" : "api:off",
settings.localDictionariesEnabled ? "local:on" : "local:off",
settings.localDictionariesEnabled ? dictionaryPreferencesSignature(settings) : ""
].join("|");
}
function dictionaryPreferencesSignature(settings) {
return settings.dictionaryPreferences.map((preference) => [
preference.name,
preference.alias,
preference.enabled ? "1" : "0",
preference.priority,
preference.allowSecondarySearches ? "1" : "0",
preference.type ?? ""
].join(",")).join(";");
}
function subtitleMinimumFontSize(root) {
const rootRect = root.getBoundingClientRect();
return rootRect.width < 420 || rootRect.height < 260 ? 11 : 14;
}
function subtitleFrameTargetFontSize(root, settings) {
const rootRect = root.getBoundingClientRect();
const width = Math.max(1, rootRect.width);
const height = Math.max(1, rootRect.height);
const baseline = Math.max(16, Math.min(64, settings.subtitleFontSize));
const frameScale = Math.sqrt(Math.min(width / 1280, height / 720));
const scaled = Math.round(baseline * Math.max(0.62, Math.min(1.45, frameScale)));
return Math.max(subtitleMinimumFontSize(root), Math.min(64, scaled));
}
function subtitleElementOverflows(element2) {
return element2.scrollHeight > element2.clientHeight + 1 || element2.scrollWidth > element2.clientWidth + 1;
}
function nextSubtitleFontSize(element2, fitted, minimum) {
const heightScale = element2.clientHeight / Math.max(1, element2.scrollHeight);
const widthScale = element2.clientWidth / Math.max(1, element2.scrollWidth);
return Math.max(minimum, Math.floor(fitted * Math.min(0.92, heightScale, widthScale)));
}
function applyKaraokeClassToWordElement(element2, cursor, progress) {
element2.classList.remove("jpdb-subtitle-word-pending", "jpdb-subtitle-word-spoken", "jpdb-subtitle-word-current");
const surface = element2.textContent?.replace(/\s+/g, "") ?? "";
if (!surface) return cursor;
const start = cursor;
const end = cursor + compactTextLength(surface);
element2.classList.add(karaokeWordClass(progress, start, end));
return end;
}
function karaokeWordClass(progress, start, end) {
if (progress >= end) return "jpdb-subtitle-word-spoken";
return progress > start ? "jpdb-subtitle-word-current" : "jpdb-subtitle-word-pending";
}
function pointInRect(x, y, rect) {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
}
function syncLineNavigationButton(button2, action, hasLines, hasVideo, hiddenByPanel, language) {
button2.hidden = !hasLines || hiddenByPanel;
button2.disabled = !hasVideo || !hasLines;
const label = uiText(language, action === "previous" ? "previousSubtitle" : "nextSubtitle");
button2.title = label;
button2.setAttribute("aria-label", label);
}
const SUBTITLE_ACTIVE_PREPARSE_BEHIND = 2;
const SUBTITLE_ACTIVE_PREPARSE_AHEAD = 7;
const TRANSCRIPT_ACTIVE_HYDRATION_BEHIND = 1;
const TRANSCRIPT_ACTIVE_HYDRATION_AHEAD = 3;
const TRANSCRIPT_HYDRATION_MAX_ROWS = 12;
const TRANSCRIPT_BACKGROUND_HYDRATION_BATCH = 1;
const TRANSCRIPT_BACKGROUND_PARSE_CONCURRENCY = 1;
const TRANSCRIPT_BACKGROUND_PARSE_AHEAD = 32;
const TRANSCRIPT_BACKGROUND_PARSE_BEHIND = 6;
const TRANSCRIPT_BACKGROUND_PARSE_LIMIT = 40;
const TRANSCRIPT_WARMUP_SIGNATURE_BUCKET_SIZE = 8;
const YOUTUBE_TRANSCRIPT_BACKGROUND_PARSE_PAUSE_MS = 120;
const SUBTITLE_EMPTY_PARSE_RETRY_MS = 2500;
const SUBTITLE_REQUEST_TIMEOUT_MS = 8e3;
const YOUTUBE_CAPTION_ACTIVATION_RETRY_MS = 2e3;
const log$3 = Logger.scope("Subtitles");
const TRACK_LOAD_OPTIONS = {
requestText: requestSubtitleText,
onYouTubeRequestError: (track, url, error) => log$3.debug("YouTube subtitle request failed", {
label: track.label,
...subtitleRequestFailureDetails(url),
error
})
};
function normalizedSubtitleText(value) {
return (value ?? "").replace(/\s+/g, " ").trim();
}
function transcriptWarmupIndexes(priority, focusIndex, rowCount) {
return [
...priority,
...forwardIndexes(focusIndex, Math.min(rowCount, focusIndex + TRANSCRIPT_BACKGROUND_PARSE_AHEAD)),
...backwardIndexes(focusIndex - 1, Math.max(0, focusIndex - TRANSCRIPT_BACKGROUND_PARSE_BEHIND))
];
}
function forwardIndexes(start, endExclusive) {
const indexes = [];
for (let index = start; index < endExclusive; index++) indexes.push(index);
return indexes;
}
function backwardIndexes(start, endInclusive) {
const indexes = [];
for (let index = start; index >= endInclusive; index--) indexes.push(index);
return indexes;
}
function trackPanelSummaryText(autoDetected, language) {
return autoDetected ? autoDetected === 1 ? uiText(language, "autoDetectedOptionSingular") : `${autoDetected} ${uiText(language, "autoDetectedOptions")}` : uiText(language, "autoDetectedTracksWillAppear");
}
function shouldReplaceLoadedCue(next, current) {
return Boolean(next && next !== current);
}
function shouldClearLoadedCue(next, current, time) {
return Boolean(!next && current && time > current.end + 0.12);
}
function loadedTrackState(cues) {
return cues.length ? "ready" : "waiting";
}
function subtitleClipboardText(primary, secondary) {
return [primary?.text.trim(), secondary?.text.trim()].filter(Boolean).join("\n");
}
function fittedSubtitleFontSize(element2, fitted, minimum, apply) {
for (let attempt = 0; attempt < 10; attempt++) {
if (!subtitleElementOverflows(element2)) return fitted;
const next = nextSubtitleFontSize(element2, fitted, minimum);
if (next >= fitted) break;
fitted = next;
apply(fitted);
}
return fitted;
}
class SubtitlePlayerController {
constructor(options) {
this.options = options;
}
root;
subtitleEl;
menuEl;
transcriptPanel;
abortController;
primaryFileInput;
secondaryFileInput;
video;
cues = [];
secondaryCues = [];
tracks = [];
currentCue;
secondaryCue;
observer;
videoResizeObserver;
discoverTimer;
tickTimer;
alignFrame;
destroyed = false;
selectedTrackId = "";
secondaryTrackId = "";
youtubeVideoId = "";
youtubeAutoSelectSuppressedVideoId = "";
lastDomCaption = "";
pendingDomCaption;
parsedHtmlCache = /* @__PURE__ */ new Map();
emptyParsedHtmlCache = /* @__PURE__ */ new Map();
pendingParsedHtml = /* @__PURE__ */ new Map();
renderSerial = 0;
panelMode = "lines";
lastMenuSignature = "";
lastTranscriptSignature = "";
transcriptScrollFrame;
transcriptHydrateFrame;
transcriptHydrationSerial = 0;
transcriptCacheWarmupSerial = 0;
transcriptCacheWarmupSignature = "";
transcriptPanelSize = loadTranscriptPanelSize();
videoInset = createSubtitleVideoInsetAdapter();
lastYomuCaptionsActive = false;
youtubeDomCaptionFallbackTrackId = "";
fullscreen = false;
lastRenderedPrimaryText = "";
lastRenderedPrimaryHtml = "";
lastRenderedPrimaryKey = "";
parseWarmupSerial = 0;
transcriptHydrationCursor = 0;
effectiveTranscriptPlacement = "right";
lastAutoCopiedCueSignature = "";
youtubeTrackDiscoveryInFlight = false;
lastYouTubeTrackDiscoveryAt = 0;
lastYouTubeCaptionActivationAt = 0;
primarySelectionRequest = 0;
secondarySelectionRequest = 0;
subtitleSourceContextKey = "";
clickHandlers = {
cue: (target) => this.seekToTranscriptRow(this.rowIndexFromTarget(target)),
previous: () => this.seekSubtitle(-1),
next: () => this.seekSubtitle(1),
copy: () => {
void this.copySubtitle();
},
"copy-row": (target) => {
void this.copyTranscriptRow(this.rowIndexFromTarget(target));
},
load: () => this.primaryFileInput?.click(),
"load-secondary": () => this.secondaryFileInput?.click(),
menu: () => this.toggleMenu(),
panel: () => this.toggleTranscriptDrawer(),
"panel-lines": () => this.openLinesPanel(),
"panel-tracks": () => this.openTracksPanel(),
"close-panel": () => this.closeTranscriptPanel(),
"primary-track": (target) => {
void this.choosePrimaryTrack(this.trackIdFromTarget(target));
},
"secondary-track": (target) => {
void this.chooseSecondaryTrack(this.trackIdFromTarget(target));
},
"toggle-secondary": () => this.toggleSecondarySubtitles(),
"toggle-native-blur": () => this.toggleNativeSubtitleBlur()
};
init() {
this.destroy();
this.destroyed = false;
this.abortController = new AbortController();
this.install();
this.observer = new MutationObserver((mutations) => {
if (mutations.every(mutationInsideReaderRoot)) return;
if (!mutations.some(mutationCouldAffectVideoDiscovery)) return;
this.scheduleDiscoverVideo();
});
this.observer.observe(document.body, { childList: true, subtree: true });
document.addEventListener("keydown", (event) => this.handleKeydown(event), this.eventOptions());
document.addEventListener("pointerdown", (event) => this.handlePointerActivity(event), this.eventOptions({ passive: true }));
document.addEventListener("pointermove", (event) => this.handlePointerActivity(event), this.eventOptions({ passive: true }));
document.addEventListener("fullscreenchange", () => {
this.fullscreen = Boolean(document.fullscreenElement);
this.syncFullscreenState();
this.scheduleAlignToVideo();
this.render();
}, this.eventOptions());
window.addEventListener("scroll", () => this.scheduleAlignToVideo(), this.eventOptions({ passive: true }));
window.addEventListener("resize", () => {
this.scheduleAlignToVideo();
}, this.eventOptions({ passive: true }));
this.discoverVideo();
this.tick();
log$3.info("Subtitle controller initialized");
}
destroy() {
this.destroyed = true;
this.abortController?.abort();
this.abortController = void 0;
this.observer?.disconnect();
this.observer = void 0;
this.videoResizeObserver?.disconnect();
this.videoResizeObserver = void 0;
if (this.discoverTimer !== void 0) window.clearTimeout(this.discoverTimer);
this.discoverTimer = void 0;
if (this.tickTimer !== void 0) window.clearTimeout(this.tickTimer);
this.tickTimer = void 0;
if (this.alignFrame !== void 0) window.cancelAnimationFrame(this.alignFrame);
this.alignFrame = void 0;
if (this.transcriptScrollFrame !== void 0) window.cancelAnimationFrame(this.transcriptScrollFrame);
this.transcriptScrollFrame = void 0;
if (this.transcriptHydrateFrame !== void 0) window.cancelAnimationFrame(this.transcriptHydrateFrame);
this.transcriptHydrateFrame = void 0;
this.clearVideoInsetForTranscriptPanel();
this.root?.remove();
this.root = void 0;
this.subtitleEl = void 0;
this.menuEl = void 0;
this.transcriptPanel = void 0;
this.primaryFileInput = void 0;
this.secondaryFileInput = void 0;
this.video = void 0;
}
eventOptions(options = {}) {
return this.abortController ? { ...options, signal: this.abortController.signal } : options;
}
refresh() {
if (!this.root) return;
const settings = this.options.getSettings();
this.syncRootVisibility(settings);
this.syncTranscriptPlacementClass();
this.syncFullscreenState();
this.syncRootStyleSettings(settings);
this.openTranscriptPanelFromSettings(settings);
this.scheduleAlignToVideo();
this.syncControls();
this.render();
this.hideControlsImmediately();
}
syncRootVisibility(settings) {
if (!this.root) return;
this.root.hidden = shouldHideSubtitleRoot(settings, this.video, this.cues);
this.root.classList.toggle("jpdb-subtitle-hidden", !settings.subtitleOverlayVisible);
this.root.classList.toggle("jpdb-subtitle-controls-auto", settings.subtitleControlsMode === "auto");
this.root.classList.toggle("jpdb-subtitle-controls-hidden", settings.subtitleControlsMode === "hidden");
this.root.classList.toggle("jpdb-subtitle-controls-always", settings.subtitleControlsMode === "always");
this.root.classList.toggle("jpdb-subtitle-controls-idle", shouldKeepIdleControlClass(this.root, settings));
}
syncRootStyleSettings(settings) {
if (!this.root) return;
setStylePropertyIfChanged(this.root, "--subtitle-font-size-target", `${settings.subtitleFontSize}px`);
setStylePropertyIfChanged(this.root, "--subtitle-font-size", `${settings.subtitleFontSize}px`);
this.root.style.setProperty("--subtitle-bottom", `${settings.subtitleBottomOffset}%`);
this.root.style.setProperty("--subtitle-color", settings.subtitleTextColor);
this.root.style.setProperty("--subtitle-outline", settings.subtitleOutlineColor);
this.root.style.setProperty("--subtitle-background-rgba", accentToRgba(settings.subtitleBackgroundColor, settings.subtitleBackgroundOpacity));
this.root.style.setProperty("--subtitle-family", settings.subtitleFontFamily);
this.root.style.setProperty("--subtitle-weight", String(settings.subtitleFontWeight));
}
openTranscriptPanelFromSettings(settings) {
if (!settings.subtitleTranscriptVisible || !this.hasTranscriptSurface() || !this.transcriptPanel?.hidden) return;
this.panelMode = "lines";
this.transcriptPanel.hidden = false;
this.renderTranscriptPanel(true);
}
install() {
if (this.root) return;
document.querySelectorAll('.jpdb-subtitle-player[data-jpdb-reader-root="true"]').forEach((element2) => element2.remove());
const root = document.createElement("div");
root.className = "jpdb-subtitle-player";
root.dataset.jpdbReaderRoot = "true";
const settings = this.options.getSettings();
const previousLabel = uiText(settings.interfaceLanguage, "previousSubtitle");
const nextLabel = uiText(settings.interfaceLanguage, "nextSubtitle");
const panelLabel = uiText(settings.interfaceLanguage, "openSubtitlePanel");
setInnerHtml(root, `
‹
›
${subtitleIcon("panel-right")}
`);
root.addEventListener("click", (event) => this.handleClick(event));
this.subtitleEl = root.querySelector(".jpdb-subtitle-text");
this.menuEl = root.querySelector(".jpdb-subtitle-menu") ?? void 0;
this.transcriptPanel = root.querySelector(".jpdb-subtitle-list");
this.primaryFileInput = root.querySelector('input[data-file="primary"]');
this.secondaryFileInput = root.querySelector('input[data-file="secondary"]');
this.primaryFileInput.addEventListener("change", () => void this.loadSubtitleFile("primary"), this.eventOptions());
this.secondaryFileInput.addEventListener("change", () => void this.loadSubtitleFile("secondary"), this.eventOptions());
document.body.appendChild(root);
this.root = root;
this.refresh();
}
scheduleDiscoverVideo() {
if (this.discoverTimer !== void 0) return;
this.discoverTimer = window.setTimeout(() => {
this.discoverTimer = void 0;
if (this.destroyed) return;
this.discoverVideo();
}, 120);
}
discoverVideo() {
if (!this.shouldDiscoverVideo()) {
this.refresh();
return;
}
this.discoverEnabledVideo();
}
shouldDiscoverVideo() {
const settings = this.options.getSettings();
return settings.subtitlePlayerEnabled && settings.subtitleAutoDetect;
}
discoverEnabledVideo() {
const candidate = this.discoverVideoCandidate();
if (candidate && candidate !== this.video) this.useDiscoveredVideoCandidate(candidate);
this.syncSubtitleSourceContext(candidate ?? this.video);
this.discoverPageSubtitleTracks();
void this.discoverYouTubeTracksThrottled(true);
this.refresh();
}
discoverVideoCandidate() {
return Array.from(document.querySelectorAll("video")).filter((video) => this.isSubtitleVideoCandidate(video)).sort((a, b) => videoElementArea(b) - videoElementArea(a))[0];
}
isSubtitleVideoCandidate(video) {
return video.readyState >= 1 || video.clientWidth > 120 || video.getBoundingClientRect().width > 120;
}
useDiscoveredVideoCandidate(candidate) {
this.video = candidate;
this.clearTransientSubtitleState();
this.removeStaleNativeTracks(candidate);
this.attachTextTracks(candidate);
this.observeVideoLayout(candidate);
log$3.info("Subtitle video detected", videoSummary(candidate));
}
attachTextTracks(video) {
for (const track of Array.from(video.textTracks)) this.addNativeTrack(track);
video.textTracks.addEventListener?.("addtrack", (event) => {
if (video !== this.video) return;
const track = event.track;
if (track) this.addNativeTrack(track);
}, this.eventOptions());
}
syncSubtitleSourceContext(video = this.video) {
const key = subtitleSourceContextKey(video);
if (!key) return false;
if (!this.subtitleSourceContextKey) {
this.subtitleSourceContextKey = key;
return false;
}
if (this.subtitleSourceContextKey === key) return false;
this.subtitleSourceContextKey = key;
this.youtubeAutoSelectSuppressedVideoId = "";
this.lastYouTubeTrackDiscoveryAt = 0;
this.clearTransientSubtitleState();
this.removeSubtitleTracks((track) => track.kind !== "file");
return true;
}
clearTransientSubtitleState() {
this.currentCue = void 0;
this.secondaryCue = void 0;
this.pendingDomCaption = void 0;
this.lastDomCaption = "";
this.lastAutoCopiedCueSignature = "";
this.lastRenderedPrimaryText = "";
this.lastRenderedPrimaryHtml = "";
this.renderSerial += 1;
this.parseWarmupSerial += 1;
}
removeStaleNativeTracks(video) {
const textTracks = new Set(Array.from(video.textTracks));
this.removeSubtitleTracks((track) => track.kind === "native" && (!track.track || !textTracks.has(track.track)));
}
removeSubtitleTracks(predicate) {
const removed = this.tracks.filter(predicate);
if (!removed.length) return 0;
this.removeSubtitleTrackIds(new Set(removed.map((track) => track.id)));
this.lastMenuSignature = "";
this.lastTranscriptSignature = "";
this.render();
this.renderOpenSubtitlePanel();
this.syncControls();
return removed.length;
}
removeSubtitleTrackIds(removedIds) {
this.tracks = this.tracks.filter((track) => !removedIds.has(track.id));
if (removedIds.has(this.selectedTrackId)) this.resetPrimarySubtitleState();
if (removedIds.has(this.secondaryTrackId)) this.resetSecondarySubtitleState();
}
renderOpenSubtitlePanel() {
if (!this.transcriptPanel || this.transcriptPanel.hidden) return;
if (this.panelMode === "tracks" || !this.hasTranscriptSurface()) this.renderTrackPanel();
else this.renderTranscriptPanel(true);
}
observeVideoLayout(video) {
this.videoResizeObserver?.disconnect();
this.videoResizeObserver = new ResizeObserver(() => this.scheduleAlignToVideo());
this.videoResizeObserver.observe(video);
video.addEventListener("loadedmetadata", () => this.scheduleAlignToVideo(), this.eventOptions({ passive: true }));
video.addEventListener("loadeddata", () => this.scheduleAlignToVideo(), this.eventOptions({ passive: true }));
video.addEventListener("play", () => this.scheduleAlignToVideo(), this.eventOptions({ passive: true }));
this.scheduleAlignToVideo();
}
addNativeTrack(track) {
if (this.tracks.some((item) => item.track === track)) return;
const id = `native-${this.tracks.length}`;
const label = track.label || track.language || `${uiText(this.options.getSettings().interfaceLanguage, "subtitleFallbackLabel")} ${this.tracks.length + 1}`;
const option = { id, label, kind: "native", language: track.language, track };
this.tracks.push(option);
track.addEventListener("cuechange", () => this.updateFromNativeTrack(track), this.eventOptions());
this.maybeAutoSelectNativeTrack(option);
window.setTimeout(() => {
if (this.destroyed) return;
this.setNativeTrackModes();
this.syncControls();
}, 0);
this.syncControls();
}
discoverPageSubtitleTracks() {
const sources = collectPageSubtitleSources(document);
const removed = this.removeStalePageSubtitleTracks(sources);
if (!sources.length) return;
const changes = this.addOrUpdatePageSubtitleTracks(sources, removed);
this.finishPageSubtitleTrackDiscovery(changes);
}
removeStalePageSubtitleTracks(sources) {
const sourceKeys = new Set(sources.map((source) => source.sourceKey));
const sourceUrls = new Set(sources.map((source) => normalizedSubtitleUrl(source.url)));
return this.removeSubtitleTracks((track) => track.kind === "remote" && !sourceKeys.has(track.sourceKey ?? "") && !(track.url && sourceUrls.has(normalizedSubtitleUrl(track.url))));
}
addOrUpdatePageSubtitleTracks(sources, removed) {
const changes = { added: 0, updated: 0, removed };
for (const source of sources) {
const result = this.addOrUpdatePageSubtitleTrack(source);
changes.added += result.added;
changes.updated += result.updated;
}
return changes;
}
finishPageSubtitleTrackDiscovery(changes) {
if (changes.added || changes.updated || changes.removed) {
this.renderTrackPanel();
this.syncControls();
}
}
addOrUpdatePageSubtitleTrack(source) {
const existing = this.findPageSubtitleTrack(source);
if (existing) return { added: 0, updated: updatePageSubtitleTrack(existing, source) ? 1 : 0 };
const track = this.createPageSubtitleTrack(source);
this.tracks.push(track);
this.maybeAutoSelectPageSubtitleTrack(track);
return { added: 1, updated: 0 };
}
findPageSubtitleTrack(source) {
return this.tracks.find((track) => track.sourceKey === source.sourceKey || track.url && sameSubtitleUrl(track.url, source.url));
}
createPageSubtitleTrack(source) {
return {
id: `remote-${this.tracks.length}`,
label: source.label,
kind: "remote",
language: source.language,
url: source.url,
sourceKey: source.sourceKey
};
}
maybeAutoSelectPageSubtitleTrack(option) {
if (option.kind !== "remote" || !option.url) return;
const selected = this.tracks.find((track) => track.id === this.selectedTrackId);
const secondary = this.tracks.find((track) => track.id === this.secondaryTrackId);
if (this.shouldAutoSelectPrimaryPageTrack(option, selected)) {
void this.selectTrack(option.id);
return;
}
if (this.shouldAutoSelectSecondaryPageTrack(option, secondary)) {
void this.selectSecondaryTrack(option.id);
}
}
shouldAutoSelectPrimaryPageTrack(option, selected) {
return isJapaneseSubtitleTrack(option) && (!this.selectedTrackId || shouldReplaceWaitingNativeTrack(selected, option, this.cues));
}
shouldAutoSelectSecondaryPageTrack(option, secondary) {
return isEnglishSubtitleTrack(option) && (!this.secondaryTrackId || shouldReplaceWaitingNativeTrack(secondary, option, this.secondaryCues));
}
maybeAutoSelectNativeTrack(option) {
const track = option.track;
if (!track) return;
const role = this.autoSelectableNativeTrackRole(option);
if (role) this.autoSelectNativeTrack(option, track, role);
}
autoSelectableNativeTrackRole(option) {
if (!this.selectedTrackId && isJapaneseSubtitleTrack(option)) return "primary";
if (!this.secondaryTrackId && isEnglishSubtitleTrack(option)) return "secondary";
return null;
}
autoSelectNativeTrack(option, track, role) {
const requestId = this.beginTrackSelection(role);
this.setSelectedNativeTrackId(role, option.id);
ensureTextTrackReadable(track);
void this.loadNativeTrackCues(option, role, requestId);
}
setSelectedNativeTrackId(role, id) {
if (role === "primary") this.selectedTrackId = id;
else this.secondaryTrackId = id;
}
async loadNativeTrackCues(option, role, requestId) {
const track = option.track;
if (!track) return;
const cues = readTextTrackCues(track);
const loadedCues = cues.length ? cues : await waitForTextTrackCues(track);
if (!this.canApplyNativeTrackCues(option, role, requestId, loadedCues)) return;
this.applyNativeTrackCues(role, option.id, loadedCues);
option.loadingState = "ready";
this.updateFromLoadedCues();
this.render();
this.syncControls();
}
canApplyNativeTrackCues(option, role, requestId, cues) {
return cues.length > 0 && this.isTrackSelectionCurrent(role, requestId, option.id);
}
applyNativeTrackCues(role, optionId, cues) {
if (role === "primary" && this.selectedTrackId === optionId) this.cues = cues;
if (role === "secondary" && this.secondaryTrackId === optionId) this.secondaryCues = cues;
}
updateFromNativeTrack(track) {
const active = track.activeCues?.[0];
if (!active) return;
this.updatePrimaryNativeTrackCue(track, active);
this.updateSecondaryNativeTrackCue(track, active);
this.render();
this.renderTranscriptPanel();
this.syncControls();
}
updatePrimaryNativeTrackCue(track, active) {
const primary = this.tracks.find((item) => item.id === this.selectedTrackId);
if (primary?.track === track) {
this.currentCue = normalizeSubtitleCues([{ start: active.startTime, end: active.endTime, text: getTextTrackCueText(active) }])[0];
if (!this.cues.length) this.cues = readTextTrackCues(track);
void this.autoCopyCurrentCue();
}
}
updateSecondaryNativeTrackCue(track, active) {
const secondary = this.tracks.find((item) => item.id === this.secondaryTrackId);
if (secondary?.track === track) {
this.secondaryCue = normalizeSubtitleCues([{ start: active.startTime, end: active.endTime, text: getTextTrackCueText(active), transcriptEligible: false }])[0];
if (!this.secondaryCues.length) this.secondaryCues = readTextTrackCues(track);
}
}
tick() {
if (this.destroyed) return;
const settings = this.options.getSettings();
if (settings.subtitlePlayerEnabled) this.tickSubtitlePlayer(settings);
this.tickTimer = window.setTimeout(() => {
this.tickTimer = void 0;
this.tick();
}, 250);
}
tickSubtitlePlayer(settings) {
this.refreshSubtitleSourcesForTick();
this.refreshNativeCueLists();
this.updateFromLoadedCues();
if (settings.subtitleKaraokeMode && cueHasExactWordTimings(this.currentCue)) this.render();
if (this.shouldUpdateFromDomCaptions()) this.updateFromDomCaptions();
}
refreshSubtitleSourcesForTick() {
if (this.syncSubtitleSourceContext(this.video)) this.refreshDiscoveredSubtitleTracks();
if (this.shouldRefreshYouTubeTracks()) void this.discoverYouTubeTracksThrottled();
}
refreshDiscoveredSubtitleTracks() {
this.discoverPageSubtitleTracks();
void this.discoverYouTubeTracksThrottled(true);
}
shouldRefreshYouTubeTracks() {
return isYouTubePage() && (!this.selectedTrackId || !this.cues.length);
}
shouldUpdateFromDomCaptions() {
return !isYouTubePage() || !this.cues.length && (Boolean(this.selectedTrackId) || !this.tracks.some((track) => track.kind === "youtube"));
}
refreshNativeCueLists() {
const primary = this.tracks.find((item) => item.id === this.selectedTrackId);
const secondary = this.tracks.find((item) => item.id === this.secondaryTrackId);
this.refreshNativeCueList(primary, this.cues.length, (cues) => {
this.cues = cues;
});
this.refreshNativeCueList(secondary, this.secondaryCues.length, (cues) => {
this.secondaryCues = cues;
});
}
refreshNativeCueList(track, currentLength, assign) {
if (!track?.track) return;
const cues = readTextTrackCues(track.track);
if (cues.length && cues.length !== currentLength) assign(cues);
}
alignToVideo() {
if (!this.root || !this.video) {
this.positionTranscriptPanel();
return;
}
const rect = this.videoLayoutRect();
this.applyVideoLayout(rect);
}
applyVideoLayout(rect) {
if (!this.root) return;
const layout = subtitleOverlayLayout(rect);
this.root.classList.toggle("jpdb-subtitle-compact-video", layout.width < 560 || layout.height < 260);
if (rect.width < 120 || rect.height < 80) {
applyElementLayout(this.root, { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight });
this.positionTranscriptPanel();
this.fitSubtitleTextToVideo();
return;
}
applyElementLayout(this.root, layout);
this.positionTranscriptPanel();
this.fitSubtitleTextToVideo();
}
updateFromLoadedCues() {
if (!this.video) return;
const time = this.video.currentTime;
const cue = this.selectedTrackId ? findActiveSubtitleCue(this.cues, time) : void 0;
const secondary = this.secondaryTrackId ? findActiveSubtitleCue(this.secondaryCues, time) : void 0;
if (this.updateLoadedCueState(cue, secondary, time)) this.afterLoadedCueStateChanged();
}
updateLoadedCueState(cue, secondary, time) {
return this.updateLoadedPrimaryCue(cue, time) || this.updateLoadedSecondaryCue(secondary);
}
afterLoadedCueStateChanged() {
this.render();
this.renderTranscriptPanel();
this.syncControls();
this.warmParseAroundActiveCue();
void this.autoCopyCurrentCue();
}
updateLoadedPrimaryCue(cue, time) {
if (shouldReplaceLoadedCue(cue, this.currentCue)) return this.replaceLoadedPrimaryCue(cue);
if (shouldClearLoadedCue(cue, this.currentCue, time)) return this.clearLoadedPrimaryCue();
return false;
}
replaceLoadedPrimaryCue(cue) {
this.currentCue = cue;
return true;
}
clearLoadedPrimaryCue() {
this.currentCue = void 0;
return true;
}
updateLoadedSecondaryCue(secondary) {
if (secondary === this.secondaryCue) return false;
this.secondaryCue = secondary;
return true;
}
updateFromDomCaptions() {
const fallback = this.domCaptionFallback();
if (!fallback) return;
this.applyDomCaptionFallback(fallback.text, fallback.selected);
}
domCaptionFallback() {
if (this.cues.length) return null;
let selected = this.tracks.find((track) => track.id === this.selectedTrackId);
if (!this.shouldUseDomCaptionFallback(selected)) return null;
selected = this.ensureDomCaptionFallbackTrack(selected);
this.ensureYouTubeDomCaptionFallbackActive(selected);
const text2 = readPageCaptionText(this.video, this.root);
if (!text2) {
this.clearDomCaptionFallbackIfExpired();
return null;
}
if (!this.isDomCaptionStable(text2, performance.now())) return null;
return { text: text2, selected };
}
ensureYouTubeDomCaptionFallbackActive(selected) {
if (selected?.kind !== "youtube") return;
if (this.youtubeDomCaptionFallbackTrackId !== this.selectedTrackId) return;
const now = performance.now();
if (now - this.lastYouTubeCaptionActivationAt < YOUTUBE_CAPTION_ACTIVATION_RETRY_MS) return;
this.lastYouTubeCaptionActivationAt = now;
activateYouTubeCaptionTrack(selected);
}
shouldUseDomCaptionFallback(selected) {
if (!this.canUseDomCaptionFallback(selected)) return false;
return this.options.getSettings().subtitleOverlayVisible;
}
canUseDomCaptionFallback(selected) {
if (isYouTubePage()) return Boolean(this.selectedTrackId || !this.tracks.some((track) => track.kind === "youtube"));
const selectedNativeTrackNeedsDomFallback = Boolean(selected?.kind === "native" && selected.track && !this.cues.length);
return !this.selectedTrackId || selectedNativeTrackNeedsDomFallback;
}
ensureDomCaptionFallbackTrack(selected) {
if (!isYouTubePage() || selected || this.tracks.some((track2) => track2.kind === "youtube")) return selected;
const track = this.createYouTubeDomCaptionFallbackTrack();
this.tracks.push(track);
this.selectedTrackId = track.id;
this.youtubeDomCaptionFallbackTrackId = track.id;
this.lastMenuSignature = "";
return track;
}
createYouTubeDomCaptionFallbackTrack() {
return {
id: `youtube-dom-${this.youtubeVideoId || getYouTubeVideoId() || Date.now()}`,
label: "YouTube native captions",
kind: "youtube",
loadingState: "waiting",
sourceKey: "youtube-dom-caption-fallback"
};
}
clearDomCaptionFallbackIfExpired() {
this.pendingDomCaption = void 0;
if (!this.cues.length && this.currentCue && (this.video?.currentTime ?? 0) > this.currentCue.end) {
this.currentCue = void 0;
this.lastDomCaption = "";
this.render();
this.syncControls();
}
}
isDomCaptionStable(text2, nowMs2) {
if (this.pendingDomCaption?.text !== text2) {
this.pendingDomCaption = { text: text2, firstSeenAt: nowMs2 };
return false;
}
return nowMs2 - this.pendingDomCaption.firstSeenAt >= 450 && text2 !== this.lastDomCaption;
}
applyDomCaptionFallback(text2, selected) {
this.lastDomCaption = text2;
const now = this.video?.currentTime ?? 0;
this.currentCue = normalizeSubtitleCues([{ start: now, end: now + 4, text: text2 }])[0];
if (selected?.loadingState === "waiting") selected.loadingState = "ready";
this.render();
this.renderTranscriptPanel();
this.syncControls();
void this.autoCopyCurrentCue();
}
render() {
if (!this.subtitleEl) return;
const settings = this.options.getSettings();
const text2 = this.currentCue?.text.trim() ?? "";
if (!text2) {
this.renderEmptySubtitle(settings);
return;
}
this.renderActiveSubtitle(text2, settings);
}
renderEmptySubtitle(settings) {
if (!this.subtitleEl) return;
setInnerHtml(this.subtitleEl, this.secondaryCue?.text ? renderSubtitleSecondary(this.secondaryCue.text, settings.subtitleNativeBlurred, settings.interfaceLanguage) : "");
}
renderActiveSubtitle(text2, settings) {
if (!this.subtitleEl) return;
const primary = this.renderPrimarySubtitle(text2, settings);
setInnerHtml(this.subtitleEl, `${primary.html}
${this.renderSecondarySubtitle(settings)}`);
this.applyRenderedPrimarySubtitle(primary, text2);
}
renderPrimarySubtitle(text2, settings) {
const activeCue = this.currentCue;
const parseKey = this.parseCacheKey(text2, settings);
const lastRenderedHasReaderWords = parsedSubtitleHtmlHasReaderWords(this.lastRenderedPrimaryHtml);
const hasReusablePrimary = this.lastRenderedPrimaryKey === parseKey && (lastRenderedHasReaderWords || this.hasFreshEmptyParsedHtml(parseKey));
return renderSubtitlePrimary({
cue: activeCue,
text: text2,
parsedHtml: this.parsedHtmlCache.get(parseKey),
hasParser: this.shouldParseSubtitles(settings),
lastRenderedText: hasReusablePrimary ? this.lastRenderedPrimaryText : "",
lastRenderedHtml: hasReusablePrimary ? this.lastRenderedPrimaryHtml : "",
karaokeMode: settings.subtitleKaraokeMode,
time: this.video?.currentTime ?? activeCue?.start ?? 0
});
}
renderSecondarySubtitle(settings) {
return settings.subtitleSecondaryVisible && this.secondaryCue?.text ? renderSubtitleSecondary(this.secondaryCue.text, settings.subtitleNativeBlurred, settings.interfaceLanguage) : "";
}
applyRenderedPrimarySubtitle(primary, text2) {
this.applyRenderedPrimaryKaraoke(primary);
this.fitSubtitleTextToVideo();
this.cacheRenderedPrimarySubtitle(primary);
this.requestParsedPrimaryIfNeeded(primary, text2);
}
applyRenderedPrimaryKaraoke(primary) {
const activeCue = this.currentCue;
if (primary.karaokeActive && activeCue) this.applyKaraokeStateToPrimary(activeCue, this.video?.currentTime ?? activeCue.start);
}
cacheRenderedPrimarySubtitle(primary) {
if (!primary.nextRenderedPrimary) return;
this.lastRenderedPrimaryText = primary.nextRenderedPrimary.text;
this.lastRenderedPrimaryHtml = primary.nextRenderedPrimary.html;
}
requestParsedPrimaryIfNeeded(primary, text2) {
if (primary.shouldRequestParse) void this.renderParsedPrimary(text2);
}
async renderParsedPrimary(text2) {
const settings = this.options.getSettings();
const key = this.parseCacheKey(text2, settings);
const serial = ++this.renderSerial;
const cached = this.parsedHtmlCache.get(key);
if (cached) {
this.replacePrimaryHtml(cached, serial);
return;
}
try {
const html = await this.parseCueHtml(text2, settings);
this.replacePrimaryHtml(html, serial);
this.lastRenderedPrimaryKey = key;
this.lastRenderedPrimaryText = text2;
this.lastRenderedPrimaryHtml = html;
} catch {
}
}
replacePrimaryHtml(html, serial) {
if (serial !== this.renderSerial) return;
const primary = this.subtitleEl?.querySelector(".jpdb-subtitle-primary");
if (primary) {
const currentCue = this.currentCue ?? null;
const shouldKaraoke = this.shouldRenderKaraokePrimary(primary, currentCue);
setInnerHtml(primary, this.primaryReplacementHtml(html, currentCue, shouldKaraoke));
this.syncKaraokePrimary(currentCue, shouldKaraoke);
this.fitSubtitleTextToVideo();
}
}
shouldRenderKaraokePrimary(primary, currentCue) {
return Boolean(this.options.getSettings().subtitleKaraokeMode && currentCue && cueHasExactWordTimings(currentCue) && normalizedSubtitleText(primary.textContent) === normalizedSubtitleText(currentCue.text));
}
primaryReplacementHtml(html, currentCue, shouldKaraoke) {
return shouldKaraoke && currentCue && !html.includes("jpdb-reader-word") ? renderSubtitleKaraokeCue(currentCue, this.video?.currentTime ?? currentCue.start) : html;
}
syncKaraokePrimary(currentCue, shouldKaraoke) {
if (!shouldKaraoke || !currentCue) return;
this.applyKaraokeStateToPrimary(currentCue, this.video?.currentTime ?? currentCue.start);
}
shouldParseSubtitles(settings = this.options.getSettings()) {
return hasSubtitleParserSource(settings);
}
parseCacheKey(text2, settings = this.options.getSettings()) {
return [
subtitleParseSourceSignature(settings),
settings.showFurigana,
settings.furiganaMode,
settings.hideKnownFurigana,
settings.wordHighlightColorSource,
settings.wordUnderlineColorSource,
settings.wordTextColorSource,
settings.subtitleHighlightColorSource,
settings.subtitleUnderlineColorSource,
settings.subtitleTextColorSource,
text2
].join(":");
}
async parseCueHtml(text2, settings = this.options.getSettings()) {
const key = this.parseCacheKey(text2, settings);
const cached = this.parsedHtmlCache.get(key);
if (cached) return cached;
const emptyCached = this.freshEmptyParsedHtml(key);
if (emptyCached) return emptyCached;
const pending = this.pendingParsedHtml.get(key);
if (pending) return pending;
const promise = (async () => {
const tokens = await this.options.parseJapanese(text2);
const html = withBreaks(renderTokensToHtml(text2, tokens, settings));
if (parsedSubtitleHtmlHasReaderWords(html)) {
this.parsedHtmlCache.set(key, html);
this.emptyParsedHtmlCache.delete(key);
if (this.parsedHtmlCache.size > 180) this.parsedHtmlCache.delete(this.parsedHtmlCache.keys().next().value ?? "");
} else {
this.emptyParsedHtmlCache.set(key, { html, expiresAt: Date.now() + SUBTITLE_EMPTY_PARSE_RETRY_MS });
if (this.emptyParsedHtmlCache.size > 180) this.emptyParsedHtmlCache.delete(this.emptyParsedHtmlCache.keys().next().value ?? "");
}
return html;
})();
this.pendingParsedHtml.set(key, promise);
try {
return await promise;
} finally {
this.pendingParsedHtml.delete(key);
}
}
hasFreshEmptyParsedHtml(key) {
return Boolean(this.freshEmptyParsedHtml(key));
}
freshEmptyParsedHtml(key) {
const cached = this.emptyParsedHtmlCache.get(key);
if (!cached) return void 0;
if (cached.expiresAt > Date.now()) return cached.html;
this.emptyParsedHtmlCache.delete(key);
return void 0;
}
warmParseAroundActiveCue() {
if (!this.shouldParseSubtitles() || !this.cues.length) return;
const active = this.activeTranscriptIndex();
const start = Math.max(0, active >= 0 ? active - SUBTITLE_ACTIVE_PREPARSE_BEHIND : 0);
const end = Math.min(
this.cues.length,
active >= 0 ? active + SUBTITLE_ACTIVE_PREPARSE_AHEAD + 1 : SUBTITLE_ACTIVE_PREPARSE_AHEAD + 1
);
const serial = ++this.parseWarmupSerial;
const settings = this.options.getSettings();
void (async () => {
for (let index = start; index < end; index++) {
if (serial !== this.parseWarmupSerial) return;
const text2 = this.cues[index]?.text.trim();
if (!text2 || this.parsedHtmlCache.has(this.parseCacheKey(text2, settings))) continue;
try {
await this.parseCueHtml(text2, settings);
} catch {
}
}
if (this.currentCue?.text.trim()) this.render();
})();
}
fitSubtitleTextToVideo() {
if (!this.root || !this.subtitleEl) return;
const settings = this.options.getSettings();
const target = subtitleFrameTargetFontSize(this.root, settings);
let fitted = target;
this.root.style.setProperty("--subtitle-font-size-target", `${target}px`);
this.root.style.setProperty("--subtitle-font-size", `${fitted}px`);
const primary = this.subtitleEl.querySelector(".jpdb-subtitle-primary");
if (!primary) return;
const minimum = subtitleMinimumFontSize(this.root);
fitted = this.fitSubtitleFontSize(fitted, minimum);
this.root.style.setProperty("--subtitle-font-size", `${fitted}px`);
}
fitSubtitleFontSize(fitted, minimum) {
if (!this.root || !this.subtitleEl) return fitted;
return fittedSubtitleFontSize(this.subtitleEl, fitted, minimum, (value) => {
this.root?.style.setProperty("--subtitle-font-size", `${value}px`);
});
}
applyKaraokeStateToPrimary(cue, time) {
const state = this.primaryKaraokeState(cue);
if (!state) return;
const progress = karaokeCharacterProgress(cue, state.words, time);
let cursor = 0;
for (const element2 of state.wordElements) {
cursor = applyKaraokeClassToWordElement(element2, cursor, progress);
}
}
primaryKaraokeState(cue) {
const primary = this.subtitleEl?.querySelector(".jpdb-subtitle-primary");
if (!primary || !cueHasExactWordTimings(cue)) return null;
const words = cue.words;
const wordElements = Array.from(primary.querySelectorAll(".jpdb-reader-word"));
return words.length && wordElements.length ? { words, wordElements } : null;
}
handleClick(event) {
const target = event.target.closest("[data-action]");
const action = target?.dataset.action;
if (!action) return;
event.preventDefault();
event.stopPropagation();
this.showControlsTemporarily();
const handler = this.clickHandlers[action];
if (!handler) return;
handler(target);
if (action !== "menu") this.syncControls();
}
rowIndexFromTarget(target) {
return Number(target.closest("[data-row-index]")?.dataset.rowIndex);
}
trackIdFromTarget(target) {
return target.closest("[data-track-id]")?.dataset.trackId;
}
handlePointerActivity(event) {
if (this.isPointerNearSubtitleSurface(event.clientX, event.clientY)) {
this.showControlsTemporarily();
} else {
this.hideControlsImmediately();
}
}
showControlsTemporarily() {
if (!this.root) return;
this.root.classList.remove("jpdb-subtitle-controls-idle");
}
hideControlsImmediately() {
if (!this.root || !this.shouldAutoIdleControls()) return;
this.root.classList.add("jpdb-subtitle-controls-idle");
}
shouldAutoIdleControls() {
const settings = this.options.getSettings();
if (!this.hasAutoIdleMode(settings)) return false;
if (isCoarsePointerDevice()) return false;
if (!this.canIdleSubtitleControls()) return false;
return !this.video || this.videoIsLargeEnoughForIdleControls();
}
hasAutoIdleMode(settings) {
return Boolean(this.root && settings.subtitleControlsMode === "auto");
}
canIdleSubtitleControls() {
if (this.hasActiveSubtitleUi()) return false;
return this.hasSubtitleIdleSurface();
}
hasActiveSubtitleUi() {
return Boolean(this.transcriptPanel && !this.transcriptPanel.hidden) || Boolean(this.root?.classList.contains("jpdb-subtitle-menu-open")) || Boolean(this.root?.matches(":focus-within"));
}
hasSubtitleIdleSurface() {
return Boolean(this.video || this.cues.length || this.currentCue?.text);
}
videoIsLargeEnoughForIdleControls() {
const rect = this.video?.getBoundingClientRect();
return Boolean(rect && rect.width > 120 && rect.height > 90);
}
isPointerNearSubtitleSurface(x, y) {
if (!this.root) return false;
if (this.pointInElement(this.root.querySelector(".jpdb-subtitle-rail"), x, y)) return true;
if (this.pointInOpenTranscriptPanel(x, y)) return true;
if (!this.video) return true;
return pointInRect(x, y, this.video.getBoundingClientRect());
}
pointInOpenTranscriptPanel(x, y) {
return Boolean(this.transcriptPanel && !this.transcriptPanel.hidden && this.pointInElement(this.transcriptPanel, x, y));
}
pointInElement(element2, x, y) {
if (!element2) return false;
const rect = element2.getBoundingClientRect();
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
}
handleKeydown(event) {
const settings = this.options.getSettings();
if (!settings.subtitlePlayerEnabled) return;
if (matchesShortcut(event, settings.shortcuts.previousSubtitle)) {
event.preventDefault();
this.seekSubtitle(-1);
} else if (matchesShortcut(event, settings.shortcuts.nextSubtitle)) {
event.preventDefault();
this.seekSubtitle(1);
} else if (matchesShortcut(event, settings.shortcuts.copySubtitle)) {
event.preventDefault();
void this.copySubtitle();
}
}
seekSubtitle(direction) {
if (!this.video) return;
if (!this.cues.length) {
this.video.currentTime = Math.max(0, this.video.currentTime + direction * 5);
return;
}
const time = this.video.currentTime;
const activeIndex = this.cues.findIndex((cue) => time >= cue.start && time <= cue.end);
const nextFuture = this.cues.findIndex((cue) => cue.start > time);
const baseIndex = activeIndex >= 0 ? activeIndex : Math.max(0, nextFuture);
const index = Math.max(0, Math.min(this.cues.length - 1, baseIndex + direction));
this.seekToCue(index);
}
seekToCue(index) {
const cue = Number.isFinite(index) ? this.cues[index] : void 0;
if (!cue) return;
this.seekToCueObject(cue);
}
seekToTranscriptRow(index) {
const row = Number.isFinite(index) ? this.transcriptRows()[index] : void 0;
if (!row) return;
if (row.cueIndex >= 0) {
this.seekToCue(row.cueIndex);
return;
}
this.seekToCueObject(row.cue);
}
seekToCueObject(cue) {
if (this.video) this.video.currentTime = Math.max(0, cue.start + this.options.getSettings().subtitleSeekPadding);
this.currentCue = cue;
this.secondaryCue = this.secondaryCues.find((item) => cue.start >= item.start - 0.35 && cue.start <= item.end + 0.35);
this.render();
this.syncControls();
this.renderTranscriptPanel();
}
async copySubtitle(index) {
const text2 = this.subtitleCopyText(Number.isInteger(index) ? index : void 0);
if (!text2) return;
await this.writeSubtitleClipboard(text2, "Subtitle clipboard copy failed");
}
subtitleCopyText(rowIndex) {
const cue = rowIndex !== void 0 ? this.cues[rowIndex] : this.currentCue;
const secondary = rowIndex !== void 0 && cue ? findAlignedCue(this.secondaryCues, cue) : this.secondaryCue;
return subtitleClipboardText(cue, secondary);
}
async copyTranscriptRow(index) {
const row = Number.isFinite(index) ? this.transcriptRows()[index] : void 0;
if (!row) return;
if (row.cueIndex >= 0) {
await this.copySubtitle(row.cueIndex);
return;
}
const secondary = findAlignedCue(this.secondaryCues, row.cue);
const text2 = subtitleClipboardText(row.cue, secondary);
if (!text2) return;
await this.writeSubtitleClipboard(text2, "Subtitle clipboard copy failed");
}
async writeSubtitleClipboard(text2, failureMessage) {
await navigator.clipboard?.writeText(text2).catch((error) => log$3.warn(failureMessage, error));
}
async autoCopyCurrentCue() {
if (!this.options.getSettings().subtitleAutoCopyLine || !this.currentCue?.text.trim()) return;
const signature = subtitleCueSignature(this.currentCue);
if (signature === this.lastAutoCopiedCueSignature) return;
this.lastAutoCopiedCueSignature = signature;
await navigator.clipboard?.writeText(this.currentCue.text.trim()).catch((error) => log$3.warn("Subtitle auto-copy failed", error));
}
async loadSubtitleFile(kind) {
const input2 = kind === "primary" ? this.primaryFileInput : this.secondaryFileInput;
const file = input2?.files?.[0];
if (!file) return;
const text2 = await file.text();
const cues = normalizeSubtitleCues(parseSubtitleText(text2), { transcriptEligible: kind === "primary" });
const track = {
id: `file-${kind}-${Date.now()}`,
label: file.name.replace(/\.(srt|vtt|ass|ssa)$/i, ""),
kind: "file",
cues
};
this.tracks.push(track);
if (kind === "primary") await this.selectTrack(track.id);
else await this.selectSecondaryTrack(track.id);
if (input2) input2.value = "";
this.updateFromLoadedCues();
log$3.info("Subtitle file loaded", { kind, name: file.name, cues: cues.length });
}
async selectTrack(id) {
const requestId = this.preparePrimaryTrackSelection(id);
this.revealPrimarySubtitleOverlay();
const loaded = await this.loadPrimaryTrackSelection(id, requestId);
if (!loaded) return;
this.applyPrimaryTrackSelection(loaded);
this.finishPrimaryTrackSelection(id, loaded.track);
}
preparePrimaryTrackSelection(id) {
const requestId = this.beginTrackSelection("primary");
this.selectedTrackId = id;
this.lastAutoCopiedCueSignature = "";
if (this.secondaryTrackId === id) this.clearSecondaryTrackSelection();
this.cues = [];
this.currentCue = void 0;
this.pendingDomCaption = void 0;
return requestId;
}
clearSecondaryTrackSelection() {
this.invalidateTrackSelection("secondary");
this.secondaryTrackId = "";
this.secondaryCues = [];
this.secondaryCue = void 0;
}
revealPrimarySubtitleOverlay() {
const settings = this.options.getSettings();
if (!settings.subtitleOverlayVisible) {
settings.subtitleOverlayVisible = true;
this.options.onSettingsChange();
}
this.root?.classList.remove("jpdb-subtitle-hidden");
}
async loadPrimaryTrackSelection(id, requestId) {
return this.loadTrackSelection({ id, requestId, role: "primary", transcriptEligible: true });
}
markTrackLoading(track) {
track.loadingState = "loading";
this.renderTrackPanel();
}
async loadTrackSelection(request) {
const selected = this.tracks.find((option) => option.id === request.id);
if (!selected) return this.currentTrackSelection(request.role, request.requestId, request.id, void 0, []);
this.markTrackLoading(selected);
this.setNativeTrackModes();
const loaded = await loadSubtitleTrackCues(selected, {
...TRACK_LOAD_OPTIONS,
tracks: this.tracks,
transcriptEligible: request.transcriptEligible
});
return this.loadedTrackSelection(request, loaded.track, loaded.cues);
}
loadedTrackSelection(request, selected, cues) {
if (!this.isTrackSelectionCurrent(request.role, request.requestId, request.id)) return null;
const trackId = selected.id;
this.setSelectedTrackId(request.role, trackId);
return this.currentTrackSelection(request.role, request.requestId, trackId, selected, cues);
}
currentTrackSelection(role, requestId, trackId, track, cues) {
return this.isTrackSelectionCurrent(role, requestId, trackId) ? { track, trackId, cues } : null;
}
setSelectedTrackId(role, trackId) {
if (role === "primary") this.selectedTrackId = trackId;
else this.secondaryTrackId = trackId;
}
applyPrimaryTrackSelection(selection) {
this.cues = selection.cues;
if (selection.trackId !== this.selectedTrackId) this.selectedTrackId = selection.trackId;
this.applyYouTubeCaptionFallback(selection.track, selection.trackId);
if (selection.track) selection.track.loadingState = loadedTrackState(this.cues);
}
applyYouTubeCaptionFallback(track, trackId) {
if (track?.kind !== "youtube") {
this.youtubeDomCaptionFallbackTrackId = "";
return;
}
this.youtubeDomCaptionFallbackTrackId = this.cues.length ? "" : trackId;
this.lastYouTubeCaptionActivationAt = 0;
if (!this.cues.length) this.ensureYouTubeDomCaptionFallbackActive(track);
}
finishPrimaryTrackSelection(id, selected) {
this.setNativeTrackModes();
this.updateFromLoadedCues();
this.warmParseAroundActiveCue();
this.render();
this.refreshTranscriptPanelAfterTrackChange();
this.syncControls();
log$3.info("Primary subtitle track selected", { id, label: selected?.label ?? "", kind: selected?.kind ?? "unknown", cues: this.cues.length });
}
async selectSecondaryTrack(id) {
const requestId = this.prepareSecondaryTrackSelection(id);
this.revealSecondarySubtitleOverlay();
const loaded = await this.loadSecondaryTrackSelection(id, requestId);
if (!loaded) return;
this.applySecondaryTrackSelection(loaded);
this.finishSecondaryTrackSelection(id, loaded.track);
}
prepareSecondaryTrackSelection(id) {
if (this.selectedTrackId === id) {
this.suppressYouTubeAutoSelectForCurrentVideo();
this.invalidateTrackSelection("primary");
this.selectedTrackId = "";
this.cues = [];
this.currentCue = void 0;
this.pendingDomCaption = void 0;
this.youtubeDomCaptionFallbackTrackId = "";
}
const requestId = this.beginTrackSelection("secondary");
this.secondaryTrackId = id;
this.secondaryCues = [];
this.secondaryCue = void 0;
return requestId;
}
revealSecondarySubtitleOverlay() {
const settings = this.options.getSettings();
if (!settings.subtitleSecondaryVisible) {
settings.subtitleSecondaryVisible = true;
this.options.onSettingsChange();
}
}
async loadSecondaryTrackSelection(id, requestId) {
return this.loadTrackSelection({ id, requestId, role: "secondary", transcriptEligible: false });
}
applySecondaryTrackSelection(selection) {
this.secondaryCues = selection.cues;
if (selection.trackId !== this.secondaryTrackId) this.secondaryTrackId = selection.trackId;
if (selection.track) selection.track.loadingState = loadedTrackState(this.secondaryCues);
}
finishSecondaryTrackSelection(id, selected) {
this.setNativeTrackModes();
this.updateFromLoadedCues();
this.warmParseAroundActiveCue();
this.render();
this.refreshTranscriptPanelAfterTrackChange();
this.syncControls();
log$3.info("Secondary subtitle track selected", { id, label: selected?.label ?? "", kind: selected?.kind ?? "unknown", cues: this.secondaryCues.length });
}
setNativeTrackModes() {
this.lastYomuCaptionsActive = applySubtitleNativeTrackModes({
tracks: this.tracks,
selectedTrackId: this.selectedTrackId,
secondaryTrackId: this.secondaryTrackId,
overlayVisible: this.options.getSettings().subtitleOverlayVisible,
hasPrimaryCues: Boolean(this.cues.length),
currentCueText: this.currentCue?.text,
youtubeDomCaptionFallbackTrackId: this.youtubeDomCaptionFallbackTrackId,
lastYomuCaptionsActive: this.lastYomuCaptionsActive
});
}
async discoverYouTubeTracksThrottled(force = false) {
if (this.youtubeTrackDiscoveryInFlight) return;
const now = performance.now();
const interval = this.tracks.some((track) => track.kind === "youtube") ? 5e3 : 1500;
if (!force && now - this.lastYouTubeTrackDiscoveryAt < interval) return;
this.lastYouTubeTrackDiscoveryAt = now;
this.youtubeTrackDiscoveryInFlight = true;
try {
await this.discoverYouTubeTracks();
} finally {
this.youtubeTrackDiscoveryInFlight = false;
}
}
async discoverYouTubeTracks() {
if (!location.hostname.includes("youtube.com")) return;
const videoId = getYouTubeVideoId();
if (!videoId) return;
this.updateYouTubeDiscoveryVideo(videoId);
const tracks = await discoverYouTubeCaptionTracks();
if (!tracks.length) return;
const { added, updatedSelectedTrack } = this.mergeYouTubeCaptionTracks(tracks);
this.finishYouTubeTrackDiscovery(added, updatedSelectedTrack);
}
updateYouTubeDiscoveryVideo(videoId) {
if (videoId === this.youtubeVideoId) return;
this.youtubeVideoId = videoId;
this.clearTransientSubtitleState();
this.removeSubtitleTracks((track) => track.kind === "youtube");
this.youtubeDomCaptionFallbackTrackId = "";
this.youtubeAutoSelectSuppressedVideoId = "";
this.lastYouTubeTrackDiscoveryAt = 0;
}
mergeYouTubeCaptionTracks(tracks) {
let added = 0;
let updatedSelectedTrack = false;
for (const track of tracks) {
const existing = this.findExistingYouTubeTrack(track);
if (existing) {
updatedSelectedTrack ||= this.updateExistingYouTubeTrack(existing, track);
continue;
}
this.addYouTubeCaptionTrack(track);
added += 1;
}
return { added, updatedSelectedTrack };
}
findExistingYouTubeTrack(track) {
const key = youtubeCaptionTrackIdentity(track);
return this.tracks.find((option) => option.kind === "youtube" && youtubeCaptionTrackIdentity(option) === key);
}
updateExistingYouTubeTrack(existing, track) {
let updatedSelectedTrack = false;
if (shouldPreferYouTubeTrackUrl(track.url, existing.url)) {
existing.url = track.url;
updatedSelectedTrack = existing.id === this.selectedTrackId && !this.cues.length;
}
existing.youtubeTrack = track.raw;
existing.autoGenerated = track.autoGenerated;
return updatedSelectedTrack;
}
addYouTubeCaptionTrack(track) {
this.tracks.push({
id: `youtube-${this.tracks.length}`,
label: track.label,
kind: "youtube",
language: track.language,
autoGenerated: track.autoGenerated,
url: track.url,
youtubeTrack: track.raw
});
}
finishYouTubeTrackDiscovery(added, updatedSelectedTrack) {
const autoTrack = this.findAutoYouTubeTrack();
if (autoTrack) {
void this.selectTrack(autoTrack.id);
return;
}
if (this.shouldReloadUpdatedSelectedTrack(updatedSelectedTrack)) {
void this.selectTrack(this.selectedTrackId);
return;
}
if (!added) return;
this.renderTrackPanel();
this.syncControls();
}
shouldReloadUpdatedSelectedTrack(updatedSelectedTrack) {
return updatedSelectedTrack && Boolean(this.selectedTrackId);
}
findAutoYouTubeTrack() {
if (this.selectedTrackId) return void 0;
if (this.youtubeAutoSelectSuppressedVideoId && this.youtubeAutoSelectSuppressedVideoId === this.youtubeVideoId) return void 0;
return [...this.tracks].filter((track) => track.kind === "youtube" && isJapaneseSubtitleTrack(track)).sort(compareSubtitleTrackOptions)[0];
}
syncControls() {
const settings = this.options.getSettings();
const hasLines = this.hasVisibleSubtitleLines();
const menuOpen = this.isSubtitleMenuOpen();
this.root?.classList.toggle("jpdb-subtitle-menu-open", menuOpen);
this.root?.classList.toggle("jpdb-subtitle-panel-open", !this.transcriptPanel?.hidden);
this.root?.classList.toggle("jpdb-subtitle-has-lines", hasLines);
this.root?.classList.toggle("jpdb-subtitle-has-track", this.hasSelectedTrackOrLines(hasLines));
this.syncTranscriptPlacementClass();
if (menuOpen) this.renderMenu();
const secondaryToggle = this.menuEl?.querySelector('[data-action="toggle-secondary"]');
if (secondaryToggle) secondaryToggle.textContent = secondarySubtitleToggleLabel(settings);
this.syncLineNavigationButtons(hasLines);
this.syncDrawerButtons(hasLines);
this.syncStatus();
this.setNativeTrackModes();
}
hasVisibleSubtitleLines() {
return Boolean(this.cues.length || this.currentCue?.text);
}
isSubtitleMenuOpen() {
return Boolean(this.menuEl && !this.menuEl.hidden);
}
hasSelectedTrackOrLines(hasLines) {
return Boolean(this.selectedTrackId || hasLines);
}
syncStatus() {
const status = this.root?.querySelector(".jpdb-subtitle-status");
if (!status) return;
const language = this.options.getSettings().interfaceLanguage;
if (this.tracks.length) {
status.textContent = this.tracks.length === 1 ? uiText(language, "subtitleTrackDetectedSingular") : `${this.tracks.length} ${uiText(language, "subtitleTracksDetected")}`;
} else {
status.textContent = uiText(language, "noSubtitleTracksDetected");
}
}
syncLineNavigationButtons(hasLines) {
const panelOpen = this.isTranscriptPanelDockedOpen();
const language = this.options.getSettings().interfaceLanguage;
for (const action of ["previous", "next"]) {
const railButton = this.root?.querySelector(`.jpdb-subtitle-rail [data-action="${action}"]`);
if (railButton) syncLineNavigationButton(railButton, action, hasLines, Boolean(this.video), panelOpen, language);
for (const button2 of this.panelLineNavigationButtons(action)) syncLineNavigationButton(button2, action, hasLines, Boolean(this.video), false, language);
}
}
isTranscriptPanelDockedOpen() {
return Boolean(this.transcriptPanel && !this.transcriptPanel.hidden && !this.fullscreen);
}
panelLineNavigationButtons(action) {
return Array.from(this.transcriptPanel?.querySelectorAll(`.jpdb-subtitle-panel-nav [data-action="${action}"]`) ?? []);
}
syncDrawerButtons(hasLines) {
const panelButton = this.root?.querySelector('[data-action="panel"]');
const state = this.drawerButtonState(hasLines);
if (panelButton) this.syncDrawerButton(panelButton, state.disablePanel, state.panelOpen);
}
drawerButtonState(hasLines) {
const panelOpen = Boolean(this.transcriptPanel && !this.transcriptPanel.hidden);
const canOpenTranscript = hasLines || this.hasTranscriptSurface();
const canOpenTracks = Boolean(this.video || this.tracks.length);
return {
panelOpen,
disablePanel: !canOpenTranscript && !canOpenTracks
};
}
syncDrawerButton(button2, disabled, pressed) {
button2.hidden = false;
button2.disabled = disabled;
const language = this.options.getSettings().interfaceLanguage;
button2.title = uiText(language, pressed ? "closeSubtitlePanel" : "openSubtitlePanel");
button2.setAttribute("aria-label", button2.title);
button2.setAttribute("aria-pressed", String(pressed));
setInnerHtml(button2, subtitleIcon(transcriptPlacementIcon(this.effectiveTranscriptPlacement)));
}
syncPanelState() {
const hasLines = Boolean(this.cues.length || this.currentCue?.text);
if (this.transcriptPanel && !this.transcriptPanel.hidden) {
this.transcriptPanel.classList.toggle("jpdb-subtitle-lines-panel", this.panelMode === "lines");
this.transcriptPanel.classList.toggle("jpdb-subtitle-tracks-panel", this.panelMode === "tracks");
}
this.syncLineNavigationButtons(hasLines);
}
syncTranscriptPlacementClass() {
if (!this.root) return;
this.root.classList.toggle("jpdb-subtitle-transcript-right", this.effectiveTranscriptPlacement === "right");
this.root.classList.toggle("jpdb-subtitle-transcript-left", this.effectiveTranscriptPlacement === "left");
this.root.classList.toggle("jpdb-subtitle-transcript-bottom", this.effectiveTranscriptPlacement === "bottom");
this.root.dataset.transcriptPlacement = this.effectiveTranscriptPlacement;
}
hasTranscriptSurface() {
return Boolean(this.cues.length || this.currentCue?.text || this.selectedTrackId);
}
preferredTranscriptDrawerMode() {
if (this.panelMode === "lines" && this.hasTranscriptSurface()) return "lines";
if (this.panelMode === "tracks") return "tracks";
return this.hasTranscriptSurface() ? "lines" : "tracks";
}
toggleTranscriptDrawer() {
if (!this.transcriptPanel) return;
if (!this.transcriptPanel.hidden) {
this.closeTranscriptPanel();
return;
}
if (this.preferredTranscriptDrawerMode() === "tracks") this.openTracksPanel();
else this.openLinesPanel();
}
openLinesPanel() {
if (!this.transcriptPanel || !this.hasTranscriptSurface()) return;
this.panelMode = "lines";
this.transcriptPanel.hidden = false;
this.options.getSettings().subtitleTranscriptVisible = true;
this.options.onSettingsChange();
if (this.menuEl) this.menuEl.hidden = true;
this.renderTranscriptPanel(true);
this.positionTranscriptPanel();
this.syncControls();
}
renderMenu() {
if (!this.menuEl) return;
const state = this.subtitleMenuState();
const signature = subtitleMenuSignature(state);
if (!this.menuEl.hidden && this.lastMenuSignature === signature) return;
this.lastMenuSignature = signature;
const language = this.options.getSettings().interfaceLanguage;
setInnerHtml(this.menuEl, `
${escapeHtml$1(uiText(language, "loadJapaneseSubtitles"))}
${escapeHtml$1(uiText(language, "loadNativeSubtitles"))}
${this.renderSubtitleMenuButtons(state, language)}
`);
}
subtitleMenuState() {
return {
hasLines: Boolean(this.cues.length || this.currentCue?.text),
hasSecondary: Boolean(this.secondaryTrackId || this.secondaryCues.length || this.secondaryCue?.text),
secondaryVisible: this.options.getSettings().subtitleSecondaryVisible,
panelOpen: Boolean(this.transcriptPanel && !this.transcriptPanel.hidden),
panelHidden: this.transcriptPanel?.hidden,
panelMode: this.panelMode,
hasTracks: Boolean(this.tracks.length)
};
}
renderSubtitleMenuButtons(state, language) {
return [
this.renderPanelMenuButton(state, language),
this.renderCopyLineMenuButton(state, language),
this.renderSecondaryMenuButton(state, language)
].join("");
}
renderPanelMenuButton(state, language) {
if (!state.hasLines && !state.hasTracks) return "";
return `${escapeHtml$1(uiText(language, state.panelOpen ? "closeSubtitleDrawer" : "openSubtitleDrawer"))} `;
}
renderCopyLineMenuButton(state, language) {
return state.hasLines ? `${escapeHtml$1(uiText(language, "copyCurrentSubtitleLine"))} ` : "";
}
renderSecondaryMenuButton(state, language) {
if (!state.hasSecondary) return "";
return `${escapeHtml$1(uiText(language, state.secondaryVisible ? "nativeSubtitlesOn" : "nativeSubtitlesOff"))} `;
}
toggleMenu() {
if (!this.menuEl) return;
this.lastMenuSignature = "";
this.renderMenu();
this.menuEl.hidden = !this.menuEl.hidden;
if (!this.menuEl.hidden && this.transcriptPanel) this.transcriptPanel.hidden = true;
this.root?.classList.toggle("jpdb-subtitle-menu-open", !this.menuEl.hidden);
this.root?.classList.toggle("jpdb-subtitle-panel-open", Boolean(this.transcriptPanel && !this.transcriptPanel.hidden));
}
toggleSecondarySubtitles() {
const settings = this.options.getSettings();
settings.subtitleSecondaryVisible = !settings.subtitleSecondaryVisible;
if (!settings.subtitleSecondaryVisible) this.secondaryCue = void 0;
this.options.onSettingsChange();
this.render();
log$3.info("Secondary subtitles toggled", { visible: settings.subtitleSecondaryVisible });
}
toggleNativeSubtitleBlur() {
const settings = this.options.getSettings();
settings.subtitleNativeBlurred = !settings.subtitleNativeBlurred;
this.options.onSettingsChange();
this.render();
log$3.info("Native subtitle blur toggled", { blurred: settings.subtitleNativeBlurred });
}
toggleTranscriptPanel() {
if (!this.transcriptPanel) return;
if (!this.hasTranscriptSurface()) {
this.toggleTrackPanel();
return;
}
const shouldOpen = this.transcriptPanel.hidden || this.panelMode !== "lines";
this.panelMode = "lines";
this.transcriptPanel.hidden = !shouldOpen;
this.options.getSettings().subtitleTranscriptVisible = shouldOpen;
this.options.onSettingsChange();
this.closeMenuForOpenTranscriptPanel();
this.renderTranscriptPanel(true);
this.positionTranscriptPanel();
this.syncPanelState();
}
closeMenuForOpenTranscriptPanel() {
if (!this.transcriptPanel || this.transcriptPanel.hidden || !this.menuEl) return;
this.menuEl.hidden = true;
}
refreshTranscriptPanelAfterTrackChange() {
if (this.shouldRestoreTranscriptPanel()) {
this.openLinesPanel();
return;
}
if (!this.isTranscriptPanelOpen()) return;
if (this.panelMode === "lines") {
if (this.hasTranscriptSurface()) this.renderTranscriptPanel(true);
else this.closeTranscriptPanel();
return;
}
this.renderTrackPanel();
this.positionTranscriptPanel();
this.syncPanelState();
}
shouldRestoreTranscriptPanel() {
return this.options.getSettings().subtitleTranscriptVisible && this.hasTranscriptSurface();
}
isTranscriptPanelOpen() {
return Boolean(this.transcriptPanel && !this.transcriptPanel.hidden);
}
openTracksPanel() {
if (!this.transcriptPanel) return;
this.panelMode = "tracks";
this.transcriptPanel.hidden = false;
this.options.getSettings().subtitleTranscriptVisible = false;
this.options.onSettingsChange();
if (this.menuEl) this.menuEl.hidden = true;
this.renderTrackPanel();
this.positionTranscriptPanel();
this.syncPanelState();
}
closeTranscriptPanel() {
if (!this.transcriptPanel) return;
this.transcriptPanel.hidden = true;
this.options.getSettings().subtitleTranscriptVisible = false;
this.options.onSettingsChange();
this.positionTranscriptPanel();
this.syncControls();
}
toggleTrackPanel() {
if (!this.transcriptPanel) return;
if (!this.transcriptPanel.hidden && this.panelMode === "tracks") {
this.closeTranscriptPanel();
return;
}
this.openTracksPanel();
}
renderTranscriptPanel(force = false) {
const panel = this.renderableTranscriptPanel();
if (!panel) return;
const state = this.transcriptPanelRenderState();
if (this.canRefreshTranscriptPanel(force, state)) return;
this.lastTranscriptSignature = state.signature;
setInnerHtml(panel, this.renderTranscriptPanelHtml(state));
this.afterTranscriptPanelRender(state);
}
renderableTranscriptPanel() {
if (!this.transcriptPanel || this.transcriptPanel.hidden) return null;
return this.panelMode === "lines" ? this.transcriptPanel : null;
}
canRefreshTranscriptPanel(force, state) {
if (force) return false;
return this.refreshExistingTranscriptPanel(state);
}
transcriptPanelRenderState() {
const rows = this.transcriptRows();
const currentCueIndex = this.activeTranscriptIndex();
const currentRowIndex = this.activeTranscriptRowIndex(rows, currentCueIndex);
const signature = [
rows.length,
currentRowIndex,
this.selectedTrackId,
this.tracks.find((track) => track.id === this.selectedTrackId)?.loadingState ?? ""
].join(":");
return { rows, currentRowIndex, signature };
}
refreshExistingTranscriptPanel(state) {
if (this.lastTranscriptSignature !== state.signature) return false;
this.updateTranscriptActiveLine(state.currentRowIndex);
this.scheduleTranscriptHydration(state.currentRowIndex);
this.scheduleTranscriptCacheWarmup(state.rows, state.currentRowIndex);
return true;
}
renderTranscriptPanelHtml(state) {
const language = this.options.getSettings().interfaceLanguage;
const closeLabel = uiText(language, "closeSubtitleDrawer");
return `
${escapeHtml$1(uiText(language, "subtitlesTitle"))}
${escapeHtml$1(this.drawerMetaText("lines", state.rows.length))}
${renderPanelModeControls("lines", this.hasTranscriptSurface(), language)}
${renderPanelNavigationControls(Boolean(this.video && state.rows.length), language)}
${closeIcon()}
${state.rows.length ? state.rows.map((row, index) => this.renderTranscriptRow(row, index, state.currentRowIndex)).join("") : this.renderTranscriptWaitingState()}
`;
}
afterTranscriptPanelRender(state) {
this.bindTranscriptScroller();
this.bindTranscriptResizeHandle();
this.positionTranscriptPanel();
this.scrollTranscriptToActive();
this.scheduleTranscriptHydration(state.currentRowIndex);
this.scheduleTranscriptCacheWarmup(state.rows, state.currentRowIndex);
this.syncPanelState();
}
renderTranscriptRow(row, index, currentIndex) {
const cue = row.cue;
const settings = this.options.getSettings();
const parsedKey = this.parseCacheKey(cue.text, settings);
const parsed = this.parsedHtmlCache.get(parsedKey);
const parsedKeyAttribute = parsed ? ` data-parsed-key="${escapeHtml$1(parsedKey)}"` : "";
return `
${parsed ?? escapeWithBreaks(cue.text)}
${subtitleIcon("copy")}
${formatSubtitleTime(cue.start)}
`;
}
transcriptRows() {
if (this.cues.length) {
return this.cues.map((cue, cueIndex) => ({ cue, cueIndex })).filter((row) => row.cue.transcriptEligible !== false);
}
return this.currentCue && this.currentCue.transcriptEligible !== false ? [{ cue: this.currentCue, cueIndex: -1 }] : [];
}
renderTranscriptWaitingState() {
const selected = this.tracks.find((track) => track.id === this.selectedTrackId);
const language = this.options.getSettings().interfaceLanguage;
const label = selected?.label ? `: ${escapeHtml$1(selected.label)}` : "";
const status = selected?.loadingState === "loading" ? uiText(language, "loadingSubtitleLines") : uiText(language, "waitingForCaptionLines");
return `${escapeHtml$1(status)}${label}. ${escapeHtml$1(uiText(language, "subtitleCurrentLineWillAppear"))}
`;
}
updateTranscriptActiveLine(currentIndex) {
if (!this.transcriptPanel || this.transcriptPanel.hidden || this.panelMode !== "lines") return;
this.transcriptPanel.querySelectorAll(".jpdb-subtitle-list-row.active").forEach((row) => row.classList.remove("active"));
const active = this.transcriptPanel.querySelector(`.jpdb-subtitle-list-row[data-row-index="${currentIndex}"]`);
if (active) active.classList.add("active");
this.scrollTranscriptToActive();
}
scrollTranscriptToActive() {
if (!this.options.getSettings().subtitleTranscriptAutoScroll || !this.transcriptPanel || this.transcriptPanel.hidden) return;
if (this.transcriptScrollFrame) cancelAnimationFrame(this.transcriptScrollFrame);
this.transcriptScrollFrame = requestAnimationFrame(() => {
this.transcriptScrollFrame = void 0;
if (this.destroyed) return;
const active = this.transcriptPanel?.querySelector(".jpdb-subtitle-list-row.active");
active?.scrollIntoView({ block: "center", inline: "nearest" });
});
}
bindTranscriptScroller() {
const scroller = this.transcriptPanel?.querySelector(".jpdb-subtitle-list-scroll");
if (!scroller || scroller.dataset.transcriptHydrationBound === "true") return;
scroller.dataset.transcriptHydrationBound = "true";
scroller.addEventListener("scroll", () => this.scheduleTranscriptHydration(), { passive: true });
}
bindTranscriptResizeHandle() {
const handle = this.transcriptPanel?.querySelector("[data-resize-transcript]");
if (!handle || handle.dataset.transcriptResizeBound === "true") return;
handle.dataset.transcriptResizeBound = "true";
handle.addEventListener("pointerdown", (event) => this.startTranscriptResize(event));
}
startTranscriptResize(event) {
if (!this.transcriptPanel) return;
event.preventDefault();
event.stopPropagation();
const placement = this.effectiveTranscriptPlacement;
const panelRect = this.transcriptPanel.getBoundingClientRect();
const startX = event.clientX;
const startY = event.clientY;
const startWidth = panelRect.width;
const startHeight = panelRect.height;
event.currentTarget.setPointerCapture?.(event.pointerId);
const onMove = (moveEvent) => {
if (placement === "bottom") {
const nextHeight = clampNumber(startHeight + startY - moveEvent.clientY, 220, Math.max(220, window.innerHeight - TRANSCRIPT_PANEL_MARGIN * 3));
this.transcriptPanelSize.bottomHeight = Math.round(nextHeight);
} else {
const nextWidth = clampNumber(startWidth + startX - moveEvent.clientX, 340, Math.max(340, window.innerWidth - TRANSCRIPT_PANEL_MARGIN * 3));
this.transcriptPanelSize.sideWidth = Math.round(nextWidth);
}
this.positionTranscriptPanel();
};
const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
saveTranscriptPanelSize(this.transcriptPanelSize);
this.positionTranscriptPanel();
};
window.addEventListener("pointermove", onMove, this.eventOptions());
window.addEventListener("pointerup", onUp, this.eventOptions({ once: true }));
}
scheduleTranscriptHydration(preferredIndex = this.activeTranscriptRowIndex()) {
if (this.transcriptHydrateFrame) return;
this.transcriptHydrateFrame = requestAnimationFrame(() => {
this.transcriptHydrateFrame = void 0;
if (this.destroyed) return;
void this.hydrateTranscriptRows(preferredIndex);
});
}
activeTranscriptIndex() {
if (!this.currentCue) return -1;
const exact = this.cues.findIndex((cue) => cue === this.currentCue);
if (exact >= 0) return exact;
return this.cues.findIndex((cue) => Math.abs(cue.start - this.currentCue.start) < 0.05 && Math.abs(cue.end - this.currentCue.end) < 0.05 && cue.text.trim() === this.currentCue.text.trim());
}
activeTranscriptRowIndex(rows = this.transcriptRows(), activeCueIndex = this.activeTranscriptIndex()) {
if (!rows.length) return -1;
const exact = this.currentTranscriptRowIndex(rows);
if (exact >= 0) return exact;
if (activeCueIndex >= 0) return rows.findIndex((row) => row.cueIndex === activeCueIndex);
return this.cues.length ? -1 : 0;
}
currentTranscriptRowIndex(rows) {
return this.currentCue ? rows.findIndex((row) => row.cue === this.currentCue) : -1;
}
async hydrateTranscriptRows(preferredIndex) {
const request = this.transcriptHydrationRequest();
if (!request) return;
const serial = ++this.transcriptHydrationSerial;
const indexes = this.transcriptHydrationIndexes(preferredIndex, request.rows.length);
for (const index of indexes) {
if (serial !== this.transcriptHydrationSerial) return;
await this.hydrateTranscriptRow(index, request.settings, request.rows);
}
}
transcriptHydrationRequest() {
if (!this.canHydrateTranscriptRows()) return null;
const settings = this.options.getSettings();
if (!canParseSubtitleTranscriptRows(settings)) return null;
const rows = this.transcriptRows();
return rows.length ? { settings, rows } : null;
}
canHydrateTranscriptRows() {
return Boolean(this.transcriptPanel && !this.transcriptPanel.hidden && this.panelMode === "lines");
}
transcriptHydrationIndexes(preferredIndex, rowCount) {
const scroller = this.transcriptPanel?.querySelector(".jpdb-subtitle-list-scroll");
const plan = planTranscriptHydrationIndexes({
preferredIndex,
rowCount,
scroller,
cursor: this.transcriptHydrationCursor,
activeBehind: TRANSCRIPT_ACTIVE_HYDRATION_BEHIND,
activeAhead: TRANSCRIPT_ACTIVE_HYDRATION_AHEAD,
maxRows: TRANSCRIPT_HYDRATION_MAX_ROWS,
backgroundBatch: TRANSCRIPT_BACKGROUND_HYDRATION_BATCH
});
this.transcriptHydrationCursor = plan.nextCursor;
return plan.indexes;
}
async hydrateTranscriptRow(index, settings, rows = this.transcriptRows()) {
const hydration = this.transcriptRowHydrationTarget(index, settings, rows);
if (!hydration) return;
const cached = this.parsedHtmlCache.get(hydration.key);
if (cached) return this.applyCachedTranscriptRowHtml(hydration, cached);
try {
const html = await this.parseCueHtml(hydration.cue.text, settings);
this.updateTranscriptRowsForParseKey(hydration.key, html);
} catch {
hydration.target.dataset.parseFailedKey = hydration.key;
hydration.target.dataset.parseFailedAt = String(Date.now());
delete hydration.target.dataset.parsedKey;
}
}
transcriptRowHydrationTarget(index, settings, rows) {
const cue = rows[index]?.cue;
const target = this.transcriptPanel?.querySelector(`.jpdb-subtitle-row-text[data-row-index="${index}"]`);
if (!cue || !target) return null;
const key = this.parseCacheKey(cue.text, settings);
return hasAttemptedTranscriptParse(target, key) ? null : { cue, target, key };
}
applyCachedTranscriptRowHtml(hydration, html) {
hydration.target.dataset.parsedKey = hydration.key;
delete hydration.target.dataset.parseEmptyKey;
delete hydration.target.dataset.parseEmptyAt;
delete hydration.target.dataset.parseFailedKey;
delete hydration.target.dataset.parseFailedAt;
setInnerHtml(hydration.target, html);
}
scheduleTranscriptCacheWarmup(rows = this.transcriptRows(), preferredIndex = this.activeTranscriptRowIndex(rows)) {
const settings = this.options.getSettings();
if (!this.shouldParseSubtitles(settings) || !rows.length) return;
const signature = this.transcriptCacheWarmupKey(rows, settings, preferredIndex);
if (signature === this.transcriptCacheWarmupSignature) return;
this.transcriptCacheWarmupSignature = signature;
const serial = ++this.transcriptCacheWarmupSerial;
void this.warmTranscriptParseCache(rows, preferredIndex, settings, serial);
}
transcriptCacheWarmupKey(rows, settings, preferredIndex) {
const first2 = rows[0]?.cue;
const last = rows.at(-1)?.cue;
return [
this.selectedTrackId,
rows.length,
preferredIndex >= 0 ? Math.floor(preferredIndex / TRANSCRIPT_WARMUP_SIGNATURE_BUCKET_SIZE) : "start",
first2 ? subtitleCueSignature(first2) : "",
last ? subtitleCueSignature(last) : "",
this.parseCacheKey("", settings)
].join("|");
}
async warmTranscriptParseCache(rows, preferredIndex, settings, serial) {
const planned = this.transcriptWarmupPlan(rows, preferredIndex, settings);
if (!planned.length) return;
let cursor = 0;
const pauseMs = this.transcriptBackgroundParsePauseMs();
const worker = async () => {
while (cursor < planned.length) {
if (serial !== this.transcriptCacheWarmupSerial) return;
const item = planned[cursor++];
if (!item || this.parsedHtmlCache.has(item.key)) continue;
try {
const html = await this.parseCueHtml(item.text, settings);
if (serial !== this.transcriptCacheWarmupSerial) return;
this.updateTranscriptRowsForParseKey(item.key, html);
} catch {
}
if (cursor < planned.length) await waitForBackgroundTranscriptParseTurn(pauseMs);
}
};
const workers = Array.from(
{ length: Math.min(TRANSCRIPT_BACKGROUND_PARSE_CONCURRENCY, planned.length) },
() => worker()
);
await Promise.all(workers);
}
transcriptWarmupPlan(rows, preferredIndex, settings) {
const priority = this.transcriptHydrationIndexes(preferredIndex, rows.length);
const focusIndex = preferredIndex >= 0 ? preferredIndex : 0;
const orderedIndexes = transcriptWarmupIndexes(priority, focusIndex, rows.length);
const seen = /* @__PURE__ */ new Set();
const plan = [];
for (const rowIndex of orderedIndexes) {
this.addTranscriptWarmupPlanItem(plan, seen, rows, rowIndex, settings);
if (plan.length >= TRANSCRIPT_BACKGROUND_PARSE_LIMIT) break;
}
return plan;
}
addTranscriptWarmupPlanItem(plan, seen, rows, rowIndex, settings) {
const text2 = rows[rowIndex]?.cue.text.trim();
if (!text2) return;
const key = this.parseCacheKey(text2, settings);
if (seen.has(key) || this.parsedHtmlCache.has(key)) return;
seen.add(key);
plan.push({ rowIndex, text: text2, key });
}
transcriptBackgroundParsePauseMs() {
return isYouTubePage() ? YOUTUBE_TRANSCRIPT_BACKGROUND_PARSE_PAUSE_MS : 0;
}
updateTranscriptRowsForParseKey(key, html) {
const panel = this.updatableTranscriptPanel();
if (!panel) return;
const hasReaderWords = parsedSubtitleHtmlHasReaderWords(html);
for (const target of Array.from(panel.querySelectorAll("[data-transcript-text]"))) {
if (!shouldApplyParsedTranscriptHtml(target, key)) continue;
if (hasReaderWords) {
target.dataset.parsedKey = key;
delete target.dataset.parseEmptyKey;
delete target.dataset.parseEmptyAt;
delete target.dataset.parseFailedKey;
delete target.dataset.parseFailedAt;
setInnerHtml(target, html);
} else {
target.dataset.parseEmptyKey = key;
target.dataset.parseEmptyAt = String(Date.now());
delete target.dataset.parsedKey;
delete target.dataset.parseFailedKey;
delete target.dataset.parseFailedAt;
}
}
}
updatableTranscriptPanel() {
if (!this.transcriptPanel) return null;
if (this.transcriptPanel.hidden) return null;
if (this.panelMode !== "lines") return null;
return this.transcriptPanel;
}
renderTrackPanel() {
if (!this.transcriptPanel || this.transcriptPanel.hidden || this.panelMode !== "tracks") return;
const state = this.trackPanelRenderState();
setInnerHtml(this.transcriptPanel, this.renderTrackPanelHtml(state));
this.bindTranscriptResizeHandle();
this.syncPanelState();
}
trackPanelRenderState() {
const tracks = [...this.tracks].sort(compareSubtitleTrackOptions);
const autoDetected = tracks.filter((track) => track.kind === "youtube" || track.kind === "native" || track.kind === "remote").length;
return { tracks, autoDetected };
}
renderTrackPanelHtml(state) {
const language = this.options.getSettings().interfaceLanguage;
const closeLabel = uiText(language, "closeSubtitleDrawer");
return `
${escapeHtml$1(uiText(language, "subtitlesTitle"))}
${escapeHtml$1(this.drawerMetaText("tracks", state.tracks.length))}
${renderPanelModeControls("tracks", this.hasTranscriptSurface(), language)}
${this.video && this.cues.length ? renderPanelNavigationControls(true, language) : ""}
${closeIcon()}
`;
}
renderTrackRow(track) {
const isPrimary = track.id === this.selectedTrackId;
const isSecondary = track.id === this.secondaryTrackId;
const language = this.options.getSettings().interfaceLanguage;
return `
${escapeHtml$1(localizedSubtitleTrackLabel(track, language))}
${escapeHtml$1(formatTrackKind(track.kind, language))}
${escapeHtml$1(trackLanguageLabel(track, language))}${trackRoleText(isPrimary, isSecondary, language)}${trackStatusText(track, language)}
${escapeHtml$1(uiText(language, isPrimary ? "unsetJapaneseSubtitles" : "japaneseSubtitles"))}
${escapeHtml$1(uiText(language, isSecondary ? "unsetNativeSubtitles" : "nativeSubtitles"))}
`;
}
drawerMetaText(mode, count) {
const language = this.options.getSettings().interfaceLanguage;
const primaryTrack = this.tracks.find((track) => track.id === this.selectedTrackId);
const secondaryTrack = this.tracks.find((track) => track.id === this.secondaryTrackId);
const primary = primaryTrack ? localizedSubtitleTrackLabel(primaryTrack, language) : void 0;
const secondary = secondaryTrack ? localizedSubtitleTrackLabel(secondaryTrack, language) : void 0;
return drawerMetaParts(mode, count, primary, secondary, language).filter(Boolean).join(" · ");
}
beginTrackSelection(role) {
if (role === "primary") {
this.primarySelectionRequest += 1;
return this.primarySelectionRequest;
}
this.secondarySelectionRequest += 1;
return this.secondarySelectionRequest;
}
invalidateTrackSelection(role) {
this.beginTrackSelection(role);
}
isTrackSelectionCurrent(role, requestId, trackId) {
return role === "primary" ? this.primarySelectionRequest === requestId && this.selectedTrackId === trackId : this.secondarySelectionRequest === requestId && this.secondaryTrackId === trackId;
}
resetPrimarySubtitleState() {
this.invalidateTrackSelection("primary");
this.selectedTrackId = "";
this.cues = [];
this.currentCue = void 0;
this.lastDomCaption = "";
this.pendingDomCaption = void 0;
this.youtubeDomCaptionFallbackTrackId = "";
this.lastAutoCopiedCueSignature = "";
this.lastRenderedPrimaryText = "";
this.lastRenderedPrimaryHtml = "";
this.renderSerial += 1;
this.parseWarmupSerial += 1;
}
resetSecondarySubtitleState() {
this.invalidateTrackSelection("secondary");
this.secondaryTrackId = "";
this.secondaryCues = [];
this.secondaryCue = void 0;
}
async choosePrimaryTrack(id) {
if (!id) return;
if (id === this.selectedTrackId) {
this.clearPrimaryTrack();
return;
}
this.youtubeAutoSelectSuppressedVideoId = "";
await this.discoverYouTubeTracksThrottled(true);
await this.selectTrack(id);
}
async chooseSecondaryTrack(id) {
if (!id) return;
if (id === this.secondaryTrackId) {
this.clearSecondaryTrack();
return;
}
await this.discoverYouTubeTracksThrottled(true);
await this.selectSecondaryTrack(id);
}
clearPrimaryTrack() {
this.suppressYouTubeAutoSelectForCurrentVideo();
this.resetPrimarySubtitleState();
this.clearPrimaryTrackLoadingStates();
this.setNativeTrackModes();
this.render();
this.refreshOpenTranscriptPanelAfterPrimaryClear();
this.syncControls();
log$3.info("Primary subtitle track cleared");
}
clearPrimaryTrackLoadingStates() {
for (const track of this.tracks) {
if (track.loadingState && track.id !== this.secondaryTrackId) track.loadingState = "idle";
}
}
refreshOpenTranscriptPanelAfterPrimaryClear() {
if (!this.isTranscriptPanelOpen()) return;
this.panelMode = "tracks";
this.renderTrackPanel();
}
suppressYouTubeAutoSelectForCurrentVideo() {
if (!isYouTubePage()) return;
this.youtubeAutoSelectSuppressedVideoId = this.youtubeVideoId || getYouTubeVideoId();
}
clearSecondaryTrack() {
this.resetSecondarySubtitleState();
this.clearSecondaryTrackLoadingStates();
this.setNativeTrackModes();
this.render();
this.refreshOpenTranscriptPanelAfterSecondaryClear();
this.syncControls();
log$3.info("Secondary subtitle track cleared");
}
clearSecondaryTrackLoadingStates() {
for (const track of this.tracks) {
if (track.loadingState && track.id !== this.selectedTrackId) track.loadingState = "idle";
}
}
refreshOpenTranscriptPanelAfterSecondaryClear() {
if (!this.isTranscriptPanelOpen()) return;
if (this.panelMode === "lines") this.renderTranscriptPanel(true);
else this.renderTrackPanel();
}
positionTranscriptPanel() {
if (this.fullscreen) {
this.clearVideoInsetForTranscriptPanel();
return;
}
if (!this.transcriptPanel || this.transcriptPanel.hidden) {
this.clearVideoInsetForTranscriptPanel();
return;
}
const panel = this.transcriptPanel;
const viewportWidth = Math.max(320, window.innerWidth);
const viewportHeight = Math.max(240, window.innerHeight);
const settings = this.options.getSettings();
const layout = computeSubtitleDrawerLayout({
viewportWidth,
viewportHeight,
anchorTop: this.transcriptAnchorRect().top,
compactPanel: shouldUseCompactSubtitleDrawer(viewportWidth),
preferredPlacement: settings.subtitleTranscriptPlacement,
size: this.transcriptPanelSize
});
applyTranscriptPanelLayout(panel, layout);
this.effectiveTranscriptPlacement = layout.placement;
this.syncTranscriptPlacementClass();
this.syncDrawerButtons(this.hasVisibleSubtitleLines());
this.applyVideoInsetForTranscriptLayout(layout);
}
applyVideoInsetForTranscriptLayout(layout) {
if (!this.video) {
this.clearVideoInsetForTranscriptPanel();
return;
}
const videoRect = this.videoLayoutRect();
if (layout.placement === "bottom") {
this.applyPageVideoInset("bottom", layout.top - videoRect.top - layout.margin);
return;
}
const availableWidth = layout.placement === "left" ? videoRect.right - (layout.left + layout.width + layout.margin) : layout.left - videoRect.left - layout.margin;
this.applyPageVideoInset(layout.placement, availableWidth);
}
syncFullscreenState() {
this.fullscreen = Boolean(document.fullscreenElement);
document.documentElement.classList.toggle("jpdb-subtitle-fullscreen", this.fullscreen);
this.root?.classList.toggle("jpdb-subtitle-fullscreen", this.fullscreen);
if (this.fullscreen) this.clearVideoInsetForTranscriptPanel();
}
scheduleAlignToVideo() {
if (this.alignFrame) cancelAnimationFrame(this.alignFrame);
this.alignFrame = requestAnimationFrame(() => {
this.alignFrame = void 0;
if (this.destroyed) return;
this.alignToVideo();
});
}
videoLayoutRect() {
return subtitleVideoLayoutRect(this.video);
}
transcriptAnchorRect() {
if (isYouTubePage()) return this.videoLayoutRect();
if (isCijVideoPage()) return this.videoLayoutRect();
if (!this.video) return this.videoLayoutRect();
return transcriptAvoidanceTarget(this.video).getBoundingClientRect();
}
clearVideoInsetForTranscriptPanel() {
this.videoInset.clear(this.video);
}
applyPageVideoInset(side, playerSize) {
if (this.fullscreen) {
this.clearVideoInsetForTranscriptPanel();
return;
}
const panelRect = this.transcriptPanel?.getBoundingClientRect();
this.videoInset.apply({
video: this.video,
side,
playerSize,
panelSize: (side === "bottom" ? panelRect?.height : panelRect?.width) ?? 0,
videoRect: this.videoLayoutRect(),
margin: TRANSCRIPT_PANEL_MARGIN
});
}
}
function waitForBackgroundTranscriptParseTurn(delayMs) {
if (delayMs <= 0) return Promise.resolve();
return new Promise((resolve) => window.setTimeout(resolve, delayMs));
}
function renderPanelNavigationControls(enabled, language) {
const previous = uiText(language, "previousSubtitle");
const next = uiText(language, "nextSubtitle");
return `
‹
›
`;
}
function renderPanelModeControls(mode, canShowLines, language) {
return `
${escapeHtml$1(uiText(language, "subtitleLines"))}
${escapeHtml$1(uiText(language, "subtitleTracks"))}
`;
}
function subtitleOverlayLayout(rect) {
const viewportWidth = Math.max(1, window.innerWidth);
const viewportHeight = Math.max(1, window.innerHeight);
const minWidth = Math.min(260, viewportWidth);
const minHeight = Math.min(160, viewportHeight);
const overflowX = rect.left < 0 || rect.right > viewportWidth;
const overflowY = rect.top < 0 || rect.bottom > viewportHeight;
const left = overlayAxisStart(rect.left, rect.right, viewportWidth, minWidth, overflowX);
const top = overlayAxisStart(rect.top, rect.bottom, viewportHeight, minHeight, overflowY);
return {
left,
top,
width: overlayAxisSize(rect.left, rect.right, viewportWidth, minWidth, overflowX, left),
height: overlayAxisSize(rect.top, rect.bottom, viewportHeight, minHeight, overflowY, top)
};
}
function overlayAxisStart(start, end, viewportSize, minSize, overflow) {
if (!overflow) return start;
const visibleStart = clampNumber(start, 0, Math.max(0, viewportSize - 1));
const visibleEnd = clampNumber(end, visibleStart, viewportSize);
const size = Math.max(minSize, visibleEnd - visibleStart || viewportSize);
return clampNumber(visibleStart, 0, Math.max(0, viewportSize - size));
}
function overlayAxisSize(start, end, viewportSize, minSize, overflow, clampedStart) {
if (!overflow) return Math.max(minSize, end - start);
const visibleEnd = clampNumber(end, clampedStart, viewportSize);
return Math.max(minSize, visibleEnd - clampedStart);
}
function applyElementLayout(element2, layout) {
setStylePropertyIfChanged(element2, "left", `${Math.round(layout.left)}px`);
setStylePropertyIfChanged(element2, "top", `${Math.round(layout.top)}px`);
setStylePropertyIfChanged(element2, "right", "auto");
setStylePropertyIfChanged(element2, "bottom", "auto");
setStylePropertyIfChanged(element2, "width", `${Math.round(layout.width)}px`);
setStylePropertyIfChanged(element2, "height", `${Math.round(layout.height)}px`);
}
function setStylePropertyIfChanged(element2, property, value) {
if (element2.style.getPropertyValue(property) === value) return;
element2.style.setProperty(property, value);
}
function clampNumber(value, min, max2) {
return Math.min(Math.max(value, min), Math.max(min, max2));
}
function transcriptPlacementIcon(placement) {
if (placement === "left") return "panel-left";
if (placement === "bottom") return "panel-bottom";
return "panel-right";
}
function subtitleIcon(name) {
const paths = {
copy: ' ',
eye: ' ',
"eye-off": ' ',
menu: ' ',
"panel-bottom": ' ',
"panel-left": ' ',
"panel-right": ' ',
play: ' ',
tracks: ' ',
transcript: ' '
};
return `${paths[name]} `;
}
function closeIcon() {
return ' ';
}
function subtitleSourceContextKey(video) {
const url = new URL(location.href);
url.hash = "";
if (isYouTubePage()) return `youtube:${getYouTubeVideoId() || url.pathname}`;
if (isCijVideoPage()) return `cij:${url.origin}${url.pathname}${url.search}`;
const videoSource = videoSourceKey(video);
return `page:${url.origin}${url.pathname}${url.search}${videoSource ? `|video:${videoSource}` : ""}`;
}
function videoSourceKey(video) {
if (!video) return "";
const direct = video.currentSrc || video.src;
if (direct) return normalizeMediaSourceForContext(direct);
const source = video.querySelector("source[src]")?.src;
return source ? normalizeMediaSourceForContext(source) : "";
}
function normalizeMediaSourceForContext(value) {
try {
const url = new URL(value, location.href);
url.hash = "";
return url.href;
} catch {
return value;
}
}
function isCijVideoPage() {
return /(^|\.)cijapanese\.com$/i.test(location.hostname) && /^\/video\//i.test(location.pathname);
}
function requestSubtitleText(url) {
if (/^(blob|data):/i.test(url)) {
return fetchSubtitleText(url);
}
if (shouldFetchSubtitleInPageContext(url)) {
return fetchSubtitleText(url).catch((error) => requestSubtitleTextWithUserscript(url, error));
}
return requestSubtitleTextWithUserscript(url);
}
function requestSubtitleTextWithUserscript(url, pageFetchError) {
const userscriptRequest = getUserscriptHttpRequest();
if (userscriptRequest) {
return new Promise((resolve, reject) => {
userscriptRequest({
method: "GET",
url,
responseType: "text",
timeout: 8e3,
onload: (response) => response.status >= 200 && response.status < 300 ? resolve(String(response.responseText ?? response.response ?? "")) : reject(new Error(`Subtitle request failed (${response.status}).`)),
onerror: reject,
ontimeout: () => reject(new Error("Subtitle request timed out."))
});
});
}
if (pageFetchError) return Promise.reject(pageFetchError);
return fetchSubtitleText(url);
}
function fetchSubtitleText(url) {
return fetch(url, { credentials: "include", signal: subtitleRequestSignal() }).then((response) => {
if (!response.ok) throw new Error(`Subtitle request failed (${response.status}).`);
return response.text();
});
}
function subtitleRequestSignal() {
return typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(SUBTITLE_REQUEST_TIMEOUT_MS) : void 0;
}
function shouldFetchSubtitleInPageContext(url) {
try {
const parsed = new URL(url, location.href);
if (parsed.origin === location.origin) return true;
return isYouTubePage() && /(^|\.)youtube\.com$/i.test(parsed.hostname) && /\/api\/timedtext$/i.test(parsed.pathname);
} catch {
return false;
}
}
function subtitleRequestFailureDetails(url) {
try {
const parsed = new URL(url, location.href);
return {
host: parsed.hostname,
path: parsed.pathname,
format: parsed.searchParams.get("fmt") ?? "",
language: parsed.searchParams.get("lang") ?? ""
};
} catch {
return { url: "invalid" };
}
}
function subtitleMenuSignature(state) {
return [
state.hasLines,
state.hasSecondary,
state.secondaryVisible,
state.panelHidden,
state.panelMode
].join(":");
}
function shouldHideSubtitleRoot(settings, video, cues) {
return !settings.subtitlePlayerEnabled || !hasSubtitlePlaybackSurface(video, cues);
}
function hasSubtitlePlaybackSurface(video, cues) {
return Boolean(video || cues.length);
}
function shouldKeepIdleControlClass(root, settings) {
if (isCoarsePointerDevice()) return false;
return settings.subtitleControlsMode === "auto" && root.classList.contains("jpdb-subtitle-controls-idle");
}
function isCoarsePointerDevice() {
return window.matchMedia?.("(pointer: coarse)").matches ?? false;
}
function trackLanguageLabel(track, language) {
return track.language ? track.language.toUpperCase() : uiText(language, "detected");
}
function localizedSubtitleTrackLabel(track, language) {
if (language !== "ja") return track.label;
if (track.label === "YouTube subtitles") return uiText(language, "youTubeSubtitles");
return track.label.replace(/ · auto-generated$/u, ` · ${uiText(language, "autoGeneratedSubtitle")}`);
}
function trackRoleText(isPrimary, isSecondary, language) {
return [
isPrimary ? ` · ${uiText(language, "japaneseOverlay")}` : "",
isSecondary ? ` · ${uiText(language, "nativeOverlay")}` : ""
].join("");
}
function drawerMetaParts(mode, count, primary, secondary, language) {
return mode === "tracks" ? drawerTrackMetaParts(count, primary, secondary, language) : drawerLineMetaParts(count, primary, secondary, language);
}
function drawerTrackMetaParts(count, primary, secondary, language) {
return [
`${count} ${uiText(language, count === 1 ? "subtitleOptionSingular" : "subtitleOptionPlural")}`,
primary ? `${uiText(language, "japaneseSubtitles")}: ${primary}` : uiText(language, "chooseJapaneseSubtitles"),
secondary ? `${uiText(language, "nativeSubtitles")}: ${secondary}` : ""
];
}
function drawerLineMetaParts(count, primary, secondary, language) {
return [
primary || uiText(language, "transcript"),
`${count} ${uiText(language, count === 1 ? "subtitleLineSingular" : "subtitleLinePlural")}`,
secondary ? `${uiText(language, "nativeSubtitles")}: ${secondary}` : ""
];
}
function videoSummary(video) {
return {
currentSrcHost: safeHost(video.currentSrc || video.src),
width: video.videoWidth || video.clientWidth,
height: video.videoHeight || video.clientHeight,
textTracks: video.textTracks.length
};
}
function videoElementArea(video) {
const rect = video.getBoundingClientRect();
return rect.width * rect.height;
}
function safeHost(value) {
try {
return new URL(value, location.href).host;
} catch {
return value ? "inline-or-invalid" : "";
}
}
function mutationInsideReaderRoot(mutation) {
const nodes = [
mutation.target,
...Array.from(mutation.addedNodes),
...Array.from(mutation.removedNodes)
];
return nodes.every((node) => {
const element2 = node.nodeType === 1 ? node : node.parentElement;
return Boolean(element2?.closest?.("[data-jpdb-reader-root]"));
});
}
function mutationCouldAffectVideoDiscovery(mutation) {
const nodes = [
...Array.from(mutation.addedNodes),
...Array.from(mutation.removedNodes)
];
return nodes.some(nodeContainsVideoElement);
}
function nodeContainsVideoElement(node) {
if (node instanceof HTMLVideoElement) return true;
return node instanceof Element && Boolean(node.querySelector("video"));
}
const log$2 = Logger.scope("VisiblePageScanner");
class VisiblePageScanner {
constructor(dependencies) {
this.dependencies = dependencies;
}
scanInFlight = false;
async scanVisiblePage(options = {}) {
const silent = Boolean(options.silent);
if (!this.beginScan()) return;
const done = log$2.time("scanVisiblePage", { silent });
try {
await this.runVisiblePageScan(silent);
} catch (error) {
this.handleVisiblePageScanError(error, silent);
} finally {
this.finishScan();
done();
}
}
async scanAsbPlayerSubtitles() {
const roots = Array.from(document.querySelectorAll(".asbplayer-offscreen, .asbplayer-subtitles-container-bottom"));
if (!roots.length) return;
const targets = roots.flatMap((root) => collectTextTargetsIn(root, 12, false)).slice(0, 12);
if (!targets.length) return;
try {
const parsed = await this.dependencies.parseJapanese(targets.map((target) => target.text));
this.applyTokens(targets, parsed);
this.preloadParsed(parsed);
} catch {
}
}
beginScan() {
if (this.scanInFlight) return false;
this.scanInFlight = true;
return true;
}
async runVisiblePageScan(silent) {
const targets = collectScanTargets();
if (!targets.length) {
this.handleEmptyVisiblePageScan(silent);
return;
}
const parsed = await this.dependencies.parseJapanese(targets.map((target) => target.text));
this.applyTokens(targets, parsed);
this.preloadParsed(parsed);
}
applyTokens(targets, parsed) {
this.dependencies.pauseMutationObserver(() => {
targets.forEach((target, index) => applyTokensToScanTarget(target, parsed[index] ?? [], this.dependencies.getSettings()));
});
}
preloadParsed(parsed) {
const tokens = parsed.flat();
this.dependencies.preloadParsedTokens(tokens);
this.dependencies.preloadImmersionTokens(tokens);
void this.dependencies.enrichAnkiWords(tokens);
}
handleEmptyVisiblePageScan(silent) {
if (!silent) this.dependencies.toast("No unscanned Japanese text found.");
}
handleVisiblePageScanError(error, silent) {
log$2.warn("Visible page scan failed", error);
if (!silent) this.dependencies.toast(error instanceof Error ? error.message : "JPDB scan failed.");
}
finishScan() {
this.scanInFlight = false;
}
}
function renderWordPills(options) {
const context = wordPillContext(options.card, options.overrideQuery);
const query = context.query;
const language = options.settings.interfaceLanguage;
const linkPills = options.settings.dictionaryLookupLinks.filter((link) => link.enabled).map((link) => {
const style = pillStyle(`lookup:${link.id || link.label}`);
if (link.action === "copy" || link.id === "copy") {
const copyTitle = uiText(language, "copyWordTitle");
return `${escapeHtml$1(uiText(language, "copyWord"))} ${copyIcon()} `;
}
const url = link.id === "jpdb" && (Boolean(options.overrideQuery) || options.isJpdbBackedCard(options.card)) ? options.jpdbUrl : formatLookupUrl(link.urlTemplate, context);
if (!url) return "";
const title = link.id === "jpdb" ? options.overrideQuery ? uiText(language, "openKanjiOnJpdb") : uiText(language, "openOnJpdb") : uiText(language, "openOnLookup").replace("{label}", link.label);
const classes = `jpdb-reader-pill jpdb-reader-action-pill${link.id === "jpdb" ? " jpdb-reader-jpdb-pill" : ""}`;
return `${escapeHtml$1(link.label)} ${externalLinkIcon()} `;
}).filter(Boolean);
const frequencyPills = renderFrequencyPills(options.metaEntries ?? [], options.settings, options.dictionaryLabel);
const pills = [...linkPills, ...frequencyPills];
return pills.length ? `${pills.join("")}
` : "";
}
function wordPillContext(card, overrideQuery) {
return {
query: overrideQuery || card.spelling || card.reading,
word: overrideQuery || card.spelling,
reading: overrideQuery || card.reading || card.spelling,
vid: String(Math.max(0, card.vid)),
sid: String(Math.max(0, card.sid))
};
}
const log$1 = Logger.scope("ReaderApp");
const TERM_AUDIO_PRELOAD_LIMIT = 8;
const NEARBY_TERM_AUDIO_PRELOAD_LIMIT = 6;
const TWO_BUTTON_REVIEW_SHORTCUTS = [
["gradeFail", "fail"],
["gradePass", "pass"]
];
const FIVE_BUTTON_REVIEW_SHORTCUTS = [
["gradeNothing", "nothing"],
["gradeSomething", "something"],
["gradeHard", "hard"],
["gradeOkay", "okay"],
["gradeEasy", "easy"]
];
function matchedReviewShortcutGrade(event, shortcuts, candidates) {
return candidates.find(([key]) => matchesShortcut(event, shortcuts[key]))?.[1] ?? null;
}
function normalizedLookupText(text2) {
return text2.replace(/\s+/g, " ").trim();
}
function isLookupableJapaneseText(text2) {
return Boolean(text2 && HAS_JAPANESE$1.test(text2));
}
function dictionaryLookupLink(target) {
return target?.closest?.("a.gloss-link[data-dictionary-lookup]") ?? null;
}
function dictionaryLookupQuery(link) {
return normalizedLookupText(link.dataset.dictionaryLookup ?? "");
}
function lookupCandidateSentence(text2) {
const sentence = normalizedLookupText(text2);
return isLookupableJapaneseText(sentence) ? sentence : "";
}
function connectedElement(element2) {
return element2?.isConnected ? element2 : void 0;
}
function hasVisibleAutoScanTargets() {
return (collectSiteScanTargets(1)?.length ?? 0) > 0 || collectVisibleTextTargets(1).length > 0;
}
function hasPressLookupEnabled(settings) {
return settings.lookupOnClick || settings.lookupOnHover;
}
function isMousePointerEvent(event) {
return !("pointerType" in event) || event.pointerType === "mouse";
}
function eventElement(event) {
return event.target instanceof Element ? event.target : null;
}
function audioPreloadLimits(options) {
return {
sourceLimit: options.sourceLimit ?? 1,
candidateLimit: options.candidateLimit ?? 1
};
}
function shouldPauseVideoForSubtitleHover(word, settings) {
return settings.subtitleMiningPause && Boolean(word.closest(".jpdb-subtitle-player"));
}
function cardDisplayTrigger(options) {
return options.trigger === "hover" ? "hover" : "modal";
}
function cardSourceLabel(card) {
return card.source ?? "jpdb";
}
function renderedWordNavigationMode(insideReaderPopup, trigger) {
return insideReaderPopup && trigger === "modal" ? "push-current" : "reset";
}
function renderedWordAnchor(word, insideReaderPopup, activePopoverAnchor) {
return insideReaderPopup ? activePopoverAnchor ?? void 0 : word;
}
function popoverAnchorRect(anchor, fallback) {
const rect = anchor?.getBoundingClientRect();
return rect && (rect.width > 0 || rect.height > 0) ? rect : fallback;
}
function shouldLockMountedPopoverPosition(popover, state) {
return state.mode !== "hover" && !popover.classList.contains("jpdb-reader-sheet") && Boolean(state.previousPopoverRect);
}
function mountedHoverPointerPosition(state, lastPointerPosition) {
const hoverPointerPosition = state.previousHoverPointerPosition ?? lastPointerPosition;
return state.mode === "hover" && hoverPointerPosition ? { ...hoverPointerPosition } : void 0;
}
function canSchedulePointerTextHoverLookup(hoverEnabled, candidate) {
return hoverEnabled && Boolean(candidate);
}
function samePointerTextLookupTarget(active, candidate) {
return active.anchor === candidate.anchor && active.text === candidate.text;
}
function pointerOffsetInsideLookup(active, offset) {
return active.start <= offset && offset < active.end || active.start < offset && offset <= active.end;
}
class ReaderApp {
abortController = new AbortController();
isDemo = false;
isDestroyed = false;
settings = DEFAULT_SETTINGS;
setImmersionTranslationBlurred = (blurred) => {
if (this.settings.immersionKitRevealTranslationOnClick === blurred) return;
this.settings = {
...this.settings,
immersionKitRevealTranslationOnClick: blurred
};
document.querySelectorAll('input[name="immersionKitRevealTranslationOnClick"]').forEach((input2) => {
input2.checked = blurred;
});
void saveSettings(this.settings);
};
jpdb = new JpdbClient(() => this.settings.apiKey.trim(), () => this.settings.corsProxyUrl);
jpdbKanji = new JpdbKanjiClient(() => this.settings.corsProxyUrl);
jpdbPublicPitch = new JpdbPublicPitchClient(() => this.settings.corsProxyUrl);
jpdbVocabulary = new JpdbVocabularyClient(() => this.settings.corsProxyUrl);
kanjiVG = new KanjiVGClient();
kanjiOrigin = new KanjiOriginClient();
immersionKit = new ImmersionKitClient();
audio = new AudioPlayer(() => this.settings);
anki = new AnkiConnectClient(() => this.settings);
rtk = new RtkClient();
dictionaries = new YomitanDictionaryStore(() => this.settings.corsProxyUrl, () => this.settings.interfaceLanguage);
cardRenderData = new CardRenderDataLoader({
getSettings: () => this.settings,
dictionaries: this.dictionaries,
jpdbPublicPitch: this.jpdbPublicPitch,
jpdbVocabulary: this.jpdbVocabulary,
anki: this.anki,
jpdb: this.jpdb,
isJpdbBackedCard: (card) => this.isJpdbBackedCard(card)
});
navigation = new PopupNavigationController(() => Boolean(
this.activePopover?.isConnected && this.activePopover.querySelector(".jpdb-reader-kanji-display")
));
dictionarySourceState = new DictionarySourceStateController({
getSettings: () => this.settings,
onStateChange: () => this.repositionActivePopover()
});
cardPopoverRenderer = new CardPopoverRenderer({
getSettings: () => this.settings,
isJpdbBackedCard: (card) => this.isJpdbBackedCard(card),
renderWordHistory: (language, trigger) => this.navigation.renderWordHistory(language, trigger),
renderWordPills: (card, jpdbUrl, metaEntries, overrideQuery) => renderWordPills({
card,
jpdbUrl,
settings: this.settings,
metaEntries,
overrideQuery,
isJpdbBackedCard: (value) => this.isJpdbBackedCard(value),
dictionaryLabel: (name) => this.dictionaryLabel(name)
}),
renderDefinitionSources: (card, entries, sentence, jpdbVocabularyInfo) => this.renderDefinitionSources(card, entries, sentence, jpdbVocabularyInfo),
dictionarySourceAttributes: (key, initiallyExpanded) => this.dictionarySourceState.attributes(key, initiallyExpanded),
dictionaryLabel: (name) => this.dictionaryLabel(name)
});
dictionaryStyles = new DictionaryStyleController({
loadCss: () => this.settings.localDictionariesEnabled ? this.dictionaries.dictionaryStyleCss(this.settings.dictionaryPreferences) : Promise.resolve(""),
onUnavailable: (error) => log$1.warn("Dictionary styles unavailable", error)
});
studySources = new StudySourceController({
getSettings: () => this.settings,
dictionarySourceAttributes: (key) => this.dictionarySourceState.attributes(key),
parseJapanese: (paragraphs) => this.parseJapanese(paragraphs),
parsePopoverJapanese: (popover) => this.parsePopoverJapanese(popover),
enrichAnkiWords: (tokens) => this.enrichAnkiWords(tokens),
isCurrentPopoverRoot: (root) => this.isCurrentPopoverRoot(root)
});
cardActions = new CardActionController({
getSettings: () => this.settings,
jpdb: this.jpdb,
anki: this.anki,
dictionaries: this.dictionaries,
isJpdbBackedCard: (card) => this.isJpdbBackedCard(card),
resolveMiningContext: (card, sentence) => this.resolveMiningContext(card, sentence),
showCard: (card, sentence, anchor, options) => this.showCard(card, sentence, anchor, options),
getActivePopoverAnchor: () => this.activePopoverAnchor?.isConnected ? this.activePopoverAnchor : void 0,
getActivePopoverMode: () => this.activePopoverMode,
showSettings: (panel) => this.showSettings(panel),
playAudio: (card, options) => this.audioActions.playTermAudio(card, options),
playMediaUrl: (audioUrl) => this.audioActions.playMediaUrl(audioUrl),
playSentenceAudio: (sentence) => this.audioActions.playSentenceAudio(sentence),
playJpdbExampleAudio: (audioIds, fallbackSentence) => this.audioActions.playJpdbExampleAudio(audioIds, fallbackSentence),
detectGrammarHints: (sentence) => this.studySources.detectGrammarHints(sentence),
parsePopoverJapanese: (popover) => this.parsePopoverJapanese(popover),
toast: (message) => this.toast(message),
invalidateCardData: () => this.cardRenderData.clear()
});
immersionPopover = new ImmersionPopoverController({
getSettings: () => this.settings,
client: this.immersionKit,
audio: this.audio,
parseJapanese: (paragraphs) => this.parseJapanese(paragraphs),
canParseJapanese: () => this.canParseJapanese(),
parsePopoverJapanese: (popover) => this.parsePopoverJapanese(popover),
enrichAnkiWords: (tokens) => this.enrichAnkiWords(tokens),
repositionPopover: () => this.repositionActivePopover(),
setImmersionTranslationBlurred: this.setImmersionTranslationBlurred,
toast: (message) => this.toast(message)
});
audioActions = new ReaderAudioActions({
audio: this.audio,
getSettings: () => this.settings,
getActivePopover: () => this.activePopover,
getHoverLookupGeneration: () => this.hoverLookupGeneration,
stopImmersionAudio: () => this.immersionPopover.stopAudio(),
toast: (message) => this.toast(message)
});
floatingButton = new FloatingButtonController();
parser = new ReaderParser({
getSettings: () => this.settings,
jpdb: this.jpdb,
dictionaries: this.dictionaries
});
onboarding = new OnboardingController({
getSettings: () => this.settings,
setSettings: (settings) => {
this.settings = settings;
this.applyTheme();
},
showSettings: (panel) => this.showSettings(panel)
});
subtitles = new SubtitlePlayerController({
getSettings: () => this.settings,
parseJapanese: async (text2) => (await this.parseJapanese([text2]))[0] ?? [],
onSettingsChange: () => void saveSettings(this.settings)
});
ocr = new ImageOcrController({
getSettings: () => this.settings,
parseJapanese: async (text2) => (await this.parseJapanese([text2]))[0] ?? [],
onLookup: (text2, sentence) => this.lookupText(text2, sentence),
onToast: (message) => this.toast(message),
shouldAutoScan: () => this.pageHasJapaneseText
});
pageScanner = new VisiblePageScanner({
getSettings: () => this.settings,
parseJapanese: (paragraphs) => this.parseJapanese(paragraphs),
pauseMutationObserver: (callback) => this.pauseAutoScanObserver(callback),
preloadParsedTokens: (tokens) => this.preloadTermAudioForTokens(tokens),
preloadImmersionTokens: (tokens) => this.immersionPopover.preloadForTokens(tokens),
enrichAnkiWords: (tokens) => this.enrichAnkiWords(tokens),
toast: (message) => this.toast(message)
});
factoryReset = new FactoryResetCoordinator({
isDestroyed: () => this.isDestroyed,
invalidateRuntimeStores: () => this.invalidateRuntimeStoresForFactoryReset(),
resetDictionaryDatabase: () => this.dictionaries.deleteDatabase({ timeoutMs: FACTORY_RESET_DICTIONARY_DELETE_TIMEOUT_MS }).then(() => ({ deleted: true })),
toast: (message) => this.toast(message),
reload: () => location.reload()
});
settingsDialog;
activePopover;
activeBackdrop;
lastCard;
lastCardSentence;
lastAnkiLookup;
selectionTimer;
autoScanTimer;
autoScanDeadline = 0;
autoScanObserver;
asbScanTimer;
hoverLookupTimer;
hoverCloseTimer;
hoverWatchTimer;
hoverPendingWord;
hoverPendingLookupKey = "";
hoverLookupInFlightKey = "";
hoverLookupGeneration = 0;
activeHoverWord;
activeHoverLookupKey = "";
activePointerTextLookup;
suppressedHoverWord;
suppressedHoverLookupKey = "";
activePopoverMode;
activePopoverAnchor;
activePopoverAnchorRect;
activePopoverPositionLocked = false;
activePopoverLockedPosition;
activePopoverResizeObserver;
lastPointerPosition;
hoverPopoverPointerPosition;
popoverRepositionFrame;
settingsPreviewOriginalAccent;
settingsPreviewOriginalLanguage;
settingsPreviewOriginalTheme;
lastAutoAudioKey = "";
lastAutoAudioAt = 0;
cardRenderRequest = 0;
dictionaryRescanPending = false;
visiblePageReparseTimer;
preloadedTermAudioKeys = /* @__PURE__ */ new Set();
pressedKeys = /* @__PURE__ */ new Set();
hoverAnchorIds = /* @__PURE__ */ new WeakMap();
nextHoverAnchorId = 1;
suppressSelectionLookupUntil = 0;
suppressWordClickUntil = 0;
pageHasJapaneseText = false;
pressLookup;
suppressMiddleAuxClickUntil = 0;
constructor() {
configureLogger({ settingsProvider: () => this.settings });
}
async init(options) {
const done = log$1.time("init", { href: location.href, devMode: Logger.isDevMode() });
const shouldShowWelcome = await this.loadInitialSettings(options);
await this.installCoreSurfaces();
if (this.leaveHostedPassivePage()) return done();
await this.initReaderPage(shouldShowWelcome);
done();
}
async loadInitialSettings(options) {
this.factoryReset.bind();
this.settings = await loadSettings();
if (options?.isDemo) this.enableDemoMode();
const shouldShowWelcome = options?.showWelcome ?? !this.isDemo;
this.settings = applyUrlBootstrapSettings(this.settings);
configureLogger({ forceEnabled: this.settings.enableLogging });
this.pageHasJapaneseText = documentHasJapaneseText();
log$1.info("Settings loaded", loggingSettingsSummary(this.settings));
return shouldShowWelcome;
}
enableDemoMode() {
this.settings.onboardingSeen = true;
this.isDemo = true;
}
async installCoreSurfaces() {
this.installStyles();
this.applyTheme();
await this.refreshDictionaryStyles();
this.registerMenuCommands();
this.bindEvents();
initJpdbReviewPageBridge();
}
leaveHostedPassivePage() {
if (!isYomuHostedPassivePage(location.href)) return false;
log$1.info("Hosted Yomu content page left passive", { href: location.href, demo: this.isDemo });
return true;
}
async initReaderPage(shouldShowWelcome) {
this.installFab();
this.subtitles.init();
this.ocr.init();
this.setupAutoScan();
if (shouldShowWelcome && !isYomuHostedAppUrl(location.href)) await this.onboarding.showIfNeeded();
if (this.shouldScanInitialPage()) void this.pageScanner.scanVisiblePage({ silent: true });
}
shouldScanInitialPage() {
return this.canParseJapanese() && (this.settings.scanVisiblePage || this.settings.autoScanJapanese) && this.pageHasJapaneseText;
}
registerMenuCommands() {
if (typeof GM_registerMenuCommand === "function") {
GM_registerMenuCommand(`${APP_NAME} settings`, () => this.showSettings());
GM_registerMenuCommand(`${APP_NAME} open new tab`, () => this.openNewTabPage());
GM_registerMenuCommand(`${APP_NAME} open video player`, () => this.openVideoPlayer());
GM_registerMenuCommand(`${APP_NAME} toggle puck`, () => {
this.settings.showFloatingButton = !this.settings.showFloatingButton;
void saveSettings(this.settings).then(() => this.installFab());
});
GM_registerMenuCommand(`${APP_NAME} Factory Reset`, () => void this.factoryReset.resetAllData());
}
}
openNewTabPage() {
const opened = window.open(NEW_TAB_PAGE_URL, "_blank");
if (opened) opened.opener = null;
if (!opened) location.href = NEW_TAB_PAGE_URL;
log$1.info("New tab page opened", { url: NEW_TAB_PAGE_URL });
}
openVideoPlayer() {
const opened = window.open(VIDEO_PLAYER_PAGE_URL, "_blank");
if (opened) opened.opener = null;
if (!opened) location.href = VIDEO_PLAYER_PAGE_URL;
log$1.info("Video player page opened", { url: VIDEO_PLAYER_PAGE_URL });
}
async invalidateRuntimeStoresForFactoryReset() {
this.dismiss({ suppressHoverTarget: false });
this.jpdb.clear();
this.parser.clearLocalCache();
this.dictionarySourceState.clear();
this.cardRenderData.clear();
this.preloadedTermAudioKeys.clear();
this.pressedKeys.clear();
this.cardRenderRequest++;
await this.dictionaries.invalidateForFactoryReset();
}
installStyles() {
if (typeof GM_addStyle === "function") {
GM_addStyle(READER_CSS);
} else {
const style = document.createElement("style");
style.textContent = READER_CSS;
appendToDocumentHead(style);
}
}
applyTheme(settings = this.settings) {
applyReaderTheme(settings);
}
async refreshDictionaryStyles() {
await this.dictionaryStyles.refresh();
}
scheduleDictionaryRescan() {
if (this.activePopover?.classList.contains("jpdb-reader-settings")) {
this.dictionaryRescanPending = true;
return;
}
this.scheduleVisiblePageReparse(120);
}
scheduleVisiblePageReparse(delay2 = 0) {
if (this.isDestroyed) return;
if (this.visiblePageReparseTimer !== void 0) return;
this.visiblePageReparseTimer = window.setTimeout(() => {
this.visiblePageReparseTimer = void 0;
void this.reparseVisiblePage();
}, Math.max(0, delay2));
}
async reparseVisiblePage() {
this.jpdb.clear();
this.parser.clearLocalCache();
this.pauseAutoScanObserver(() => unwrapReaderWords(document));
if (!this.canParseJapanese()) {
return;
}
await this.pageScanner.scanVisiblePage({ silent: true });
}
applyAccentColor(color) {
applyReaderAccentColor(color);
}
applyWordColors(settings = this.settings) {
applyReaderWordColors(settings);
}
installFab() {
this.floatingButton.install(
this.settings,
() => void saveSettings(this.settings),
() => this.showSettings()
);
}
destroy() {
this.isDestroyed = true;
this.factoryReset.destroy();
this.abortController.abort();
this.autoScanObserver?.disconnect();
this.subtitles.destroy();
window.clearTimeout(this.autoScanTimer);
window.clearTimeout(this.asbScanTimer);
window.clearTimeout(this.selectionTimer);
window.clearTimeout(this.visiblePageReparseTimer);
window.clearTimeout(this.hoverLookupTimer);
window.clearTimeout(this.hoverCloseTimer);
window.clearTimeout(this.hoverWatchTimer);
if (this.popoverRepositionFrame !== void 0) {
window.cancelAnimationFrame(this.popoverRepositionFrame);
this.popoverRepositionFrame = void 0;
}
this.activePopoverResizeObserver?.disconnect();
this.floatingButton.destroy();
this.activePopover?.remove();
this.activeBackdrop?.remove();
document.querySelectorAll(".jpdb-reader-word, .jpdb-reader-furigana, .jpdb-reader-ruby").forEach((el) => {
if (el.classList.contains("jpdb-reader-word") || el.classList.contains("jpdb-reader-ruby")) {
const text2 = document.createTextNode(el.textContent || "");
el.replaceWith(text2);
} else {
el.remove();
}
});
this.dictionaryStyles.remove();
document.querySelectorAll("[data-jpdb-reader-root]").forEach((el) => el.remove());
}
setupAutoScan() {
this.autoScanObserver?.disconnect();
this.autoScanObserver = new MutationObserver((mutations) => {
if (mutations.some(mutationTouchesAsbPlayer)) this.scheduleAsbPlayerScan(120);
else if (mutations.every(mutationInsideReaderRoot$1)) return;
else if (mutations.some(mutationMayContainJapaneseText)) {
this.pageHasJapaneseText = true;
this.scheduleAutoScan(450);
}
});
this.observeAutoScanMutations();
window.addEventListener("scroll", () => this.scheduleAutoScan(500), { passive: true });
window.addEventListener("resize", () => this.scheduleAutoScan(700), { passive: true });
if (this.pageHasJapaneseText) this.scheduleAutoScan(600);
}
observeAutoScanMutations() {
this.autoScanObserver?.observe(document.body, AUTO_SCAN_OBSERVER_OPTIONS);
}
pauseAutoScanObserver(callback) {
const observer = this.autoScanObserver;
if (!observer) return callback();
observer.disconnect();
try {
return callback();
} finally {
if (this.autoScanObserver === observer) this.observeAutoScanMutations();
}
}
scheduleAutoScan(delay2) {
if (!this.canScheduleAutoScan()) return;
const deadline = Date.now() + delay2;
if (this.autoScanTimer && this.autoScanDeadline <= deadline) return;
window.clearTimeout(this.autoScanTimer);
this.autoScanDeadline = deadline;
this.autoScanTimer = window.setTimeout(() => {
this.runScheduledAutoScan();
}, delay2);
}
canScheduleAutoScan() {
return !this.isDestroyed && this.settings.autoScanJapanese && this.canParseJapanese() && this.pageHasJapaneseText;
}
runScheduledAutoScan() {
this.autoScanTimer = void 0;
this.autoScanDeadline = 0;
void this.pageScanner.scanAsbPlayerSubtitles();
if (hasVisibleAutoScanTargets()) void this.pageScanner.scanVisiblePage({ silent: true });
}
scheduleAsbPlayerScan(delay2) {
if (this.isDestroyed) return;
if (!this.settings.autoScanJapanese || !this.canParseJapanese()) return;
window.clearTimeout(this.asbScanTimer);
this.asbScanTimer = window.setTimeout(() => void this.pageScanner.scanAsbPlayerSubtitles(), delay2);
}
bindEvents() {
document.addEventListener("click", (event) => {
if (this.isDestroyed) return;
const target = event.target;
if (target.closest?.('[data-jpdb-reader-root] [data-action="kanji"][data-kanji]')) return;
if (target.closest?.("[data-settings-preview-lookup]")) return;
const word = target.closest?.(".jpdb-reader-word");
if (!word && target.closest?.("[data-jpdb-reader-root] a.gloss-link[data-dictionary-lookup]")) return;
if (!word) {
if (!this.settings.lookupOnClick) return;
const candidate = this.lookupCandidateFromPoint(event.clientX, event.clientY, event.target);
if (!candidate) return;
event.preventDefault();
event.stopPropagation();
this.suppressSelectionLookupUntil = Date.now() + 350;
const insideActivePopover = this.activePopoverMode === "modal" && this.isInsideActivePopover(event.target);
void this.showLookupCandidate(candidate, "modal", {
navigation: insideActivePopover ? "push-current" : "reset",
preservePosition: insideActivePopover
});
return;
}
if (Date.now() < this.suppressWordClickUntil) {
event.preventDefault();
event.stopPropagation();
return;
}
const insideReaderPopup = Boolean(word.closest(".jpdb-reader-popover"));
if (!this.settings.lookupOnClick && !insideReaderPopup) return;
event.preventDefault();
event.stopPropagation();
this.suppressSelectionLookupUntil = Date.now() + 350;
if (word.closest(".jpdb-subtitle-player") && this.settings.subtitleMiningPause) pauseActiveVideo();
void this.showWord(word, { trigger: "click" });
}, { capture: true });
document.addEventListener("mousedown", (event) => {
if (this.isDestroyed) return;
if (!this.shouldCaptureMiddleMouseLookup(event)) return;
event.preventDefault();
event.stopPropagation();
}, { capture: true, passive: false });
document.addEventListener("auxclick", (event) => {
if (this.isDestroyed) return;
if (event.button !== 1 || Date.now() > this.suppressMiddleAuxClickUntil) return;
event.preventDefault();
event.stopPropagation();
}, { capture: true });
document.addEventListener("pointerdown", (event) => {
this.dismissModalPopoverForOutsidePointer(event);
this.dismissHoverPopoverForOutsidePointer(event);
this.beginPressLookup(event);
}, { capture: true, passive: false });
document.addEventListener("pointermove", (event) => {
this.updatePressLookup(event);
}, { capture: true, passive: false });
document.addEventListener("pointerup", (event) => {
this.endPressLookup(event);
}, { capture: true });
document.addEventListener("pointercancel", (event) => {
this.endPressLookup(event);
}, { capture: true });
document.addEventListener("pointerover", (event) => {
this.handleHoverPointer(event);
}, { capture: true });
document.addEventListener("pointermove", (event) => {
this.handleHoverPointer(event);
}, { capture: true });
document.addEventListener("pointerout", (event) => {
this.handleHoverPointerOut(event);
}, { capture: true });
if (!window.PointerEvent) {
document.addEventListener("mouseover", (event) => {
this.handleHoverPointer(event);
}, { capture: true });
document.addEventListener("mousemove", (event) => {
this.handleHoverPointer(event);
}, { capture: true });
document.addEventListener("mouseout", (event) => {
this.handleHoverPointerOut(event);
}, { capture: true });
}
document.addEventListener("keyup", () => {
if (this.isDestroyed) return;
if (!this.settings.parseSelection) return;
window.clearTimeout(this.selectionTimer);
this.selectionTimer = window.setTimeout(() => void this.lookupSelection(), 120);
});
document.addEventListener("mouseup", () => {
if (this.isDestroyed) return;
if (!this.settings.parseSelection) return;
window.clearTimeout(this.selectionTimer);
this.selectionTimer = window.setTimeout(() => void this.lookupSelection(), 140);
});
document.addEventListener("touchend", () => {
if (this.isDestroyed) return;
if (!this.settings.parseSelection) return;
window.clearTimeout(this.selectionTimer);
this.selectionTimer = window.setTimeout(() => void this.lookupSelection(), 180);
}, { passive: true });
document.addEventListener("keydown", (event) => {
if (this.isDestroyed) return;
this.pressedKeys.add(normalizePressedKey(event.key));
if (isEditableTarget(event.target)) return;
const escapeClose = this.settings.shortcuts.closePopup.trim().toLowerCase() === "escape" && event.key === "Escape";
if ((escapeClose || matchesShortcut(event, this.settings.shortcuts.closePopup)) && this.hasOpenReaderDialog()) {
event.preventDefault();
this.dismiss({ suppressHoverTarget: true });
return;
}
if ((this.settings.shortcuts.hoverLookup ?? "").trim() && this.shouldLookupOnHover(event)) this.scheduleHoverLookupAtPointer(event);
if (matchesShortcut(event, this.settings.shortcuts.scanPage)) {
event.preventDefault();
log$1.info("Shortcut triggered visible page scan");
void this.pageScanner.scanVisiblePage({ silent: true });
return;
}
if (matchesShortcut(event, this.settings.shortcuts.openSettings)) {
event.preventDefault();
log$1.info("Shortcut opened settings");
this.showSettings();
return;
}
if (matchesShortcut(event, this.settings.shortcuts.toggleOcr)) {
event.preventDefault();
this.settings.ocrEnabled = !this.settings.ocrEnabled;
void saveSettings(this.settings);
this.ocr.refresh();
log$1.info("Shortcut toggled OCR", { enabled: this.settings.ocrEnabled });
this.toast(uiText(this.settings.interfaceLanguage, this.settings.ocrEnabled ? "imageReadingEnabled" : "imageReadingHidden"));
return;
}
if (matchesShortcut(event, this.settings.shortcuts.scanImages)) {
event.preventDefault();
log$1.info("Shortcut triggered image scan");
void this.ocr.scanVisible();
return;
}
if (this.lastCard && this.activePopover && matchesShortcut(event, this.settings.shortcuts.playAudio)) {
event.preventDefault();
void this.audioActions.playTermAudio(this.lastCard, { userGesture: true });
return;
}
const grade = this.shortcutGrade(event);
if (this.lastCard && grade && this.activePopover?.classList.contains("jpdb-reader-popover")) {
event.preventDefault();
const ankiCardId = this.lastAnkiLookup?.primary?.primaryCardId ?? null;
if (!ankiCardId && !this.settings.jpdbMiningEnabled) return;
const card = this.lastCard;
const sentence = this.lastCardSentence;
const anchor = this.activePopoverAnchor?.isConnected ? this.activePopoverAnchor : void 0;
const trigger = this.activePopoverMode === "hover" ? "hover" : "modal";
void this.cardActions.reviewGrade(grade, card, sentence, {
ankiCardId: Number.isFinite(ankiCardId) && ankiCardId ? ankiCardId : void 0
}).then(() => this.showCard(card, sentence, anchor, {
autoPlay: false,
trigger,
navigation: "preserve",
preservePosition: true
})).catch((error) => {
log$1.warn("Shortcut review failed", { grade, ankiCardId: Number.isFinite(ankiCardId) ? ankiCardId : void 0 }, error);
this.toast(error instanceof Error ? error.message : uiText(this.settings.interfaceLanguage, "reviewFailed"));
});
}
});
document.addEventListener("keyup", (event) => {
this.pressedKeys.delete(normalizePressedKey(event.key));
if ((this.settings.shortcuts.hoverLookup ?? "").trim() && !this.shouldLookupOnHover(event)) {
this.cancelPendingHoverLookup();
if (this.activePopoverMode === "hover") this.scheduleHoverClose(0, { ignoreCssHover: true });
}
});
window.addEventListener("blur", () => {
this.pressedKeys.clear();
this.cancelPendingHoverLookup();
if (this.activePopoverMode === "hover") this.scheduleHoverClose(0, { ignoreCssHover: true });
});
}
shortcutGrade(event) {
if (!this.settings.enableReviews) return null;
const shortcuts = this.settings.twoButtonReviews ? TWO_BUTTON_REVIEW_SHORTCUTS : FIVE_BUTTON_REVIEW_SHORTCUTS;
return matchedReviewShortcutGrade(event, this.settings.shortcuts, shortcuts);
}
shouldLookupOnHover(event) {
return this.settings.lookupOnHover && shortcutIsPressed(this.settings.shortcuts.hoverLookup ?? "", event, this.pressedKeys);
}
beginPressLookup(event) {
const request = this.pressLookupRequest(event);
if (!request) return;
if (request.isMiddleScan) this.captureMiddleMouseLookup(event);
this.pressLookup = this.createPressLookup(event, request.isMiddleScan);
if (request.isMiddleScan) this.updatePressLookup(event);
}
pressLookupRequest(event) {
const isMiddleScan = this.shouldCaptureMiddleMouseLookup(event);
if (!this.canBeginPressLookup(event, isMiddleScan)) return null;
if (!isMiddleScan && !this.wordFromEventTarget(event.target)) return null;
return { isMiddleScan };
}
canBeginPressLookup(event, isMiddleScan) {
if (this.isDestroyed) return false;
if (this.isInsideActivePopover(event.target)) return false;
if (isMiddleScan) return true;
return this.canBeginPrimaryPressLookup(event);
}
canBeginPrimaryPressLookup(event) {
return hasPressLookupEnabled(this.settings) && (event.pointerType !== "mouse" || event.button === 0);
}
createPressLookup(event, isMiddleScan) {
return {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
active: isMiddleScan,
source: isMiddleScan ? "middle" : "primary",
captureTarget: isMiddleScan && event.target instanceof Element ? event.target : void 0
};
}
updatePressLookup(event) {
if (this.isDestroyed) return;
const pressLookup = this.pressLookup;
if (!pressLookup || pressLookup.pointerId !== event.pointerId) return;
this.lastPointerPosition = { x: event.clientX, y: event.clientY };
if (!this.activatePressLookupIfNeeded(pressLookup, event)) return;
event.preventDefault();
event.stopPropagation();
this.updateActivePressLookupTarget(pressLookup, event);
}
updateActivePressLookupTarget(pressLookup, event) {
const targetAtPointer = document.elementFromPoint(event.clientX, event.clientY);
if (this.isInsideActivePopover(targetAtPointer)) {
this.cancelHoverClose();
return;
}
const word = this.wordFromPoint(event.clientX, event.clientY);
if (!word) {
if (pressLookup.source === "middle") this.scheduleHoverClose();
return;
}
this.showPressLookupWord(pressLookup, word, event);
}
showPressLookupWord(pressLookup, word, event) {
this.rememberHoverPopoverPointer(event);
if (this.refreshPressLookupWord(pressLookup, word)) return;
pressLookup.lastWord = word;
this.cancelHoverClose();
window.clearTimeout(this.hoverLookupTimer);
this.hoverLookupTimer = void 0;
this.hoverPendingWord = void 0;
this.hoverPendingLookupKey = "";
if (word.closest(".jpdb-subtitle-player") && this.settings.subtitleMiningPause) pauseActiveVideo();
const hoverLookupGeneration = this.nextHoverLookupGeneration();
void this.showWord(word, { trigger: "hover", hoverLookupGeneration });
}
activatePressLookupIfNeeded(pressLookup, event) {
if (pressLookup.active) return true;
const distance = Math.hypot(event.clientX - pressLookup.startX, event.clientY - pressLookup.startY);
if (distance < 8) return false;
pressLookup.active = true;
this.suppressWordClickUntil = Date.now() + 700;
this.suppressedHoverWord = void 0;
return true;
}
refreshPressLookupWord(pressLookup, word) {
if (word !== pressLookup.lastWord) return false;
if (this.activePopoverMode === "hover" && this.activeHoverWord === word) this.scheduleActivePopoverReposition();
return true;
}
endPressLookup(event) {
if (this.isDestroyed) return;
const pressLookup = this.matchingPressLookup(event);
if (!pressLookup) return;
this.finishPressLookupRelease(event, pressLookup);
this.pressLookup = void 0;
}
matchingPressLookup(event) {
const pressLookup = this.pressLookup;
return pressLookup?.pointerId === event.pointerId ? pressLookup : null;
}
finishPressLookupRelease(event, pressLookup) {
if (pressLookup.active) this.suppressPressLookupAfterRelease();
if (pressLookup.source === "middle") this.finishMiddlePressLookup(event, pressLookup);
}
suppressPressLookupAfterRelease() {
this.suppressWordClickUntil = Date.now() + 700;
this.suppressSelectionLookupUntil = Date.now() + 350;
}
finishMiddlePressLookup(event, pressLookup) {
event.preventDefault();
event.stopPropagation();
this.finishMiddleMouseLookup(pressLookup);
if (this.activePopoverMode === "hover" && !this.isHoverContextActive()) this.scheduleHoverClose();
}
shouldCaptureMiddleMouseLookup(event) {
if (!this.settings.lookupOnMiddleMouse || event.button !== 1) return false;
if (this.isInsideActivePopover(event.target)) return false;
return isMousePointerEvent(event) && !this.isNativeMiddleClickTarget(eventElement(event));
}
captureMiddleMouseLookup(event) {
event.preventDefault();
event.stopPropagation();
this.suppressMiddleAuxClickUntil = Date.now() + 1200;
this.suppressSelectionLookupUntil = Date.now() + 350;
document.documentElement.classList.add("jpdb-reader-middle-scan-active");
try {
if (event.target instanceof Element) event.target.setPointerCapture?.(event.pointerId);
} catch {
}
}
finishMiddleMouseLookup(pressLookup) {
this.suppressMiddleAuxClickUntil = Date.now() + 700;
document.documentElement.classList.remove("jpdb-reader-middle-scan-active");
try {
pressLookup.captureTarget?.releasePointerCapture?.(pressLookup.pointerId);
} catch {
}
}
isNativeMiddleClickTarget(target) {
return Boolean(target?.closest([
"a[href]",
"button",
"input",
"textarea",
"select",
"summary",
'[role="button"]',
'[contenteditable="true"]',
"[data-jpdb-reader-root]"
].join(",")));
}
wordFromEventTarget(target) {
const element2 = target instanceof Element ? target : null;
const word = element2?.closest?.(".jpdb-reader-word");
return word && this.canLookupReaderWord(word) ? word : null;
}
wordFromPoint(x, y) {
for (const element2 of document.elementsFromPoint(x, y)) {
const word = element2.closest?.(".jpdb-reader-word");
if (word && this.canLookupReaderWord(word)) return word;
}
return null;
}
preloadHoverWordAudio(word) {
this.preloadReaderWordAudio(word, { sourceLimit: 2, candidateLimit: 1 });
this.preloadNearbyReaderWordAudio(word);
}
preloadReaderWordAudio(word, options = {}) {
if (!this.canPreloadReaderAudio()) return false;
const card = this.preloadableReaderWordCard(word);
if (!card) return false;
if (!this.reservePreloadedTermAudio(card)) return false;
this.audio.preload(card, audioPreloadLimits(options));
return true;
}
canPreloadReaderAudio() {
return this.settings.audioEnabled && this.settings.autoPlayAudio;
}
reservePreloadedTermAudio(card) {
const key = cardKey$1(card);
if (this.preloadedTermAudioKeys.has(key)) return false;
this.preloadedTermAudioKeys.add(key);
return true;
}
preloadableReaderWordCard(word) {
const card = this.getCachedCard(Number(word.dataset.vid), Number(word.dataset.sid));
return card && isUsefulImmersionPreloadQuery(card.spelling) ? card : null;
}
preloadNearbyReaderWordAudio(word) {
this.queueNearbyReaderWordAudioPreloads(word);
}
queueNearbyReaderWordAudioPreloads(word) {
const words = this.lookupableReaderWords();
const index = words.indexOf(word);
return index < 0 ? 0 : this.queueReaderWordAudioPreloads(words.slice(index + 1));
}
lookupableReaderWords() {
return Array.from(document.querySelectorAll(".jpdb-reader-word")).filter((candidate) => candidate.isConnected && this.canLookupReaderWord(candidate));
}
queueReaderWordAudioPreloads(words) {
let queued = 0;
for (const candidate of words) {
if (this.preloadReaderWordAudio(candidate)) queued++;
if (queued >= NEARBY_TERM_AUDIO_PRELOAD_LIMIT) break;
}
return queued;
}
canLookupReaderWord(word) {
if (!word.closest("[data-jpdb-reader-root]")) return true;
return Boolean(word.closest(".jpdb-subtitle-player, .jpdb-ocr-layer, .jpdb-reader-popover"));
}
canHoverLookupReaderWord(word) {
if (!word.closest("[data-jpdb-reader-root]")) return true;
return Boolean(word.closest(".jpdb-subtitle-player, .jpdb-ocr-layer, .jpdb-reader-newtab-immersion"));
}
handleHoverPointer(event) {
if (this.shouldIgnoreHoverPointer(event)) return;
this.lastPointerPosition = { x: event.clientX, y: event.clientY };
const insideActivePopover = this.handleActivePopoverHover(event);
if (insideActivePopover) return;
const word = this.hoverReaderWordForEvent(event);
if (!word) {
if (insideActivePopover) return;
this.handlePointerTextHover(event);
return;
}
this.handleReaderWordHover(word, event);
}
shouldIgnoreHoverPointer(event) {
return this.isDestroyed || this.pressLookup?.source === "middle" || event.pointerType === "touch";
}
handleActivePopoverHover(event) {
if (!this.isInsideActivePopover(event.target)) return false;
this.cancelHoverClose();
return true;
}
isPointerInsideActiveHoverWord(event) {
if (this.activePopoverMode !== "hover" || !this.activeHoverWord?.isConnected) return false;
const rect = this.activeHoverWord.getBoundingClientRect();
return event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom;
}
hoverReaderWordForEvent(event) {
const word = event.target.closest?.(".jpdb-reader-word");
return word && this.canHoverLookupReaderWord(word) ? word : null;
}
handlePointerTextHover(event) {
const hoverEnabled = this.shouldLookupOnHover(event);
const candidate = hoverEnabled ? this.lookupCandidateFromPoint(event.clientX, event.clientY, event.target) : null;
if (candidate && this.refreshActivePointerTextHover(candidate, event)) return;
this.cancelMissingPointerTextCandidate(candidate);
this.scheduleInactiveHoverClose();
if (!canSchedulePointerTextHoverLookup(hoverEnabled, candidate)) return;
this.rememberHoverPopoverPointer(event);
this.schedulePointerTextLookup(candidate, event);
}
refreshActivePointerTextHover(candidate, event) {
if (!this.isActivePointerTextLookup(candidate)) return false;
this.rememberHoverPopoverPointer(event);
this.cancelHoverClose();
this.refreshActiveHoverAnchor(candidate.anchor);
this.scheduleActivePopoverReposition();
return true;
}
cancelMissingPointerTextCandidate(candidate) {
if (!candidate) this.cancelPendingHoverLookup();
}
scheduleInactiveHoverClose() {
if (this.activePopoverMode === "hover" && !this.isHoverContextActive({ ignoreCssHover: true })) {
this.scheduleHoverClose(void 0, { ignoreCssHover: true });
}
}
handleReaderWordHover(word, event) {
this.rememberHoverPopoverPointer(event);
const hoverLookupKey = this.hoverLookupKeyForWord(word);
if (this.isActiveHoverLookup(hoverLookupKey)) {
this.cancelHoverClose();
this.refreshActiveHoverAnchor(word);
this.scheduleActivePopoverReposition();
return;
}
if (this.activePopoverMode === "hover" && this.activeHoverWord === word) {
this.cancelHoverClose();
this.scheduleActivePopoverReposition();
return;
}
if (!this.shouldLookupOnHover(event)) return;
this.preloadHoverWordAudio(word);
this.scheduleHoverLookup(word, event);
}
handleHoverPointerOut(event) {
if (this.isDestroyed) return;
this.lastPointerPosition = { x: event.clientX, y: event.clientY };
const related = event.relatedTarget;
if (this.handleActivePopoverPointerOut(event, related)) return;
this.handleReaderWordPointerOut(event, related);
}
handleActivePopoverPointerOut(event, related) {
if (this.isInsideActivePopover(event.target)) {
if (this.isInsideActivePopover(related) || this.activeHoverWord && this.isInsideNode(related, this.activeHoverWord)) return true;
this.scheduleHoverClose(void 0, { ignoreCssHover: true });
return true;
}
return false;
}
handleReaderWordPointerOut(event, related) {
const word = this.pointerOutReaderWord(event, related);
if (!word) return;
const hoverLookupKey = this.hoverLookupKeyForWord(word);
this.cancelPendingHoverLookupForWord(word, hoverLookupKey);
this.clearSuppressedHoverForWord(word, hoverLookupKey);
this.handleActiveHoverWordPointerOut(word, related);
}
pointerOutReaderWord(event, related) {
const word = event.target.closest?.(".jpdb-reader-word");
return word && !(related && word.contains(related)) ? word : null;
}
cancelPendingHoverLookupForWord(word, hoverLookupKey) {
if (this.hoverPendingWord === word || this.hoverPendingLookupKey === hoverLookupKey || this.hoverLookupInFlightKey === hoverLookupKey) {
this.cancelPendingHoverLookup();
}
}
clearSuppressedHoverForWord(word, hoverLookupKey) {
if (this.suppressedHoverWord === word) this.suppressedHoverWord = void 0;
if (this.suppressedHoverLookupKey === hoverLookupKey) this.suppressedHoverLookupKey = "";
}
handleActiveHoverWordPointerOut(word, related) {
if (this.activePopoverMode !== "hover" || this.activeHoverWord !== word) return;
if (this.isInsideActivePopover(related)) {
this.cancelHoverClose();
return;
}
this.scheduleHoverClose(void 0, { ignoreCssHover: true });
}
scheduleHoverLookupAtPointer(event) {
const pointer = this.activeHoverPointerPosition();
if (!pointer) return;
this.hoverPopoverPointerPosition = { ...pointer };
this.scheduleHoverLookupForPointer(pointer, event);
}
activeHoverPointerPosition() {
return !this.isDestroyed && this.lastPointerPosition ? this.lastPointerPosition : null;
}
scheduleHoverLookupForPointer(pointer, event) {
const target = document.elementFromPoint(pointer.x, pointer.y);
const word = this.hoverReaderWordFromElement(target);
if (word) {
this.scheduleHoverLookup(word, event);
return;
}
this.schedulePointerTextLookupForPointer(pointer, target, event);
}
hoverReaderWordFromElement(element2) {
const word = element2?.closest?.(".jpdb-reader-word");
return word && this.canHoverLookupReaderWord(word) ? word : null;
}
schedulePointerTextLookupForPointer(pointer, target, event) {
const candidate = this.lookupCandidateFromPoint(pointer.x, pointer.y, target);
if (candidate) this.schedulePointerTextLookup(candidate, event);
}
dismissModalPopoverForOutsidePointer(event) {
if (this.isDestroyed || this.activePopoverMode !== "modal" || !this.activePopover) return;
if (this.isInsideActivePopover(event.target)) return;
this.dismiss({ suppressHoverTarget: true });
}
dismissHoverPopoverForOutsidePointer(event) {
if (this.isDestroyed || this.activePopoverMode !== "hover") return;
const target = event.target;
if (!this.shouldDismissHoverForPointerTarget(target)) return;
this.dismiss({ suppressHoverTarget: false });
}
shouldDismissHoverForPointerTarget(target) {
if (this.isInsideActivePopover(target)) return false;
return !(this.activeHoverWord && this.isInsideNode(target, this.activeHoverWord));
}
rememberHoverPopoverPointer(event) {
this.hoverPopoverPointerPosition = { x: event.clientX, y: event.clientY };
}
nextHoverLookupGeneration() {
this.hoverLookupGeneration++;
return this.hoverLookupGeneration;
}
cancelPendingHoverLookup() {
window.clearTimeout(this.hoverLookupTimer);
this.hoverLookupTimer = void 0;
this.hoverPendingWord = void 0;
this.hoverPendingLookupKey = "";
this.hoverLookupInFlightKey = "";
this.nextHoverLookupGeneration();
}
scheduleActivePopoverReposition() {
if (this.activePopoverMode !== "hover" || !this.activePopover || this.activePopover.classList.contains("jpdb-reader-sheet")) return;
if (this.popoverRepositionFrame !== void 0) return;
this.popoverRepositionFrame = window.requestAnimationFrame(() => {
this.popoverRepositionFrame = void 0;
this.repositionActivePopover();
});
}
scheduleHoverLookup(word, event) {
const hoverLookupKey = this.hoverLookupKeyForWord(word);
if (this.shouldSkipHoverLookupSchedule(word, hoverLookupKey)) return;
this.preloadHoverWordAudio(word);
this.cancelHoverClose();
window.clearTimeout(this.hoverLookupTimer);
const hoverLookupGeneration = this.nextHoverLookupGeneration();
this.hoverPendingWord = word;
this.hoverPendingLookupKey = hoverLookupKey;
this.installHoverLookupTimer(() => this.runScheduledHoverLookup(word, event, hoverLookupGeneration));
}
shouldSkipHoverLookupSchedule(word, hoverLookupKey) {
if (this.isSuppressedHoverLookup(word, hoverLookupKey)) return true;
if (this.isActiveHoverLookup(hoverLookupKey)) {
this.refreshActiveHoverAnchor(word);
return true;
}
return this.isSameActiveHoverWord(word) || this.isPendingHoverLookup(word, hoverLookupKey) || this.isInFlightHoverLookup(hoverLookupKey);
}
isSuppressedHoverLookup(word, hoverLookupKey) {
return this.suppressedHoverWord === word || Boolean(hoverLookupKey && this.suppressedHoverLookupKey === hoverLookupKey);
}
isSameActiveHoverWord(word) {
return this.activePopoverMode === "hover" && this.activeHoverWord === word;
}
isPendingHoverLookup(word, hoverLookupKey) {
return Boolean((this.hoverPendingWord === word || hoverLookupKey && this.hoverPendingLookupKey === hoverLookupKey) && this.hoverLookupTimer);
}
isInFlightHoverLookup(hoverLookupKey) {
return Boolean(hoverLookupKey && this.hoverLookupInFlightKey === hoverLookupKey);
}
installHoverLookupTimer(runLookup) {
const delay2 = Math.max(0, this.settings.hoverOpenDelayMs);
if (delay2 === 0) runLookup();
else this.hoverLookupTimer = window.setTimeout(runLookup, delay2);
}
runScheduledHoverLookup(word, event, hoverLookupGeneration) {
if (this.hoverLookupGeneration !== hoverLookupGeneration) return;
this.hoverLookupTimer = void 0;
this.hoverPendingWord = void 0;
this.hoverPendingLookupKey = "";
const activeWord = this.resolveScheduledHoverWord(word);
if (!activeWord || !this.canRunScheduledHoverLookup(activeWord, event)) return;
const activeHoverLookupKey = this.hoverLookupKeyForWord(activeWord);
if (activeHoverLookupKey) this.hoverLookupInFlightKey = activeHoverLookupKey;
void this.showWord(activeWord, { trigger: "hover", hoverLookupGeneration }).finally(() => void 0);
window.setTimeout(() => {
if (this.hoverLookupInFlightKey === activeHoverLookupKey) this.hoverLookupInFlightKey = "";
}, 0);
}
resolveScheduledHoverWord(word) {
if (word.isConnected) return word;
return this.lastPointerPosition ? this.hoverWordFromPoint(this.lastPointerPosition.x, this.lastPointerPosition.y) ?? null : null;
}
hoverWordFromPoint(x, y) {
for (const element2 of document.elementsFromPoint(x, y)) {
const word = this.hoverReaderWordFromElement(element2);
if (word) return word;
}
return null;
}
canRunScheduledHoverLookup(activeWord, event) {
const hoverLookupKey = this.hoverLookupKeyForWord(activeWord);
if (!this.isRunnableScheduledHoverWord(activeWord, hoverLookupKey)) return false;
if (this.isActiveHoverLookup(hoverLookupKey)) {
this.refreshActiveHoverAnchor(activeWord);
return false;
}
if (!this.canOpenHoverLookupForWord(activeWord, event)) return false;
if (shouldPauseVideoForSubtitleHover(activeWord, this.settings)) pauseActiveVideo();
return true;
}
isRunnableScheduledHoverWord(activeWord, hoverLookupKey) {
return activeWord.isConnected && !this.isSuppressedHoverLookup(activeWord, hoverLookupKey);
}
canOpenHoverLookupForWord(activeWord, event) {
return this.isWordHoverActive(activeWord) && this.settings.lookupOnHover && shortcutIsPressed(this.settings.shortcuts.hoverLookup ?? "", event, this.pressedKeys);
}
schedulePointerTextLookup(candidate, event) {
if (this.isActivePointerTextLookup(candidate)) {
this.refreshActiveHoverAnchor(candidate.anchor);
return;
}
const hoverLookupKey = this.pendingPointerTextLookupKey(candidate);
if (this.isPointerTextLookupAlreadyQueued(hoverLookupKey)) return;
this.cancelHoverClose();
window.clearTimeout(this.hoverLookupTimer);
const hoverLookupGeneration = this.nextHoverLookupGeneration();
this.hoverPendingWord = void 0;
this.hoverPendingLookupKey = hoverLookupKey;
const runLookup = () => {
if (this.hoverLookupGeneration !== hoverLookupGeneration) return;
this.hoverLookupTimer = void 0;
this.hoverPendingLookupKey = "";
if (!candidate.anchor.isConnected || !this.settings.lookupOnHover) return;
if (!shortcutIsPressed(this.settings.shortcuts.hoverLookup ?? "", event, this.pressedKeys)) return;
if (hoverLookupKey) this.hoverLookupInFlightKey = hoverLookupKey;
void this.showLookupCandidate(candidate, "hover", { hoverLookupGeneration }).finally(() => void 0);
window.setTimeout(() => {
if (this.hoverLookupInFlightKey === hoverLookupKey) this.hoverLookupInFlightKey = "";
}, 0);
};
const delay2 = Math.max(0, this.settings.hoverOpenDelayMs);
if (delay2 === 0) {
runLookup();
return;
}
this.hoverLookupTimer = window.setTimeout(runLookup, delay2);
}
isPointerTextLookupAlreadyQueued(hoverLookupKey) {
return Boolean(hoverLookupKey && (this.hoverPendingLookupKey === hoverLookupKey && this.hoverLookupTimer || this.hoverLookupInFlightKey === hoverLookupKey));
}
cancelHoverClose() {
window.clearTimeout(this.hoverCloseTimer);
this.hoverCloseTimer = void 0;
}
scheduleHoverClose(delay2 = this.settings.hoverCloseDelayMs, options = {}) {
if (this.activePopoverMode !== "hover") return;
this.cancelHoverClose();
this.hoverCloseTimer = window.setTimeout(() => {
this.hoverCloseTimer = void 0;
if (this.isHoverContextActive(options)) return;
this.dismiss({ suppressHoverTarget: false });
}, Math.max(0, delay2));
}
isHoverContextActive(options = {}) {
if (this.activeHoverWord && this.isWordHoverActive(this.activeHoverWord, options)) return true;
if (this.isPopoverCssHoverActive(options)) return true;
const target = this.currentHoverPointerTarget(options);
return target ? this.isInsideActiveHoverContext(target) : false;
}
isPopoverCssHoverActive(options) {
return !options.ignoreCssHover && Boolean(this.activePopover?.matches(":hover"));
}
currentHoverPointerTarget(options) {
if (options.ignorePointerPosition || !this.lastPointerPosition) return null;
return document.elementFromPoint(this.lastPointerPosition.x, this.lastPointerPosition.y);
}
isInsideActiveHoverContext(target) {
return this.isInsideActivePopover(target) || Boolean(this.activeHoverWord && this.isInsideNode(target, this.activeHoverWord));
}
isWordHoverActive(word, options = {}) {
if (!options.ignoreCssHover && word.matches(":hover")) return true;
if (options.ignorePointerPosition) return false;
if (!this.lastPointerPosition) return false;
const target = document.elementFromPoint(this.lastPointerPosition.x, this.lastPointerPosition.y);
return this.isInsideNode(target, word);
}
hoverLookupKeyForWord(word) {
const vid = Number(word.dataset.vid);
const sid = Number(word.dataset.sid);
if (!Number.isFinite(vid) || !Number.isFinite(sid)) return "";
return `word:${vid}:${sid}:${word.dataset.sentence ?? ""}`;
}
pendingPointerTextLookupKey(candidate) {
return `text-pending:${this.hoverAnchorId(candidate.anchor)}:${candidate.start}:${candidate.end}:${candidate.text.length}`;
}
activePointerTextLookupKey(candidate, start, end, card) {
return `text:${this.hoverAnchorId(candidate.anchor)}:${start}:${end}:${cardKey$1(card)}`;
}
hoverAnchorId(anchor) {
const existing = this.hoverAnchorIds.get(anchor);
if (existing) return existing;
const next = this.nextHoverAnchorId++;
this.hoverAnchorIds.set(anchor, next);
return next;
}
isActiveHoverLookup(hoverLookupKey) {
return Boolean(hoverLookupKey && this.activePopover && this.activePopoverMode === "hover" && this.activeHoverLookupKey === hoverLookupKey);
}
isActivePointerTextLookup(candidate) {
const active = this.activePointerTextLookup;
if (!active || !this.hasActiveHoverPopover()) return false;
if (!samePointerTextLookupTarget(active, candidate)) return false;
return pointerOffsetInsideLookup(active, candidate.offset);
}
hasActiveHoverPopover() {
return this.activePopoverMode === "hover" && Boolean(this.activePopover);
}
refreshActiveHoverAnchor(anchor) {
if (!this.canRefreshActiveHoverAnchor(anchor)) return;
if (this.activePopoverAnchor === anchor && this.activeHoverWord === anchor) return;
this.activePopoverAnchor = anchor;
this.activeHoverWord = anchor;
this.captureActiveHoverAnchorRect(anchor);
this.repositionActivePopover();
}
canRefreshActiveHoverAnchor(anchor) {
return Boolean(this.activePopover && this.activePopoverMode === "hover" && anchor.isConnected);
}
captureActiveHoverAnchorRect(anchor) {
const rect = anchor.getBoundingClientRect();
if (rect.width > 0 || rect.height > 0) this.activePopoverAnchorRect = rect;
}
isInsideActivePopover(node) {
return Boolean(this.activePopover && this.isInsideNode(node, this.activePopover));
}
isInsideNode(node, root) {
return Boolean(node && (node === root || root.contains(node)));
}
hasOpenReaderDialog() {
return Boolean(this.activePopover || this.activeBackdrop || document.querySelector("[data-jpdb-reader-root].jpdb-reader-popover, [data-jpdb-reader-root].jpdb-reader-settings, [data-jpdb-reader-root].jpdb-reader-backdrop"));
}
async parseJapanese(paragraphs, options) {
return this.parser.parse(paragraphs, options);
}
canParseJapanese() {
return this.parser.canParse();
}
getCachedCard(vid, sid) {
return this.parser.getCachedCard(vid, sid);
}
isJpdbBackedCard(card) {
return this.parser.isJpdbBackedCard(card);
}
async lookupSelection() {
if (this.isDestroyed) return;
if (Date.now() < this.suppressSelectionLookupUntil) return;
const selected = this.selectionLookupText();
if (!selected) return;
await this.lookupText(selected, getSelectionSentence());
}
selectionLookupText() {
const selected = getSelectionText();
if (selected.length < 1) return "";
if (selected.length > 120) return "";
if (!HAS_JAPANESE$1.test(selected)) return "";
if (document.activeElement?.closest?.("[data-jpdb-reader-root]")) return "";
return selected;
}
async lookupText(text2, sentence = text2, options = {}) {
const context = this.textLookupDisplayContext(text2, options);
if (!context) return;
const done = log$1.time("lookupText", { length: context.selected.length, trigger: context.trigger });
try {
if (await this.showLocalLookupCard(context, sentence)) return;
const [tokens] = await this.parseJapanese([sentence], { jpdbTimeoutMs: 1200 });
await this.showTextLookupResult(context, tokens, sentence);
} catch (error) {
log$1.warn("Lookup failed; trying local fallback", { selected: context.selected }, error);
await this.showLocalOrFallbackLookupCard(context, sentence, error);
} finally {
done();
}
}
textLookupDisplayContext(text2, options) {
const selected = normalizedLookupText(text2);
if (!isLookupableJapaneseText(selected)) return null;
const trigger = this.activeTextLookupTrigger();
const navigation = options.navigation ?? "reset";
return this.createTextLookupDisplayContext(selected, trigger, navigation, options);
}
createTextLookupDisplayContext(selected, trigger, navigation, options) {
return {
selected,
anchor: options.anchor ?? connectedElement(this.activePopoverAnchor),
trigger,
navigation,
preservePosition: this.textLookupPreservePosition(navigation, options),
previousNavigationEntry: this.textLookupPreviousNavigationEntryForOptions(trigger, navigation, options)
};
}
textLookupPreservePosition(navigation, options) {
return options.preservePosition ?? this.shouldPreserveLookupPosition(navigation);
}
textLookupPreviousNavigationEntryForOptions(trigger, navigation, options) {
return options.previousNavigationEntry ?? this.textLookupPreviousNavigationEntry(trigger, navigation);
}
activeTextLookupTrigger() {
return this.activePopoverMode === "hover" ? "hover" : "modal";
}
shouldPreserveLookupPosition(navigation) {
return navigation !== "reset" && Boolean(this.activePopover);
}
textLookupPreviousNavigationEntry(trigger, navigation) {
return trigger === "modal" && navigation === "push-current" ? this.navigation.activeKanjiEntry() : void 0;
}
async showTextLookupResult(context, tokens, sentence) {
const selectedToken = pickTokenForSelection(tokens, context.selected);
if (selectedToken) {
void this.showCard(selectedToken.card, selectedToken.sentence ?? sentence, context.anchor, this.textLookupCardOptions(context));
return;
}
if (tokens.length) {
this.showTokenList(tokens, context.selected, context.anchor, this.textLookupCardOptions(context));
return;
}
await this.showLocalOrFallbackLookupCard(context, sentence);
}
async showLocalLookupCard(context, sentence) {
const localEntries = await this.localLookupEntries(context.selected);
if (!localEntries.length) return false;
void this.showCard(this.parser.localCardFromEntry(localEntries[0]), sentence, context.anchor, this.textLookupCardOptions(context));
return true;
}
async showLocalOrFallbackLookupCard(context, sentence, error) {
if (await this.showLocalLookupCard(context, sentence)) return;
if (error) this.toast(error instanceof Error ? error.message : uiText(this.settings.interfaceLanguage, "jpdbLookupFailed"));
void this.showCard(this.parser.fallbackCardFromText(context.selected), sentence, context.anchor, this.textLookupCardOptions(context));
}
async localLookupEntries(selected) {
return this.settings.localDictionariesEnabled ? this.dictionaries.lookup(selected, selected, this.settings.localDictionaryMaxResults, this.settings.dictionaryPreferences).catch(() => []) : [];
}
textLookupCardOptions(context) {
return {
trigger: context.trigger,
navigation: context.navigation,
preservePosition: context.preservePosition,
previousNavigationEntry: context.previousNavigationEntry
};
}
handleDictionaryLookupLink(event, anchor, trigger) {
const link = dictionaryLookupLink(event.target);
if (!link) return false;
const query = dictionaryLookupQuery(link);
if (!query) return false;
event.preventDefault();
event.stopPropagation();
void this.lookupDictionaryReference(query, link.dataset.dictionaryReading ?? "", link.dataset.dictionary ?? "", anchor, trigger, this.isInsideActivePopover(event.target));
return true;
}
async lookupDictionaryReference(query, reading, sourceDictionary, anchor, trigger, preservePosition = false) {
if (!HAS_JAPANESE$1.test(query)) return;
const normalizedReading = reading.replace(/\s+/g, " ").trim();
const navigation = trigger === "modal" ? "push-current" : "reset";
const done = log$1.time("dictionaryReferenceLookup", { query, hasReading: Boolean(normalizedReading), sourceDictionary, trigger });
try {
const localEntries = await this.dictionaryReferenceLocalEntries(query, normalizedReading, sourceDictionary);
const preferredEntry = localEntries.find((entry) => entry.dictionary === sourceDictionary) ?? localEntries[0];
const previousNavigationEntry = this.textLookupPreviousNavigationEntry(trigger, navigation);
if (preferredEntry) {
await this.showCard(this.parser.localCardFromEntry(preferredEntry), query, anchor, { autoPlay: false, trigger, navigation, preservePosition, previousNavigationEntry });
return;
}
await this.lookupText(query, query, { navigation, preservePosition, previousNavigationEntry });
} finally {
done();
}
}
async dictionaryReferenceLocalEntries(query, reading, sourceDictionary) {
if (!this.settings.localDictionariesEnabled) return [];
return await this.dictionaries.lookup(query, reading || query, this.settings.localDictionaryMaxResults, this.settings.dictionaryPreferences).catch((error) => {
log$1.warn("Dictionary reference local lookup failed", { query, reading, sourceDictionary }, error);
return [];
});
}
lookupCandidateFromPoint(x, y, eventTarget) {
const element2 = this.pointerLookupElement(x, y, eventTarget);
if (!element2) return null;
const position = this.usablePointerTextPosition(element2, x, y);
if (!position) return null;
const characterOffset = pointerTextCharacterOffset(position.node, position.offset, x, y);
if (characterOffset === null) return null;
return this.lookupCandidateFromTextPosition(position.node, characterOffset);
}
pointerLookupElement(x, y, eventTarget) {
const element2 = eventTarget instanceof Element ? eventTarget : document.elementFromPoint(x, y);
return element2 && !this.isNativeTextLookupTarget(element2) ? element2 : null;
}
usablePointerTextPosition(element2, x, y) {
const position = caretTextPositionFromPoint(x, y);
return this.isUsablePointerTextPosition(element2, position) ? position : null;
}
lookupCandidateFromTextPosition(node, characterOffset) {
const run = japaneseRunAt(node.data, characterOffset);
if (!run || isLowValuePointerText(node.data, node.parentElement)) return null;
return this.pointerTextLookupFromRun(node, run);
}
isUsablePointerTextPosition(element2, position) {
return Boolean(position && position.node.parentElement && (element2.contains(position.node) || position.node.parentElement.contains(element2)) && !position.node.parentElement.closest(".jpdb-reader-word"));
}
pointerTextLookupFromRun(node, run) {
return {
text: node.data,
offset: run.offset,
start: run.start,
end: run.end,
anchor: node.parentElement
};
}
isNativeTextLookupTarget(target) {
return Boolean(target.closest([
"a[href]",
"button",
"input",
"textarea",
"select",
"summary",
'[role="button"]',
'[contenteditable="true"]',
".jpdb-reader-word"
].join(",")));
}
async showLookupCandidate(candidate, trigger, options = {}) {
const sentence = lookupCandidateSentence(candidate.text);
if (!sentence) return;
const done = log$1.time("lookupTextAtPointer", { length: sentence.length, offset: candidate.offset, trigger });
try {
await this.showFirstPointerTextCandidate(candidate, sentence, trigger, options);
} catch {
} finally {
done();
}
}
async showFirstPointerTextCandidate(candidate, sentence, trigger, options) {
if (await this.showLocalPointerTextCandidate(candidate, sentence, trigger, options)) return;
if (await this.showParsedPointerTextCandidate(candidate, sentence, trigger, options)) return;
if (await this.showFallbackPointerTextCandidate(candidate, sentence, trigger, options)) return;
}
async showParsedPointerTextCandidate(candidate, sentence, trigger, options) {
const [tokens] = await this.parseJapanese([candidate.text], { jpdbTimeoutMs: 1200 });
const token = pointerTokenAtOffset(tokens ?? [], candidate.offset);
if (!token) return false;
await this.showPointerTextCard(token.card, token.sentence ?? sentence, candidate, { start: token.start, end: token.end }, trigger, options);
return true;
}
async showLocalPointerTextCandidate(candidate, sentence, trigger, options) {
const localMatch = await this.lookupLocalEntryAtOffset(candidate.text, candidate.offset);
if (!localMatch) return false;
const card = this.parser.localCardFromEntry(localMatch.entry);
await this.showPointerTextCard(card, sentence, candidate, localMatch, trigger, options);
return true;
}
async showFallbackPointerTextCandidate(candidate, sentence, trigger, options) {
const fallbackTerm = fallbackLookupTermAtOffset(candidate.text, candidate.offset);
if (!fallbackTerm) return false;
const card = this.parser.fallbackCardFromText(fallbackTerm);
await this.showPointerTextCard(card, sentence, candidate, candidate, trigger, options);
return true;
}
async showPointerTextCard(card, sentence, candidate, range, trigger, options) {
const pointerTextLookup = { anchor: candidate.anchor, text: candidate.text, start: range.start, end: range.end };
await this.showCard(card, sentence, candidate.anchor, {
trigger,
navigation: options.navigation ?? "reset",
preservePosition: options.preservePosition,
hoverLookupKey: trigger === "hover" ? this.activePointerTextLookupKey(candidate, range.start, range.end, card) : void 0,
hoverLookupGeneration: options.hoverLookupGeneration,
pointerTextLookup: trigger === "hover" ? pointerTextLookup : void 0
});
}
async lookupLocalEntryAtOffset(text2, offset) {
if (!this.settings.localDictionariesEnabled) return void 0;
const run = japaneseRunAt(text2, offset);
if (!run) return void 0;
return await this.lookupLocalEntryInRun(text2, run);
}
async lookupLocalEntryInRun(text2, run) {
return await this.lookupForwardLocalEntryInRun(text2, run) ?? await this.lookupBackwardLocalEntryInRun(text2, run);
}
async lookupForwardLocalEntryInRun(text2, run) {
const maxEnd = Math.min(run.end, run.offset + 18);
for (let end = maxEnd; end > run.offset; end--) {
const surface = text2.slice(run.offset, end);
const entry = await this.lookupSingleLocalSurface(surface);
if (entry) return { entry, start: run.offset, end };
}
return void 0;
}
async lookupBackwardLocalEntryInRun(text2, run) {
if (run.offset <= run.start) return void 0;
for (let start = run.offset - 1; start >= run.start; start--) {
const surface = text2.slice(start, run.offset + 1);
const entry = await this.lookupSingleLocalSurface(surface);
if (entry) return { entry, start, end: run.offset + 1 };
}
return void 0;
}
async lookupSingleLocalSurface(surface) {
return (await this.dictionaries.lookup(surface, surface, 1, this.settings.dictionaryPreferences).catch(() => []))[0];
}
async showWord(word, options = {}) {
const insideReaderPopup = Boolean(word.closest(".jpdb-reader-popover"));
const card = this.cardForRenderedWord(word);
if (!card) {
await this.handleMissingRenderedWordCard(word, options, insideReaderPopup);
return;
}
this.rememberRenderedWordMiningContext(word, card, insideReaderPopup);
const context = this.renderedWordDisplayContext(word, options, insideReaderPopup);
if (context.hoverLookupKey && this.isActiveHoverLookup(context.hoverLookupKey)) {
this.refreshActiveHoverAnchor(word);
return;
}
this.preloadHoverWordAudio(word);
await this.showCard(card, context.sentence, context.anchor, {
trigger: context.trigger,
navigation: context.navigation,
preservePosition: context.insideReaderPopup && context.trigger === "modal",
previousNavigationEntry: context.previousNavigationEntry,
hoverLookupKey: context.hoverLookupKey,
hoverLookupGeneration: options.hoverLookupGeneration,
insideReaderPopup: context.insideReaderPopup
});
}
cardForRenderedWord(word) {
const vid = Number(word.dataset.vid);
const sid = Number(word.dataset.sid);
return this.getCachedCard(vid, sid);
}
async handleMissingRenderedWordCard(word, options, insideReaderPopup) {
const vid = Number(word.dataset.vid);
const sid = Number(word.dataset.sid);
if (insideReaderPopup && await this.lookupUncachedPopupWord(word, options)) return;
if (!insideReaderPopup && await this.lookupUncachedPageWord(word, options)) {
this.scheduleVisiblePageReparse();
return;
}
log$1.warn("Clicked word missing from cache; scheduling page reparse", { vid, sid });
this.scheduleVisiblePageReparse();
}
async lookupUncachedPageWord(word, options) {
const expression = normalizedLookupText(readerWordSurfaceText(word));
if (!isLookupableJapaneseText(expression)) return false;
const trigger = this.renderedWordTrigger(options.trigger, false);
const navigation = options.navigation ?? renderedWordNavigationMode(false, trigger);
await this.lookupText(expression, this.renderedWordSentence(word) ?? expression, {
anchor: renderedWordAnchor(word, false, this.activePopoverAnchor),
navigation,
preservePosition: trigger === "hover",
previousNavigationEntry: this.renderedWordPreviousNavigationEntryForOptions(options, false, trigger, navigation)
});
return true;
}
async lookupUncachedPopupWord(word, options) {
const expression = normalizedLookupText(readerWordSurfaceText(word));
if (!isLookupableJapaneseText(expression)) return false;
const trigger = this.renderedWordTrigger(options.trigger, true);
const navigation = options.navigation ?? renderedWordNavigationMode(true, trigger);
await this.lookupText(expression, this.renderedWordSentence(word) ?? expression, {
anchor: renderedWordAnchor(word, true, this.activePopoverAnchor),
navigation,
preservePosition: true,
previousNavigationEntry: this.renderedWordPreviousNavigationEntryForOptions(options, true, trigger, navigation)
});
return true;
}
rememberRenderedWordMiningContext(word, card, insideReaderPopup) {
if (!insideReaderPopup || !word.closest(".jpdb-reader-example-card")) return;
this.immersionPopover.rememberPageMiningContext(card, word.dataset.sentence || void 0, word);
}
renderedWordDisplayContext(word, options, insideReaderPopup) {
const trigger = this.renderedWordTrigger(options.trigger, insideReaderPopup);
const navigation = options.navigation ?? renderedWordNavigationMode(insideReaderPopup, trigger);
return {
sentence: this.renderedWordSentence(word),
anchor: renderedWordAnchor(word, insideReaderPopup, this.activePopoverAnchor),
trigger,
navigation,
hoverLookupKey: this.renderedWordHoverLookupKey(word, trigger),
previousNavigationEntry: this.renderedWordPreviousNavigationEntryForOptions(options, insideReaderPopup, trigger, navigation),
insideReaderPopup
};
}
renderedWordSentence(word) {
return nearestReadableSentenceForElement(word, word.dataset.sentence || "") || void 0;
}
renderedWordHoverLookupKey(word, trigger) {
return trigger === "hover" ? this.hoverLookupKeyForWord(word) : void 0;
}
renderedWordPreviousNavigationEntryForOptions(options, insideReaderPopup, trigger, navigation) {
return options.previousNavigationEntry ?? this.renderedWordPreviousNavigationEntry(insideReaderPopup, trigger, navigation);
}
renderedWordTrigger(trigger, insideReaderPopup) {
if (insideReaderPopup && this.activePopoverMode) return this.activePopoverMode;
return trigger === "hover" ? "hover" : "modal";
}
renderedWordPreviousNavigationEntry(insideReaderPopup, trigger, navigation) {
return insideReaderPopup && trigger === "modal" && navigation === "push-current" ? this.navigation.activeKanjiEntry() : void 0;
}
showTokenList(tokens, selected, anchor, options = {}) {
if (!tokens.length) return;
const trigger = options.trigger === "hover" ? "hover" : "modal";
const navigation = options.navigation ?? "reset";
this.prepareTokenListNavigation(trigger, navigation);
const popover = this.createPopover();
setInnerHtml(popover, this.renderTokenListHtml(tokens, selected));
this.installTokenListHandlers(popover, tokens, anchor, { trigger, navigation, previousNavigationEntry: options.previousNavigationEntry });
this.mountPopover(popover, anchor, { mode: trigger, preservePosition: options.preservePosition });
void this.parsePopoverJapanese(popover);
}
prepareTokenListNavigation(trigger, navigation) {
if (trigger === "modal" && navigation === "reset") this.navigation.clearWord();
}
renderTokenListHtml(tokens, selected) {
return `
${escapeHtml$1(uiText(this.settings.interfaceLanguage, "selection"))}
${tokens.map((token) => this.renderTokenListButton(token)).join("")}
${escapeHtml$1(uiText(this.settings.interfaceLanguage, "parsedFrom"))}: ${escapeHtml$1(selected)}
`;
}
renderTokenListButton(token) {
return `
${escapeHtml$1(token.card.spelling)} ${this.renderTokenListReading(token)}
`;
}
renderTokenListReading(token) {
return token.card.reading !== token.card.spelling ? `${escapeHtml$1(token.card.reading)} ` : "";
}
installTokenListHandlers(popover, tokens, anchor, context) {
popover.addEventListener("click", (event) => {
const button2 = event.target.closest("button[data-vid]");
if (!button2) return;
this.showTokenListCard(button2, tokens, anchor, context);
});
}
showTokenListCard(button2, tokens, anchor, context) {
const card = this.getCachedCard(Number(button2.dataset.vid), Number(button2.dataset.sid));
if (!card) return;
void this.showCard(card, tokens.find((token) => token.card === card)?.sentence, anchor, {
trigger: context.trigger,
navigation: context.navigation,
preservePosition: true,
previousNavigationEntry: context.previousNavigationEntry
});
}
async showCard(card, sentence, anchor, options = {}) {
this.lastCard = card;
this.lastCardSentence = sentence;
const popover = this.createPopover();
const trigger = cardDisplayTrigger(options);
const navigation = options.navigation ?? "reset";
const hoverLookupGeneration = trigger === "hover" ? options.hoverLookupGeneration : void 0;
const hoverLookupKey = trigger === "hover" ? options.hoverLookupKey ?? "" : "";
const isCurrentHoverCard = () => hoverLookupGeneration === void 0 || this.hoverLookupGeneration === hoverLookupGeneration || this.isActiveHoverLookup(hoverLookupKey);
this.navigation.updateWord(card, sentence, trigger, navigation, options.previousNavigationEntry);
this.navigation.clearKanji();
const done = log$1.time("showCard", { term: card.spelling, source: cardSourceLabel(card), trigger });
this.rememberCardMiningContext(card, sentence, anchor, options);
const fallbackAnkiLookup = { state: "not-in-deck", notes: [], primary: null };
this.lastAnkiLookup = fallbackAnkiLookup;
this.maybePreloadLookupCardAudio(card, options);
const renderData = this.cardRenderData.load(card);
const requestId = ++this.cardRenderRequest;
const mounted = await this.mountInitialCardShell(popover, card, sentence, anchor, {
trigger,
navigation,
options,
renderData,
fallbackAnkiLookup,
requestId,
isCurrentHoverCard,
hoverLookupGeneration
});
if (!mounted) {
done();
return;
}
const renderState = { fullRenderCompleted: false };
this.renderDeferredCardLocalEntries(popover, card, sentence, trigger, renderData, fallbackAnkiLookup, mounted, renderState, isCurrentHoverCard);
const fullData = await renderData.all;
renderState.fullRenderCompleted = true;
if (!this.isCurrentCardRender(popover, mounted.requestId, isCurrentHoverCard)) {
done();
return;
}
this.renderCompletedCardPopover(popover, card, sentence, trigger, fullData);
done();
}
rememberCardMiningContext(card, sentence, anchor, options) {
const hasNestedImmersionContext = options.insideReaderPopup && Boolean(this.immersionPopover.activeContextFor(card));
if (hasNestedImmersionContext || this.immersionPopover.hasActiveContext(card, sentence)) return;
this.immersionPopover.rememberPageMiningContext(card, sentence, anchor);
}
async mountInitialCardShell(popover, card, sentence, anchor, context) {
if (!context.isCurrentHoverCard()) {
return null;
}
setInnerHtml(popover, this.cardPopoverRenderer.render(
card,
sentence,
context.trigger,
loadingCardRenderData([], context.fallbackAnkiLookup)
));
this.installCardPopoverHandlers(popover, card, sentence, anchor, context.trigger);
this.mountPopover(popover, anchor, {
mode: context.trigger,
preservePosition: this.initialCardPreservePosition(context),
hoverLookupKey: context.options.hoverLookupKey,
pointerTextLookup: context.options.pointerTextLookup
});
this.installInitialCardBehaviors(popover, card, sentence, context, null);
return { instantLocalEntries: null, requestId: context.requestId };
}
initialCardPreservePosition(context) {
return context.options.preservePosition ?? (context.trigger === "modal" && context.navigation !== "reset");
}
installInitialCardBehaviors(popover, card, sentence, context, instantLocalEntries) {
this.maybeAutoPlayInitialCard(card, context);
this.maybeParseInstantLocalEntries(popover, instantLocalEntries);
this.studySources.installLoaders(popover, sentence);
this.maybeLoadImmersionExamples(popover, card);
}
maybeAutoPlayInitialCard(card, context) {
if (!this.shouldAutoPlayInitialCard(card, context)) return;
void this.audioActions.playTermAudio(card, { hoverLookupGeneration: context.hoverLookupGeneration });
}
maybePreloadLookupCardAudio(card, options) {
if (options.autoPlay === false || !this.canPreloadReaderAudio()) return;
this.audio.preload(card, { sourceLimit: 3, candidateLimit: 1 });
}
shouldAutoPlayInitialCard(card, context) {
return context.options.autoPlay !== false && this.isCurrentCardForAutoPlay(context) && this.shouldAutoPlay(card, context.trigger);
}
isCurrentCardForAutoPlay(context) {
return context.trigger === "modal" || context.isCurrentHoverCard();
}
maybeParseInstantLocalEntries(popover, instantLocalEntries) {
if (instantLocalEntries?.length) void this.parsePopoverJapanese(popover);
}
maybeLoadImmersionExamples(popover, card) {
if (this.settings.immersionKitEnabled) void this.immersionPopover.loadExamples(popover, card);
}
renderDeferredCardLocalEntries(popover, card, sentence, trigger, renderData, fallbackAnkiLookup, mounted, renderState, isCurrentHoverCard) {
if (mounted.instantLocalEntries !== null) return;
void renderData.localEntries.then((localEntries) => {
if (renderState.fullRenderCompleted || !this.isCurrentCardRender(popover, mounted.requestId, isCurrentHoverCard)) return;
clearNestedParseState(popover);
setInnerHtml(popover, this.cardPopoverRenderer.render(card, sentence, trigger, loadingCardRenderData(localEntries, this.lastAnkiLookup ?? fallbackAnkiLookup)));
this.repositionActivePopover();
void this.parsePopoverJapanese(popover);
});
}
renderCompletedCardPopover(popover, card, sentence, trigger, data) {
this.lastAnkiLookup = data.ankiLookup;
this.applyAnkiLookupToRenderedWords(card, data.ankiLookup);
clearNestedParseState(popover);
setInnerHtml(popover, this.cardPopoverRenderer.render(card, sentence, trigger, { ...data, loading: false }));
this.repositionActivePopover();
void this.parsePopoverJapanese(popover);
if (this.settings.immersionKitEnabled) {
const immersionExamples = this.immersionPopover.searchExamples(card, {
relatedQueries: this.immersionRelatedQueries(data.jpdbVocabularyInfo)
});
void this.immersionPopover.loadExamples(popover, card, immersionExamples);
}
this.studySources.installLoaders(popover, sentence);
}
isCurrentCardRender(popover, _requestId, isCurrentHoverCard) {
return isCurrentHoverCard() && popover.isConnected && this.activePopover === popover;
}
immersionRelatedQueries(info) {
if (!info) return [];
return info.compounds.flatMap((compound) => [compound.term, compound.reading]).filter(Boolean);
}
installCardPopoverHandlers(popover, card, sentence, anchor, trigger) {
popover.addEventListener("click", (event) => {
if (this.handleDictionaryLookupLink(event, anchor, trigger)) return;
const kanjiButton = event.target.closest('[data-action="kanji"]');
if (kanjiButton) {
event.preventDefault();
event.stopPropagation();
void this.showKanjiCard(card, kanjiButton.dataset.kanji ?? "", sentence, anchor, { preservePosition: true });
return;
}
const button2 = event.target.closest("[data-action]");
if (!button2) return;
event.preventDefault();
event.stopPropagation();
if (button2.dataset.action === "word-history-back") {
void this.showPreviousWord(anchor, trigger);
return;
}
if (button2.dataset.action === "mining-collapse") {
this.toggleMiningControls(button2);
return;
}
if (button2.dataset.action === "deck-picker") {
if (this.openDeckPickerForAdd(button2, card, sentence)) return;
}
if (button2.dataset.action === "add" && this.openDeckPickerForAdd(button2, card, sentence)) return;
void this.handleCardAction(button2, card, sentence);
});
}
toggleMiningControls(button2) {
const actions = button2.closest(".jpdb-reader-actions");
if (!actions) return;
this.setMiningControlsExpanded(button2, actions.classList.contains("jpdb-reader-actions-mining-collapsed"));
}
setMiningControlsExpanded(button2, expanded) {
const actions = button2.closest(".jpdb-reader-actions");
if (!actions) return;
actions.classList.toggle("jpdb-reader-actions-mining-collapsed", !expanded);
button2.setAttribute("aria-expanded", String(expanded));
const label = uiText(this.settings.interfaceLanguage, expanded ? "hideMiningActions" : "showMiningActions");
button2.setAttribute("aria-label", label);
button2.title = label;
}
openDeckPickerForAdd(button2, card, sentence) {
const picker = button2.closest(".jpdb-reader-mining-details")?.querySelector("[data-add-deck-select]");
if (!picker) return false;
const wrapper = picker.closest(".jpdb-reader-mining-details");
const toggle = wrapper?.querySelector(".jpdb-reader-mining-title");
if (picker.classList.contains("jpdb-reader-add-deck-select-open")) {
picker.focus();
return true;
}
const controller = new AbortController();
const cleanup = () => {
controller.abort();
picker.classList.remove("jpdb-reader-add-deck-select-open");
wrapper?.classList.remove("jpdb-reader-deck-picker-open");
toggle?.setAttribute("aria-expanded", "false");
picker.selectedIndex = 0;
};
picker.addEventListener("change", () => {
const option = picker.selectedOptions[0];
const deckId = option?.dataset.deckId?.trim();
if (!deckId) {
cleanup();
return;
}
button2.dataset.deckSource = option.dataset.deckSource === "anki" ? "anki" : "jpdb";
button2.dataset.deckId = deckId;
const originalAction = button2.dataset.action;
button2.dataset.action = "add";
cleanup();
void this.handleCardAction(button2, card, sentence).finally(() => {
if (originalAction) button2.dataset.action = originalAction;
delete button2.dataset.deckSource;
delete button2.dataset.deckId;
});
}, { signal: controller.signal });
picker.addEventListener("blur", () => {
window.setTimeout(() => {
if (document.activeElement !== picker) cleanup();
}, 180);
}, { once: true, signal: controller.signal });
picker.classList.add("jpdb-reader-add-deck-select-open");
wrapper?.classList.add("jpdb-reader-deck-picker-open");
toggle?.setAttribute("aria-expanded", "true");
picker.focus();
const showPicker = picker.showPicker;
if (showPicker) {
try {
showPicker.call(picker);
} catch {
}
}
return true;
}
async showPreviousWord(anchor, trigger = "modal") {
const previous = this.navigation.popPreviousWord();
if (!previous) return;
if (previous.kind === "kanji") {
await this.showKanjiCard(previous.card, previous.kanji, previous.sentence, anchor, {
navigation: "preserve",
preservePosition: true
});
return;
}
await this.showCard(previous.card, previous.sentence, anchor, {
autoPlay: false,
trigger,
navigation: "preserve",
preservePosition: true
});
}
async showPreviousKanji(anchor) {
const previous = this.navigation.popPreviousKanji();
if (!previous) return;
await this.showKanjiCard(previous.card, previous.kanji, previous.sentence, anchor, {
navigation: "preserve",
preservePosition: true
});
}
shouldAutoPlay(card, trigger) {
if (!this.settings.autoPlayAudio) return false;
if (!this.shouldAutoPlayForTrigger(trigger)) return false;
const key = `${card.vid}:${card.sid}`;
const now = Date.now();
if (key === this.lastAutoAudioKey && now - this.lastAutoAudioAt < 2500) return false;
this.lastAutoAudioKey = key;
this.lastAutoAudioAt = now;
return true;
}
shouldAutoPlayForTrigger(trigger) {
const mode = this.settings.audioAutoPlayMode;
if (mode === "all") return true;
return mode === "hover" ? trigger === "hover" : trigger === "modal";
}
async showKanjiCard(card, kanji, sentence, anchor, options = {}) {
if (!isKanjiCharacter$1(kanji)) return;
const navigation = options.navigation ?? "reset";
this.navigation.updateKanji(card, kanji, sentence, navigation);
const popover = this.createPopover();
const language = this.settings.interfaceLanguage;
const kanjiCharacters = uniqueKanji(card.spelling);
const jpdbUrl = `https://jpdb.io/kanji/${encodeURIComponent(kanji)}`;
const detailsPromises = this.kanjiDetailPromises(kanji);
this.renderKanjiCardShell(popover, card, kanji, kanjiCharacters, jpdbUrl, language);
this.installKanjiCardActions(popover, card, kanji, sentence, anchor);
this.mountPopover(popover, anchor, { preservePosition: options.preservePosition });
this.startKanjiProgressiveRender(popover, detailsPromises, card, kanji, language);
}
kanjiDetailPromises(kanji) {
const needsKanjiVG = this.settings.kanjivgEnabled || this.settings.kanjiOriginsEnabled && this.settings.kanjiOriginGraphEnabled;
return {
jpdbInfo: this.jpdbKanjiDetailPromise(kanji),
kanjiEntries: this.localKanjiEntriesPromise(kanji),
rtkInfo: this.rtkDetailPromise(kanji),
kanjiVGInfo: needsKanjiVG ? this.kanjiVG.lookup(kanji).catch(() => null) : Promise.resolve(null)
};
}
jpdbKanjiDetailPromise(kanji) {
return this.settings.jpdbKanjiEnabled ? this.jpdbKanji.lookup(kanji).catch(() => null) : Promise.resolve(null);
}
localKanjiEntriesPromise(kanji) {
return this.settings.localDictionariesEnabled && this.settings.localDictionaryShowKanji ? this.dictionaries.lookupKanji(kanji, this.settings.localDictionaryMaxResults, this.settings.dictionaryPreferences).catch(() => []) : Promise.resolve([]);
}
rtkDetailPromise(kanji) {
return this.settings.rtkEnabled ? this.rtk.lookup(kanji).catch(() => null) : Promise.resolve(null);
}
renderKanjiCardShell(popover, card, kanji, kanjiCharacters, jpdbUrl, language) {
setInnerHtml(popover, `
${renderModalNavigation({
...this.navigation.kanjiModalBack(card, language),
controlsHtml: this.renderKanjiNavigationControls(kanjiCharacters, kanji, language)
})}
${this.renderKanjiSourceMounts(kanji, language)}
${this.renderKanjiActionBar(card)}
`);
}
renderKanjiNavigationControls(kanjiCharacters, kanji, language) {
if (kanjiCharacters.length <= 1) return "";
const index = Math.max(0, kanjiCharacters.indexOf(kanji));
const previous = kanjiCharacters[(index - 1 + kanjiCharacters.length) % kanjiCharacters.length];
const next = kanjiCharacters[(index + 1) % kanjiCharacters.length];
return `
‹
›
`;
}
installKanjiCardActions(popover, card, kanji, sentence, anchor) {
installMiningDrawerHandle(popover, (button2, expanded) => this.setMiningControlsExpanded(button2, expanded));
popover.addEventListener("click", (event) => {
if (this.handleDictionaryLookupLink(event, anchor, "modal")) return;
const actionButton = event.target.closest("[data-action]");
const action = actionButton?.dataset.action;
if (!action) return;
event.preventDefault();
event.stopPropagation();
if (action === "copy-word") {
void copyText(kanji).then(() => this.toast(uiText(this.settings.interfaceLanguage, "copiedWord")));
return;
}
if (action === "jpdb-kanji-action") {
const actionId = actionButton.dataset.kanjiActionId ?? "";
void this.performJpdbKanjiAction(actionId, card, kanji, sentence, anchor);
return;
}
if (action === "mining-collapse") {
this.toggleMiningControls(actionButton);
return;
}
if (action === "grade") {
void this.handleCardAction(actionButton, card, sentence);
return;
}
if (action === "word-back") void this.showCard(card, sentence, anchor, { autoPlay: false, navigation: "preserve", preservePosition: true });
if (action === "kanji-history-back") void this.showPreviousKanji(anchor);
if (action === "kanji-prev" || action === "kanji-next") void this.showKanjiCard(card, actionButton.dataset.kanji ?? kanji, sentence, anchor, { navigation: "push-current", preservePosition: true });
if (action === "kanji") void this.showKanjiCard(card, actionButton.dataset.kanji ?? kanji, sentence, anchor, { navigation: "push-current", preservePosition: true });
if (action === "similar-word") void this.lookupText(actionButton.dataset.expression ?? "", actionButton.dataset.expression ?? "", { navigation: "push-current", preservePosition: true });
});
}
startKanjiProgressiveRender(popover, detailsPromises, card, kanji, language) {
if (this.settings.similarKanjiWords) {
void this.renderSimilarKanjiWordsProgressively(popover, detailsPromises.jpdbInfo, kanji, card);
}
if (this.settings.uchisenEnabled) {
void this.renderUchisenInto(popover, kanji);
}
void this.renderKanjiDetailsInto(popover, detailsPromises, kanji, language);
if (this.settings.kanjivgEnabled) {
void this.renderKanjiVGInto(popover, detailsPromises.kanjiVGInfo, kanji, language);
}
}
async performJpdbKanjiAction(actionId, card, kanji, sentence, anchor) {
if (!actionId) return;
try {
await this.jpdbKanji.performAction(actionId);
this.toast(uiText(this.settings.interfaceLanguage, "jpdbKanjiUpdated"));
await this.showKanjiCard(card, kanji, sentence, anchor, { preservePosition: true });
} catch (error) {
log$1.warn("JPDB kanji action failed", { kanji }, error);
this.toast(uiText(this.settings.interfaceLanguage, "jpdbKanjiUpdateFailedRuntime"));
}
}
renderKanjiSourceMounts(kanji, language) {
const mounts = [];
for (const sourceId of orderedKanjiSourceIds(this.settings)) {
const mount = this.renderKanjiSourceMount(sourceId, kanji, language);
if (mount) mounts.push(mount);
}
return mounts.join("");
}
renderKanjiSourceMount(sourceId, kanji, language) {
if (sourceId === KANJI_STROKE_SOURCE_ID) {
const sourceStateKey = kanjiSourceStateKey(KANJI_STROKE_SOURCE_ID);
return renderKanjiPractice(null, kanji, language, this.dictionarySourceState.isOpen(sourceStateKey), sourceStateKey, this.kanjiSourceTitle(sourceId));
}
if (sourceId === KANJI_JPDB_SOURCE_ID) return "
";
if (sourceId === KANJI_RTK_SOURCE_ID) return "
";
if (sourceId === KANJI_UCHISEN_SOURCE_ID) return "
";
if (sourceId === KANJI_DICTIONARIES_SOURCE_ID) return "
";
const dictionaryName = kanjiDictionaryNameFromSourceId(sourceId);
if (dictionaryName) return `
`;
if (sourceId === KANJI_SIMILAR_WORDS_SOURCE_ID) {
const sourceStateKey = kanjiSourceStateKey(KANJI_SIMILAR_WORDS_SOURCE_ID);
return renderSimilarKanjiWordsShell(
kanji,
language,
sourceStateKey,
this.dictionarySourceState.isOpen(sourceStateKey),
(key, initiallyExpanded) => this.dictionarySourceState.attributes(key, initiallyExpanded),
this.kanjiSourceTitle(sourceId)
);
}
if (sourceId === KANJI_ORIGINS_SOURCE_ID) return "
";
return "";
}
renderKanjiActionBar(card) {
const reviewButtons = this.renderKanjiReviewButtons(card);
return `
`;
}
renderKanjiReviewButtons(card) {
if (!this.settings.enableReviews) return "";
const states = normalizeCardStates(card.cardState);
if (states.includes("blacklisted") || states.includes("never-forget")) return "";
const ankiLookup = this.lastCard && cardKey$1(this.lastCard) === cardKey$1(card) ? this.lastAnkiLookup : void 0;
const ankiNote = ankiLookup?.primary ?? null;
const canReviewWithAnki = Boolean(ankiNote?.primaryCardId);
const canReviewWithJpdb = this.isJpdbBackedCard(card) && Boolean(this.settings.apiKey.trim()) && this.settings.jpdbMiningEnabled;
if (!canReviewWithAnki && !canReviewWithJpdb) return "";
return renderReviewButtons(this.settings, ankiNote);
}
updateKanjiMiningControls(popover, controls) {
const actions = popover.querySelector("[data-kanji-actions]");
const miningMount = popover.querySelector("[data-kanji-mining-mount]");
if (!actions || !miningMount) return;
const hasControls = Boolean(controls);
const hasReview = actions.dataset.kanjiHasReview === "true";
actions.hidden = !hasControls && !hasReview;
actions.classList.toggle("jpdb-reader-actions-has-mining", hasControls);
actions.classList.toggle("jpdb-reader-actions-mining-collapsed", hasControls);
const gutter = actions.querySelector(".jpdb-reader-actions-gutter");
if (gutter) gutter.hidden = !hasControls;
const collapseButton = actions.querySelector('[data-action="mining-collapse"]');
if (collapseButton && hasControls) this.setMiningControlsExpanded(collapseButton, false);
miningMount.hidden = !hasControls;
setInnerHtml(miningMount, controls);
}
async renderKanjiDetailsInto(popover, detailsPromises, kanji, language) {
let jpdbInfo = null;
let kanjiEntries = [];
let rtkInfo = null;
let kanjiVGInfo = null;
const practiceDoodle = installKanjiPracticeDoodle(popover, () => this.settings.interfaceLanguage, () => kanjiVGInfo);
const keywordMount = popover.querySelector("[data-kanji-keyword-mount]");
const miningMount = popover.querySelector("[data-kanji-mining-mount]");
const jpdbMount = popover.querySelector("[data-kanji-jpdb-mount]");
const rtkMount = popover.querySelector("[data-kanji-rtk-mount]");
const definitionsMounts = Array.from(popover.querySelectorAll("[data-kanji-definitions-mount]"));
const renderKeyword = () => {
if (!popover.isConnected || !keywordMount?.isConnected) return;
setInnerHtml(keywordMount, renderKanjiKeywordLine(jpdbInfo, rtkInfo, kanjiEntries, language));
this.repositionActivePopover();
};
const renderRtk = () => {
if (!popover.isConnected || !rtkMount?.isConnected) return;
const componentSummaries = buildRtkComponentSummaries(rtkInfo, jpdbInfo, kanjiEntries);
const sourceStateKey = kanjiSourceStateKey(KANJI_RTK_SOURCE_ID);
setInnerHtml(rtkMount, renderRtkInfo(rtkInfo, componentSummaries, language, this.dictionarySourceState.isOpen(sourceStateKey), sourceStateKey));
this.repositionActivePopover();
};
const jpdbInfoPromise = detailsPromises.jpdbInfo.then((info) => {
jpdbInfo = info;
if (!popover.isConnected) return;
renderKeyword();
if (miningMount?.isConnected) this.updateKanjiMiningControls(popover, renderJpdbKanjiMiningControls(jpdbInfo, language));
if (jpdbMount?.isConnected) {
const sourceStateKey = kanjiSourceStateKey(KANJI_JPDB_SOURCE_ID);
setInnerHtml(jpdbMount, renderJpdbKanjiInfo(jpdbInfo, language, this.dictionarySourceState.isOpen(sourceStateKey), sourceStateKey, this.kanjiSourceTitle(KANJI_JPDB_SOURCE_ID)));
}
renderRtk();
});
const kanjiEntriesPromise = detailsPromises.kanjiEntries.then((entries) => {
kanjiEntries = entries;
if (!popover.isConnected) return;
renderKeyword();
for (const definitionsMount of definitionsMounts.filter((mount) => mount.isConnected)) {
const dictionaryName = definitionsMount.dataset.kanjiDictionary;
const sourceId = definitionsMount.dataset.kanjiSourceId ?? KANJI_DICTIONARIES_SOURCE_ID;
const visibleEntries = dictionaryName ? kanjiEntries.filter((entry) => entry.dictionary === dictionaryName) : kanjiEntries;
setInnerHtml(definitionsMount, renderKanjiDefinitions(
visibleEntries,
(key, initiallyExpanded) => this.dictionarySourceState.attributes(key, initiallyExpanded),
(name) => this.dictionaryLabel(name),
sourceId,
dictionaryName ? this.dictionaryLabel(dictionaryName) : this.kanjiSourceTitle(KANJI_DICTIONARIES_SOURCE_ID),
language
));
}
renderRtk();
});
const rtkInfoPromise = detailsPromises.rtkInfo.then((info) => {
rtkInfo = info;
if (!popover.isConnected) return;
renderKeyword();
renderRtk();
});
const kanjiVGInfoPromise = detailsPromises.kanjiVGInfo.then((info) => {
kanjiVGInfo = info;
if (!popover.isConnected) return;
practiceDoodle.reassess();
});
await Promise.all([jpdbInfoPromise, kanjiEntriesPromise, rtkInfoPromise, kanjiVGInfoPromise]);
if (!popover.isConnected) return;
const resolvedJpdbInfo = jpdbInfo;
const resolvedRtkInfo = rtkInfo;
const resolvedKanjiVGInfo = kanjiVGInfo;
if (this.settings.kanjiOriginsEnabled) {
void this.renderKanjiOriginsInto(popover, kanji, resolvedJpdbInfo, resolvedRtkInfo, resolvedKanjiVGInfo, kanjiEntries);
}
void this.parsePopoverJapanese(popover);
this.repositionActivePopover();
}
async renderUchisenInto(popover, kanji) {
const mount = popover.querySelector("[data-kanji-uchisen-mount]");
if (!mount) return;
const sourceStateKey = kanjiSourceStateKey(KANJI_UCHISEN_SOURCE_ID);
const sourceAttributes = () => this.dictionarySourceState.attributes(sourceStateKey, this.dictionarySourceState.isOpen(sourceStateKey));
setInnerHtml(mount, `
Uchisen
${escapeHtml$1(uiText(this.settings.interfaceLanguage, "loadingMnemonicImages"))}
`);
const data = await loadUchisenData(kanji).catch(() => {
return { images: [], componentGroups: [], kanjiKeyword: null };
});
if (!popover.isConnected || !mount.isConnected) return;
if (!data.images.length) {
mount.remove();
return;
}
await installUchisenCarousel(mount, kanji, data.images, {
sourceAttributes: sourceAttributes(),
detailsClass: "jpdb-reader-local jpdb-reader-source-card yomu-jpdb-uchisen-source",
summaryClass: "jpdb-reader-local-title",
bodyClass: "jpdb-reader-local-entry yomu-jpdb-uchisen-body",
componentGroups: data.componentGroups,
kanjiKeyword: data.kanjiKeyword
});
this.repositionActivePopover();
}
async lookupSimilarKanjiWordsWhenIdle(kanji) {
await this.waitForIdle();
return this.dictionaries.lookupSimilarTermsByKanji(kanji, this.settings.similarKanjiWordLimit, this.settings.dictionaryPreferences);
}
waitForIdle(timeoutMs = 75) {
return new Promise((resolve) => {
if ("requestIdleCallback" in window) {
window.requestIdleCallback(() => resolve(), { timeout: timeoutMs });
return;
}
setTimeout(resolve, 0);
});
}
renderSimilarKanjiWordsProgressively(popover, jpdbInfoPromise, kanji, card) {
const section = this.ensureSimilarKanjiWordsSection(popover, kanji);
const mount = section?.querySelector("[data-kanji-similar-mount]");
if (!section || !mount) return;
let started = false;
let jpdbLoaded = false;
let localLoaded = !this.settings.localDictionariesEnabled;
let jpdbVocabulary = [];
let localEntries = [];
const render = () => {
if (!popover.isConnected || !section.isConnected || !mount.isConnected) return;
const content = renderSimilarKanjiWordsContent(localEntries, jpdbVocabulary, card, this.settings, (name) => this.dictionaryLabel(name));
setInnerHtml(mount, content || `${uiText(this.settings.interfaceLanguage, jpdbLoaded && localLoaded ? "noSimilarWords" : "loadingSimilarWords")}
`);
this.repositionActivePopover();
};
const load = () => {
if (!section.open || started) return;
started = true;
render();
const jpdbVocabularyPromise = jpdbInfoPromise.then((info) => {
jpdbVocabulary = info?.vocabulary ?? [];
jpdbLoaded = true;
render();
}).catch(() => {
jpdbLoaded = true;
render();
});
const localEntriesPromise = this.settings.localDictionariesEnabled ? this.lookupSimilarKanjiWordsWhenIdle(kanji).then((entries) => {
localEntries = entries;
localLoaded = true;
render();
}).catch(() => {
localLoaded = true;
render();
}) : Promise.resolve();
void Promise.all([jpdbVocabularyPromise, localEntriesPromise]).then(() => {
});
};
section.addEventListener("toggle", load);
load();
}
ensureSimilarKanjiWordsSection(popover, kanji) {
const existing = popover.querySelector("[data-kanji-similar-words]");
if (existing) return existing;
const stack = popover.querySelector(".jpdb-reader-kanji-section-stack");
if (!stack) return null;
const sourceStateKey = kanjiSourceStateKey(KANJI_SIMILAR_WORDS_SOURCE_ID);
stack.insertAdjacentHTML("beforeend", renderSimilarKanjiWordsShell(
kanji,
this.settings.interfaceLanguage,
sourceStateKey,
this.dictionarySourceState.isOpen(sourceStateKey),
(key, initiallyExpanded) => this.dictionarySourceState.attributes(key, initiallyExpanded),
this.kanjiSourceTitle(KANJI_SIMILAR_WORDS_SOURCE_ID)
));
this.dictionarySourceState.installTracking(popover);
return stack.querySelector("[data-kanji-similar-words]");
}
async renderKanjiVGInto(popover, kanjiVGPromise, kanji, language) {
const info = await kanjiVGPromise;
if (!info || !popover.isConnected) return;
const elements = this.kanjiVGStageElements(popover, kanji);
if (!elements) return;
const { stage, ghost, help } = elements;
setInnerHtml(ghost, info.svg);
help.textContent = `${info.strokeCount} ${uiText(language, "strokes")}`;
stage.classList.remove("trace-hidden");
const trace = stage.closest(".jpdb-reader-kanjivg")?.querySelector("[data-doodle-trace]");
if (trace) trace.textContent = uiText(language, "hideTrace");
}
kanjiVGStageElements(popover, kanji) {
const stage = Array.from(popover.querySelectorAll(".jpdb-reader-doodle-stage")).find((candidate) => candidate.dataset.kanji === kanji);
if (!stage) return null;
const ghost = stage.querySelector(".jpdb-reader-doodle-ghost");
if (!ghost) return null;
const help = stage.closest(".jpdb-reader-kanjivg")?.querySelector(".jpdb-reader-help");
if (!help) return null;
return { stage, ghost, help };
}
async renderKanjiOriginsInto(popover, kanji, jpdbInfo, rtkInfo, kanjiVGInfo, kanjiEntries) {
const mount = popover.querySelector("[data-kanji-origin-mount]");
if (!mount) return;
const sourceInfo = await this.lookupKanjiOriginSourceInfo(kanji);
if (!this.canRenderKanjiOriginMount(popover, mount)) return;
this.renderKanjiOriginMount(mount, kanji, jpdbInfo, rtkInfo, kanjiVGInfo, kanjiEntries, sourceInfo);
this.installKanjiOriginImageFallbacks(mount);
}
async lookupKanjiOriginSourceInfo(kanji) {
return await this.kanjiOrigin.lookup(kanji, this.settings).catch((error) => {
log$1.warn("Kanji origin lookup failed", { kanji }, error);
return null;
});
}
canRenderKanjiOriginMount(popover, mount) {
return popover.isConnected && mount.isConnected;
}
renderKanjiOriginMount(mount, kanji, jpdbInfo, rtkInfo, kanjiVGInfo, kanjiEntries, sourceInfo) {
const facts = buildKanjiFacts(kanji, jpdbInfo, rtkInfo, this.settings.kanjivgEnabled ? kanjiVGInfo : null, kanjiEntries, sourceInfo);
const sourceStateKey = kanjiSourceStateKey(KANJI_ORIGINS_SOURCE_ID);
setInnerHtml(mount, renderKanjiOrigins(
facts,
this.kanjiOriginGraph(kanji, jpdbInfo, rtkInfo, kanjiVGInfo, kanjiEntries, sourceInfo),
sourceInfo,
this.settings,
this.settings.interfaceLanguage,
this.dictionarySourceState.isOpen(sourceStateKey),
sourceStateKey,
jpdbInfo ? new Set([
jpdbInfo.type ? "Type" : null,
jpdbInfo.frequency ? "Frequency" : null
].filter(Boolean)) : void 0,
this.kanjiSourceTitle(KANJI_ORIGINS_SOURCE_ID)
));
installOriginGraphInteractions(mount);
}
kanjiOriginGraph(kanji, jpdbInfo, rtkInfo, kanjiVGInfo, kanjiEntries, sourceInfo) {
return this.settings.kanjiOriginGraphEnabled ? buildKanjiOriginGraph(kanji, jpdbInfo, rtkInfo, kanjiEntries, sourceInfo, kanjiVGInfo) : null;
}
installKanjiOriginImageFallbacks(mount) {
mount.querySelectorAll("[data-radical-frame]").forEach((image) => {
image.addEventListener("error", () => image.remove(), { once: true });
});
}
renderDefinitionSources(card, entries, sentence, jpdbVocabularyInfo = null) {
const grouped = groupTermEntriesByDictionary(entries);
const setup = this.renderFallbackSetupSource(card);
const sourceIds = orderedDefinitionSourceIds(this.settings, [...grouped.keys()]);
const dictionarySourceIds = sourceIds.filter((sourceId) => grouped.has(sourceId));
let renderedDictionaries = false;
const sections = [
setup,
...sourceIds.map((sourceId) => {
if (sourceId === JPDB_DEFINITION_SOURCE_ID) return renderJpdbDefinitionSource(card, (key, initiallyExpanded) => this.dictionarySourceState.attributes(key, initiallyExpanded), jpdbVocabularyInfo, this.settings.interfaceLanguage);
if (sourceId === STUDY_TRANSLATION_SOURCE_ID) return this.studySources.renderTranslationSource(sentence);
if (sourceId === STUDY_GRAMMAR_SOURCE_ID) return this.studySources.renderGrammarSource(sentence);
if (sourceId === IMMERSION_KIT_SOURCE_ID) return this.renderImmersionKitMount();
if (grouped.has(sourceId)) {
if (renderedDictionaries) return "";
renderedDictionaries = true;
return renderLocalDefinitionSourcesSection(
dictionarySourceIds,
grouped,
this.settings,
(key, initiallyExpanded) => this.dictionarySourceState.attributes(key, initiallyExpanded),
(name) => this.dictionaryLabel(name),
card
);
}
return "";
})
].filter(Boolean);
return sections.length ? `${sections.join("")}
` : `${uiText(this.settings.interfaceLanguage, "noDefinitions")}
`;
}
renderFallbackSetupSource(card) {
return "";
}
renderImmersionKitMount() {
if (!this.settings.immersionKitEnabled) return "";
return `
${uiText(this.settings.interfaceLanguage, "immersionKit")}
${uiText(this.settings.interfaceLanguage, "loadingExamples")}
`;
}
async parsePopoverJapanese(popover) {
if (!this.isCurrentPopoverRoot(popover)) return;
const plan = nestedTextParsePlan(popover, 120);
if (!plan || nestedParseAlreadyScheduled(popover, plan.parseKey)) return;
await this.parseNestedJapaneseContent(popover, plan, () => this.isCurrentPopoverRoot(popover));
}
async parseNestedJapaneseContent(root, plan, isCurrent) {
root.dataset.jpdbReaderParseLoadingKey = plan.parseKey;
try {
const parsed = await this.parseJapanese(plan.targets.map((target) => target.text), { jpdbTimeoutMs: 1200 });
if (!isCurrent() || root.dataset.jpdbReaderParseLoadingKey !== plan.parseKey) return;
applyNestedParsePlan(plan, parsed, this.settings);
root.dataset.jpdbReaderParseKey = plan.parseKey;
this.afterNestedJapaneseParsed(parsed);
} catch {
} finally {
clearNestedParseLoadingKey(root, plan.parseKey);
}
}
afterNestedJapaneseParsed(parsed) {
const tokens = parsed.flat();
this.preloadTermAudioForTokens(tokens);
void this.enrichAnkiWords(tokens);
}
isCurrentPopoverRoot(root) {
return Boolean(root.isConnected && this.activePopover && (root === this.activePopover || this.activePopover.contains(root)));
}
async enrichAnkiWords(tokens) {
if (!this.settings.ankiEnabled) return;
const seen = /* @__PURE__ */ new Set();
const uniqueTokens = tokens.filter((token) => {
const key = cardKey$1(token.card);
if (seen.has(key)) return false;
seen.add(key);
return true;
}).slice(0, 16);
for (const token of uniqueTokens) {
const lookup = await this.anki.findExistingCards(token.card);
this.applyAnkiLookupToRenderedWords(token.card, lookup);
}
}
preloadTermAudioForTokens(tokens) {
if (!this.canPreloadReaderAudio()) return;
this.queueTermAudioPreloads(tokens);
}
queueTermAudioPreloads(tokens) {
let queued = 0;
for (const token of tokens) {
if (this.preloadTermAudioForToken(token)) queued++;
if (queued >= TERM_AUDIO_PRELOAD_LIMIT) break;
}
return queued;
}
preloadTermAudioForToken(token) {
if (!isUsefulImmersionPreloadQuery(token.card.spelling)) return false;
const key = cardKey$1(token.card);
if (this.preloadedTermAudioKeys.has(key)) return false;
this.preloadedTermAudioKeys.add(key);
this.audio.preload(token.card, { sourceLimit: 1, candidateLimit: 1 });
return true;
}
dictionaryLabel(name) {
return this.settings.dictionaryPreferences.find((item) => item.name === name)?.alias || name;
}
kanjiSourceTitle(sourceId) {
if (sourceId === KANJI_STROKE_SOURCE_ID) return uiText(this.settings.interfaceLanguage, "strokePractice");
if (sourceId === KANJI_JPDB_SOURCE_ID) return uiText(this.settings.interfaceLanguage, "readingsComponents");
if (sourceId === KANJI_RTK_SOURCE_ID) return "RTK";
if (sourceId === KANJI_DICTIONARIES_SOURCE_ID) return uiText(this.settings.interfaceLanguage, "kanjiDictionaries");
if (sourceId === KANJI_SIMILAR_WORDS_SOURCE_ID) return uiText(this.settings.interfaceLanguage, "sourceNameWordsUsingKanji");
if (sourceId === KANJI_ORIGINS_SOURCE_ID) return uiText(this.settings.interfaceLanguage, "originStructure");
return kanjiSourceLabel(this.settings, sourceId);
}
applyAnkiLookupToRenderedWords(card, ankiLookup) {
if (!ankiLookup.primary) return;
const selector = `.jpdb-reader-word[data-vid="${card.vid}"][data-sid="${card.sid}"]`;
document.querySelectorAll(selector).forEach((word) => {
word.classList.add(`anki-${ankiLookup.state}`);
word.dataset.ankiState = ankiLookup.state;
word.dataset.ankiDecks = ankiLookup.primary?.deckNames.join(", ") ?? "";
word.title = `Anki: ${cardStateLabel(ankiLookup.state, this.settings.interfaceLanguage)}${word.dataset.ankiDecks ? ` (${word.dataset.ankiDecks})` : ""}`;
});
}
async handleCardAction(button2, card, sentence) {
if (button2.disabled) return;
button2.disabled = true;
const action = button2.dataset.action;
const anchor = this.connectedActivePopoverAnchor();
const trigger = this.activeTextLookupTrigger();
const done = log$1.time("cardAction", { action, term: card.spelling, trigger });
try {
const shouldRefresh = await this.cardActions.perform(action, button2, card, sentence);
if (shouldRefresh) await this.showCard(card, sentence, anchor, { autoPlay: false, trigger, navigation: "preserve", preservePosition: true });
log$1.info("Card action completed", { action, term: card.spelling });
} catch (error) {
log$1.warn("Card action failed", { action, term: card.spelling }, error);
this.toast(error instanceof Error ? error.message : uiText(this.settings.interfaceLanguage, "actionFailed"));
} finally {
done();
button2.disabled = false;
}
}
connectedActivePopoverAnchor() {
return this.activePopoverAnchor?.isConnected ? this.activePopoverAnchor : void 0;
}
async resolveMiningContext(card, sentence) {
const activeContext = this.immersionPopover.activeContextFor(card);
const storedImmersionContext = this.immersionPopover.storedContextFor(card);
const anchor = this.activePopoverAnchor;
const context = await resolveMiningContext({
term: card.spelling,
sentence,
settings: this.settings,
activeContext,
storedContext: storedImmersionContext,
sourceKind: inferMiningSourceKind({
hostname: location.hostname,
hasVideo: Boolean(anchor?.closest(".jpdb-subtitle-player")) || Boolean(document.querySelector("video"))
}),
imageDataUrl: this.settings.ankiCaptureScreenshot ? this.ocr.captureSourceImageForElement(anchor ?? null) : void 0,
videoImageDataUrl: this.settings.ankiCaptureScreenshot ? captureActiveVideoFrame() : void 0,
fetchImageDataUrl: (imageUrl, timeoutMs) => this.immersionKit.fetchDataUrl(imageUrl, timeoutMs, this.settings.corsProxyUrl),
fetchAudioDataUrl: (audioUrls, timeoutMs) => this.immersionKit.fetchDataUrl(audioUrls, timeoutMs, this.settings.corsProxyUrl)
});
return context;
}
showSettings(panel) {
this.getSettingsDialog().open(panel);
}
getSettingsDialog() {
this.settingsDialog ??= new SettingsDialogController({
getSettings: () => this.settings,
setSettings: (settings) => {
this.settings = settings;
},
jpdb: this.jpdb,
dictionaries: this.dictionaries,
anki: this.anki,
audio: this.audio,
subtitles: this.subtitles,
ocr: this.ocr,
createBackdrop: () => createReaderBackdrop(() => this.dismiss()),
mountDialog: (backdrop, form) => this.mountSettingsDialog(backdrop, form),
dismiss: () => this.dismiss(),
toast: (message) => this.toast(message),
applyTheme: (settings) => this.applyTheme(settings),
applyAccentColor: (color) => this.applyAccentColor(color),
applyWordColors: (settings) => this.applyWordColors(settings),
lookupText: (text2, sentence, anchor) => this.lookupText(text2, sentence || text2, { anchor }),
installFab: () => this.installFab(),
refreshDictionaryStyles: () => this.refreshDictionaryStyles(),
scheduleDictionaryRescan: () => this.scheduleDictionaryRescan(),
refreshNewTabIfCurrent: () => void 0,
clearDictionarySourceOpenOverrides: () => this.dictionarySourceState.clear(),
resetAllData: () => this.factoryReset.resetAllData(),
beginSettingsPreview: (accent, language, theme) => {
this.settingsPreviewOriginalAccent = accent;
this.settingsPreviewOriginalLanguage = language;
this.settingsPreviewOriginalTheme = theme;
},
clearSettingsPreview: () => {
this.settingsPreviewOriginalAccent = void 0;
this.settingsPreviewOriginalLanguage = void 0;
this.settingsPreviewOriginalTheme = void 0;
}
});
return this.settingsDialog;
}
mountSettingsDialog(backdrop, form) {
this.dismiss();
document.body.append(backdrop, form);
this.activeBackdrop = backdrop;
this.activePopover = form;
form.focus();
}
createPopover() {
return createReaderPopover(APP_NAME, this.settings);
}
mountPopover(popover, anchor, options = {}) {
const state = this.popoverMountState(anchor, options);
this.dismiss({ suppressHoverTarget: false, preserveNavigation: true, preserveHoverGeneration: state.mode === "hover" });
this.appendMountedPopover(popover, state);
this.activateMountedPopover(popover, state, options);
this.dictionarySourceState.installTracking(popover);
this.installMountedPopoverSurface(popover, state);
this.finishMountedPopoverLifecycle(popover, state.mode);
}
popoverMountState(anchor, options) {
const mode = options.mode ?? "modal";
const backdrop = mode === "hover" || shouldUseSheet(this.settings) ? void 0 : createReaderBackdrop(() => this.dismiss());
const resolvedAnchor = connectedElement(anchor) ?? connectedElement(this.activePopoverAnchor);
const anchorRect = popoverAnchorRect(resolvedAnchor, this.activePopoverAnchorRect);
const previousPopoverRect = options.preservePosition ? this.activePopover?.getBoundingClientRect() : void 0;
const previousHoverPointerPosition = this.hoverPopoverPointerPosition;
return { mode, backdrop, resolvedAnchor, anchorRect, previousPopoverRect, previousHoverPointerPosition };
}
appendMountedPopover(popover, state) {
const useBackdrop = Boolean(state.backdrop);
popover.setAttribute("aria-modal", String(useBackdrop));
if (state.backdrop) document.body.append(state.backdrop, popover);
else document.body.append(popover);
}
activateMountedPopover(popover, state, options) {
this.activeBackdrop = state.backdrop;
this.activePopover = popover;
this.activePopoverMode = state.mode;
this.activePopoverAnchor = state.resolvedAnchor;
this.activePopoverAnchorRect = state.anchorRect;
this.activePopoverPositionLocked = shouldLockMountedPopoverPosition(popover, state);
this.activeHoverWord = state.mode === "hover" ? state.resolvedAnchor : void 0;
this.activeHoverLookupKey = state.mode === "hover" ? options.hoverLookupKey ?? "" : "";
this.activePointerTextLookup = state.mode === "hover" ? options.pointerTextLookup : void 0;
this.hoverPopoverPointerPosition = mountedHoverPointerPosition(state, this.lastPointerPosition);
popover.classList.toggle("jpdb-reader-sheet-sticky", this.isStickyMountedSheet(popover, state));
}
installMountedPopoverSurface(popover, state) {
if (!popover.classList.contains("jpdb-reader-sheet")) {
this.activePopoverResizeObserver = new ResizeObserver(() => this.repositionActivePopover());
this.activePopoverResizeObserver.observe(popover);
this.installPopoverBodyStabilizers(popover);
if (state.previousPopoverRect) {
this.lockActivePopoverPosition(state.previousPopoverRect);
this.placeActivePopoverWithoutMoving(popover, state.previousPopoverRect);
this.syncActivePopoverFixedHeight();
} else {
this.activePopoverPositionLocked = false;
this.repositionActivePopover();
this.lockActivePopoverPosition(popover.getBoundingClientRect());
}
requestAnimationFrame(() => this.repositionActivePopover());
} else {
installSheetHandle(popover, () => this.dismiss());
if (this.isStickyMountedSheet(popover, state)) {
installSheetCloseButton(popover, () => this.dismiss(), uiText(this.settings.interfaceLanguage, "closeDrawer"));
}
}
}
isStickyMountedSheet(popover, state) {
return state.mode === "modal" && this.settings.stickyBottomSheet && popover.classList.contains("jpdb-reader-sheet");
}
finishMountedPopoverLifecycle(popover, mode) {
if (mode === "hover") {
this.installHoverPopoverLifecycle(popover);
this.startHoverWatch();
return;
}
popover.focus();
}
repositionActivePopover() {
const popover = this.repositionableActivePopover();
if (!popover) return;
const scrollBody = this.popoverScrollBody(popover);
const scrollTop = scrollBody.scrollTop;
this.prepareActivePopoverForPositioning(popover);
if (this.repositionLockedActivePopoverIfNeeded(popover)) {
this.restorePopoverScrollTop(scrollBody, scrollTop);
return;
}
this.repositionUnlockedActivePopover(popover);
this.restorePopoverScrollTop(scrollBody, scrollTop);
}
prepareActivePopoverForPositioning(popover) {
if (this.shouldUseFixedModalHeight(popover)) popover.style.height = "";
}
repositionLockedActivePopoverIfNeeded(popover) {
if (!this.activePopoverPositionLocked) return false;
this.repositionLockedActivePopover(popover);
return true;
}
repositionUnlockedActivePopover(popover) {
this.refreshActivePopoverAnchorRect();
positionPopover(
popover,
this.activePopoverAnchor?.isConnected ? this.activePopoverAnchor : void 0,
this.activePopoverAnchorRect,
{
followPoint: this.shouldFollowActivePointerText() ? this.hoverPopoverPointerPosition : void 0,
maxHeight: popoverMaxHeightSetting(this.settings)
}
);
this.syncActivePopoverFixedHeight();
}
repositionableActivePopover() {
if (!this.activePopover) return null;
if (this.activePopover.classList.contains("jpdb-reader-sheet")) return null;
return this.activePopover;
}
repositionLockedActivePopover(popover) {
if (!this.activePopoverLockedPosition) this.lockActivePopoverPosition(popover.getBoundingClientRect());
this.placeActivePopoverWithoutMoving(popover, this.activePopoverLockedPosition ?? popover.getBoundingClientRect());
this.syncActivePopoverFixedHeight();
}
lockActivePopoverPosition(rect) {
this.activePopoverPositionLocked = true;
this.activePopoverLockedPosition = { left: rect.left, top: rect.top };
}
placeActivePopoverWithoutMoving(popover, rect) {
const maxHeight = this.activePopoverMaxHeightAtTop(rect.top);
popover.style.left = `${rect.left}px`;
popover.style.top = `${rect.top}px`;
popover.style.maxHeight = `${maxHeight}px`;
}
activePopoverMaxHeightAtTop(top) {
const margin = 8;
const availableHeight = Math.max(0, window.innerHeight - top - margin);
const configuredMaxHeight = popoverMaxHeightSetting(this.settings);
return configuredMaxHeight ? Math.min(availableHeight, configuredMaxHeight) : availableHeight;
}
refreshActivePopoverAnchorRect() {
if (!this.activePopoverAnchor?.isConnected) return;
const rect = this.activePopoverAnchor.getBoundingClientRect();
if (rect.width > 0 || rect.height > 0) this.activePopoverAnchorRect = rect;
}
shouldFollowActivePointerText() {
return this.activePopoverMode === "hover" && Boolean(this.activePointerTextLookup);
}
shouldUseFixedModalHeight(popover) {
return this.activePopoverMode !== "hover" && this.settings.popoverHeightMode === "fixed" && !popover.classList.contains("jpdb-reader-sheet") && Boolean(popover.querySelector(".jpdb-reader-popover-body"));
}
syncActivePopoverFixedHeight() {
const popover = this.activePopover;
if (!popover) return;
if (!this.shouldUseFixedModalHeight(popover)) {
popover.style.height = "";
return;
}
const maxHeight = Number.parseFloat(popover.style.maxHeight);
if (Number.isFinite(maxHeight) && maxHeight > 0) popover.style.height = `${maxHeight}px`;
}
installPopoverBodyStabilizers(popover) {
if (popover.dataset.jpdbReaderBodyStabilizers === "true") return;
popover.dataset.jpdbReaderBodyStabilizers = "true";
popover.addEventListener("click", (event) => {
const target = event.target instanceof HTMLElement ? event.target : null;
const summary = target?.closest("summary");
if (!summary || !popover.contains(summary)) return;
this.stabilizePopoverBodyAround(popover, summary);
}, true);
}
stabilizePopoverBodyAround(popover, anchor) {
const scrollBody = this.popoverScrollBody(popover);
const scrollTop = scrollBody.scrollTop;
const anchorTop = anchor.getBoundingClientRect().top;
requestAnimationFrame(() => {
if (!popover.isConnected || !anchor.isConnected) return;
const delta = anchor.getBoundingClientRect().top - anchorTop;
if (Math.abs(delta) > 0.5) scrollBody.scrollTop = scrollTop + delta;
});
}
popoverScrollBody(popover) {
return popover.querySelector(".jpdb-reader-popover-body") ?? popover;
}
restorePopoverScrollTop(scrollBody, scrollTop) {
if (scrollBody.scrollTop !== scrollTop) scrollBody.scrollTop = scrollTop;
}
installHoverPopoverLifecycle(popover) {
popover.addEventListener("pointerenter", (event) => {
this.lastPointerPosition = { x: event.clientX, y: event.clientY };
this.cancelHoverClose();
});
popover.addEventListener("pointerleave", (event) => {
this.lastPointerPosition = { x: event.clientX, y: event.clientY };
if (this.activeHoverWord && this.isInsideNode(event.relatedTarget, this.activeHoverWord)) return;
this.scheduleHoverClose(void 0, { ignoreCssHover: true });
});
}
startHoverWatch() {
window.clearTimeout(this.hoverWatchTimer);
const tick = () => {
this.hoverWatchTimer = void 0;
if (this.activePopoverMode !== "hover") return;
if (!this.isHoverContextActive({ ignorePointerPosition: true })) {
this.dismiss({ suppressHoverTarget: false });
return;
}
this.hoverWatchTimer = window.setTimeout(tick, Math.max(90, this.settings.hoverCloseDelayMs));
};
this.hoverWatchTimer = window.setTimeout(tick, Math.max(90, this.settings.hoverCloseDelayMs));
}
dismiss(options = { suppressHoverTarget: true }) {
const hadSettingsDialog = Boolean(this.activePopover?.classList.contains("jpdb-reader-settings"));
this.clearHoverDismissState(options);
this.audio.stop();
this.immersionPopover.stopAudio();
this.updateSuppressedHoverTarget(options);
this.cardRenderRequest++;
this.restoreSettingsPreviewState();
this.removeReaderDialogNodes();
this.clearActivePopoverState();
if (!options.preserveNavigation) {
this.navigation.clearWord();
this.navigation.clearKanji();
}
if (hadSettingsDialog) this.schedulePendingDictionaryRescan();
}
clearHoverDismissState(options) {
window.clearTimeout(this.hoverLookupTimer);
window.clearTimeout(this.hoverCloseTimer);
window.clearTimeout(this.hoverWatchTimer);
if (this.popoverRepositionFrame !== void 0) {
window.cancelAnimationFrame(this.popoverRepositionFrame);
this.popoverRepositionFrame = void 0;
}
this.hoverLookupTimer = void 0;
this.hoverCloseTimer = void 0;
this.hoverWatchTimer = void 0;
this.hoverPopoverPointerPosition = void 0;
this.hoverPendingWord = void 0;
this.hoverPendingLookupKey = "";
this.hoverLookupInFlightKey = "";
if (!options.preserveHoverGeneration) this.nextHoverLookupGeneration();
}
updateSuppressedHoverTarget(options) {
const suppressTarget = this.activePopoverMode === "hover" ? this.activeHoverWord : this.activePopoverAnchor;
if (options.suppressHoverTarget && suppressTarget?.isConnected && suppressTarget.classList.contains("jpdb-reader-word")) {
this.suppressedHoverWord = suppressTarget;
this.suppressedHoverLookupKey = this.hoverLookupKeyForWord(suppressTarget);
} else {
this.suppressedHoverLookupKey = "";
}
}
restoreSettingsPreviewState() {
if (!this.hasActiveSettingsPreviewPopover()) {
this.clearSettingsPreviewOriginals();
return;
}
if (this.settingsPreviewOriginalAccent !== void 0) {
this.applyAccentColor(this.settingsPreviewOriginalAccent);
this.applyWordColors();
}
if (this.settingsPreviewOriginalLanguage !== void 0) {
this.settings.interfaceLanguage = this.settingsPreviewOriginalLanguage;
}
if (this.settingsPreviewOriginalTheme !== void 0) {
this.settings.theme = this.settingsPreviewOriginalTheme;
}
this.applyTheme();
this.clearSettingsPreviewOriginals();
}
clearSettingsPreviewOriginals() {
this.settingsPreviewOriginalAccent = void 0;
this.settingsPreviewOriginalLanguage = void 0;
this.settingsPreviewOriginalTheme = void 0;
}
hasActiveSettingsPreviewPopover() {
return Boolean(this.activePopover?.classList.contains("jpdb-reader-settings"));
}
removeReaderDialogNodes() {
this.activePopover?.remove();
this.activeBackdrop?.remove();
this.activePopoverResizeObserver?.disconnect();
document.querySelectorAll("[data-jpdb-reader-root].jpdb-reader-popover, [data-jpdb-reader-root].jpdb-reader-settings, [data-jpdb-reader-root].jpdb-reader-backdrop").forEach((element2) => element2.remove());
}
clearActivePopoverState() {
this.activePopover = void 0;
this.activeBackdrop = void 0;
this.activePopoverResizeObserver = void 0;
this.activePopoverPositionLocked = false;
this.activePopoverLockedPosition = void 0;
this.activePopoverAnchorRect = void 0;
this.activePopoverMode = void 0;
this.activePopoverAnchor = void 0;
this.activeHoverWord = void 0;
this.activeHoverLookupKey = "";
this.activePointerTextLookup = void 0;
}
schedulePendingDictionaryRescan() {
if (!this.dictionaryRescanPending) return;
this.dictionaryRescanPending = false;
window.setTimeout(() => this.scheduleDictionaryRescan(), 80);
}
toast(message) {
const toast = document.createElement("div");
toast.className = "jpdb-reader-toast";
toast.dataset.jpdbReaderRoot = "true";
toast.setAttribute("role", "status");
toast.setAttribute("aria-live", "polite");
toast.textContent = message;
document.body.appendChild(toast);
window.setTimeout(() => toast.remove(), 3200);
}
}
function pointerTokenAtOffset(tokens, offset) {
return tokens.find((token) => tokenContainsPointerOffset(token, offset));
}
function tokenContainsPointerOffset(token, offset) {
return token.start <= offset && offset < token.end || token.start < offset && offset <= token.end;
}
function cardStateLabel(state, language) {
const key = CARD_STATE_LABEL_KEYS[state];
return key ? uiText(language, key) : state;
}
const CARD_STATE_LABEL_KEYS = {
new: "stateNew",
learning: "stateLearning",
known: "stateKnown",
due: "stateDue",
failed: "stateFailed",
locked: "stateLocked",
"never-forget": "stateNeverForget",
blacklisted: "stateBlacklisted",
suspended: "stateSuspended",
"not-in-deck": "stateNotInDeck",
redundant: "stateRedundant"
};
const log = Logger.scope("ReaderBoot");
const RUNTIME_MARKER_ID = "jpdb-reader-runtime-owner";
function bootReaderApp() {
const bootWindow = window;
const runtimeKind = detectYomuRuntimeKind();
const ownerId = claimYomuRuntime(runtimeKind);
if (!ownerId) return;
const isRealRuntime = runtimeKind !== "demo";
discardDemoRuntimeForRealBoot(bootWindow, isRealRuntime);
if (!canReplaceExistingRuntime(bootWindow, runtimeKind)) return;
destroyExistingRuntimeApps(bootWindow);
bootWindow.__yomuReaderAppInitialized = true;
bootWindow.__jpdbPopupReaderInitialized = true;
bootWindow.__yomuRuntimeKind = runtimeKind;
bootWindow.__yomuRuntimeOwnerId = ownerId;
const app = new ReaderApp();
bindRuntimeClaims(app, ownerId, runtimeKind);
registerBootedRuntime(bootWindow, app, isRealRuntime);
startBootedRuntime(app, ownerId, runtimeKind, isRealRuntime);
}
function discardDemoRuntimeForRealBoot(bootWindow, isRealRuntime) {
if (!bootWindow.__yomuReaderAppInitialized || !bootWindow.__yomuDemoApp || !isRealRuntime) return;
bootWindow.__yomuDemoApp.destroy();
delete bootWindow.__yomuDemoApp;
bootWindow.__yomuReaderAppInitialized = false;
}
function canReplaceExistingRuntime(bootWindow, runtimeKind) {
if (!bootWindow.__yomuReaderAppInitialized) return true;
const existingPriority = runtimePriority(bootWindow.__yomuRuntimeKind ?? "demo");
return existingPriority < runtimePriority(runtimeKind);
}
function destroyExistingRuntimeApps(bootWindow) {
if (!bootWindow.__yomuReaderAppInitialized) return;
bootWindow.__yomuRealApp?.destroy();
bootWindow.__yomuDemoApp?.destroy();
}
function registerBootedRuntime(bootWindow, app, isRealRuntime) {
if (isRealRuntime) {
bootWindow.__yomuRealApp = app;
dispatchWindowEvent(createWindowCustomEvent("yomu-extension-loaded"));
return;
}
bootWindow.__yomuDemoApp = app;
addWindowEventListener("yomu-extension-loaded", () => {
if (bootWindow.__yomuDemoApp === app) {
app.destroy();
delete bootWindow.__yomuDemoApp;
}
});
}
function startBootedRuntime(app, ownerId, runtimeKind, isRealRuntime) {
void app.init({
isDemo: !isRealRuntime,
showWelcome: runtimeKind === "userscript"
}).catch((error) => {
releaseYomuRuntime(ownerId);
log.error("Initialization failed", error);
throw error;
});
}
function detectYomuRuntimeKind() {
const global = globalThis;
if (global.chrome?.runtime?.id || global.browser?.runtime?.id) return "extension";
if (typeof GM_getValue === "function") return "userscript";
return "demo";
}
function runtimePriority(kind) {
if (kind === "extension") return 3;
if (kind === "userscript") return 2;
return 1;
}
function claimYomuRuntime(kind) {
const ownerId = `${kind}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const existing = document.getElementById(RUNTIME_MARKER_ID);
const existingKind = normalizeRuntimeKind(existing?.dataset.yomuRuntimeKind);
if (existing && runtimePriority(existingKind) >= runtimePriority(kind)) {
return null;
}
dispatchWindowEvent(createWindowCustomEvent("yomu-reader-runtime-claim", { ownerId, kind, priority: runtimePriority(kind) }));
const marker = existing ?? document.createElement("meta");
marker.id = RUNTIME_MARKER_ID;
marker.dataset.yomuRuntimeKind = kind;
marker.dataset.yomuRuntimeOwner = ownerId;
marker.setAttribute("name", RUNTIME_MARKER_ID);
marker.setAttribute("content", kind);
if (!marker.isConnected) appendToDocumentHead(marker);
return ownerId;
}
function bindRuntimeClaims(app, ownerId, kind) {
addWindowEventListener("yomu-reader-runtime-claim", (event) => {
const detail = event.detail;
if (!detail || detail.ownerId === ownerId) return;
const nextKind = normalizeRuntimeKind(detail.kind);
if (runtimePriority(nextKind) < runtimePriority(kind)) return;
log.info("Yielding to another Yomu runtime", { current: kind, next: nextKind });
app.destroy();
releaseYomuRuntime(ownerId);
clearBootWindowOwner(app, ownerId);
});
}
function clearBootWindowOwner(app, ownerId) {
const bootWindow = window;
if (bootWindow.__yomuRuntimeOwnerId !== ownerId) return;
bootWindow.__yomuReaderAppInitialized = false;
delete bootWindow.__yomuRuntimeOwnerId;
delete bootWindow.__yomuRuntimeKind;
if (bootWindow.__yomuDemoApp === app) delete bootWindow.__yomuDemoApp;
if (bootWindow.__yomuRealApp === app) delete bootWindow.__yomuRealApp;
}
function releaseYomuRuntime(ownerId) {
const marker = document.getElementById(RUNTIME_MARKER_ID);
if (marker?.dataset.yomuRuntimeOwner === ownerId) marker.remove();
}
function normalizeRuntimeKind(value) {
return value === "extension" || value === "userscript" || value === "demo" ? value : "demo";
}
installUserscriptHttpBridge();
if (!isYomuNewTabUrl(location.href)) bootReaderApp();
})();