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:
Huang Xin
2026-06-15 13:56:00 +08:00
committed by GitHub
parent 51fede1a0d
commit 4908245042
90 changed files with 6573 additions and 125 deletions
@@ -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);
}
};