fix(dictionary): normalize lookup query with trim + case fallback (#4192)

A double-click selection can carry trailing whitespace and most imported
dictionaries store headwords lowercased, so an exact match on the raw
selection often misses (e.g. `Hello` or `world ` fail to resolve
`hello`/`world`). Case-sensitive formats like mdict are hit hardest since
their reader compares the raw word.

Seed the lookup history with a trimmed word and try ordered query
variants (trimmed, lowercase, title-case, uppercase) per provider,
keeping the first hit. Closes #4176.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-17 02:52:48 +08:00
committed by GitHub
parent 2d30868d23
commit 8dfc0e945e
4 changed files with 135 additions and 14 deletions
@@ -10,6 +10,7 @@ import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
import { getEnabledProviders } from '@/services/dictionaries/registry';
import { buildLookupCandidates } from '@/services/dictionaries/lookupCandidates';
import { isTauriAppPlatform } from '@/services/environment';
import {
getBuiltinWebSearch,
@@ -84,13 +85,14 @@ export function useDictionaryResults({
const definitionProviders = useMemo(() => providers.filter((p) => p.kind !== 'web'), [providers]);
const webSearchProviders = useMemo(() => providers.filter((p) => p.kind === 'web'), [providers]);
const [historyStack, setHistoryStack] = useState<string[]>([word]);
const currentWord = historyStack[historyStack.length - 1] ?? word;
const [historyStack, setHistoryStack] = useState<string[]>([word.trim()]);
const currentWord = historyStack[historyStack.length - 1] ?? word.trim();
// Reset the history when the host reopens with a new word from outside
// (selection change in the reader).
// (selection change in the reader). A double-click selection can carry
// trailing whitespace, so trim before seeding.
useEffect(() => {
setHistoryStack([word]);
setHistoryStack([word.trim()]);
}, [word]);
const [cards, setCards] = useState<Record<string, CardState>>({});
@@ -209,16 +211,25 @@ export function useDictionaryResults({
if (!container) {
outcome = { ok: false, reason: 'error', message: 'no container' };
} else {
container.replaceChildren();
outcome = await provider.lookup(currentWord, {
lang: langCode,
signal: controller.signal,
container,
onNavigate: pushWord,
isDarkMode,
bg: themeCode.bg,
fg: themeCode.fg,
});
// Try normalized query variants (trimmed, case-folded) in
// priority order and keep the first hit. Case-sensitive
// formats (mdict) otherwise miss `Hello` / `world ` style
// selections whose headword is stored lowercased.
outcome = { ok: false, reason: 'empty' };
for (const candidate of buildLookupCandidates(currentWord)) {
container.replaceChildren();
outcome = await provider.lookup(candidate, {
lang: langCode,
signal: controller.signal,
container,
onNavigate: pushWord,
isDarkMode,
bg: themeCode.bg,
fg: themeCode.fg,
});
if (controller.signal.aborted) return;
if (outcome.ok || outcome.reason !== 'empty') break;
}
}
} catch (err) {
outcome = {