140b71ee30
Expose `::part(dict-content)` on the MDict shadow content and add a dictionary popup font-size setting (Settings → Language → Dictionaries), independent of the main reading view. - mdictProvider: tag the in-shadow body with `part="dict-content"` and a stable `dict-shadow-host` class so the popup's `::part()` rule can reach across the shadow boundary — MDict is the only provider that renders into a shadow root, so ordinary popup CSS can't touch it. - DictionarySettings.fontScale (default 1) with setFontScale + load-merge; synced cross-device via the `dictionarySettings.fontScale` whitelist entry. - DictionaryResultsView drives `--dict-font-scale` + `data-dict-content` on each per-tab container. globals.css re-bases the light-DOM Tailwind text utilities to `em` within that scope and sizes the MDict shadow body via `::part(dict-content)`, so every provider scales from one lever. - SettingsSelect control (85–175%) in the Dictionaries panel. Tests: jsdom unit tests (part attribute, store fontScale, sync whitelist) plus a real-Chromium browser test for the em-rebasing + `::part` + custom- property-inheritance CSS contract jsdom cannot model. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
506 lines
19 KiB
TypeScript
506 lines
19 KiB
TypeScript
'use client';
|
|
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { MdArrowBack, MdChevronRight, MdSettings } from 'react-icons/md';
|
|
import clsx from 'clsx';
|
|
import { openUrl } from '@tauri-apps/plugin-opener';
|
|
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
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,
|
|
substituteUrlTemplate,
|
|
} from '@/services/dictionaries/webSearchTemplates';
|
|
import type {
|
|
DictionaryLookupOutcome,
|
|
DictionaryProvider,
|
|
WebSearchEntry,
|
|
} from '@/services/dictionaries/types';
|
|
|
|
const isTauri = isTauriAppPlatform();
|
|
|
|
interface CardState {
|
|
state: 'loading' | 'loaded' | 'empty' | 'unsupported' | 'error';
|
|
loadKey: string;
|
|
outcome?: DictionaryLookupOutcome;
|
|
expanded: boolean;
|
|
}
|
|
|
|
export interface UseDictionaryResultsArgs {
|
|
word: string;
|
|
lang?: string;
|
|
}
|
|
|
|
export interface DictionaryResultsState {
|
|
currentWord: string;
|
|
canGoBack: boolean;
|
|
goBack: () => void;
|
|
visibleDefinitionProviders: DictionaryProvider[];
|
|
webSearchProviders: DictionaryProvider[];
|
|
cards: Record<string, CardState>;
|
|
setContainerRef: (id: string) => (el: HTMLDivElement | null) => void;
|
|
handleContainerClick: (e: React.MouseEvent) => void;
|
|
toggleExpanded: (id: string) => void;
|
|
resolveWebSearchUrl: (id: string) => string | undefined;
|
|
onWebSearchClickTauri: (e: React.MouseEvent<HTMLAnchorElement>, id: string) => void;
|
|
noProvidersAtAll: boolean;
|
|
/** Dictionary popup font-size multiplier (#4443); `1` = default sizes. */
|
|
fontScale: number;
|
|
}
|
|
|
|
/**
|
|
* State + lookup orchestration shared by the desktop popup and the mobile
|
|
* bottom sheet. Owns:
|
|
* - the in-component history stack (for in-content link navigation),
|
|
* - the per-provider lookup fan-out + abort wiring,
|
|
* - per-card expand/collapse with the ≤ 3-results auto-expand default,
|
|
* - external-link delegation (Tauri vs web target="_blank"),
|
|
* - web-search URL resolution.
|
|
*
|
|
* Both wrappers mount this hook and feed its return value into
|
|
* {@link DictionaryResultsHeader} (sticky title + back + manage gear) and
|
|
* {@link DictionaryResultsBody} (card stack + web-search rows).
|
|
*/
|
|
export function useDictionaryResults({
|
|
word,
|
|
lang,
|
|
}: UseDictionaryResultsArgs): DictionaryResultsState {
|
|
const { appService } = useEnv();
|
|
const { dictionaries, settings } = useCustomDictionaryStore();
|
|
const isDarkMode = useThemeStore((s) => s.isDarkMode);
|
|
const themeCode = useThemeStore((s) => s.themeCode);
|
|
|
|
const computedProviders = getEnabledProviders({
|
|
settings,
|
|
dictionaries,
|
|
fs: appService ?? undefined,
|
|
});
|
|
const providersSignature = computedProviders.map((p) => p.id).join('|');
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
const providers = useMemo<DictionaryProvider[]>(() => computedProviders, [providersSignature]);
|
|
|
|
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.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). A double-click selection can carry
|
|
// trailing whitespace, so trim before seeding.
|
|
useEffect(() => {
|
|
setHistoryStack([word.trim()]);
|
|
}, [word]);
|
|
|
|
const [cards, setCards] = useState<Record<string, CardState>>({});
|
|
// Cards the user has manually toggled. The auto-expand reconciliation
|
|
// (≤ 3 results → default expanded) only writes to cards NOT in this set.
|
|
const [manuallyToggled, setManuallyToggled] = useState<Record<string, boolean>>({});
|
|
|
|
const containerRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
const setContainerRef = useCallback(
|
|
(id: string) => (el: HTMLDivElement | null) => {
|
|
if (el) containerRefs.current.set(id, el);
|
|
else containerRefs.current.delete(id);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const pushWord = useCallback((next: string) => {
|
|
const trimmed = next.trim();
|
|
if (!trimmed) return;
|
|
setHistoryStack((prev) => {
|
|
if (prev[prev.length - 1] === trimmed) return prev;
|
|
return [...prev, trimmed];
|
|
});
|
|
}, []);
|
|
|
|
const goBack = useCallback(() => {
|
|
setHistoryStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev));
|
|
}, []);
|
|
|
|
const toggleExpanded = useCallback((id: string) => {
|
|
setCards((prev) => {
|
|
const old = prev[id];
|
|
if (!old) return prev;
|
|
return { ...prev, [id]: { ...old, expanded: !old.expanded } };
|
|
});
|
|
setManuallyToggled((prev) => (prev[id] ? prev : { ...prev, [id]: true }));
|
|
}, []);
|
|
|
|
// Reset manual-toggle tracking when the looked-up word changes — the
|
|
// auto-expand decision should re-evaluate against the new result count.
|
|
useEffect(() => {
|
|
setManuallyToggled({});
|
|
}, [currentWord]);
|
|
|
|
// Auto-expand decision: when ≤ 3 providers have settled with results,
|
|
// default-expand all of them. With > 3, default-collapse. User toggles
|
|
// are sticky (tracked in `manuallyToggled`).
|
|
useEffect(() => {
|
|
const loadedIds = Object.entries(cards)
|
|
.filter(([, c]) => c.state === 'loaded')
|
|
.map(([id]) => id);
|
|
if (loadedIds.length === 0) return;
|
|
const shouldExpand = loadedIds.length <= 3;
|
|
setCards((prev) => {
|
|
let changed = false;
|
|
const next = { ...prev };
|
|
for (const id of loadedIds) {
|
|
if (manuallyToggled[id]) continue;
|
|
const c = prev[id];
|
|
if (!c) continue;
|
|
if (c.expanded !== shouldExpand) {
|
|
next[id] = { ...c, expanded: shouldExpand };
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed ? next : prev;
|
|
});
|
|
}, [cards, manuallyToggled]);
|
|
|
|
// External-link delegation inside provider-rendered DOM: on Tauri we
|
|
// route http(s) anchors through `openUrl` because target="_blank" doesn't
|
|
// work; on web we let the anchor handle it natively.
|
|
const handleContainerClick = useCallback((e: React.MouseEvent) => {
|
|
if (!isTauri) return;
|
|
if (e.defaultPrevented) return;
|
|
const anchor = (e.target as Element | null)?.closest?.('a');
|
|
if (!anchor) return;
|
|
const rawHref = anchor.getAttribute('href');
|
|
if (!rawHref || !/^https?:\/\//i.test(rawHref)) return;
|
|
e.preventDefault();
|
|
void openUrl(rawHref).catch((err) => {
|
|
console.warn('Failed to open external URL', rawHref, err);
|
|
});
|
|
}, []);
|
|
|
|
// Lookup orchestration: fan out across all definition providers in
|
|
// parallel whenever currentWord (or the provider list) changes.
|
|
useEffect(() => {
|
|
if (!definitionProviders.length) return;
|
|
const langCode = typeof lang === 'string' ? lang : Array.isArray(lang) ? lang[0] : undefined;
|
|
const loadKey = `${currentWord}::${langCode || ''}`;
|
|
|
|
setCards(() => {
|
|
const next: Record<string, CardState> = {};
|
|
for (const provider of definitionProviders) {
|
|
next[provider.id] = {
|
|
state: 'loading',
|
|
loadKey,
|
|
outcome: undefined,
|
|
expanded: false,
|
|
};
|
|
}
|
|
return next;
|
|
});
|
|
|
|
const controllers = new Map<string, AbortController>();
|
|
definitionProviders.forEach((provider) => {
|
|
const controller = new AbortController();
|
|
controllers.set(provider.id, controller);
|
|
|
|
const run = async () => {
|
|
let outcome: DictionaryLookupOutcome;
|
|
try {
|
|
if (provider.init) await provider.init();
|
|
const container = containerRefs.current.get(provider.id);
|
|
if (!container) {
|
|
outcome = { ok: false, reason: 'error', message: 'no container' };
|
|
} else {
|
|
// Try normalized query variants (trimmed, case-folded) then
|
|
// language-aware lemma candidates in priority order, keeping the
|
|
// first hit. Case-sensitive formats (mdict) otherwise miss
|
|
// `Hello` / `world ` style selections whose headword is stored
|
|
// lowercased, and dictionaries that store only base headwords
|
|
// (e.g. Oxford Dictionary of English) miss inflected selections
|
|
// like `ran` / `mice` / `analyses`.
|
|
outcome = { ok: false, reason: 'empty' };
|
|
for (const candidate of buildLookupCandidates(currentWord, langCode)) {
|
|
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 = {
|
|
ok: false,
|
|
reason: 'error',
|
|
message: err instanceof Error ? err.message : String(err),
|
|
};
|
|
}
|
|
if (controller.signal.aborted) return;
|
|
const state = outcome.ok
|
|
? 'loaded'
|
|
: outcome.reason === 'empty'
|
|
? 'empty'
|
|
: outcome.reason === 'unsupported'
|
|
? 'unsupported'
|
|
: 'error';
|
|
setCards((prev) => {
|
|
const old = prev[provider.id];
|
|
if (!old || old.loadKey !== loadKey) return prev;
|
|
return { ...prev, [provider.id]: { ...old, state, outcome } };
|
|
});
|
|
};
|
|
void run();
|
|
});
|
|
|
|
return () => controllers.forEach((c) => c.abort());
|
|
}, [currentWord, definitionProviders, lang, pushWord, isDarkMode, themeCode.bg, themeCode.fg]);
|
|
|
|
// Visible cards = providers that are still loading or finished with a
|
|
// result. Empty/unsupported/error cards are removed entirely.
|
|
const visibleDefinitionProviders = definitionProviders.filter((p) => {
|
|
const card = cards[p.id];
|
|
if (!card) return true;
|
|
return card.state === 'loading' || card.state === 'loaded';
|
|
});
|
|
|
|
const resolveWebSearchUrl = useCallback(
|
|
(id: string): string | undefined => {
|
|
if (id.startsWith('web:builtin:')) {
|
|
const tpl = getBuiltinWebSearch(id);
|
|
return tpl ? substituteUrlTemplate(tpl.urlTemplate, currentWord) : undefined;
|
|
}
|
|
const list: WebSearchEntry[] = settings.webSearches ?? [];
|
|
const tpl = list.find((t) => t.id === id);
|
|
if (!tpl || tpl.deletedAt) return undefined;
|
|
return substituteUrlTemplate(tpl.urlTemplate, currentWord);
|
|
},
|
|
[currentWord, settings.webSearches],
|
|
);
|
|
|
|
const onWebSearchClickTauri = useCallback(
|
|
(e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
|
|
if (!isTauri) return;
|
|
e.preventDefault();
|
|
const url = resolveWebSearchUrl(id);
|
|
if (!url) return;
|
|
void openUrl(url).catch((err) => {
|
|
console.warn('Failed to open external URL', url, err);
|
|
});
|
|
},
|
|
[resolveWebSearchUrl],
|
|
);
|
|
|
|
const canGoBack = historyStack.length > 1;
|
|
const noProvidersAtAll = providers.length === 0;
|
|
|
|
return {
|
|
currentWord,
|
|
canGoBack,
|
|
goBack,
|
|
visibleDefinitionProviders,
|
|
webSearchProviders,
|
|
cards,
|
|
setContainerRef,
|
|
handleContainerClick,
|
|
toggleExpanded,
|
|
resolveWebSearchUrl,
|
|
onWebSearchClickTauri,
|
|
noProvidersAtAll,
|
|
fontScale: settings.fontScale ?? 1,
|
|
};
|
|
}
|
|
|
|
interface DictionaryResultsHeaderProps {
|
|
headerClassName?: string;
|
|
currentWord: string;
|
|
canGoBack: boolean;
|
|
goBack: () => void;
|
|
onManage?: () => void;
|
|
}
|
|
|
|
export const DictionaryResultsHeader: React.FC<DictionaryResultsHeaderProps> = ({
|
|
headerClassName,
|
|
currentWord,
|
|
canGoBack,
|
|
goBack,
|
|
onManage,
|
|
}) => {
|
|
const _ = useTranslation();
|
|
return (
|
|
<div className={clsx('flex h-8 w-full items-center justify-between px-2', headerClassName)}>
|
|
<div className='flex h-8 w-8 items-center justify-center'>
|
|
{canGoBack ? (
|
|
<button
|
|
type='button'
|
|
aria-label={_('Back')}
|
|
onClick={goBack}
|
|
className='btn btn-ghost btn-circle h-8 min-h-8 w-8'
|
|
>
|
|
<MdArrowBack size={20} />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
<span data-testid='dict-title' className='line-clamp-1 flex-1 text-center font-bold'>
|
|
{currentWord}
|
|
</span>
|
|
<div className='flex h-8 w-8 items-center justify-center'>
|
|
{onManage ? (
|
|
<button
|
|
type='button'
|
|
aria-label={_('Manage Dictionaries')}
|
|
title={_('Manage Dictionaries')}
|
|
onClick={onManage}
|
|
className='btn btn-ghost btn-square btn-xs text-base-content/60 hover:text-base-content not-eink:hover:bg-base-200/60'
|
|
>
|
|
<MdSettings size={16} />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface DictionaryResultsBodyProps extends DictionaryResultsState {}
|
|
|
|
export const DictionaryResultsBody: React.FC<DictionaryResultsBodyProps> = ({
|
|
visibleDefinitionProviders,
|
|
webSearchProviders,
|
|
cards,
|
|
setContainerRef,
|
|
handleContainerClick,
|
|
toggleExpanded,
|
|
resolveWebSearchUrl,
|
|
onWebSearchClickTauri,
|
|
noProvidersAtAll,
|
|
fontScale,
|
|
}) => {
|
|
const _ = useTranslation();
|
|
return (
|
|
<div className='flex h-full flex-col'>
|
|
<div className='flex-1 overflow-y-auto'>
|
|
{noProvidersAtAll ? (
|
|
<div className='flex h-full flex-col items-center justify-center px-6 text-center'>
|
|
<h1 className='text-base font-bold'>{_('No dictionaries enabled')}</h1>
|
|
<p className='not-eink:opacity-70 mt-2 text-sm'>
|
|
{_('Enable a dictionary in Settings → Language → Dictionaries.')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{visibleDefinitionProviders.length > 0 && (
|
|
<section className='px-4 pt-2'>
|
|
<h3 className='not-eink:opacity-60 mb-2 text-xs font-medium uppercase tracking-wide'>
|
|
{_('Dictionaries')}
|
|
</h3>
|
|
<ul className='flex flex-col gap-3'>
|
|
{visibleDefinitionProviders.map((p) => {
|
|
const card = cards[p.id];
|
|
const isLoading = !card || card.state === 'loading';
|
|
const expanded = card?.expanded ?? false;
|
|
const sourceLabel =
|
|
card?.outcome?.ok && card.outcome.sourceLabel
|
|
? card.outcome.sourceLabel
|
|
: _(p.label);
|
|
return (
|
|
<li key={p.id}>
|
|
<div
|
|
data-testid='dict-card'
|
|
role='button'
|
|
tabIndex={0}
|
|
aria-expanded={expanded}
|
|
onClick={(e) => {
|
|
const path = e.nativeEvent.composedPath();
|
|
for (const node of path) {
|
|
if (node === e.currentTarget) break;
|
|
if (node instanceof Element) {
|
|
const tag = node.tagName;
|
|
if (tag === 'A' || tag === 'BUTTON' || tag === 'IMG') return;
|
|
}
|
|
}
|
|
toggleExpanded(p.id);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
toggleExpanded(p.id);
|
|
}
|
|
}}
|
|
className={clsx('cursor-pointer rounded-lg')}
|
|
>
|
|
{isLoading && (
|
|
<div
|
|
data-testid='dict-card-skeleton'
|
|
className='bg-base-200/50 h-12 animate-pulse rounded'
|
|
/>
|
|
)}
|
|
<div
|
|
ref={setContainerRef(p.id)}
|
|
onClick={handleContainerClick}
|
|
// `data-dict-content` + `--dict-font-scale` drive the
|
|
// popup font-size rules in globals.css (#4443): the
|
|
// light-DOM `font-size` cascade and the MDict shadow
|
|
// `::part(dict-content)` rule both read this scope.
|
|
data-dict-content=''
|
|
style={{ '--dict-font-scale': fontScale } as React.CSSProperties}
|
|
className={clsx(
|
|
'font-sans',
|
|
isLoading && 'hidden',
|
|
!isLoading &&
|
|
!expanded &&
|
|
'line-clamp-4 max-h-40 overflow-hidden [-webkit-box-orient:vertical] [display:-webkit-box]',
|
|
)}
|
|
/>
|
|
{!isLoading && (
|
|
<div className='border-base-content/10 -me-4 mt-2 border-b pb-2'>
|
|
<span className='not-eink:opacity-60 text-xs'>{sourceLabel}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
|
|
{webSearchProviders.length > 0 && (
|
|
<section className='px-4 pt-4'>
|
|
<h3 className='not-eink:opacity-60 mb-2 text-xs font-medium uppercase tracking-wide'>
|
|
{_('Search the web')}
|
|
</h3>
|
|
<ul className='flex flex-col'>
|
|
{webSearchProviders.map((p) => {
|
|
const url = resolveWebSearchUrl(p.id);
|
|
return (
|
|
<li key={p.id}>
|
|
<a
|
|
href={url ?? '#'}
|
|
target={isTauri ? undefined : '_blank'}
|
|
rel='noopener noreferrer'
|
|
onClick={(e) => onWebSearchClickTauri(e, p.id)}
|
|
className='hover:bg-base-200/40 flex w-full items-center justify-between rounded-md px-2 py-3 text-left text-sm no-underline'
|
|
>
|
|
<span>{_(p.label)}</span>
|
|
<MdChevronRight className='not-eink:opacity-60' size={18} />
|
|
</a>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|