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:
@@ -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;
|
||||
Reference in New Issue
Block a user