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:
@@ -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