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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user