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
@@ -85,6 +85,11 @@ class OpenExternalUrlArgs {
var url: String? = null
}
@InvokeArg
class ShowLookupPopoverArgs {
var word: String? = null
}
@InvokeArg
class FetchProductsRequestArgs {
val productIds: List<String>? = null
@@ -876,6 +881,77 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
invoke.resolve(ret)
}
/**
* Hand a selected word off to whatever dictionary / lookup app the
* user has installed, via the standard `ACTION_PROCESS_TEXT`
* intent (Android 6.0+). This is the same dispatch the system
* "selection toolbar" uses for "Translate" / "Define" actions, so
* any third-party dictionary that registers the intent (ColorDict,
* GoldenDict, 欧路, Pleco, etc.) shows up without extra work on
* our side.
*
* Important: we deliberately do NOT wrap the intent with
* `Intent.createChooser`. Chooser-style dialogs always re-prompt
* (no "Always use this app" affordance), which the user found
* annoying when they have a single preferred dictionary. Plain
* `startActivity(intent)` instead surfaces the standard system
* disambiguation dialog with the "Just once / Always" buttons —
* picking "Always" makes subsequent lookups go straight to that
* app. When only one app handles the intent, Android skips the
* picker entirely and launches it directly.
*
* If no app is installed that responds to the intent, returns
* `unavailable: true` instead of throwing — the TS layer surfaces
* a hint rather than a generic error in that case.
*/
@Command
fun show_lookup_popover(invoke: Invoke) {
val args = invoke.parseArgs(ShowLookupPopoverArgs::class.java)
val word = args.word?.trim().orEmpty()
if (word.isEmpty()) {
return invoke.reject("empty word")
}
try {
val intent = Intent(Intent.ACTION_PROCESS_TEXT).apply {
type = "text/plain"
putExtra(Intent.EXTRA_PROCESS_TEXT, word)
// Read-only — we don't want third-party apps writing
// back into a clipboard or selection slot we don't own.
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
}
// Probe for handlers before dispatching. An ActivityNotFound
// crash is a worse UX than a quiet "no dictionary app"
// result; surface the empty case explicitly.
val pm = activity.packageManager
val handlers = pm.queryIntentActivities(intent, 0)
if (handlers.isEmpty()) {
val ret = JSObject()
ret.put("success", false)
ret.put("unavailable", true)
return invoke.resolve(ret)
}
// FLAG_ACTIVITY_NEW_TASK is required because `activity`
// here is the plugin's host activity context — without it,
// some OEM ROMs reject the dispatch with "Calling
// startActivity() from outside of an Activity context".
// The system disambiguation dialog still appears (with the
// Always/Just once buttons) for multi-handler cases; for
// single-handler cases it goes straight through.
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
activity.startActivity(intent)
val ret = JSObject()
ret.put("success", true)
invoke.resolve(ret)
} catch (e: Exception) {
Log.e("NativeBridgePlugin", "show_lookup_popover failed", e)
invoke.reject("Failed to look up word: ${e.message}")
}
}
}
@app.tauri.annotation.InvokeArg
@@ -20,6 +20,7 @@ const COMMANDS: &[&str] = &[
"set_screen_brightness",
"get_external_sdcard_path",
"open_external_url",
"show_lookup_popover",
"select_directory",
"get_storefront_region_code",
"register_listener",
@@ -1143,12 +1143,103 @@ class NativeBridgePlugin: Plugin {
invoke.resolve(["available": false, "error": "OSStatus \(status)"])
}
}
@objc public func show_lookup_popover(_ invoke: Invoke) {
// Bridge for the system-dictionary "Look Up" surface on iOS.
// We use `UIReferenceLibraryViewController`, which is the same
// view UIKit presents for the Look Up callout in editable text
// views. Two notes:
//
// * The controller refuses to render and `dictionaryHasDefinitionForTerm`
// returns false for empty strings, so guard for that explicitly
// to keep the rejection path consistent with the Rust models.
// * Presentation must happen on the main thread, against a
// view controller that's actually in the active window
// hierarchy. We reach the foreground scene (matching how
// other commands here pick a `keyWindow`) and walk down to
// the topmost presented controller so the lookup view sits
// above any modal sheet (settings, notebook, etc.) the user
// might have open when they tap "dictionary".
guard let args = try? invoke.parseArgs(ShowLookupPopoverArgs.self) else {
return invoke.reject("Failed to parse arguments")
}
let trimmed = args.word.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return invoke.reject("empty word")
}
DispatchQueue.main.async {
let windows = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
let keyWindow = windows.first(where: { $0.isKeyWindow }) ?? windows.first
guard let rootVC = keyWindow?.rootViewController else {
invoke.reject("no root view controller")
return
}
// Drill past any modally-presented stack so the dictionary
// view appears on top of (not behind) settings dialogs etc.
var presenter: UIViewController = rootVC
while let next = presenter.presentedViewController {
presenter = next
}
// `UIReferenceLibraryViewController` itself doesn't expose
// dictionary availability, but the static
// `dictionaryHasDefinitionForTerm:` does. We don't surface
// "no definition" as an error Apple's view shows its own
// "No definition found" / download UI in that case, which is
// the expected UX. Logging it is enough for diagnostics.
let hasDefinition = UIReferenceLibraryViewController.dictionaryHasDefinition(forTerm: trimmed)
if !hasDefinition {
logger.log("[show_lookup_popover] no built-in dictionary entry for '\(trimmed, privacy: .private)'")
}
let dictVC = UIReferenceLibraryViewController(term: trimmed)
// Constrain the lookup to the lower half of the screen so the
// book content the user just selected from stays visible above
// it the full-screen default feels heavyweight for a quick
// dictionary glance. On iOS 15+ we use a half-detent sheet
// presentation: medium height by default, but the user can
// drag-to-expand to full if they want more room. iPad keeps
// the form sheet (a native floating panel) since half-screen
// sheets look out of place on tablet-class screens.
if UIDevice.current.userInterfaceIdiom == .pad {
dictVC.modalPresentationStyle = .formSheet
} else if #available(iOS 15.0, *) {
dictVC.modalPresentationStyle = .pageSheet
if let sheet = dictVC.sheetPresentationController {
sheet.detents = [.medium(), .large()]
// Default to medium (lower half). The system handles drag
// to expand to large; we don't need to track changes.
sheet.selectedDetentIdentifier = .medium
// Keep the grabber visible so users discover they can
// expand or drag down to dismiss.
sheet.prefersGrabberVisible = true
// Round only the top corners (the standard iOS sheet look).
sheet.preferredCornerRadius = 16
}
} else {
// iOS 14 and earlier sheets/detents API doesn't exist; fall
// back to a centered form sheet so it at least doesn't take
// the full screen.
dictVC.modalPresentationStyle = .formSheet
}
presenter.present(dictVC, animated: true) {
invoke.resolve(["success": true])
}
}
}
}
class SyncPassphraseSetArgs: Decodable {
let passphrase: String
}
class ShowLookupPopoverArgs: Decodable {
let word: String
}
@_cdecl("init_plugin_native_bridge")
func initPlugin() -> Plugin {
return NativeBridgePlugin()
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-show-lookup-popover"
description = "Enables the show_lookup_popover command without any pre-configured scope."
commands.allow = ["show_lookup_popover"]
[[permission]]
identifier = "deny-show-lookup-popover"
description = "Denies the show_lookup_popover command without any pre-configured scope."
commands.deny = ["show_lookup_popover"]
@@ -25,6 +25,7 @@ Default permissions for the plugin
- `allow-set-screen-brightness`
- `allow-get-external-sdcard-path`
- `allow-open-external-url`
- `allow-show-lookup-popover`
- `allow-select-directory`
- `allow-get-storefront-region-code`
- `allow-request-manage-storage-permission`
@@ -961,6 +962,32 @@ Denies the set_system_ui_visibility command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-show-lookup-popover`
</td>
<td>
Enables the show_lookup_popover command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-show-lookup-popover`
</td>
<td>
Denies the show_lookup_popover command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-use-background-audio`
</td>
@@ -22,6 +22,7 @@ permissions = [
"allow-set-screen-brightness",
"allow-get-external-sdcard-path",
"allow-open-external-url",
"allow-show-lookup-popover",
"allow-select-directory",
"allow-get-storefront-region-code",
"allow-request-manage-storage-permission",
@@ -714,6 +714,18 @@
"const": "deny-set-system-ui-visibility",
"markdownDescription": "Denies the set_system_ui_visibility command without any pre-configured scope."
},
{
"description": "Enables the show_lookup_popover command without any pre-configured scope.",
"type": "string",
"const": "allow-show-lookup-popover",
"markdownDescription": "Enables the show_lookup_popover command without any pre-configured scope."
},
{
"description": "Denies the show_lookup_popover command without any pre-configured scope.",
"type": "string",
"const": "deny-show-lookup-popover",
"markdownDescription": "Denies the show_lookup_popover command without any pre-configured scope."
},
{
"description": "Enables the use_background_audio command without any pre-configured scope.",
"type": "string",
@@ -727,10 +739,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`"
}
]
}
@@ -166,6 +166,19 @@ pub(crate) async fn open_external_url<R: Runtime>(
app.native_bridge().open_external_url(payload)
}
/// See [`ShowLookupPopoverRequest`] in `models.rs` for platform-by-
/// platform behavior. The mobile bridge dispatches into the iOS /
/// Android plugin; desktop returns `UnsupportedPlatformError` and the
/// TS layer keeps the macOS-specific path going through the
/// top-level `show_lookup_popover` Tauri command (AppKit HUD).
#[command]
pub(crate) async fn show_lookup_popover<R: Runtime>(
app: AppHandle<R>,
payload: ShowLookupPopoverRequest,
) -> Result<ShowLookupPopoverResponse> {
app.native_bridge().show_lookup_popover(payload)
}
#[command]
pub(crate) async fn select_directory<R: Runtime>(
app: AppHandle<R>,
@@ -166,6 +166,19 @@ impl<R: Runtime> NativeBridge<R> {
Err(crate::Error::UnsupportedPlatformError)
}
/// Desktop has no mobile-style "system dictionary intent" surface;
/// macOS's HUD is invoked through a separate top-level Tauri
/// command (`show_lookup_popover` in `src/macos/system_dictionary.rs`),
/// and Linux/Windows have no native target. Return
/// UnsupportedPlatformError here so the TS layer doesn't
/// accidentally dispatch through the mobile plugin on desktop.
pub fn show_lookup_popover(
&self,
_payload: ShowLookupPopoverRequest,
) -> crate::Result<ShowLookupPopoverResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn select_directory(&self) -> crate::Result<SelectDirectoryResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
@@ -76,6 +76,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::set_screen_brightness,
commands::get_external_sdcard_path,
commands::open_external_url,
commands::show_lookup_popover,
commands::select_directory,
commands::get_storefront_region_code,
commands::request_manage_storage_permission,
@@ -216,6 +216,17 @@ impl<R: Runtime> NativeBridge<R> {
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn show_lookup_popover(
&self,
payload: ShowLookupPopoverRequest,
) -> crate::Result<ShowLookupPopoverResponse> {
self.0
.run_mobile_plugin("show_lookup_popover", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn select_directory(&self) -> crate::Result<SelectDirectoryResponse> {
self.0
@@ -222,6 +222,36 @@ pub struct OpenExternalUrlResponse {
pub error: Option<String>,
}
/// Hand a word off to the platform's native dictionary surface.
///
/// On iOS this presents `UIReferenceLibraryViewController` modally
/// (the same UI Apple uses for `Look Up` in UIKit text views). On
/// Android it dispatches `ACTION_PROCESS_TEXT` so any installed
/// dictionary app (ColorDict, GoldenDict, 欧路, etc.) can handle the
/// word; we don't bind to a specific package so users can stick with
/// their preferred dictionary. Desktop platforms return
/// `UnsupportedPlatformError` — macOS goes through a separate native
/// command in `src/macos/system_dictionary.rs` that uses the AppKit
/// HUD surface, which doesn't exist on iOS/Android.
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ShowLookupPopoverRequest {
pub word: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ShowLookupPopoverResponse {
pub success: bool,
/// `unavailable` is set on Android when no app responded to the
/// `ACTION_PROCESS_TEXT` intent (i.e. the user has no dictionary
/// installed). The TS layer can surface a "no dictionary app"
/// hint without us having to push a localized string from
/// native code.
pub unavailable: Option<bool>,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SelectDirectoryResponse {
+2
View File
@@ -175,6 +175,8 @@ pub fn run() {
macos::apple_auth::start_apple_sign_in,
#[cfg(target_os = "macos")]
macos::traffic_light::set_traffic_lights,
#[cfg(target_os = "macos")]
macos::system_dictionary::show_lookup_popover,
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
discord_rpc::update_book_presence,
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
@@ -1,4 +1,5 @@
pub mod apple_auth;
pub mod menu;
pub mod safari_auth;
pub mod system_dictionary;
pub mod traffic_light;
@@ -0,0 +1,365 @@
/// macOS native dictionary "Look Up" HUD bridge.
///
/// Renders the small floating definition popover that the system shows
/// for the right-click → Look Up menu item, **without** raising
/// Dictionary.app to the foreground.
///
/// Background — why we DON'T use `NSPerformService("Look Up in
/// Dictionary", …)`: that service is registered by Dictionary.app
/// itself (`NSMessage = doLookupService` in its Info.plist) and its
/// implementation is "bring Dictionary.app to the front and show the
/// word", which is exactly the surface we want to avoid. The actual
/// inline HUD comes from a different AppKit entry point:
///
/// -[NSView showDefinitionForAttributedString:atPoint:]
///
/// We call it on the Tauri window's `contentView` (the WKWebView's
/// host view), positioning the HUD at the view's center. AppKit
/// shifts the popover to keep it on-screen, so the center is a safe
/// default when we can't get a per-selection position from the JS
/// side.
use std::sync::Mutex;
use block::ConcreteBlock;
use cocoa::base::{id, nil};
use cocoa::foundation::{NSPoint, NSRect, NSString};
use objc::runtime::Object;
use objc::{class, msg_send, sel, sel_impl};
use serde::Deserialize;
use tauri::Manager;
/// Optional positional hint forwarded from the JS bridge. Coordinates
/// are in the webview viewport's CSS-pixel space (origin top-left,
/// Y-down) and refer to the **bottom-left baseline** of the selection
/// — that is the same anchor AppKit's
/// `-[NSView showDefinitionForAttributedString:atPoint:]` interprets
/// `atPoint` as, so the HUD label that AppKit re-draws lands on top
/// of the original word instead of being shifted to the right.
///
/// `scale` is the JS `window.devicePixelRatio`. NSView's
/// `showDefinitionForAttributedString:atPoint:` takes view-local
/// points, which on standard Tauri/macOS already match CSS pixels
/// (the WKWebView reports its bounds in points, and the JS viewport
/// is sized in those same points). So `scale` is informational for
/// future tuning — we currently ignore it and treat the JS rect as
/// if already in points. Logged in `info!` so we can revisit if we
/// see drift on Retina hosts.
///
/// `font_size`, `font_family` and `color` describe the typography of
/// the underlying paragraph so we can build an NSAttributedString
/// that visually matches the original word. Without them AppKit
/// falls back to the default 13 pt system font, which is what made
/// our HUD look like a tiny sticky note next to the selection.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LookupAnchor {
x: f64,
y: f64,
#[serde(default = "default_scale")]
scale: f64,
#[serde(default)]
font_size: Option<f64>,
#[serde(default)]
font_family: Option<String>,
#[serde(default)]
color: Option<String>,
}
fn default_scale() -> f64 {
1.0
}
#[tauri::command]
pub fn show_lookup_popover(
app: tauri::AppHandle,
word: String,
window_label: Option<String>,
anchor: Option<LookupAnchor>,
) -> Result<(), String> {
let trimmed = word.trim();
if trimmed.is_empty() {
return Err("empty word".into());
}
// Resolve the target Tauri window. The annotator passes its own
// label (typically `main` or `reader-<id>`); fall back to `main`
// if absent so manual invocations from devtools still work.
let label = window_label.as_deref().unwrap_or("main");
let window = app
.get_webview_window(label)
.or_else(|| app.get_webview_window("main"))
.ok_or_else(|| format!("window not found: {label}"))?;
let ns_window_ptr = window.ns_window().map_err(|e| e.to_string())? as *mut Object;
let ns_window = ThreadSafeNSWindow(ns_window_ptr);
let owned = trimmed.to_owned();
run_on_main_thread(move || unsafe {
// Move the wrapper itself into the closure so the auto-trait
// checker uses our `Send` impl, then unwrap the pointer.
let ns_window = ns_window;
show_definition_for_word(ns_window.0, &owned, anchor.as_ref());
});
Ok(())
}
/// SAFETY: must be called on the main thread; `ns_window` must point
/// to a live NSWindow.
unsafe fn show_definition_for_word(
ns_window: *mut Object,
word: &str,
anchor: Option<&LookupAnchor>,
) {
if ns_window.is_null() {
log::warn!("[system_dictionary] ns_window is null; skipping HUD");
return;
}
// Resolve `contentView` — a plain NSView that hosts the WKWebView
// and whose `showDefinitionForAttributedString:atPoint:` we can
// call. The view exists for the lifetime of the window, so the
// pointer is safe to use synchronously here.
let content_view: *mut Object = msg_send![ns_window, contentView];
if content_view.is_null() {
log::warn!("[system_dictionary] contentView is null; skipping HUD");
return;
}
let word_ns: id = NSString::alloc(nil).init_str(word);
let attributed = build_attributed_string(word_ns, anchor);
if attributed.is_null() {
log::warn!("[system_dictionary] failed to build NSAttributedString");
return;
}
let bounds: NSRect = msg_send![content_view, bounds];
// Map JS viewport coords (origin top-left, Y-down) into NSView
// coords (origin bottom-left, Y-up). When no anchor is supplied,
// fall back to the contentView's center.
let point = match anchor {
Some(a) => {
// Clamp to view bounds so an off-page selection (e.g. user
// scrolled away before the command landed) doesn't push
// the HUD outside its host view.
let x =
a.x.clamp(bounds.origin.x, bounds.origin.x + bounds.size.width);
let y_topdown =
a.y.clamp(bounds.origin.y, bounds.origin.y + bounds.size.height);
let y = bounds.origin.y + bounds.size.height - y_topdown;
log::debug!(
"[system_dictionary] anchor=({:.1},{:.1}) scale={:.2} → ns=({:.1},{:.1})",
a.x,
a.y,
a.scale,
x,
y
);
NSPoint::new(x, y)
}
None => NSPoint::new(
bounds.origin.x + bounds.size.width / 2.0,
bounds.origin.y + bounds.size.height / 2.0,
),
};
let _: () =
msg_send![content_view, showDefinitionForAttributedString: attributed atPoint: point];
// Release our retain on the attributed string. `alloc/init`
// returns a +1 retained instance and we hold no further reference.
let _: () = msg_send![attributed, release];
}
/// Build the `NSMutableAttributedString` we hand to AppKit. When the
/// JS side supplies a `font_size` / `font_family` / `color`, we mirror
/// them via NSFont / NSForegroundColor attributes so the small label
/// AppKit re-renders matches the underlying paragraph. Without these
/// attributes AppKit defaults to 13 pt system font and the HUD label
/// looks visibly smaller than the original word — exactly the bug
/// observed before this helper existed.
///
/// SAFETY: must run on the main thread; `word_ns` must be a valid
/// `NSString*` (typically from `NSString::alloc(nil).init_str(...)`).
/// Returns a `+1` retained `NSMutableAttributedString*` — caller is
/// responsible for releasing it.
unsafe fn build_attributed_string(word_ns: id, anchor: Option<&LookupAnchor>) -> *mut Object {
let mutable_class = class!(NSMutableAttributedString);
let attributed: *mut Object = msg_send![mutable_class, alloc];
let attributed: *mut Object = msg_send![attributed, initWithString: word_ns];
if attributed.is_null() {
return attributed;
}
// Apply font + colour attributes when we have hints. Skipping the
// attribute when the corresponding hint is missing lets AppKit
// fall back to its own defaults (still better than a blank label).
let length: cocoa::foundation::NSUInteger = msg_send![attributed, length];
let full_range = cocoa::foundation::NSRange::new(0, length);
if let Some(a) = anchor {
let font_size = a.font_size.filter(|v| v.is_finite() && *v > 0.0);
if let Some(size) = font_size {
// Compensate for the visual gap between a `font-size` in
// the WKWebView (CSS pixels) and an NSFont of the "same"
// point size: NSFont's ascender/descender metrics pack
// glyphs noticeably tighter than WebKit's default
// line-box layout, so a 16 pt NSFont renders ~85% of the
// body text's apparent size. Bumping by ~1.15 brings the
// HUD label back in line with the underlying paragraph.
//
// We do NOT divide by `devicePixelRatio` here — both CSS
// pixels and NSView points are 1:1 on macOS regardless of
// Retina (Retina just means 1 point = 2 device pixels for
// both sides), so a /scale would actually *halve* the
// font on Retina hosts.
const NSFONT_TO_CSS_PX: f64 = 0.9;
let scaled = size * NSFONT_TO_CSS_PX;
let font: id = if let Some(name) = a.font_family.as_deref().and_then(first_family) {
let name_ns: id = NSString::alloc(nil).init_str(&name);
let font: id = msg_send![class!(NSFont), fontWithName: name_ns size: scaled];
let _: () = msg_send![name_ns, release];
if font.is_null() {
msg_send![class!(NSFont), systemFontOfSize: scaled]
} else {
font
}
} else {
msg_send![class!(NSFont), systemFontOfSize: scaled]
};
if !font.is_null() {
let key: id = NSString::alloc(nil).init_str("NSFont");
let _: () = msg_send![attributed, addAttribute: key value: font range: full_range];
let _: () = msg_send![key, release];
}
}
if let Some(color_hex) = a.color.as_deref().and_then(parse_css_color_to_rgba) {
let (r, g, b, alpha) = color_hex;
let color: id = msg_send![
class!(NSColor),
colorWithSRGBRed: r as f64
green: g as f64
blue: b as f64
alpha: alpha as f64
];
if !color.is_null() {
let key: id = NSString::alloc(nil).init_str("NSColor");
let _: () = msg_send![attributed, addAttribute: key value: color range: full_range];
let _: () = msg_send![key, release];
}
}
}
attributed
}
/// Pick the first font-family token from a CSS `font-family` stack
/// (e.g. `"Source Han Serif", "Noto Serif CJK SC", serif`) and strip
/// its surrounding quotes / whitespace. Returns `None` for the
/// generic-family-only case (`serif`, `sans-serif`, …) so we fall
/// back to the system font instead of asking AppKit to look up a
/// face by an alias it doesn't understand.
fn first_family(stack: &str) -> Option<String> {
for raw in stack.split(',') {
let trimmed = raw.trim().trim_matches('"').trim_matches('\'').trim();
if trimmed.is_empty() {
continue;
}
let lower = trimmed.to_ascii_lowercase();
if matches!(
lower.as_str(),
"serif"
| "sans-serif"
| "monospace"
| "cursive"
| "fantasy"
| "system-ui"
| "ui-serif"
| "ui-sans-serif"
| "ui-monospace"
| "ui-rounded"
| "math"
| "emoji"
| "fangsong"
) {
return None;
}
return Some(trimmed.to_owned());
}
None
}
/// Parse a subset of CSS color forms into normalized sRGB
/// `(r, g, b, a)` values in `[0, 1]`. Supports `rgb(r, g, b)` and
/// `rgba(r, g, b, a)` — the only forms `getComputedStyle` returns
/// for the `color` property in modern WebKit, so we don't need a
/// full CSS-color parser here.
fn parse_css_color_to_rgba(input: &str) -> Option<(f32, f32, f32, f32)> {
let trimmed = input.trim();
let inner = trimmed
.strip_prefix("rgba(")
.or_else(|| trimmed.strip_prefix("rgb("))
.and_then(|s| s.strip_suffix(')'))?;
let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
if parts.len() < 3 {
return None;
}
let r = parts[0].parse::<f32>().ok()? / 255.0;
let g = parts[1].parse::<f32>().ok()? / 255.0;
let b = parts[2].parse::<f32>().ok()? / 255.0;
let a = if parts.len() >= 4 {
parts[3].parse::<f32>().ok()?
} else {
1.0
};
Some((
r.clamp(0.0, 1.0),
g.clamp(0.0, 1.0),
b.clamp(0.0, 1.0),
a.clamp(0.0, 1.0),
))
}
/// Thread-safety wrapper for an `NSWindow*`. Tauri's command handler
/// thread isn't necessarily the main thread, so we hand the pointer
/// through to the main-thread block via this wrapper. Use only with
/// pointers obtained from `Window::ns_window()` while the window is
/// still alive.
struct ThreadSafeNSWindow(*mut Object);
unsafe impl Send for ThreadSafeNSWindow {}
unsafe impl Sync for ThreadSafeNSWindow {}
/// Run `f` on the main thread. Synchronously when already on the main
/// thread; otherwise enqueued on `NSOperationQueue mainQueue`. AppKit
/// requires NSView calls (and most NSWindow accessors) to run on the
/// main thread.
fn run_on_main_thread<F>(f: F)
where
F: FnOnce() + Send + 'static,
{
unsafe {
// The cocoa crate exposes `-[NSThread isMainThread]` as Rust
// `bool`, not the C BOOL — match that here.
let is_main: bool = msg_send![class!(NSThread), isMainThread];
if is_main {
f();
return;
}
// `ConcreteBlock` requires the closure to be `Fn`, but we
// need `FnOnce` semantics for the move-out. Wrap in a
// `Mutex<Option<F>>` so the block body can take it on first
// invocation; NSOperationQueue only runs the block once, so
// the unwrap-or-skip pattern is safe.
let once = Mutex::new(Some(f));
let block = ConcreteBlock::new(move || {
if let Ok(mut guard) = once.lock() {
if let Some(callable) = guard.take() {
callable();
}
}
});
let block = block.copy();
let queue: *mut Object = msg_send![class!(NSOperationQueue), mainQueue];
let _: () = msg_send![queue, addOperationWithBlock: &*block];
}
}
@@ -638,13 +638,17 @@ describe('customDictionaryStore — loadCustomDictionaries reconciliation', () =
// so user-imported dicts stay at the top of the list (rather than
// stranded after the builtins where the user might miss them).
// Existing imp-known is already after builtins (intentional user
// choice persisted in providerOrder) so it stays put.
// choice persisted in providerOrder) so it stays put. The
// `builtin:system` sentinel was added in the default order when
// the system-dictionary provider landed; backfill appends it
// after the persisted builtins on hydration.
expect(after.providerOrder).toEqual([
'imp-orphaned-1',
'imp-orphaned-2',
'builtin:wiktionary',
'builtin:wikipedia',
'imp-known',
'builtin:system',
'web:builtin:google',
'web:builtin:urban',
'web:builtin:merriam-webster',
@@ -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);
};
@@ -30,6 +30,10 @@ import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
import { eventDispatcher } from '@/utils/event';
import { evictProvider } from '@/services/dictionaries/registry';
import { BUILTIN_PROVIDER_IDS } from '@/services/dictionaries/types';
import {
isSystemDictionaryAvailable,
isSystemDictionarySupported,
} from '@/services/dictionaries/systemDictionary';
import { queueDictionaryBinaryUpload } from '@/services/sync/replicaBinaryUpload';
import type { ImportedDictionary, WebSearchEntry } from '@/services/dictionaries/types';
import {
@@ -92,6 +96,7 @@ const builtinWebLabel = (id: string, _: (key: string) => string): string => {
const builtinLabel = (id: string, _: (key: string) => string): string => {
if (id === BUILTIN_PROVIDER_IDS.wiktionary) return _('Wiktionary');
if (id === BUILTIN_PROVIDER_IDS.wikipedia) return _('Wikipedia');
if (id === BUILTIN_PROVIDER_IDS.systemDictionary) return _('System Dictionary');
return id;
};
@@ -338,7 +343,32 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
const dictById = new Map(dictionaries.map((d) => [d.id, d]));
const webById = new Map((settings.webSearches ?? []).map((w) => [w.id, w]));
const rows: ProviderRow[] = [];
// Cache cross-row platform checks so we don't re-walk navigator
// for every system-id encounter (and so the first iteration
// settles before the conditional inside the loop).
const systemSupported = isSystemDictionarySupported();
const systemAvailable = isSystemDictionaryAvailable();
for (const id of settings.providerOrder) {
if (id === BUILTIN_PROVIDER_IDS.systemDictionary) {
// On platforms that don't expose a native dictionary surface
// (web, Linux, Windows), hide the row entirely so the user
// never sees an option that can't work. On supported-but-not-
// yet-wired platforms (iOS, Android in v1), surface the row
// with the toggle disabled so it stays discoverable.
if (!systemSupported) continue;
const disabled = !systemAvailable;
rows.push({
id,
label: builtinLabel(id, _),
kind: 'builtin',
badge: _('System'),
disabled,
reason: disabled
? _('System dictionary integration is coming soon on this platform.')
: undefined,
});
continue;
}
if (id.startsWith('builtin:')) {
rows.push({
id,
@@ -45,6 +45,9 @@ interface RegistryArgs {
const builtinFor = (id: string): DictionaryProvider | undefined => {
if (id === BUILTIN_PROVIDER_IDS.wiktionary) return wiktionaryProvider;
if (id === BUILTIN_PROVIDER_IDS.wikipedia) return wikipediaProvider;
// System dictionary is a sentinel — it has no in-popup UI. The
// annotator handles it before reaching the popup; the registry
// filters it out of `getEnabledProviders` so no empty tab appears.
return undefined;
};
@@ -110,6 +113,8 @@ const getOrCreate = (
* - Filters out disabled ids.
* - Filters out imported entries that are soft-deleted, unavailable on this
* device, or flagged unsupported.
* - Filters out the system-dictionary sentinel (no in-popup UI; handled
* directly by the annotator before the popup opens).
* - Preserves the order in `settings.providerOrder`.
*/
export const getEnabledProviders = ({
@@ -121,6 +126,10 @@ export const getEnabledProviders = ({
const out: DictionaryProvider[] = [];
for (const id of settings.providerOrder) {
if (settings.providerEnabled[id] === false) continue;
// System-dictionary sentinel is intentionally skipped here — see
// `isSystemDictionaryEnabled` for the dispatch path used by the
// annotator's "Dictionary" button.
if (id === BUILTIN_PROVIDER_IDS.systemDictionary) continue;
if (id.startsWith('builtin:')) {
const provider = getOrCreate(id, undefined, undefined, settings);
if (provider) out.push(provider);
@@ -140,6 +149,18 @@ export const getEnabledProviders = ({
return out;
};
/**
* True when the user has enabled the system-dictionary entry in
* settings. The annotator checks this before opening the in-app popup
* when set, the selection is handed to the OS instead. Independent
* of `getEnabledProviders` (which filters the sentinel out so no empty
* tab appears) so callers don't need to look at order/list shape to
* make the dispatch decision.
*/
export const isSystemDictionaryEnabled = (settings: DictionarySettings): boolean => {
return settings.providerEnabled[BUILTIN_PROVIDER_IDS.systemDictionary] === true;
};
/** Drop a single provider from the cache (e.g. after dictionary deletion). */
export const evictProvider = (id: string): void => {
const cached = instanceCache.get(id);
@@ -0,0 +1,193 @@
/**
* System (OS-native) dictionary bridge.
*
* Hands a word off to the platform's native dictionary surface:
* - **macOS**: AppKit's `-[NSView showDefinitionForAttributedString:atPoint:]`
* is invoked through the `show_lookup_popover` Rust command in
* `src-tauri/src/macos/system_dictionary.rs`. Shows the inline
* "Look Up" HUD popover (the same surface as right-click Look Up)
* without raising Dictionary.app to the foreground. Optionally
* anchored at the selection's bottom-center via {@link SystemDictionaryAnchor}.
* - **iOS**: presents `UIReferenceLibraryViewController` modally via
* the native-bridge plugin's `show_lookup_popover` command (Swift
* side in `tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift`).
* This is the same controller UIKit uses for the Look Up callout in
* editable text views.
* - **Android**: dispatches `Intent.ACTION_PROCESS_TEXT` through the
* native-bridge plugin (`tauri-plugin-native-bridge/android/.../NativeBridgePlugin.kt`).
* Any installed dictionary or translation app that registered the
* intent (ColorDict, GoldenDict, , Pleco, Google Translate, etc.)
* appears in the system chooser. When the user has no compatible
* app, the bridge returns `unavailable: true` and we resolve the
* handoff as `false` so the annotator just dismisses the popup
* silently per the Q2 design decision.
*
* Web / Linux / Windows: the registry filter and the settings UI hide
* the system-dictionary entry on these platforms, so the entry point
* here should never be reached. We still return `false` defensively
* rather than throwing the worst case is a non-event from the user's
* perspective.
*/
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { isTauriAppPlatform } from '@/services/environment';
import { getOSPlatform } from '@/utils/misc';
import type { Rect } from '@/utils/sel';
/**
* Optional positional hint for the lookup HUD (macOS only). When
* provided, the macOS bridge anchors the popover near the selection's
* bottom-center (in webview-viewport CSS pixels). Without it, the HUD
* is centered in the window's contentView. iOS and Android ignore
* this their native UIs handle their own placement.
*/
export interface SystemDictionaryAnchor {
/** Selection rect in the webview's viewport (CSS pixels, top-down). */
rect: Rect;
/**
* Optional text style sampled from the original selection. Forwarded
* to AppKit so the small HUD label that
* `-[NSView showDefinitionForAttributedString:atPoint:]` re-renders
* matches the underlying paragraph's font/color (right-click Look
* Up has the same look because it shares the host text view's
* attributes; we have to ship them across explicitly because our
* "host view" is a WKWebView).
*/
style?: SystemDictionaryAnchorStyle;
}
export interface SystemDictionaryAnchorStyle {
/** Font size in CSS pixels of the outer webview viewport. */
fontSize?: number;
/** Comma-separated CSS font-family stack. */
fontFamily?: string;
/** Foreground color in any CSS color form (e.g. `rgb(0, 0, 0)`). */
color?: string;
}
/**
* Platforms where the system-dictionary handoff is implemented. The
* settings UI uses this to gate visibility of the "System Dictionary"
* row, and the registry uses it to filter the provider out of the
* popup tab list on unsupported hosts.
*/
export const isSystemDictionarySupported = (): boolean => {
if (!isTauriAppPlatform()) return false;
const os = typeof navigator !== 'undefined' ? getOSPlatform() : 'unknown';
return os === 'macos' || os === 'ios' || os === 'android';
};
/**
* Returns true when the platform's system-dictionary handoff is
* actually wired up end-to-end. Now that all three native targets are
* implemented, this matches {@link isSystemDictionarySupported}; it
* stays as a separate function so the settings UI can grow a
* "available but no dictionary app installed" state in the future
* without having to change call sites.
*/
export const isSystemDictionaryAvailable = (): boolean => isSystemDictionarySupported();
/** Wire shape for the Android/iOS native-bridge `show_lookup_popover` command. */
interface MobileLookupResponse {
success: boolean;
/** Android: true when no app responded to ACTION_PROCESS_TEXT. */
unavailable?: boolean;
error?: string;
}
/**
* Invoke the platform's native dictionary for `word`. Returns `true`
* when the OS handoff was dispatched (the OS is responsible for the
* "not found" UI from there); `false` when the platform is not yet
* supported, no dictionary app is installed (Android), or the handoff
* failed at the JS bridge level.
*
* Per the Q2 design decision, callers treat `false` as silent failure
* the system-dictionary path is opt-in, so a no-op is acceptable
* recovery rather than an in-app fallback (which would defeat the
* "exclusive" semantics of the setting).
*/
export const invokeSystemDictionary = async (
word: string,
anchor?: SystemDictionaryAnchor,
): Promise<boolean> => {
const trimmed = word.trim();
if (!trimmed) return false;
if (!isTauriAppPlatform()) return false;
const os = getOSPlatform();
try {
if (os === 'macos') {
// Calls the Rust `show_lookup_popover` command in
// `src-tauri/src/macos/system_dictionary.rs`, which calls
// AppKit's `-[NSView showDefinitionForAttributedString:atPoint:]`
// on the current window's contentView. The system shows its
// inline HUD popover (same as right-click → Look Up) —
// Dictionary.app stays in the background.
const windowLabel = getCurrentWindow().label;
let anchorPayload:
| {
x: number;
y: number;
scale: number;
fontSize?: number;
fontFamily?: string;
color?: string;
}
| undefined;
if (anchor) {
const { rect, style } = anchor;
// AppKit interprets `atPoint` as the BOTTOM-LEFT BASELINE of
// the small label it re-draws using the supplied attributed
// string. `rect.bottom` from `getBoundingClientRect()` is the
// inline box's bottom edge, which sits a descender below the
// baseline. Without compensating, the HUD label drifts down
// by ~0.2 × fontSize relative to the original word. Subtract
// an estimated descender so the re-drawn label's baseline
// lines up with the original paragraph's baseline. 0.2 is the
// typical descender ratio for Latin fonts and a reasonable
// catch-all for the CJK-leaning fonts foliate ships with.
const descender = (style?.fontSize ?? 0) * 0.2;
anchorPayload = {
x: rect.left,
y: rect.bottom - descender,
scale:
typeof window !== 'undefined' && Number.isFinite(window.devicePixelRatio)
? window.devicePixelRatio || 1
: 1,
fontSize: style?.fontSize,
fontFamily: style?.fontFamily,
color: style?.color,
};
}
await invoke('show_lookup_popover', {
word: trimmed,
windowLabel,
anchor: anchorPayload,
});
return true;
}
if (os === 'ios' || os === 'android') {
// Both mobile targets share the same plugin command name on the
// native-bridge plugin. iOS presents `UIReferenceLibraryViewController`
// modally; Android dispatches `ACTION_PROCESS_TEXT` to the user's
// installed dictionary app(s) via a chooser. Anchor coordinates
// are not forwarded — neither platform's native UI uses them.
const result = await invoke<MobileLookupResponse>(
'plugin:native-bridge|show_lookup_popover',
{ payload: { word: trimmed } },
);
// Android-only: chooser empty → no dictionary app installed.
// Treat as silent no-op rather than an error per Q2 semantics.
if (result?.unavailable) {
console.info('[systemDictionary] no dictionary app installed for ACTION_PROCESS_TEXT');
return false;
}
return result?.success === true;
}
return false;
} catch (error) {
console.warn('[systemDictionary] handoff failed', error);
return false;
}
};
@@ -176,6 +176,17 @@ export interface DictionarySettings {
export const BUILTIN_PROVIDER_IDS = {
wiktionary: 'builtin:wiktionary',
wikipedia: 'builtin:wikipedia',
/**
* "Sentinel" id for the OS-native dictionary (macOS Dictionary.app via the
* `dict://` URL scheme; iOS `UIReferenceLibraryViewController`; Android
* `ACTION_PROCESS_TEXT`). The provider has no `lookup`-time UI: when this
* is the only enabled provider, the annotator's "Dictionary" button skips
* the in-app popup entirely and hands the selection to the OS. The
* settings UI enforces single-select between this id and any other
* provider so the popup either always opens (no system) or never opens
* (system only).
*/
systemDictionary: 'builtin:system',
} as const;
export type BuiltinProviderId = (typeof BUILTIN_PROVIDER_IDS)[keyof typeof BUILTIN_PROVIDER_IDS];
@@ -34,11 +34,16 @@ const BUILTIN_WEB_ORDER = [
const DEFAULT_DICTIONARY_SETTINGS: DictionarySettings = {
providerOrder: [
BUILTIN_PROVIDER_IDS.systemDictionary,
BUILTIN_PROVIDER_IDS.wiktionary,
BUILTIN_PROVIDER_IDS.wikipedia,
...BUILTIN_WEB_ORDER,
],
providerEnabled: {
// System dictionary is opt-in — enabling it disables the rest (and
// vice versa) via the settings UI's exclusivity rule. Default off
// so existing users see no behavior change on upgrade.
[BUILTIN_PROVIDER_IDS.systemDictionary]: false,
[BUILTIN_PROVIDER_IDS.wiktionary]: true,
[BUILTIN_PROVIDER_IDS.wikipedia]: true,
[BUILTIN_WEB_SEARCH_IDS.google]: false,
@@ -405,12 +410,40 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
},
setEnabled: (id, enabled) => {
set((state) => ({
settings: {
...state.settings,
providerEnabled: { ...state.settings.providerEnabled, [id]: enabled },
},
}));
set((state) => {
const SYSTEM_ID = BUILTIN_PROVIDER_IDS.systemDictionary;
// Exclusivity: enabling the system-dictionary entry disables all
// other providers, and enabling any non-system provider disables
// the system entry. The settings UI relies on this so the lookup
// path either ALWAYS opens the in-app popup or NEVER does — no
// mixed states. Disabling never cascades (toggling system off
// doesn't auto-enable the others; the user picks).
const isSystem = id === SYSTEM_ID;
const next: Record<string, boolean> = { ...state.settings.providerEnabled };
if (enabled) {
if (isSystem) {
// Turn off everything else.
for (const key of Object.keys(next)) {
if (key !== SYSTEM_ID) next[key] = false;
}
// Cover ids that may live in providerOrder without an
// explicit `false` entry (built-in defaults seed `true`).
for (const orderedId of state.settings.providerOrder) {
if (orderedId !== SYSTEM_ID) next[orderedId] = false;
}
next[SYSTEM_ID] = true;
} else {
// Enabling a non-system provider implicitly turns off system.
next[SYSTEM_ID] = false;
next[id] = true;
}
} else {
next[id] = false;
}
return {
settings: { ...state.settings, providerEnabled: next },
};
});
},
setDefaultProviderId: (id) => {
+86
View File
@@ -84,6 +84,92 @@ export const isPointInRect = (point: Point, rect: Rect, padding: number = 1): bo
);
};
/**
* Resolve the bounding rect of a {@link Range} in the OUTER webview's
* viewport coordinate system (CSS pixels, top-down).
*
* Foliate renders book pages inside an iframe with a CSS transform
* (its column-pagination layout uses non-identity `matrix(...)` to
* shift columns). A naive `range.getBoundingClientRect()` returns
* coordinates in the iframe's local viewport, which won't line up
* with anything outside the iframe. This helper applies the iframe's
* transform scale and offset, mirroring the math in {@link getPosition}.
*
* Returns `null` when the range is detached (no iframe ancestor) or
* has no client rects (collapsed / off-screen).
*/
export const getRangeRectInWebview = (range: Range): Rect | null => {
const rect = range.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return null;
const frameElement = getIframeElement(range);
// No iframe ancestor — range lives directly in the host document
// (e.g. fixed-layout PDF). Pass through the rect as-is.
if (!frameElement) {
return { top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left };
}
const transform = getComputedStyle(frameElement).transform;
const match = transform.match(/matrix\((.+)\)/);
const [sx, , , sy] = match?.[1]?.split(/\s*,\s*/)?.map((x) => parseFloat(x)) ?? [];
const scaleX = Number.isFinite(sx) ? sx! : 1;
const scaleY = Number.isFinite(sy) ? sy! : 1;
const frame = frameElement.getBoundingClientRect();
return {
top: scaleY * rect.top + frame.top,
bottom: scaleY * rect.bottom + frame.top,
left: scaleX * rect.left + frame.left,
right: scaleX * rect.right + frame.left,
};
};
/**
* Sample the visual style (font size / family / color) of the text
* underneath a {@link Range}. Used by the macOS system-dictionary
* bridge so the inline HUD label matches the original paragraph's
* typography `-[NSView showDefinitionForAttributedString:atPoint:]`
* re-draws the word using whatever attributes we hand in, and a plain
* unattributed string falls back to AppKit's small system font.
*
* The font-size is scaled by the iframe's vertical transform so the
* value is in **outer webview** CSS pixels (matching what AppKit
* receives via the contentView, which itself reports its bounds in
* CSS pixels on standard Tauri/macOS).
*
* Returns `null` when the range has no element parent we can sample.
*/
export interface RangeTextStyle {
fontSize: number;
fontFamily: string;
color: string;
}
export const getRangeTextStyleInWebview = (range: Range): RangeTextStyle | null => {
const node: Node | null =
range.startContainer.nodeType === Node.ELEMENT_NODE
? range.startContainer
: range.startContainer.parentElement;
if (!node || node.nodeType !== Node.ELEMENT_NODE) return null;
const element = node as Element;
const style = element.ownerDocument?.defaultView?.getComputedStyle(element);
if (!style) return null;
const frameElement = getIframeElement(range);
let scaleY = 1;
if (frameElement) {
const transform = getComputedStyle(frameElement).transform;
const match = transform.match(/matrix\((.+)\)/);
const parts = match?.[1]?.split(/\s*,\s*/)?.map((x) => parseFloat(x));
const sy = parts?.[3];
if (Number.isFinite(sy)) scaleY = sy!;
}
const fontSizePx = parseFloat(style.fontSize) || 0;
return {
fontSize: fontSizePx * scaleY,
fontFamily: style.fontFamily,
color: style.color,
};
};
export const isPointerInsideSelection = (selection: Selection, ev: PointerEvent) => {
if (selection.rangeCount === 0) return false;
const range = selection.getRangeAt(0);