- {providers.length > 1 && (
-
- {providers.map((p) => {
- const isActive = p.id === activeTab;
- return (
-
- );
- })}
-
- )}
-
- {providers.length === 0 ? (
-
-
{_('No dictionaries enabled')}
-
- {_('Enable a dictionary in Settings → Language → Dictionaries.')}
-
-
- ) : (
- providers.map((p) => {
- const isActive = p.id === activeTab;
- const state = tabStates[p.id]?.state ?? 'idle';
- const showBack = isActive && canGoBack;
- return (
-
- {showBack && (
-
- )}
-
- {isActive && state === 'loading' && (
-
-
-
- )}
-
- );
- })
- )}
-
- {/* 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) && (
-
- )}
+ {/* `overflow-hidden rounded-lg` clips the body's section backgrounds /
+ borders to the Popup's rounded shape. */}
+
);
};
-const renderErrorPlaceholder = (
- container: HTMLElement,
- word: string,
- outcome: DictionaryLookupOutcome,
- _: (key: string, opts?: Record
) => 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: `${word}`,
- });
- } 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;
diff --git a/apps/readest-app/src/app/reader/components/annotator/DictionaryResultsView.tsx b/apps/readest-app/src/app/reader/components/annotator/DictionaryResultsView.tsx
new file mode 100644
index 00000000..b98d895f
--- /dev/null
+++ b/apps/readest-app/src/app/reader/components/annotator/DictionaryResultsView.tsx
@@ -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;
+ 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, 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(() => 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([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>({});
+ // 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>({});
+
+ const containerRefs = useRef