diff --git a/apps/readest-app/src/__tests__/app/reader/annotator/DictionarySheet.test.tsx b/apps/readest-app/src/__tests__/app/reader/annotator/DictionarySheet.test.tsx new file mode 100644 index 00000000..f3c23df0 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/reader/annotator/DictionarySheet.test.tsx @@ -0,0 +1,448 @@ +/** + * DictionarySheet tests — the mobile / narrow-viewport bottom sheet that + * replaces DictionaryPopup for `< sm` viewports. + * + * Lookups go through real providers backed by the on-disk dict fixtures in + * `src/__tests__/fixtures/data/dicts/`. The registry module is mocked per + * test so we can hand the sheet a controlled provider list (real StarDict / + * DICT instances + a couple of tiny in-test providers for navigation and + * abort assertions). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor, act } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +import type { DictionaryProvider, DictionaryLookupOutcome } from '@/services/dictionaries/types'; +import { BUILTIN_WEB_SEARCH_IDS } from '@/services/dictionaries/types'; +import type { ImportedDictionary } from '@/services/dictionaries/types'; +import type { BaseDir } from '@/types/system'; +import { createStarDictProvider } from '@/services/dictionaries/providers/starDictProvider'; +import { createDictProvider } from '@/services/dictionaries/providers/dictProvider'; +import { useCustomDictionaryStore } from '@/store/customDictionaryStore'; + +import { + IFO_FIXTURE_NAME, + IDX_FIXTURE_NAME, + DICT_FIXTURE_NAME, + readIfoFile, + readIdxFile, + readDictFile as readStarDictFile, +} from '../../../services/dictionaries/_stardictFixtures'; +import { + INDEX_FIXTURE_NAME, + DICT_FIXTURE_NAME as DICTD_FIXTURE_NAME, + readIndexFile, + readDictFile as readDictdFile, +} from '../../../services/dictionaries/_dictFixtures'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +// Replace Dialog with a thin shell so the sheet's internals are testable +// without dragging in theme/device/responsive/haptics dependencies. +vi.mock('@/components/Dialog', () => ({ + default: ({ + children, + header, + isOpen, + onClose, + }: { + children: ReactNode; + header?: ReactNode; + isOpen: boolean; + onClose: () => void; + snapHeight?: number; + contentClassName?: string; + dismissible?: boolean; + }) => + isOpen ? ( +
+
{header}
+
{children}
+
+ ) : null, +})); + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (s: string) => s, +})); + +const mockOpenUrl = vi.fn().mockResolvedValue(undefined); +vi.mock('@tauri-apps/plugin-opener', () => ({ + openUrl: (...args: unknown[]) => mockOpenUrl(...args), +})); + +vi.mock('@/services/environment', async () => { + const actual = + await vi.importActual('@/services/environment'); + return { + ...actual, + isTauriAppPlatform: () => false, + }; +}); + +// Mock the registry: every test pushes its own providers onto this list. +const providersForNextRender: DictionaryProvider[] = []; +vi.mock('@/services/dictionaries/registry', () => ({ + getEnabledProviders: () => [...providersForNextRender], + __resetRegistryForTests: vi.fn(), + evictProvider: vi.fn(), +})); + +// EnvProvider needs an appService; provide one with the file API the +// (unmocked) StarDict provider uses for fixture reads. +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ + envConfig: { getAppService: vi.fn().mockResolvedValue(null) }, + appService: { openFile: vi.fn() }, + }), +})); + +// --------------------------------------------------------------------------- +// Fixture-backed real providers +// --------------------------------------------------------------------------- + +const realStarDictDict: ImportedDictionary = { + id: 'stardict:cmudict', + kind: 'stardict', + name: 'CMU American English spelling', + bundleDir: 'cmudict-bundle', + files: { ifo: IFO_FIXTURE_NAME, idx: IDX_FIXTURE_NAME, dict: DICT_FIXTURE_NAME }, + addedAt: 1, +}; + +const realDictdDict: ImportedDictionary = { + id: 'dict:freedict-eng-nld', + kind: 'dict', + name: 'FreeDict English-Dutch', + bundleDir: 'freedict-eng-nld-bundle', + files: { dict: DICTD_FIXTURE_NAME, index: INDEX_FIXTURE_NAME }, + addedAt: 2, +}; + +const makeStarDictFs = () => ({ + openFile: async (p: string, _base: BaseDir) => { + const base = p.split('/').pop()!; + if (base === IFO_FIXTURE_NAME) return readIfoFile(); + if (base === IDX_FIXTURE_NAME) return readIdxFile(); + if (base === DICT_FIXTURE_NAME) return readStarDictFile(); + throw new Error(`Unknown stardict fixture: ${base}`); + }, +}); + +const makeDictdFs = () => ({ + openFile: async (p: string, _base: BaseDir) => { + const base = p.split('/').pop()!; + if (base === INDEX_FIXTURE_NAME) return readIndexFile(); + if (base === DICTD_FIXTURE_NAME) return readDictdFile(); + throw new Error(`Unknown dictd fixture: ${base}`); + }, +}); + +const buildRealStarDictProvider = (): DictionaryProvider => + createStarDictProvider({ dict: realStarDictDict, fs: makeStarDictFs() }); + +const buildRealDictdProvider = (): DictionaryProvider => + createDictProvider({ dict: realDictdDict, fs: makeDictdFs() }); + +// --------------------------------------------------------------------------- +// In-test providers — small fakes for behaviors that real fixture data +// can't easily exercise (in-content navigation, slow lookups for abort). +// --------------------------------------------------------------------------- + +const buildNavProvider = (nextWord: string): DictionaryProvider => ({ + id: 'nav-test', + kind: 'stardict', + label: 'Nav Test', + async lookup(word, ctx) { + const a = document.createElement('a'); + a.href = '#'; + a.textContent = `→ ${nextWord}`; + a.setAttribute('rel', 'mw:WikiLink'); + a.setAttribute('data-testid', 'nav-link'); + a.addEventListener('click', (e) => { + e.preventDefault(); + ctx.onNavigate?.(nextWord); + }); + ctx.container.append(document.createTextNode(`current: ${word} `)); + ctx.container.append(a); + return { ok: true, headword: word, sourceLabel: 'Nav Test' }; + }, +}); + +const buildSlowProvider = (abortObserver: { aborted: boolean }): DictionaryProvider => ({ + id: 'slow', + kind: 'stardict', + label: 'Slow', + async lookup(_word, ctx): Promise { + return new Promise((resolve) => { + ctx.signal.addEventListener('abort', () => { + abortObserver.aborted = true; + resolve({ ok: false, reason: 'error', message: 'aborted' }); + }); + }); + }, +}); + +const buildEmptyProvider = (id: string, label: string): DictionaryProvider => ({ + id, + kind: 'stardict', + label, + async lookup() { + return { ok: false, reason: 'empty' }; + }, +}); + +// --------------------------------------------------------------------------- +// Sheet harness +// --------------------------------------------------------------------------- + +import DictionarySheet from '@/app/reader/components/annotator/DictionarySheet'; + +const renderSheet = ( + props: Partial<{ + word: string; + lang: string; + onDismiss: () => void; + onManage: () => void; + }> = {}, +) => + render( + {})} + onManage={props.onManage} + />, + ); + +const resetStoreToEmpty = () => { + useCustomDictionaryStore.setState({ + dictionaries: [], + settings: { + providerOrder: [], + providerEnabled: {}, + webSearches: [], + }, + }); +}; + +beforeEach(() => { + providersForNextRender.length = 0; + mockOpenUrl.mockClear(); + resetStoreToEmpty(); +}); + +afterEach(() => { + cleanup(); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('DictionarySheet — header', () => { + it('renders the looked-up word and a manage button; no back button at history length 1', async () => { + providersForNextRender.push(buildRealStarDictProvider()); + renderSheet({ word: 'hello', onManage: vi.fn() }); + + expect((await screen.findByTestId('dict-title')).textContent).toBe('hello'); + expect(screen.getByLabelText('Manage Dictionaries')).toBeTruthy(); + expect(screen.queryByLabelText('Back')).toBeNull(); + }); +}); + +describe('DictionarySheet — concurrent lookup', () => { + it('fans out lookups across every enabled definition provider', async () => { + const stardict = buildRealStarDictProvider(); + const dictd = buildRealDictdProvider(); + const stardictSpy = vi.spyOn(stardict, 'lookup'); + const dictdSpy = vi.spyOn(dictd, 'lookup'); + providersForNextRender.push(stardict, dictd); + + renderSheet({ word: 'hello' }); + + await waitFor(() => { + expect(stardictSpy).toHaveBeenCalledWith('hello', expect.any(Object)); + expect(dictdSpy).toHaveBeenCalledWith('hello', expect.any(Object)); + }); + }); + + it('renders the cmudict card after the lookup settles', async () => { + providersForNextRender.push(buildRealStarDictProvider()); + renderSheet({ word: 'hello' }); + + await screen.findByText('CMU American English spelling'); + }); + + it('hides cards from providers that return empty', async () => { + providersForNextRender.push( + buildRealStarDictProvider(), + buildEmptyProvider('empty:1', 'Empty One'), + buildEmptyProvider('empty:2', 'Empty Two'), + ); + renderSheet({ word: 'hello' }); + + // The cmudict card eventually appears. + await screen.findByText('CMU American English spelling'); + // The two empty providers never render a card. + expect(screen.queryByText('Empty One')).toBeNull(); + expect(screen.queryByText('Empty Two')).toBeNull(); + }); +}); + +describe('DictionarySheet — expand / collapse', () => { + it('toggles aria-expanded when a card is tapped', async () => { + providersForNextRender.push(buildRealStarDictProvider()); + renderSheet({ word: 'hello' }); + + // Wait for the lookup to finish (source label visible). + await screen.findByText('CMU American English spelling'); + const card = screen.getByTestId('dict-card'); + // With ≤ 3 results the sheet defaults to expanded. + await waitFor(() => expect(card.getAttribute('aria-expanded')).toBe('true')); + + fireEvent.click(card); + expect(card.getAttribute('aria-expanded')).toBe('false'); + + fireEvent.click(card); + expect(card.getAttribute('aria-expanded')).toBe('true'); + }); + + it('defaults to collapsed when more than 3 providers have results', async () => { + // Four providers, all with content → > 3 → default-collapsed. + const providers: DictionaryProvider[] = []; + for (let i = 0; i < 4; i++) { + providers.push({ + id: `pseudo:${i}`, + kind: 'stardict', + label: `Pseudo ${i}`, + async lookup(word, ctx) { + ctx.container.append(document.createTextNode(`def for ${word} #${i}`)); + return { ok: true, headword: word, sourceLabel: `Pseudo ${i}` }; + }, + }); + } + providersForNextRender.push(...providers); + renderSheet({ word: 'hello' }); + + await screen.findByText('Pseudo 0'); + const cards = screen.getAllByTestId('dict-card'); + expect(cards).toHaveLength(4); + for (const card of cards) { + expect(card.getAttribute('aria-expanded')).toBe('false'); + } + }); +}); + +describe('DictionarySheet — in-content navigation', () => { + it('pushes onto the history stack when a provider link triggers onNavigate; back button appears', async () => { + providersForNextRender.push(buildNavProvider('world')); + renderSheet({ word: 'hello' }); + + expect((await screen.findByTestId('dict-title')).textContent).toBe('hello'); + expect(screen.queryByLabelText('Back')).toBeNull(); + + const navLink = await screen.findByTestId('nav-link'); + await act(async () => { + fireEvent.click(navLink); + }); + + await waitFor(() => { + expect(screen.getByTestId('dict-title').textContent).toBe('world'); + }); + expect(screen.getByLabelText('Back')).toBeTruthy(); + }); + + it('back button pops the history stack and restores the previous word', async () => { + providersForNextRender.push(buildNavProvider('world')); + renderSheet({ word: 'hello' }); + + const navLink = await screen.findByTestId('nav-link'); + await act(async () => { + fireEvent.click(navLink); + }); + await waitFor(() => expect(screen.getByTestId('dict-title').textContent).toBe('world')); + + fireEvent.click(screen.getByLabelText('Back')); + await waitFor(() => expect(screen.getByTestId('dict-title').textContent).toBe('hello')); + expect(screen.queryByLabelText('Back')).toBeNull(); + }); +}); + +describe('DictionarySheet — web search row', () => { + it('renders a link with the resolved URL and target="_blank" on the web build', async () => { + // Real built-in Google web-search provider, via the registry mock. + const googleEntry: DictionaryProvider = { + id: BUILTIN_WEB_SEARCH_IDS.google, + kind: 'web', + label: 'Google', + async lookup() { + return { ok: true }; + }, + }; + // Enable the google entry in the store so the sheet can resolve its + // urlTemplate from BUILTIN_WEB_SEARCHES. + useCustomDictionaryStore.setState({ + dictionaries: [], + settings: { + providerOrder: [BUILTIN_WEB_SEARCH_IDS.google], + providerEnabled: { [BUILTIN_WEB_SEARCH_IDS.google]: true }, + webSearches: [], + }, + }); + providersForNextRender.push(googleEntry); + + renderSheet({ word: 'hello world' }); + + // The test setup mocks `isTauriAppPlatform: () => false`, so we + // exercise the web-build path: anchor with href + target="_blank", + // openUrl untouched. + const link = (await screen.findByRole('link', { name: /Google/i })) as HTMLAnchorElement; + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toContain('noopener'); + const url = link.getAttribute('href') ?? ''; + expect(url.startsWith('https://www.google.com/search')).toBe(true); + expect(url).toContain(encodeURIComponent('hello world')); + + fireEvent.click(link); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); +}); + +describe('DictionarySheet — empty state', () => { + it('renders "No dictionaries enabled" + manage gear when zero providers are configured', async () => { + // providersForNextRender stays empty. + const onManage = vi.fn(); + renderSheet({ word: 'hello', onManage }); + + expect(await screen.findByText('No dictionaries enabled')).toBeTruthy(); + const gear = screen.getByLabelText('Manage Dictionaries'); + fireEvent.click(gear); + expect(onManage).toHaveBeenCalledTimes(1); + }); +}); + +describe('DictionarySheet — abort on unmount', () => { + it('aborts in-flight provider lookups when the sheet unmounts', async () => { + const observer = { aborted: false }; + providersForNextRender.push(buildSlowProvider(observer)); + + const { unmount } = renderSheet({ word: 'hello' }); + + // Wait until the lookup has been kicked off (the provider hasn't + // resolved yet — it's pending on its abort listener). + await waitFor(() => { + // Skeleton card rendered while loading. + expect(screen.queryByTestId('dict-card-skeleton')).toBeTruthy(); + }); + + unmount(); + + await waitFor(() => { + expect(observer.aborted).toBe(true); + }); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index c51282c0..0ff69c0e 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -43,6 +43,7 @@ import { annotationToolButtons } from './AnnotationTools'; import AnnotationRangeEditor from './AnnotationRangeEditor'; import AnnotationPopup from './AnnotationPopup'; import DictionaryPopup from './DictionaryPopup'; +import DictionarySheet from './DictionarySheet'; import TranslatorPopup from './TranslatorPopup'; import useShortcuts from '@/hooks/useShortcuts'; import ProofreadPopup from './ProofreadPopup'; @@ -123,7 +124,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const maxWidth = window.innerWidth - 2 * popupPadding; const maxHeight = window.innerHeight - 2 * popupPadding; const dictPopupWidth = Math.min(480, maxWidth); - const dictPopupHeight = Math.min(300, maxHeight); + // Tall enough to fit a header + 2-3 expanded cards comfortably. The popup + // shows all enabled providers stacked (no tabs) so it needs more vertical + // room than the legacy single-tab layout. + const dictPopupHeight = Math.min(480, maxHeight); const transPopupWidth = Math.min(480, maxWidth); const transPopupHeight = Math.min(265, maxHeight); const proofreadPopupWidth = Math.min(440, maxWidth); @@ -963,26 +967,45 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { return (
- {showDictionaryPopup && trianglePosition && dictPopupPosition && ( - { - // Dismiss the popup so the user returns to the reader cleanly - // when they close settings; the dictionaries sub-page in the - // SettingsDialog is enough surface for managing providers. + {showDictionaryPopup && + (() => { + // Below `sm` (or short landscape) we present the dictionary as a + // bottom sheet — the anchored popup gets cramped at this size. + // Matches the `isMobile` heuristic used by `Dialog`. + const useSheet = window.innerWidth < 640 || window.innerHeight < 640; + const onManage = () => { + // Dismiss so the user returns to the reader cleanly when they + // close settings; the dictionaries sub-page in SettingsDialog + // is enough surface for managing providers. handleDismissPopupAndSelection(); setSettingsDialogBookKey(bookKey); setActiveSettingsItemId('settings.language.dictionaries.manage'); setSettingsDialogOpen(true); - }} - /> - )} + }; + if (useSheet) { + return ( + + ); + } + if (!trianglePosition || !dictPopupPosition) return null; + return ( + + ); + })()} {showDeepLPopup && trianglePosition && translatorPopupPosition && ( void; /** - * Invoked when the user clicks the bottom-right "Manage Dictionaries" - * icon. The host (Annotator) decides how to navigate — typically by - * opening the SettingsDialog and deep-linking to the dictionaries - * sub-page. + * Invoked when the user clicks the header gear. The host (Annotator) + * decides how to navigate — typically by opening the SettingsDialog and + * deep-linking to the dictionaries sub-page. */ onManage?: () => void; } -interface TabState { - history: { items: string[]; index: number }; - loadKey: string; - state: 'idle' | 'loading' | 'loaded' | 'empty' | 'error' | 'unsupported'; - outcome?: DictionaryLookupOutcome; -} - -const initialTabState = (word: string): TabState => ({ - history: { items: [word], index: 0 }, - loadKey: '', - state: 'idle', -}); - const DictionaryPopup: React.FC = ({ word, lang, @@ -54,245 +36,7 @@ const DictionaryPopup: React.FC = ({ onDismiss, onManage, }) => { - const _ = useTranslation(); - const { envConfig, appService } = useEnv(); - const { dictionaries, settings, setDefaultProviderId, saveCustomDictionaries } = - useCustomDictionaryStore(); - - // Compute the enabled-provider list, then memoize by the resulting ID - // signature so unrelated settings tweaks (e.g. `setDefaultProviderId` - // saving the last-used tab) don't change the array reference and trigger - // a spurious lookup-effect re-fire mid-init. - 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(() => computedProviders, [providersSignature]); - - const fallbackTabId = providers[0]?.id; - const initialTab = useMemo(() => { - if (!providers.length) return undefined; - if (settings.defaultProviderId && providers.some((p) => p.id === settings.defaultProviderId)) { - return settings.defaultProviderId; - } - return fallbackTabId; - }, [providers, settings.defaultProviderId, fallbackTabId]); - - const [activeTab, setActiveTab] = useState(initialTab); - const [tabStates, setTabStates] = useState>(() => { - const seed: Record = {}; - providers.forEach((p) => { - seed[p.id] = initialTabState(word); - }); - return seed; - }); - - // Reset all tabs when the looked-up word changes from outside. - useEffect(() => { - setTabStates((prev) => { - const next: Record = {}; - for (const provider of providers) { - const old = prev[provider.id]; - if (old && old.history.items[0] === word && old.history.index === 0) { - next[provider.id] = old; - } else { - next[provider.id] = initialTabState(word); - } - } - return next; - }); - }, [word, providers]); - - // If the persisted defaultProviderId disappears (provider disabled / removed), - // fall back to the first available tab. - useEffect(() => { - if (!providers.length) { - if (activeTab !== undefined) setActiveTab(undefined); - return; - } - if (!activeTab || !providers.some((p) => p.id === activeTab)) { - setActiveTab(fallbackTabId); - } - }, [providers, activeTab, fallbackTabId]); - - // Persist last-active tab as the user switches. - const persistTimerRef = useRef | null>(null); - useEffect(() => { - if (!activeTab) return; - if (settings.defaultProviderId === activeTab) return; - setDefaultProviderId(activeTab); - if (persistTimerRef.current) clearTimeout(persistTimerRef.current); - persistTimerRef.current = setTimeout(() => { - void saveCustomDictionaries(envConfig).catch(() => {}); - }, 500); - return () => { - if (persistTimerRef.current) { - clearTimeout(persistTimerRef.current); - persistTimerRef.current = null; - } - }; - }, [ - activeTab, - settings.defaultProviderId, - setDefaultProviderId, - saveCustomDictionaries, - envConfig, - ]); - - // Per-tab DOM container refs. Providers render into these. - const containerRefs = useRef>(new Map()); - const setContainerRef = useCallback( - (id: string) => (el: HTMLDivElement | null) => { - if (el) containerRefs.current.set(id, el); - else containerRefs.current.delete(id); - }, - [], - ); - - /** - * Click delegation for provider-rendered anchors. - * - * Providers (Wikipedia "Read on Wikipedia →", error placeholders, etc.) - * append `` elements imperatively to `ctx.container`. Those elements - * can't use the React `Link` component, so route external http(s) - * clicks through Tauri's `openUrl` here. Internal clicks (relative - * `/wiki/...` links from Wiktionary, intercepted by the provider for - * in-popup history) keep their existing behaviour — we only act when - * the raw `href` attribute starts with `http(s)://`. - */ - const handleContainerClick = useCallback((e: React.MouseEvent) => { - if (!isTauri) return; // Non-Tauri: target="_blank" + rel handles it. - if (e.defaultPrevented) return; // Provider already handled it. - 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); - }); - }, []); - - const pushHistory = useCallback((tabId: string, nextWord: string) => { - const trimmed = nextWord.trim(); - if (!trimmed) return; - setTabStates((prev) => { - const old = prev[tabId]; - if (!old) return prev; - const currentWord = old.history.items[old.history.index]; - if (currentWord === trimmed) return prev; - const items = [...old.history.items.slice(0, old.history.index + 1), trimmed]; - return { - ...prev, - [tabId]: { ...old, history: { items, index: items.length - 1 } }, - }; - }); - }, []); - - const goBack = useCallback((tabId: string) => { - setTabStates((prev) => { - const old = prev[tabId]; - if (!old || old.history.index === 0) return prev; - return { - ...prev, - [tabId]: { ...old, history: { ...old.history, index: old.history.index - 1 } }, - }; - }); - }, []); - - const activeTabState = activeTab ? tabStates[activeTab] : undefined; - const lookupIndex = activeTabState?.history.index ?? 0; - const lookupWord = activeTabState?.history.items[lookupIndex] ?? word; - const lookupLoadKey = activeTabState?.loadKey ?? ''; - const lookupState = activeTabState?.state; - - // Lookup effect — runs whenever the active tab's lookupWord changes (initial - // activation, history navigation, or word prop changes). - useEffect(() => { - if (!activeTab) return; - if (!activeTabState) return; - const provider = providers.find((p) => p.id === activeTab); - if (!provider) return; - const langCode = typeof lang === 'string' ? lang : Array.isArray(lang) ? lang[0] : undefined; - const loadKey = `${lookupWord}::${langCode || ''}`; - // Skip only when we already have a settled outcome for this loadKey. - // We must NOT skip on `state==='loading'`: a previous effect cleanup - // may have aborted the in-flight run before it could update state, in - // which case the next fire is the only chance to actually load the - // result. Skipping here would deadlock the tab on the spinner. - const isSettled = - lookupState === 'loaded' || - lookupState === 'empty' || - lookupState === 'error' || - lookupState === 'unsupported'; - if (lookupLoadKey === loadKey && isSettled) return; - - const container = containerRefs.current.get(activeTab); - if (!container) return; - container.replaceChildren(); - container.scrollTop = 0; - - const controller = new AbortController(); - setTabStates((prev) => { - const old = prev[activeTab]; - if (!old) return prev; - return { ...prev, [activeTab]: { ...old, loadKey, state: 'loading' } }; - }); - - let cancelled = false; - const run = async () => { - let outcome: DictionaryLookupOutcome; - try { - if (provider.init) await provider.init(); - outcome = await provider.lookup(lookupWord, { - lang: langCode, - signal: controller.signal, - container, - onNavigate: (next) => pushHistory(activeTab, next), - }); - } catch (error) { - outcome = { - ok: false, - reason: 'error', - message: error instanceof Error ? error.message : String(error), - }; - } - if (cancelled || controller.signal.aborted) return; - if (!outcome.ok && container.childElementCount === 0) { - renderErrorPlaceholder(container, lookupWord, outcome, _); - } - const state = outcome.ok - ? 'loaded' - : outcome.reason === 'empty' - ? 'empty' - : outcome.reason === 'unsupported' - ? 'unsupported' - : 'error'; - setTabStates((prev) => { - const old = prev[activeTab]; - if (!old || old.loadKey !== loadKey) return prev; - return { ...prev, [activeTab]: { ...old, state, outcome } }; - }); - }; - void run(); - - return () => { - cancelled = true; - controller.abort(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTab, providers, lookupWord, lang, lookupIndex]); - - const canGoBack = !!activeTabState && activeTabState.history.index > 0; - - const sourceLabel = - activeTabState?.outcome?.ok && activeTabState.outcome.sourceLabel - ? activeTabState.outcome.sourceLabel - : undefined; - + const state = useDictionaryResults({ word, lang }); return ( = ({ className='select-text' onDismiss={onDismiss} > - {/* `overflow-hidden rounded-lg` clips child surfaces (the tab strip's - gray bg, the bottom footer divider, etc.) to the popup's rounded - shape — without it the tab bar's `bg-base-300/40` and `border-b` - paint into the corner area and break the rounded edge. */} -
- {providers.length > 1 && ( -
- {providers.map((p) => { - const isActive = p.id === activeTab; - return ( - - ); - })} -
- )} - - {providers.length === 0 ? ( -
-

{_('No dictionaries enabled')}

-

- {_('Enable a dictionary in Settings → Language → Dictionaries.')} -

-
- ) : ( - providers.map((p) => { - const isActive = p.id === activeTab; - const state = tabStates[p.id]?.state ?? 'idle'; - const showBack = isActive && canGoBack; - return ( -