Files
readest/apps/readest-app/src/components/settings/WordLensPanel.tsx
T
Huang Xin b1346bf16d feat(wordlens): en-en glosses, styling, derivation lemmas, display-time cap (#4744)
* feat(wordlens): support en-en monolingual glosses

Gloss difficult English words with a short English definition for learners
reading English with English hints.

- build: buildEnEn + shortDefGloss read ECDICT's English `definition` column
  (first/primary WordNet sense, POS-stripped, drop ;-example, <=24 word-boundary
  with trailing-connector trim). New `en-en` CLI branch; buildEnZh/buildEnEn now
  share a buildEnPack core.
- gating: drop the hardcoded `hint === source` rejections (wordlensSection,
  WordLensPanel) so same-language packs are allowed; availability is decided by
  the manifest (resolvePack returns null when no pack exists).
- data: data/wordlens/en-en.json (26,578 entries) + regenerated manifest.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(wordlens): gloss styling, derivation lemmas, display-time cap

Builds on the en-en monolingual gloss support with refinements and
regenerated packs.

- settings: per-book gloss <rt> font size (em) and color in
  Settings > Language > Word Lens (getRubyStyles reads viewSettings).
- en-en hints: WordNet hybrid (a simpler synonym, else a category
  hypernym, else the ECDICT definition) instead of raw verbose
  definitions.
- lemmatization: gate difficulty by the lemma rank for every English
  source pair. enBaseFormCandidates now also covers -able/-ible suffixes
  and negative prefixes (un/in/im/ir/il), so insufferable resolves to
  suffer. A candidate is accepted when the English definition names the
  base OR the Chinese translations share a content character, which keeps
  true derivations (insufferable -> suffer) and rejects coincidental
  stems (capable -> cap). en-X packs inherit the en-en lemma table.
- display cap: the max gloss length is applied at render time in
  cleanGloss (MAX_GLOSS_LEN), so the packs store the full hint and the
  cap can change without regenerating data.
- tooling: pnpm wordlens:preview to sample pack entries; cache build
  corpora under data/wordlens/.sources (gitignored).
- data: regenerate en-en, en-zh and en-de/es/fr/pt/ru plus the manifest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:15:34 +02:00

415 lines
15 KiB
TypeScript

import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { saveViewSettings } from '@/helpers/settings';
import { getLocale } from '@/utils/misc';
import { formatBytes } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { TRANSLATED_LANGS } from '@/services/constants';
import {
WORD_LENS_MIN_LEVEL,
WORD_LENS_MAX_LEVEL,
cefrLabel,
} from '@/services/wordlens/difficulty';
import { toWordLensSource } from '@/app/reader/utils/wordlensSection';
import {
deletePack,
ensurePack,
fetchManifest,
getPackStatus,
listAvailableTargets,
type WordLensManifest,
type WordLensPack,
} from '@/services/wordlens/glossPacks';
import SubPageHeader from './SubPageHeader';
import ColorInput from './color/ColorInput';
import { BoxedList, SettingsRow, SettingsSelect, SettingsSwitchRow } from './primitives';
// Swatch shown for the "default" (muted, theme-adaptive) gloss color, which has
// no fixed hex of its own. Picking any color overrides; "Default" clears back.
const DEFAULT_GLOSS_SWATCH = '#808080';
interface WordLensPanelProps {
bookKey: string;
onBack: () => void;
}
const baseCode = (lang?: string | null): string => (lang || '').toLowerCase().split('-')[0] || '';
const WordLensPanel: React.FC<WordLensPanelProps> = ({ bookKey, onBack }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { getViewSettings, setViewSettings } = useReaderStore();
const { getBookData } = useBookDataStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
const bookData = getBookData(bookKey);
const appLang = baseCode(getLocale());
const bookSource = toWordLensSource(bookData?.book?.primaryLanguage);
const [wordLensEnabled, setWordLensEnabled] = useState(viewSettings.wordLensEnabled ?? false);
const [wordLensLevel, setWordLensLevel] = useState(viewSettings.wordLensLevel ?? 3);
const [hintLang, setHintLang] = useState(viewSettings.wordLensHintLang || appLang);
const [glossFontSize, setGlossFontSize] = useState(viewSettings.wordLensGlossFontSize ?? 0.5);
const [glossColor, setGlossColor] = useState(viewSettings.wordLensGlossColor ?? '');
// Track the latest gloss color so ColorInput's onCommit (no args) persists the
// value the user landed on after dragging, without re-injecting CSS per tick.
const glossColorRef = useRef(glossColor);
glossColorRef.current = glossColor;
const [autoDownload, setAutoDownload] = useState(
settings.globalReadSettings.wordLensAutoDownload ?? true,
);
const [manifest, setManifest] = useState<WordLensManifest | null>(null);
const [packStatus, setPackStatus] = useState<{ pack: WordLensPack; downloaded: boolean } | null>(
null,
);
const [resolving, setResolving] = useState(false);
const [downloading, setDownloading] = useState(false);
const [progress, setProgress] = useState<number | null>(null);
// Fetch the manifest once on mount to filter the hint-language selector and
// resolve the data-pack row. If it fails, the selector falls back to the full
// TRANSLATED_LANGS list (see availableTargets below).
useEffect(() => {
if (!appService) return;
let cancelled = false;
void fetchManifest(appService).then((m) => {
if (!cancelled) setManifest(m);
});
return () => {
cancelled = true;
};
}, [appService]);
useEffect(() => {
if (wordLensEnabled === viewSettings.wordLensEnabled) return;
saveViewSettings(envConfig, bookKey, 'wordLensEnabled', wordLensEnabled, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wordLensEnabled]);
useEffect(() => {
if (wordLensLevel === viewSettings.wordLensLevel) return;
saveViewSettings(envConfig, bookKey, 'wordLensLevel', wordLensLevel, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wordLensLevel]);
// Re-resolve the data-pack row whenever the (source → hint) pair changes.
useEffect(() => {
if (!appService || !bookSource) {
setPackStatus(null);
return;
}
const hint = baseCode(hintLang) || appLang;
// Same-language pairs (e.g. en-en) are allowed; getPackStatus returns null
// when the manifest has no pack for the pair, which renders the empty row.
if (!hint) {
setPackStatus(null);
return;
}
let cancelled = false;
setResolving(true);
void getPackStatus(appService, bookSource, hint)
.then((status) => {
if (!cancelled) setPackStatus(status);
})
.finally(() => {
if (!cancelled) setResolving(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService, bookSource, hintLang, appLang, manifest]);
// The manifest's `target` codes are 2-letter base codes (en, zh, fr…), while
// TRANSLATED_LANGS keys can be regional (zh-CN, pt-BR). Match by base code so
// e.g. an en→zh pack surfaces the "简体中文"/"正體中文" options.
const availableTargets = manifest && bookSource ? listAvailableTargets(manifest, bookSource) : [];
const getHintLangOptions = () => {
const entries = Object.entries(TRANSLATED_LANGS).map(([value, label]) => ({ value, label }));
// When the manifest is available, restrict to the targets it offers for the
// book's source language (matching the resolvable packs). Without a manifest
// we show the full list so the selector stays usable offline.
const filtered =
availableTargets.length > 0
? entries.filter((o) => availableTargets.includes(baseCode(o.value)))
: entries;
filtered.sort((a, b) => a.label.localeCompare(b.label));
return [{ value: '', label: _('Auto') }, ...filtered];
};
const hintLangOptions = getHintLangOptions();
// Map the stored hint (possibly '' = auto, or a base code like 'zh') to the
// option that should appear selected. '' shows Auto; a base code resolves to
// the first option whose base code matches (e.g. 'zh' → 'zh-CN').
const selectedHintValue = (() => {
const stored = viewSettings.wordLensHintLang;
if (!stored) return '';
if (hintLangOptions.some((o) => o.value === stored)) return stored;
const byBase = hintLangOptions.find((o) => baseCode(o.value) === baseCode(stored));
return byBase?.value ?? '';
})();
const handleSelectHintLang = (event: React.ChangeEvent<HTMLSelectElement>) => {
const option = event.target.value;
setHintLang(option || appLang);
saveViewSettings(envConfig, bookKey, 'wordLensHintLang', option, false, false);
viewSettings.wordLensHintLang = option;
setViewSettings(bookKey, { ...viewSettings });
};
// Gloss appearance (font size + color) drives the <rt> CSS via getRubyStyles, so
// saving with applyStyles (default) re-injects the stylesheet and restyles the
// already-rendered glosses live — no DOM rebuild / re-gloss needed.
const glossFontSizeOptions = [
{ value: '0.4', label: _('Small') },
{ value: '0.5', label: _('Default') },
{ value: '0.65', label: _('Large') },
{ value: '0.8', label: _('Extra Large') },
];
const handleSelectGlossFontSize = (event: React.ChangeEvent<HTMLSelectElement>) => {
const value = Number(event.target.value) || 0.5;
setGlossFontSize(value);
saveViewSettings(envConfig, bookKey, 'wordLensGlossFontSize', value);
};
const handleGlossColorChange = (hex: string) => setGlossColor(hex);
const handleGlossColorCommit = () => {
saveViewSettings(envConfig, bookKey, 'wordLensGlossColor', glossColorRef.current);
};
const handleResetGlossColor = () => {
setGlossColor('');
saveViewSettings(envConfig, bookKey, 'wordLensGlossColor', '');
};
const handleToggleAutoDownload = () => {
const next = !autoDownload;
setAutoDownload(next);
settings.globalReadSettings.wordLensAutoDownload = next;
setSettings(settings);
saveSettings(envConfig, settings);
};
const handleDownload = async () => {
if (!appService || !packStatus || downloading) return;
setDownloading(true);
setProgress(0);
try {
const path = await ensurePack(appService, packStatus.pack, {
allowDownload: true,
onProgress: ({ progress: done, total }) => {
if (total > 0) setProgress(Math.floor((done / total) * 100));
},
});
if (path) {
setPackStatus({ ...packStatus, downloaded: true });
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Word Lens data downloaded'),
});
} else {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to download Word Lens data'),
});
}
} catch {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to download Word Lens data'),
});
} finally {
setDownloading(false);
setProgress(null);
}
};
const handleDelete = async () => {
if (!appService || !packStatus) return;
await deletePack(appService, packStatus.pack);
setPackStatus({ ...packStatus, downloaded: false });
eventDispatcher.dispatch('toast', { type: 'info', message: _('Word Lens data removed') });
};
const renderDataPackRow = () => {
if (!bookSource) {
return (
<SettingsRow
label={_('Data pack')}
description={_('Open a book to manage its data pack.')}
/>
);
}
if (resolving) {
return (
<SettingsRow label={_('Data pack')}>
<span className='loading loading-spinner loading-sm' />
</SettingsRow>
);
}
if (!packStatus) {
return (
<SettingsRow
label={_('Data pack')}
description={_('No data available for this language pair yet.')}
/>
);
}
const size = formatBytes(packStatus.pack.bytes);
if (packStatus.downloaded) {
return (
<SettingsRow label={_('Data pack')} description={`${_('Downloaded')} · ${size}`}>
<button
type='button'
onClick={handleDelete}
className='btn btn-ghost btn-sm eink-bordered shrink-0'
>
{_('Delete')}
</button>
</SettingsRow>
);
}
return (
<SettingsRow label={_('Data pack')} description={size}>
<div className='flex items-center gap-2'>
{downloading && progress !== null && progress > 0 && (
<div
className='radial-progress flex items-center justify-center'
style={
{
'--value': progress,
'--size': '2.25rem',
fontSize: '0.6rem',
lineHeight: '0.8rem',
} as React.CSSProperties
}
aria-valuenow={progress}
role='progressbar'
>
{progress}%
</div>
)}
<button
type='button'
onClick={handleDownload}
disabled={downloading}
className='btn btn-primary btn-contrast btn-sm shrink-0'
>
{_('Download')}
</button>
</div>
</SettingsRow>
);
};
return (
<div className={clsx('my-4 w-full space-y-6')}>
<SubPageHeader
parentLabel={_('Language')}
currentLabel={_('Word Lens')}
description={_(
'Show a short native-language hint above difficult words. Words above your level get a hint.',
)}
onBack={onBack}
/>
<BoxedList title={_('Word Lens')} data-setting-id='settings.wordlens.main'>
<SettingsSwitchRow
label={_('Enable Word Lens')}
checked={wordLensEnabled}
onChange={() => setWordLensEnabled(!wordLensEnabled)}
data-setting-id='settings.wordlens.enabled'
/>
<SettingsRow label={_('Level')} description={_('CEFR level')} disabled={!wordLensEnabled}>
<div className='flex items-center gap-2'>
<input
type='range'
className='range range-sm eink-bordered'
min={WORD_LENS_MIN_LEVEL}
max={WORD_LENS_MAX_LEVEL}
step={1}
value={wordLensLevel}
disabled={!wordLensEnabled}
aria-label={_('Vocabulary level')}
onChange={(e) => setWordLensLevel(Number(e.target.value))}
data-setting-id='settings.wordlens.level'
/>
<span className='text-base-content/70 w-6 text-end text-sm tabular-nums'>
{cefrLabel(wordLensLevel)}
</span>
</div>
</SettingsRow>
<SettingsRow label={_('Language')}>
<SettingsSelect
value={selectedHintValue}
onChange={handleSelectHintLang}
ariaLabel={_('Language')}
options={hintLangOptions}
/>
</SettingsRow>
<SettingsRow
label={_('Hint size')}
description={_('Gloss text size above the word')}
disabled={!wordLensEnabled}
>
<SettingsSelect
value={String(glossFontSize)}
onChange={handleSelectGlossFontSize}
ariaLabel={_('Hint size')}
options={glossFontSizeOptions}
disabled={!wordLensEnabled}
/>
</SettingsRow>
<SettingsRow
label={_('Hint color')}
description={glossColor ? glossColor : _('Default')}
disabled={!wordLensEnabled}
>
<div className='flex items-center gap-2'>
{glossColor && (
<button
type='button'
onClick={handleResetGlossColor}
className='btn btn-ghost btn-xs eink-bordered shrink-0'
>
{_('Default')}
</button>
)}
<ColorInput
label={_('Hint color')}
value={glossColor || DEFAULT_GLOSS_SWATCH}
onChange={handleGlossColorChange}
onCommit={handleGlossColorCommit}
swatchOnly
showPickerIcon
pickerPosition='right'
/>
</div>
</SettingsRow>
</BoxedList>
<BoxedList title={_('Data')}>
{renderDataPackRow()}
<SettingsSwitchRow
label={_('Auto-download')}
checked={autoDownload}
onChange={handleToggleAutoDownload}
/>
</BoxedList>
</div>
);
};
export default WordLensPanel;