feat(reader): Word Wise inline vocabulary hints (#4589)
* feat(reader): Word Wise — inline native-language vocabulary hints Kindle-style Word Wise: a short native-language gloss renders above difficult words as you read (always-on ruby), gated by a CEFR vocabulary-level slider (A1–C2); tapping a glossed word opens the existing dictionary. - Pipeline: CEFR→frequency-rank difficulty, inflection-aware gloss index, pure offset-aware planner (EN regex + jieba for CJK). - Rendering: <ruby cfi-skip>…<rt cfi-inert> injected per occurrence — CFI-transparent (verified), so highlights/bookmarks/progress are unaffected; kept out of TTS word offsets and find-in-book. - Delivery: gloss packs are version-controlled in data/wordwise/, mirrored to R2, and downloaded on demand into local storage (sha-verified, single-flight) when enabled. - Settings: a Word Wise sub-page under Settings → Language (enable, level, hint language, per-pack download/manage, auto-download toggle). - Build tooling: scripts/build-wordwise-data.mjs (ECDICT / CC-CEDICT+HSK / WikDict + FrequencyWords, with lemmatization) and scripts/sync-wordwise-r2.mjs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * data(wordwise): bundled gloss packs + manifest + attribution 13 frequency-trimmed gloss packs (en↔中文 + es/fr/de/pt/it/ru↔en, ~19 MB) generated by build-wordwise-data.mjs from ECDICT (MIT), CC-CEDICT + HSK, and WikDict + FrequencyWords (CC-BY-SA). Source of truth, mirrored to the CDN via `pnpm wordwise:sync`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { DOUBLE_CLICK_INTERVAL_THRESHOLD_MS, LONG_HOLD_THRESHOLD } from '@/services/constants';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { findGlossWord } from '@/app/reader/utils/wordwiseRuby';
|
||||
|
||||
let lastClickTime = 0;
|
||||
let longHoldTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -224,6 +225,15 @@ export const handleClick = (
|
||||
return;
|
||||
}
|
||||
|
||||
// Word Wise: tapping a glossed word looks it up in the dictionary. Checked
|
||||
// after the drag/long-hold guards so only a clean single tap triggers it.
|
||||
const glossWord = findGlossWord(element);
|
||||
if (glossWord) {
|
||||
const ruby = element?.closest('ruby.ww-gloss') ?? null;
|
||||
eventDispatcher.dispatch('wordwise-dictionary', { bookKey, element: ruby, word: glossWord });
|
||||
return;
|
||||
}
|
||||
|
||||
window.postMessage(
|
||||
{
|
||||
type: 'iframe-single-click',
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { GlossOccurrence } from '@/services/wordwise/types';
|
||||
|
||||
const GLOSS_CLASS = 'ww-gloss';
|
||||
|
||||
interface Segment {
|
||||
node: Text;
|
||||
/** Offset of this node's text within the concatenated model string. */
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface SectionTextModel {
|
||||
text: string;
|
||||
locate(offset: number): { node: Text; offset: number };
|
||||
}
|
||||
|
||||
const isEligible = (node: Text): boolean => {
|
||||
let p: Node | null = node.parentNode;
|
||||
while (p) {
|
||||
if (p.nodeType === Node.ELEMENT_NODE) {
|
||||
const tag = (p as Element).tagName.toLowerCase();
|
||||
if (tag === 'rt' || tag === 'rp' || tag === 'ruby' || tag === 'script' || tag === 'style') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
p = p.parentNode;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Concatenate eligible text nodes and remember each node's slice offset. */
|
||||
export const buildSectionTextModel = (doc: Document): SectionTextModel => {
|
||||
const root = doc.body ?? doc.documentElement;
|
||||
const segments: Segment[] = [];
|
||||
let text = '';
|
||||
if (root) {
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (n) =>
|
||||
isEligible(n as Text) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
|
||||
});
|
||||
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
||||
const t = n as Text;
|
||||
const data = t.data;
|
||||
if (!data) continue;
|
||||
segments.push({ node: t, start: text.length });
|
||||
text += data;
|
||||
}
|
||||
}
|
||||
const locate = (offset: number) => {
|
||||
let lo = 0;
|
||||
let hi = segments.length - 1;
|
||||
let seg = segments[0]!;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
const s = segments[mid]!;
|
||||
if (offset < s.start) hi = mid - 1;
|
||||
else {
|
||||
seg = s;
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
return { node: seg.node, offset: offset - seg.start };
|
||||
};
|
||||
return { text, locate };
|
||||
};
|
||||
|
||||
const occurrenceRange = (
|
||||
doc: Document,
|
||||
model: SectionTextModel,
|
||||
occ: GlossOccurrence,
|
||||
): Range | null => {
|
||||
const s = model.locate(occ.start);
|
||||
const e = model.locate(occ.end);
|
||||
if (s.node !== e.node) return null;
|
||||
const range = doc.createRange();
|
||||
range.setStart(s.node, s.offset);
|
||||
range.setEnd(e.node, e.offset);
|
||||
return range;
|
||||
};
|
||||
|
||||
/** Wrap each occurrence as <ruby class=ww-gloss cfi-skip>word<rt cfi-inert>gloss</rt></ruby>. */
|
||||
export const applyGlosses = (
|
||||
doc: Document,
|
||||
model: SectionTextModel,
|
||||
occurrences: GlossOccurrence[],
|
||||
): void => {
|
||||
const sorted = [...occurrences].sort((a, b) => b.start - a.start);
|
||||
for (const occ of sorted) {
|
||||
const range = occurrenceRange(doc, model, occ);
|
||||
if (!range) continue;
|
||||
const ruby = doc.createElement('ruby');
|
||||
ruby.className = GLOSS_CLASS;
|
||||
ruby.setAttribute('cfi-skip', '');
|
||||
const rt = doc.createElement('rt');
|
||||
rt.setAttribute('cfi-inert', '');
|
||||
rt.textContent = occ.gloss;
|
||||
try {
|
||||
const word = range.extractContents();
|
||||
ruby.appendChild(word);
|
||||
ruby.appendChild(rt);
|
||||
range.insertNode(ruby);
|
||||
} catch {
|
||||
// Range became invalid (concurrent mutation); skip this one.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Unwrap every injected gloss, restoring the original text. */
|
||||
export const clearGlosses = (doc: Document): void => {
|
||||
const rubies = doc.querySelectorAll(`ruby.${GLOSS_CLASS}`);
|
||||
rubies.forEach((ruby) => {
|
||||
ruby.querySelectorAll('rt').forEach((rt) => rt.remove());
|
||||
const parent = ruby.parentNode;
|
||||
if (!parent) return;
|
||||
while (ruby.firstChild) parent.insertBefore(ruby.firstChild, ruby);
|
||||
parent.removeChild(ruby);
|
||||
});
|
||||
(doc.body ?? doc.documentElement)?.normalize();
|
||||
};
|
||||
|
||||
/** Given a tap target, return the base word if it is inside a gloss, else null. */
|
||||
export const findGlossWord = (target: HTMLElement | null): string | null => {
|
||||
const ruby = target?.closest(`ruby.${GLOSS_CLASS}`);
|
||||
if (!ruby) return null;
|
||||
const clone = ruby.cloneNode(true) as Element;
|
||||
clone.querySelectorAll('rt').forEach((rt) => rt.remove());
|
||||
const word = clone.textContent?.trim() ?? '';
|
||||
return word || null;
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ViewSettings } from '@/types/book';
|
||||
import type { AppService } from '@/types/system';
|
||||
import type { ProgressHandler } from '@/utils/transfer';
|
||||
import { canTokenizeSource, getRankCutoff } from '@/services/wordwise/difficulty';
|
||||
import { loadGlossIndex } from '@/services/wordwise/glossPacks';
|
||||
import { planGlosses } from '@/services/wordwise/planner';
|
||||
import { buildSectionTextModel, applyGlosses, clearGlosses } from '@/app/reader/utils/wordwiseRuby';
|
||||
import { cutZh, isJiebaReady, initJieba } from '@/utils/jieba';
|
||||
|
||||
/** Normalize a book language tag to its 2-letter base source code, or null. */
|
||||
export const toWordWiseSource = (lang?: string | null): string | null => {
|
||||
if (!lang) return null;
|
||||
const base = lang.toLowerCase().split('-')[0];
|
||||
return base || null;
|
||||
};
|
||||
|
||||
interface RefreshContext {
|
||||
appService: AppService;
|
||||
bookLang?: string | null;
|
||||
/** App UI language base code, used as the hint when none is selected. */
|
||||
appLang: string;
|
||||
/**
|
||||
* Whether the reader may silently download an uncached pack. Threaded to
|
||||
* loadGlossIndex → ensurePack; when false an uncached pack yields no glosses
|
||||
* (the user downloads it explicitly from the Word Wise sub-page).
|
||||
*/
|
||||
allowDownload?: boolean;
|
||||
onProgress?: ProgressHandler;
|
||||
}
|
||||
|
||||
// Per-document generation counter. Dragging the difficulty slider fires the
|
||||
// settings effect repeatedly, producing overlapping refresh calls. A later call
|
||||
// supersedes earlier ones: each call stamps its generation, and any call whose
|
||||
// stamp is stale after the `await` bails before touching the DOM, so the latest
|
||||
// refresh always builds and applies against a clean, un-glossed document.
|
||||
const refreshGen = new WeakMap<Document, number>();
|
||||
|
||||
/** Re-render glosses for one section doc. Clears first, then injects if enabled. */
|
||||
export const refreshSectionGlosses = async (
|
||||
doc: Document,
|
||||
viewSettings: ViewSettings,
|
||||
ctx: RefreshContext,
|
||||
): Promise<void> => {
|
||||
// This runs fire-and-forget (`void refreshSectionGlosses(...)`), and its
|
||||
// post-await synchronous DOM work (buildSectionTextModel / planGlosses /
|
||||
// applyGlosses → range.insertNode) can throw. Contain everything so a failure
|
||||
// never escapes as an unhandledrejection.
|
||||
try {
|
||||
const myGen = (refreshGen.get(doc) ?? 0) + 1;
|
||||
refreshGen.set(doc, myGen);
|
||||
clearGlosses(doc);
|
||||
if (!viewSettings.wordWiseEnabled) return;
|
||||
const source = toWordWiseSource(ctx.bookLang);
|
||||
if (!source || !canTokenizeSource(source)) return;
|
||||
const hint = (viewSettings.wordWiseHintLang || ctx.appLang).toLowerCase().split('-')[0] || '';
|
||||
if (!hint || hint === source) return; // no self-gloss
|
||||
const index = await loadGlossIndex(ctx.appService, source, hint, {
|
||||
onProgress: ctx.onProgress,
|
||||
allowDownload: ctx.allowDownload,
|
||||
});
|
||||
if (refreshGen.get(doc) !== myGen) return; // a newer refresh superseded us
|
||||
if (!index) return;
|
||||
if (source === 'zh' && !isJiebaReady()) {
|
||||
void initJieba();
|
||||
return;
|
||||
}
|
||||
const model = buildSectionTextModel(doc);
|
||||
const occ = planGlosses(model.text, index, {
|
||||
sourceLang: source,
|
||||
rankCutoff: getRankCutoff(source, viewSettings.wordWiseLevel),
|
||||
cutZh: source === 'zh' ? cutZh : undefined,
|
||||
});
|
||||
if (occ.length) applyGlosses(doc, model, occ);
|
||||
} catch (err) {
|
||||
console.warn('[wordwise] refresh failed', err);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user