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
@@ -174,6 +174,10 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
// (Android long-press selects text via selectionchange before touchend). The
// pending action runs on touchend so popups don't open under an active touch.
const deferredQuickActionRef = useRef(createDeferredActionState());
// Set when a Word Wise gloss tap synthesizes a selection so the
// selection-change effect opens the dictionary popup instead of the
// annotation toolbar. Cleared as soon as it's consumed.
const pendingWordWiseDictRef = useRef(false);
const showingPopup =
showAnnotPopup || showDictionaryPopup || showDeepLPopup || showProofreadPopup;
@@ -556,6 +560,47 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
useFoliateEvents(view, { onLoad, onCreateOverlay, onDrawAnnotation, onShowAnnotation });
// Word Wise: open the dictionary popup for a tapped glossed word. The tap is
// detected in the iframe click handler (iframeEventHandlers.ts), which sends
// the gloss <ruby> element here. We synthesize a selection over the base word
// (excluding the <rt> hint) so the existing dictionary popup positions itself.
useEffect(() => {
const handleWordWiseDictionary = (event: CustomEvent) => {
const { element, word } = event.detail as { element: Element | null; word: string };
if (event.detail?.bookKey !== bookKey || !element || !word) return;
// Read the view fresh: this handler is registered once (deps [bookKey]) and
// the closed-over `view` may still be null from when the effect first ran.
const view = getView(bookKey);
const doc = element.ownerDocument;
const content = view?.renderer?.getContents().find((c) => c.doc === doc);
if (!content || content.index == null) return;
const index = content.index;
const rt = element.querySelector('rt');
const range = doc.createRange();
try {
range.selectNodeContents(element);
if (rt) range.setEndBefore(rt);
} catch {
return;
}
const text = range.toString().trim() || word;
pendingWordWiseDictRef.current = true;
setSelection({
key: bookKey,
text,
range,
index,
cfi: view?.getCFI(index, range),
page: index + 1,
});
};
eventDispatcher.on('wordwise-dictionary', handleWordWiseDictionary);
return () => {
eventDispatcher.off('wordwise-dictionary', handleWordWiseDictionary);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey]);
useEffect(() => {
handleShowPopup(showingPopup);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -772,6 +817,11 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
useEffect(() => {
setHighlightOptionsVisible(!!(selection && selection.annotated));
if (selection && selection.text.trim().length > 0) {
// Read-and-reset the Word Wise dictionary flag up front so it can never
// stick to a later selection if an early return below fires (e.g. a gloss
// tap whose synthesized range yields an off-frame/zero position).
const wantWordWiseDict = pendingWordWiseDictRef.current;
pendingWordWiseDictRef.current = false;
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
if (!gridFrame) return;
const rect = gridFrame.getBoundingClientRect();
@@ -816,7 +866,10 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
setTrianglePosition(triangPos);
const { enableAnnotationQuickActions, annotationQuickAction } = viewSettings;
if (enableAnnotationQuickActions && annotationQuickAction && isTextSelected.current) {
if (wantWordWiseDict) {
setShowAnnotPopup(false);
setShowDictionaryPopup(true);
} else if (enableAnnotationQuickActions && annotationQuickAction && isTextSelected.current) {
handleQuickAction();
} else {
handleShowAnnotPopup();