feat(dictionary): add system dictionary provider for macOS, iOS, and Android (#4219)

Hand selected words off to the platform's native dictionary surface
when the user opts into the new "System Dictionary" entry under
Settings → Languages → Dictionaries. The setting is exclusive: enabling
it disables all other providers (and vice versa) so the in-app lookup
button either always opens the popup or always invokes the OS — no
mixed states.

Per platform:
- macOS: AppKit's -[NSView showDefinitionForAttributedString:atPoint:]
  via a top-level Tauri command in src-tauri/src/macos/system_dictionary.rs.
  Anchored at the selection's bottom-center (CSS pixels mapped into
  NSView coords), so the inline Lookup HUD appears just below the
  highlighted text without raising Dictionary.app to the foreground.
- iOS: UIReferenceLibraryViewController presented as a half-detent
  pageSheet on iPhone (medium → large drag-to-expand) and as a
  formSheet on iPad. Implemented in the native-bridge plugin.
- Android: ACTION_PROCESS_TEXT intent with EXTRA_PROCESS_TEXT_READONLY,
  dispatched without createChooser so users get the standard system
  disambiguation dialog with "Just once / Always" buttons. Reports
  unavailable=true when no app handles the intent so the TS layer can
  silently skip rather than open an empty chooser.

Web/Linux/Windows hide the row entirely. The provider is a sentinel —
the registry filters it out of the popup tab list (it has no in-popup
UI) and the annotator's handleDictionary checks isSystemDictionaryEnabled
to dispatch directly to the native bridge before opening the in-app
DictionaryPopup.
This commit is contained in:
loveheaven
2026-05-19 13:03:52 +08:00
committed by GitHub
parent d35d2002c4
commit 05da6bdf43
23 changed files with 1074 additions and 10 deletions
@@ -15,6 +15,8 @@ import { useReaderStore } from '@/store/readerStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
import { isSystemDictionaryEnabled } from '@/services/dictionaries/registry';
import { invokeSystemDictionary } from '@/services/dictionaries/systemDictionary';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useDeviceControlStore } from '@/store/deviceStore';
@@ -24,7 +26,13 @@ import { useReadwiseSync } from '../../hooks/useReadwiseSync';
import { useHardcoverSync } from '../../hooks/useHardcoverSync';
import { useTextSelector } from '../../hooks/useTextSelector';
import { Point, Position, TextSelection } from '@/utils/sel';
import { getPopupPosition, getPosition, getTextFromRange } from '@/utils/sel';
import {
getPopupPosition,
getPosition,
getRangeRectInWebview,
getRangeTextStyleInWebview,
getTextFromRange,
} from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { findTocItemBS } from '@/services/nav';
import { throttle } from '@/utils/throttle';
@@ -793,6 +801,27 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const handleDictionary = () => {
if (!selection || !selection.text) return;
// System-dictionary path: when the user has opted in via Settings →
// Languages → Dictionaries, hand the selection to the OS instead of
// opening the in-app popup. Exclusivity is enforced at the store
// level (enabling system disables everything else and vice versa),
// so a single check on the system flag is sufficient.
const dictSettings = useCustomDictionaryStore.getState().settings;
if (isSystemDictionaryEnabled(dictSettings)) {
// Build the macOS HUD anchor: the selection rect (so the HUD
// appears at the original word) and the underlying paragraph's
// text style (so AppKit re-draws the small label at the same
// font size / colour as the original, matching the system
// right-click → Look Up presentation).
const rect = selection.range ? getRangeRectInWebview(selection.range) : null;
const style = selection.range ? getRangeTextStyleInWebview(selection.range) : null;
void invokeSystemDictionary(
selection.text,
rect ? { rect, style: style ?? undefined } : undefined,
);
handleDismissPopupAndSelection();
return;
}
setShowAnnotPopup(false);
setShowDictionaryPopup(true);
};