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:
+76
@@ -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",
|
||||
|
||||
+91
@@ -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()
|
||||
|
||||
+13
@@ -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"]
|
||||
+27
@@ -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",
|
||||
|
||||
+14
-2
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user