diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt index 3d3d4f75..d36d01df 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt @@ -85,6 +85,11 @@ class OpenExternalUrlArgs { var url: String? = null } +@InvokeArg +class ShowLookupPopoverArgs { + var word: String? = null +} + @InvokeArg class FetchProductsRequestArgs { val productIds: List? = 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 diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs index ece34b20..7f166334 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs @@ -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", diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift index 5ccc4f3d..66919d22 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift @@ -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() diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/show_lookup_popover.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/show_lookup_popover.toml new file mode 100644 index 00000000..90060edb --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/show_lookup_popover.toml @@ -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"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md index 765407f8..0987f0a3 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md @@ -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. +`native-bridge:allow-show-lookup-popover` + + + + +Enables the show_lookup_popover command without any pre-configured scope. + + + + + + + +`native-bridge:deny-show-lookup-popover` + + + + +Denies the show_lookup_popover command without any pre-configured scope. + + + + + + + `native-bridge:allow-use-background-audio` diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml index 1773e1c3..ba69b4b8 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml @@ -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", diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json index 3e6a82db..b8b04462 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json @@ -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`" } ] } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs index 89b0021f..4bcc799f 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs @@ -166,6 +166,19 @@ pub(crate) async fn open_external_url( 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( + app: AppHandle, + payload: ShowLookupPopoverRequest, +) -> Result { + app.native_bridge().show_lookup_popover(payload) +} + #[command] pub(crate) async fn select_directory( app: AppHandle, diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs index cb4f4461..a99549ef 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs @@ -166,6 +166,19 @@ impl NativeBridge { 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 { + Err(crate::Error::UnsupportedPlatformError) + } + pub fn select_directory(&self) -> crate::Result { Err(crate::Error::UnsupportedPlatformError) } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs index 7ac0df0f..3c02deb8 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs @@ -76,6 +76,7 @@ pub fn init() -> TauriPlugin { 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, diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs index 4d5860dd..ec119885 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs @@ -216,6 +216,17 @@ impl NativeBridge { } } +impl NativeBridge { + pub fn show_lookup_popover( + &self, + payload: ShowLookupPopoverRequest, + ) -> crate::Result { + self.0 + .run_mobile_plugin("show_lookup_popover", payload) + .map_err(Into::into) + } +} + impl NativeBridge { pub fn select_directory(&self) -> crate::Result { self.0 diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs index e8140d63..90294791 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs @@ -222,6 +222,36 @@ pub struct OpenExternalUrlResponse { pub error: Option, } +/// 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, + pub error: Option, +} + #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SelectDirectoryResponse { diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index 9f9a9c2b..6d12333c 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -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"))] diff --git a/apps/readest-app/src-tauri/src/macos/mod.rs b/apps/readest-app/src-tauri/src/macos/mod.rs index d13b7851..399e2479 100644 --- a/apps/readest-app/src-tauri/src/macos/mod.rs +++ b/apps/readest-app/src-tauri/src/macos/mod.rs @@ -1,4 +1,5 @@ pub mod apple_auth; pub mod menu; pub mod safari_auth; +pub mod system_dictionary; pub mod traffic_light; diff --git a/apps/readest-app/src-tauri/src/macos/system_dictionary.rs b/apps/readest-app/src-tauri/src/macos/system_dictionary.rs new file mode 100644 index 00000000..05a1f37c --- /dev/null +++ b/apps/readest-app/src-tauri/src/macos/system_dictionary.rs @@ -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, + #[serde(default)] + font_family: Option, + #[serde(default)] + color: Option, +} + +fn default_scale() -> f64 { + 1.0 +} + +#[tauri::command] +pub fn show_lookup_popover( + app: tauri::AppHandle, + word: String, + window_label: Option, + anchor: Option, +) -> 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-`); 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 { + 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::().ok()? / 255.0; + let g = parts[1].parse::().ok()? / 255.0; + let b = parts[2].parse::().ok()? / 255.0; + let a = if parts.len() >= 4 { + parts[3].parse::().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) +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>` 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]; + } +} diff --git a/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts b/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts index 6738cae6..f8f1d751 100644 --- a/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts +++ b/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts @@ -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', diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index cef0cbfd..f6d084c7 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -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); }; diff --git a/apps/readest-app/src/components/settings/CustomDictionaries.tsx b/apps/readest-app/src/components/settings/CustomDictionaries.tsx index a3450e8a..0408829c 100644 --- a/apps/readest-app/src/components/settings/CustomDictionaries.tsx +++ b/apps/readest-app/src/components/settings/CustomDictionaries.tsx @@ -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 = ({ 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, diff --git a/apps/readest-app/src/services/dictionaries/registry.ts b/apps/readest-app/src/services/dictionaries/registry.ts index fd72869e..c723cfa9 100644 --- a/apps/readest-app/src/services/dictionaries/registry.ts +++ b/apps/readest-app/src/services/dictionaries/registry.ts @@ -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); diff --git a/apps/readest-app/src/services/dictionaries/systemDictionary.ts b/apps/readest-app/src/services/dictionaries/systemDictionary.ts new file mode 100644 index 00000000..d9076459 --- /dev/null +++ b/apps/readest-app/src/services/dictionaries/systemDictionary.ts @@ -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 => { + 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( + '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; + } +}; diff --git a/apps/readest-app/src/services/dictionaries/types.ts b/apps/readest-app/src/services/dictionaries/types.ts index 507a0c91..2becf4c6 100644 --- a/apps/readest-app/src/services/dictionaries/types.ts +++ b/apps/readest-app/src/services/dictionaries/types.ts @@ -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]; diff --git a/apps/readest-app/src/store/customDictionaryStore.ts b/apps/readest-app/src/store/customDictionaryStore.ts index 25eaad72..74d6f3c8 100644 --- a/apps/readest-app/src/store/customDictionaryStore.ts +++ b/apps/readest-app/src/store/customDictionaryStore.ts @@ -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((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 = { ...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) => { diff --git a/apps/readest-app/src/utils/sel.ts b/apps/readest-app/src/utils/sel.ts index b0a9d18e..cd0d9b1f 100644 --- a/apps/readest-app/src/utils/sel.ts +++ b/apps/readest-app/src/utils/sel.ts @@ -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);