feat(reader): replace dictionary tabs with stacked result cards (#4071)

Redesign the dictionary lookup UI as a single scrolling list of
expandable cards — one per provider that has a result — instead of
a tab strip with one active provider at a time.

Behavior:
- All enabled dictionaries are queried in parallel; cards render in
  user-defined order. Cards whose provider returns no result, an
  unsupported format, or an error are removed entirely.
- Cards default to expanded when 3 or fewer providers have results,
  collapsed (4-line preview) otherwise. Manual taps are sticky
  across re-renders; the auto-decision is reset only when a new
  word is looked up.
- Web-search providers (Google, Urban, Merriam-Webster, custom
  templates) appear in a separate "Search the web" section as
  tappable rows. On the web build they use native target="_blank"
  anchors; on Tauri the click is routed through openUrl since
  target="_blank" doesn't open externally there.
- The header carries a back arrow (when in-content link navigation
  has pushed onto the history stack), the looked-up word, and a
  gear that deep-links to Settings → Language → Dictionaries.

Mobile / narrow viewports (<sm) get the same UX as a bottom sheet
(Dialog with snapHeight 0.75); sm+ viewports keep the anchored
popup with triangle pointer. Both share useDictionaryResults +
DictionaryResultsHeader/Body.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-06 10:30:30 +08:00
committed by GitHub
parent e7f370453e
commit a272ba892a
5 changed files with 1021 additions and 447 deletions
@@ -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 ? (
<div role='dialog' data-testid='dialog'>
<div data-testid='dialog-header'>{header}</div>
<div data-testid='dialog-body'>{children}</div>
<button data-testid='dialog-overlay-close' onClick={onClose} aria-label='backdrop' />
</div>
) : 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<typeof import('@/services/environment')>('@/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<DictionaryLookupOutcome> {
return new Promise<DictionaryLookupOutcome>((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(
<DictionarySheet
word={props.word ?? 'hello'}
lang={props.lang}
onDismiss={props.onDismiss ?? (() => {})}
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);
});
});
});
@@ -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 (
<div ref={containerRef} role='toolbar' tabIndex={-1}>
{showDictionaryPopup && trianglePosition && dictPopupPosition && (
<DictionaryPopup
word={selection?.text as string}
lang={bookData.bookDoc?.metadata.language as string}
position={dictPopupPosition}
trianglePosition={trianglePosition}
popupWidth={dictPopupWidth}
popupHeight={dictPopupHeight}
onDismiss={handleDismissPopupAndSelection}
onManage={() => {
// 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 (
<DictionarySheet
word={selection?.text as string}
lang={bookData.bookDoc?.metadata.language as string}
onDismiss={handleDismissPopupAndSelection}
onManage={onManage}
/>
);
}
if (!trianglePosition || !dictPopupPosition) return null;
return (
<DictionaryPopup
word={selection?.text as string}
lang={bookData.bookDoc?.metadata.language as string}
position={dictPopupPosition}
trianglePosition={trianglePosition}
popupWidth={dictPopupWidth}
popupHeight={dictPopupHeight}
onDismiss={handleDismissPopupAndSelection}
onManage={onManage}
/>
);
})()}
{showDeepLPopup && trianglePosition && translatorPopupPosition && (
<TranslatorPopup
text={selection?.text as string}
@@ -1,18 +1,14 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MdArrowBack, MdSettings } from 'react-icons/md';
import clsx from 'clsx';
import { openUrl } from '@tauri-apps/plugin-opener';
'use client';
import React from 'react';
import Popup from '@/components/Popup';
import { Position } from '@/utils/sel';
import { useTranslation } from '@/hooks/useTranslation';
import { useEnv } from '@/context/EnvContext';
import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
import { getEnabledProviders } from '@/services/dictionaries/registry';
import { isTauriAppPlatform } from '@/services/environment';
import type { DictionaryProvider, DictionaryLookupOutcome } from '@/services/dictionaries/types';
const isTauri = isTauriAppPlatform();
import {
useDictionaryResults,
DictionaryResultsHeader,
DictionaryResultsBody,
} from './DictionaryResultsView';
interface DictionaryPopupProps {
word: string;
@@ -23,27 +19,13 @@ interface DictionaryPopupProps {
popupHeight: number;
onDismiss?: () => 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<DictionaryPopupProps> = ({
word,
lang,
@@ -54,245 +36,7 @@ const DictionaryPopup: React.FC<DictionaryPopupProps> = ({
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<DictionaryProvider[]>(() => 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<string | undefined>(initialTab);
const [tabStates, setTabStates] = useState<Record<string, TabState>>(() => {
const seed: Record<string, TabState> = {};
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<string, TabState> = {};
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<ReturnType<typeof setTimeout> | 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<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);
},
[],
);
/**
* Click delegation for provider-rendered anchors.
*
* Providers (Wikipedia "Read on Wikipedia →", error placeholders, etc.)
* append `<a>` 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 (
<Popup
width={popupWidth}
@@ -302,171 +46,21 @@ const DictionaryPopup: React.FC<DictionaryPopupProps> = ({
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. */}
<div className='relative flex h-full flex-col overflow-hidden rounded-lg'>
{providers.length > 1 && (
<div
role='tablist'
className='tabs tabs-bordered border-base-content/10 not-eink:bg-base-300/40 flex shrink-0 border-b'
>
{providers.map((p) => {
const isActive = p.id === activeTab;
return (
<button
key={p.id}
type='button'
role='tab'
aria-selected={isActive}
onClick={() => setActiveTab(p.id)}
title={_(p.label)}
className={clsx(
'tab !grid min-w-0 max-w-max flex-1 px-2 text-sm',
isActive
? 'tab-active text-base-content'
: 'text-base-content/70 hover:text-base-content',
)}
>
{/* Phantom: invisible, always bold. Defines the cell's
max-content width so it doesn't change with active state. */}
<span
aria-hidden
className='invisible col-start-1 row-start-1 w-full truncate text-left font-semibold'
>
{_(p.label)}
</span>
{/* Visible label stacked over the phantom in the same cell. */}
<span
className={clsx(
'col-start-1 row-start-1 w-full truncate text-left',
isActive && 'font-semibold',
)}
>
{_(p.label)}
</span>
</button>
);
})}
</div>
)}
{providers.length === 0 ? (
<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>
) : (
providers.map((p) => {
const isActive = p.id === activeTab;
const state = tabStates[p.id]?.state ?? 'idle';
const showBack = isActive && canGoBack;
return (
<div
key={p.id}
role='tabpanel'
hidden={!isActive}
className={clsx('relative min-h-0 flex-1', isActive ? 'flex flex-col' : 'hidden')}
>
{showBack && (
<button
type='button'
onClick={() => goBack(p.id)}
aria-label={_('Back')}
className='btn btn-ghost btn-circle text-base-content bg-base-200/80 hover:bg-base-200 absolute left-2 top-2 z-10 h-8 min-h-8 w-8 p-0 shadow-sm'
>
<MdArrowBack size={18} />
</button>
)}
<div
ref={setContainerRef(p.id)}
data-state={state}
onClick={handleContainerClick}
className='flex-grow overflow-y-auto px-4 pb-4 font-sans'
style={{ paddingTop: showBack ? 48 : 16 }}
/>
{isActive && state === 'loading' && (
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
<span className='loading loading-spinner loading-md not-eink:opacity-60' />
</div>
)}
</div>
);
})
)}
{/* Footer always renders so the manage-dictionaries icon has a
consistent home at the bottom-right of every tab. The source
label fills the left side when present; otherwise the spacer
pushes the icon to the right. */}
{(sourceLabel || onManage) && (
<footer className='mt-auto flex shrink-0 items-center gap-2 px-3 py-1.5'>
{sourceLabel ? (
<span
className='not-eink:opacity-60 min-w-0 flex-1 truncate text-sm'
title={`Source: ${sourceLabel}`}
>
Source: {sourceLabel}
</span>
) : (
<span className='flex-1' />
)}
{onManage && (
<button
type='button'
onClick={onManage}
aria-label={_('Manage Dictionaries')}
title={_('Manage Dictionaries')}
className='btn btn-ghost btn-square btn-xs text-base-content/60 hover:text-base-content not-eink:hover:bg-base-200/60 shrink-0'
>
<MdSettings size={16} />
</button>
)}
</footer>
)}
{/* `overflow-hidden rounded-lg` clips the body's section backgrounds /
borders to the Popup's rounded shape. */}
<div className='flex h-full flex-col overflow-hidden rounded-lg pt-2'>
<DictionaryResultsHeader
currentWord={state.currentWord}
canGoBack={state.canGoBack}
goBack={state.goBack}
onManage={onManage}
/>
<div className='min-h-0 flex-1'>
<DictionaryResultsBody {...state} />
</div>
</div>
</Popup>
);
};
const renderErrorPlaceholder = (
container: HTMLElement,
word: string,
outcome: DictionaryLookupOutcome,
_: (key: string, opts?: Record<string, string | number>) => string,
): void => {
const wrapper = document.createElement('div');
wrapper.className =
'flex flex-col items-center justify-center w-full h-full text-center absolute inset-0 px-6';
const h1 = document.createElement('h1');
h1.className = 'text-base font-bold';
const p = document.createElement('p');
p.className = 'mt-2 text-sm not-eink:opacity-75';
if (!outcome.ok && outcome.reason === 'empty') {
h1.innerText = _('No definitions found');
// Skip target="_blank" on Tauri — see the comment in
// `wikipediaProvider.ts`. The popup's container click handler routes
// the click through `openUrl` for Tauri.
const targetAttr = isTauri ? '' : ' target="_blank"';
p.innerHTML = _('Search for {{word}} on the web.', {
word: `<a href="https://www.google.com/search?q=${encodeURIComponent(
word,
)}"${targetAttr} rel="noopener noreferrer" class="not-eink:text-primary underline">${word}</a>`,
});
} else if (!outcome.ok && outcome.reason === 'unsupported') {
h1.innerText = _('Dictionary unsupported');
p.innerText = outcome.message ?? _('This dictionary format is not supported yet.');
} else {
h1.innerText = _('Error');
p.innerText = (!outcome.ok && outcome.message) || _('Unable to load the word.');
}
wrapper.append(h1, p);
container.append(wrapper);
};
export default DictionaryPopup;
@@ -0,0 +1,467 @@
'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 { useCustomDictionaryStore } from '@/store/customDictionaryStore';
import { getEnabledProviders } from '@/services/dictionaries/registry';
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;
}
/**
* 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 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]);
const currentWord = historyStack[historyStack.length - 1] ?? word;
// Reset the history when the host reopens with a new word from outside
// (selection change in the reader).
useEffect(() => {
setHistoryStack([word]);
}, [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 {
container.replaceChildren();
outcome = await provider.lookup(currentWord, {
lang: langCode,
signal: controller.signal,
container,
onNavigate: pushWord,
});
}
} 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]);
// 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,
};
}
interface DictionaryResultsHeaderProps {
currentWord: string;
canGoBack: boolean;
goBack: () => void;
onManage?: () => void;
}
export const DictionaryResultsHeader: React.FC<DictionaryResultsHeaderProps> = ({
currentWord,
canGoBack,
goBack,
onManage,
}) => {
const _ = useTranslation();
return (
<div className='flex h-8 w-full items-center justify-between px-2'>
<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,
}) => {
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 target = e.target as Element | null;
if (target?.closest('a, button')) 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}
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>
);
};
@@ -0,0 +1,42 @@
'use client';
import React from 'react';
import Dialog from '@/components/Dialog';
import {
useDictionaryResults,
DictionaryResultsHeader,
DictionaryResultsBody,
} from './DictionaryResultsView';
interface DictionarySheetProps {
word: string;
lang?: string;
onDismiss: () => void;
onManage?: () => void;
}
const DictionarySheet: React.FC<DictionarySheetProps> = ({ word, lang, onDismiss, onManage }) => {
const state = useDictionaryResults({ word, lang });
return (
<Dialog
isOpen
snapHeight={0.75}
dismissible
header={
<DictionaryResultsHeader
currentWord={state.currentWord}
canGoBack={state.canGoBack}
goBack={state.goBack}
onManage={onManage}
/>
}
contentClassName='!px-0'
onClose={onDismiss}
>
<DictionaryResultsBody {...state} />
</Dialog>
);
};
export default DictionarySheet;