mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-14 18:47:42 +01:00
feat: add selection toolbar window for mac (#980)
* feat: add selection window page
* fix: chat input
* feat: add selection page
* chore: add
* chore: test
* feat: add
* feat: add store
* feat: add selection settings
* chore: remove unused code
* docs: add release note
* docs: add release note
* chore: format code
* chore: format code
* fix: copy error
* disable hashbrown default feature
* Enable unstable feature allocator_api
To make coco-app compile in CI:
```
--> /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/hashbrown-0.15.5/src/raw/mod.rs:3856:12
|
3856 | impl<T, A: Allocator> RawIntoIter<T, A> {
| ^^^^^^^^^
|
= note: see issue #32838 <https://github.com/rust-lang/rust/issues/32838> for more information
= help: add `#![feature(allocator_api)]` to the crate attributes to enable
= note: this compiler was built on 2025-06-25; consider upgrading it if it is out of date
```
I don't know why it does not compile, feature `allocator-api2` is
enabled for `hashbrown 0.15.5`, so technically [1] it should not use the
allocator APIs from the std. According to [2], enabling the `nightly`
feature of `allocator-api2` may cause this issue as well, but it is not
enabled in our case either.
Anyway, enabling `#![feature(allocator_api)]` should make it work.
[1]: b751eef8e9/src/raw/alloc.rs (L26-L47)
[2]: https://github.com/rust-lang/hashbrown/issues/564
* put it in main.rs
* format main.rs
* Enable default-features for hashbrown 0.15.5
* format main.rs
* enable feature allocator-api2
* feat: add selection set config
* fix: selection setting
* fix: ci error
* fix: ci error
* fix: ci error
* fix: ci error
* merge: merge main
* fix: rust code warn
* fix: rust code error
* fix: rust code error
* fix: selection settings
* style: selection styles
* style: selection styles
---------
Co-authored-by: Steve Lau <stevelauc@outlook.com>
This commit is contained in:
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -14,6 +14,7 @@
|
||||
"dyld",
|
||||
"elif",
|
||||
"errmsg",
|
||||
"frontmost",
|
||||
"fullscreen",
|
||||
"fulltext",
|
||||
"headlessui",
|
||||
@@ -40,6 +41,7 @@
|
||||
"nowrap",
|
||||
"nspanel",
|
||||
"nsstring",
|
||||
"objc",
|
||||
"overscan",
|
||||
"partialize",
|
||||
"patchelf",
|
||||
|
||||
@@ -13,6 +13,7 @@ Information about release notes of Coco App is provided here.
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- feat: add selection toolbar window for mac #980
|
||||
- feat: add a heartbeat worker to check Coco server availability #988
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
@@ -19,9 +19,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.2",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-autostart": "~2.2.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "~2.3.2",
|
||||
"@tauri-apps/plugin-deep-link": "^2.2.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
|
||||
|
||||
73
pnpm-lock.yaml
generated
73
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
||||
'@headlessui/react':
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-separator':
|
||||
specifier: ^1.1.8
|
||||
version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3(@types/react@18.3.26)(react@18.3.1)
|
||||
@@ -20,6 +23,9 @@ importers:
|
||||
'@tauri-apps/plugin-autostart':
|
||||
specifier: ~2.2.0
|
||||
version: 2.2.0
|
||||
'@tauri-apps/plugin-clipboard-manager':
|
||||
specifier: ~2.3.2
|
||||
version: 2.3.2
|
||||
'@tauri-apps/plugin-deep-link':
|
||||
specifier: ^2.2.1
|
||||
version: 2.4.5
|
||||
@@ -1029,6 +1035,32 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-primitive@2.1.4':
|
||||
resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-separator@1.1.8':
|
||||
resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.2.3':
|
||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||
peerDependencies:
|
||||
@@ -1038,6 +1070,15 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.2.4':
|
||||
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@react-aria/focus@3.20.2':
|
||||
resolution: {integrity: sha512-Q3rouk/rzoF/3TuH6FzoAIKrl+kzZi9LHmr8S5EqLAOyP9TXIKG34x2j42dZsAhrw7TbF9gA8tBKwnCNH4ZV+Q==}
|
||||
peerDependencies:
|
||||
@@ -1286,6 +1327,9 @@ packages:
|
||||
'@tauri-apps/plugin-autostart@2.2.0':
|
||||
resolution: {integrity: sha512-TzVcDZdOvdot0avkpstUWJKKEl4cyxLpFB9DZZRW5zH8k+Bv8IVJmO0zyYuw+7oKlGdHOINbD/7Je7GHMViw5w==}
|
||||
|
||||
'@tauri-apps/plugin-clipboard-manager@2.3.2':
|
||||
resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==}
|
||||
|
||||
'@tauri-apps/plugin-deep-link@2.4.5':
|
||||
resolution: {integrity: sha512-Zf2RTj1D9IQQ45/jqW8XTKvql24HqlPjcpv0mV/O2jHQkNe11HOTZBVj6IK37qs+MWV7xZzcmazx/QVZnhAwaQ==}
|
||||
|
||||
@@ -4553,6 +4597,24 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.26
|
||||
|
||||
'@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.2.4(@types/react@18.3.26)(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.26
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.26)
|
||||
|
||||
'@radix-ui/react-separator@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.26
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.26)
|
||||
|
||||
'@radix-ui/react-slot@1.2.3(@types/react@18.3.26)(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.26)(react@18.3.1)
|
||||
@@ -4560,6 +4622,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.26
|
||||
|
||||
'@radix-ui/react-slot@1.2.4(@types/react@18.3.26)(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.26)(react@18.3.1)
|
||||
react: 18.3.1
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.26
|
||||
|
||||
'@react-aria/focus@3.20.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@react-aria/interactions': 3.25.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -4746,6 +4815,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.9.0
|
||||
|
||||
'@tauri-apps/plugin-clipboard-manager@2.3.2':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.9.0
|
||||
|
||||
'@tauri-apps/plugin-deep-link@2.4.5':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.9.0
|
||||
|
||||
1845
src-tauri/Cargo.lock
generated
1845
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -119,6 +119,7 @@ toml = "0.8"
|
||||
path-clean = "1.0.1"
|
||||
actix-files = "0.6.8"
|
||||
actix-web = "4.11.0"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.23.0"
|
||||
@@ -130,6 +131,8 @@ objc2 = "0.6.2"
|
||||
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }
|
||||
objc2-application-services = { version = "0.3.1", features = ["HIServices"] }
|
||||
objc2-core-graphics = { version = "=0.3.1", features = ["CGEvent"] }
|
||||
# macOS-only: used by selection_monitor.rs to check AX trust/prompt
|
||||
macos-accessibility-client = "0.0.1"
|
||||
|
||||
[target."cfg(target_os = \"linux\")".dependencies]
|
||||
gio = "0.21.2"
|
||||
|
||||
@@ -38,5 +38,9 @@
|
||||
<string>Coco AI requires camera access for scanning documents and capturing images.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Coco AI uses speech recognition to convert your voice into text for a hands-free experience.</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Coco AI requires access to Apple Events to enable certain features, such as opening files and applications.</string>
|
||||
<key>NSAccessibility</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main", "chat", "settings", "check"],
|
||||
"windows": ["main", "chat", "settings", "check", "selection"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:allow-emit",
|
||||
@@ -30,6 +30,7 @@
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:deny-internal-toggle-maximize",
|
||||
"core:window:allow-set-shadow",
|
||||
"core:window:allow-set-position",
|
||||
"core:app:allow-set-app-theme",
|
||||
"shell:default",
|
||||
"http:default",
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
// use crate::common::health::Status;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RequestAccessTokenResponse {
|
||||
pub access_token: String,
|
||||
pub expire_in: u32,
|
||||
}
|
||||
@@ -1,26 +1,8 @@
|
||||
use crate::common;
|
||||
use reqwest::Response;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tauri_plugin_store::JsonValue;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GetResponse {
|
||||
pub _id: String,
|
||||
pub _source: Source,
|
||||
pub result: String,
|
||||
pub payload: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Source {
|
||||
pub id: String,
|
||||
pub created: String,
|
||||
pub updated: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
pub async fn get_response_body_text(response: Response) -> Result<String, String> {
|
||||
let status = response.status().as_u16();
|
||||
let body = response
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod assistant;
|
||||
pub mod auth;
|
||||
pub mod connector;
|
||||
pub mod datasource;
|
||||
pub mod document;
|
||||
|
||||
@@ -3,6 +3,7 @@ mod autostart;
|
||||
mod common;
|
||||
mod extension;
|
||||
mod search;
|
||||
mod selection_monitor;
|
||||
mod server;
|
||||
mod settings;
|
||||
mod setup;
|
||||
@@ -72,24 +73,13 @@ async fn change_window_height(handle: AppHandle, height: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ThemeChangedPayload {
|
||||
#[allow(dead_code)]
|
||||
is_dark_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[allow(dead_code)]
|
||||
struct Payload {
|
||||
args: Vec<String>,
|
||||
cwd: String,
|
||||
}
|
||||
// Removed unused Payload to avoid unnecessary serde derive macro invocations
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let ctx = tauri::generate_context!();
|
||||
|
||||
let mut app_builder = tauri::Builder::default();
|
||||
let mut app_builder = tauri::Builder::default().plugin(tauri_plugin_clipboard_manager::init());
|
||||
// Set up logger first
|
||||
app_builder = app_builder.plugin(set_up_tauri_logger());
|
||||
|
||||
@@ -208,7 +198,9 @@ pub fn run() {
|
||||
setup::backend_setup,
|
||||
util::app_lang::update_app_lang,
|
||||
util::path::path_absolute,
|
||||
util::logging::app_log_dir
|
||||
util::logging::app_log_dir,
|
||||
selection_monitor::set_selection_enabled,
|
||||
selection_monitor::get_selection_enabled,
|
||||
])
|
||||
.setup(|app| {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -218,7 +210,6 @@ pub fn run() {
|
||||
log::trace!("Dock icon should be hidden now");
|
||||
}
|
||||
|
||||
|
||||
/* ----------- This code must be executed on the main thread and must not be relocated. ----------- */
|
||||
let app_handle = app.app_handle();
|
||||
let main_window = app_handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
|
||||
|
||||
373
src-tauri/src/selection_monitor.rs
Normal file
373
src-tauri/src/selection_monitor.rs
Normal file
@@ -0,0 +1,373 @@
|
||||
/// Event payload sent to the frontend when selection is detected.
|
||||
/// Coordinates use logical (Quartz) points with a top-left origin.
|
||||
/// Note: `y` is flipped on the backend to match the frontend’s usage.
|
||||
use tauri::Emitter;
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
struct SelectionEventPayload {
|
||||
text: String,
|
||||
x: i32,
|
||||
y: i32,
|
||||
}
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Global toggle: selection monitoring enabled by default.
|
||||
static SELECTION_ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
struct SelectionEnabledPayload {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
/// Read the current selection monitoring state.
|
||||
pub fn is_selection_enabled() -> bool {
|
||||
SELECTION_ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Update the monitoring state and broadcast to the frontend.
|
||||
fn set_selection_enabled_internal(app_handle: &tauri::AppHandle, enabled: bool) {
|
||||
SELECTION_ENABLED.store(enabled, Ordering::Relaxed);
|
||||
let _ = app_handle.emit("selection-enabled", SelectionEnabledPayload { enabled });
|
||||
}
|
||||
|
||||
/// Tauri command: set selection monitoring state.
|
||||
#[tauri::command]
|
||||
pub fn set_selection_enabled(app_handle: tauri::AppHandle, enabled: bool) {
|
||||
set_selection_enabled_internal(&app_handle, enabled);
|
||||
}
|
||||
|
||||
/// Tauri command: get selection monitoring state.
|
||||
#[tauri::command]
|
||||
pub fn get_selection_enabled() -> bool {
|
||||
is_selection_enabled()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
|
||||
// Entrypoint: checks permissions (macOS), initializes, and starts a background watcher thread.
|
||||
log::info!("start_selection_monitor: 入口函数启动");
|
||||
use std::time::Duration;
|
||||
use tauri::Emitter;
|
||||
|
||||
// Sync initial enabled state to the frontend on startup.
|
||||
set_selection_enabled_internal(&app_handle, is_selection_enabled());
|
||||
|
||||
// Accessibility permission is required to read selected text in the foreground app.
|
||||
// If not granted, prompt the user once; if still not granted, skip starting the watcher.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let trusted_before = macos_accessibility_client::accessibility::application_is_trusted();
|
||||
if !trusted_before {
|
||||
let _ = macos_accessibility_client::accessibility::application_is_trusted_with_prompt();
|
||||
}
|
||||
let trusted_after = macos_accessibility_client::accessibility::application_is_trusted();
|
||||
if !trusted_after {
|
||||
return;
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
log::info!("start_selection_monitor: 非 macOS 平台,无划词监控");
|
||||
}
|
||||
|
||||
// Background thread: drives popup show/hide based on mouse and AX selection state.
|
||||
std::thread::spawn(move || {
|
||||
#[cfg(target_os = "macos")]
|
||||
use objc2_app_kit::NSWorkspace;
|
||||
use objc2_core_graphics::CGEvent;
|
||||
use objc2_core_graphics::{CGDisplayBounds, CGGetActiveDisplayList, CGMainDisplayID};
|
||||
|
||||
// Get current mouse position (logical top-left origin), flipping `y` to match frontend usage.
|
||||
let current_mouse_point_global = || -> (i32, i32) {
|
||||
unsafe {
|
||||
let event = CGEvent::new(None);
|
||||
let pt = objc2_core_graphics::CGEvent::location(event.as_deref());
|
||||
|
||||
// Enumerate active displays to compute global bounds and pick the display containing the cursor.
|
||||
let mut displays: [u32; 16] = [0; 16];
|
||||
let mut display_count: u32 = 0;
|
||||
let _ = CGGetActiveDisplayList(
|
||||
displays.len() as u32,
|
||||
displays.as_mut_ptr(),
|
||||
&mut display_count,
|
||||
);
|
||||
if display_count == 0 {
|
||||
// Fallback to main display.
|
||||
let did = CGMainDisplayID();
|
||||
let b = CGDisplayBounds(did);
|
||||
let min_x_pt = b.origin.x as f64;
|
||||
let max_top_pt = (b.origin.y + b.size.height) as f64;
|
||||
let min_bottom_pt = b.origin.y as f64;
|
||||
let total_h_pt = max_top_pt - min_bottom_pt;
|
||||
|
||||
let x_top_left = (pt.x as f64 - min_x_pt).round() as i32;
|
||||
let y_top_left = (max_top_pt - pt.y as f64).round() as i32;
|
||||
let y_flipped = (total_h_pt.round() as i32 - y_top_left).max(0);
|
||||
|
||||
return (x_top_left, y_flipped);
|
||||
}
|
||||
|
||||
let mut chosen = CGMainDisplayID(); // default fallback
|
||||
log::info!(
|
||||
"current_mouse: pt=({:.1},{:.1}) → display={}",
|
||||
pt.x as f64,
|
||||
pt.y as f64,
|
||||
chosen
|
||||
);
|
||||
|
||||
let mut min_x_pt = f64::INFINITY;
|
||||
let mut max_top_pt = f64::NEG_INFINITY;
|
||||
let mut min_bottom_pt = f64::INFINITY;
|
||||
for i in 0..display_count as usize {
|
||||
let did = displays[i];
|
||||
let b = CGDisplayBounds(did);
|
||||
if (b.origin.x as f64) < min_x_pt {
|
||||
min_x_pt = b.origin.x as f64;
|
||||
}
|
||||
let top = (b.origin.y + b.size.height) as f64;
|
||||
if top > max_top_pt {
|
||||
max_top_pt = top;
|
||||
}
|
||||
if (b.origin.y as f64) < min_bottom_pt {
|
||||
min_bottom_pt = b.origin.y as f64;
|
||||
}
|
||||
|
||||
let in_x = pt.x >= b.origin.x && pt.x <= b.origin.x + b.size.width;
|
||||
let in_y = pt.y >= b.origin.y && pt.y <= b.origin.y + b.size.height;
|
||||
if in_x && in_y {
|
||||
chosen = did;
|
||||
log::info!(
|
||||
"current_mouse: pt=({:.1},{:.1}) → display={} → point_global_top_left=(x={}, y={})",
|
||||
pt.x as f64,
|
||||
pt.y as f64,
|
||||
chosen,
|
||||
b.origin.x,
|
||||
b.origin.y
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let total_h_pt = max_top_pt - min_bottom_pt;
|
||||
|
||||
let x_top_left = (pt.x as f64 - min_x_pt).round() as i32;
|
||||
let y_top_left = (max_top_pt - pt.y as f64).round() as i32;
|
||||
let y_flipped = (total_h_pt.round() as i32 - y_top_left).max(0);
|
||||
|
||||
(x_top_left, y_flipped)
|
||||
}
|
||||
};
|
||||
|
||||
// Determine whether the frontmost app is this process (Coco).
|
||||
// Avoid misinterpreting empty selection when interacting with the popup itself.
|
||||
let is_frontmost_app_me = || -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe {
|
||||
let workspace = NSWorkspace::sharedWorkspace();
|
||||
if let Some(frontmost) = workspace.frontmostApplication() {
|
||||
let pid = frontmost.processIdentifier();
|
||||
let my_pid = std::process::id() as i32;
|
||||
return pid == my_pid;
|
||||
}
|
||||
}
|
||||
false
|
||||
};
|
||||
|
||||
// Selection-driven state machine.
|
||||
let mut popup_visible = false;
|
||||
let mut last_text = String::new();
|
||||
|
||||
// Stability and hide thresholds (tunable).
|
||||
let stable_threshold = 2; // same content ≥2 times → stable selection
|
||||
let empty_threshold = 2; // empty value ≥2 times → stable empty
|
||||
let mut stable_text = String::new();
|
||||
let mut stable_count = 0;
|
||||
let mut empty_count = 0;
|
||||
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
|
||||
// If disabled: do not read AX / do not show popup; hide if currently visible.
|
||||
if !is_selection_enabled() {
|
||||
if popup_visible {
|
||||
let _ = app_handle.emit("selection-detected", "");
|
||||
popup_visible = false;
|
||||
last_text.clear();
|
||||
stable_text.clear();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip empty-selection hide checks while interacting with the Coco popup.
|
||||
let front_is_me = is_frontmost_app_me();
|
||||
|
||||
// Lightweight retries to smooth out transient AX focus instability.
|
||||
let selected_text = if front_is_me {
|
||||
// Do not read selection during popup interaction to avoid false empty.
|
||||
None
|
||||
} else {
|
||||
// Up to 2 retries, 35ms apart.
|
||||
read_selected_text_with_retries(2, 35)
|
||||
};
|
||||
|
||||
match selected_text {
|
||||
Some(text) if !text.is_empty() => {
|
||||
empty_count = 0;
|
||||
if text == stable_text {
|
||||
stable_count += 1;
|
||||
} else {
|
||||
stable_text = text.clone();
|
||||
stable_count = 1;
|
||||
}
|
||||
|
||||
// Update/show only when selection is stable to avoid flicker.
|
||||
if stable_count >= stable_threshold {
|
||||
if !popup_visible || text != last_text {
|
||||
let (x, y) = current_mouse_point_global();
|
||||
let payload = SelectionEventPayload {
|
||||
text: text.clone(),
|
||||
x,
|
||||
y,
|
||||
};
|
||||
|
||||
let _ = app_handle.emit("selection-detected", payload);
|
||||
last_text = text;
|
||||
popup_visible = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// If not Coco in front and selection is empty: accumulate empties, then hide.
|
||||
if !front_is_me {
|
||||
stable_count = 0;
|
||||
empty_count += 1;
|
||||
if popup_visible && empty_count >= empty_threshold {
|
||||
let _ = app_handle.emit("selection-detected", "");
|
||||
popup_visible = false;
|
||||
last_text.clear();
|
||||
stable_text.clear();
|
||||
}
|
||||
} else {
|
||||
// When Coco is frontmost: do not hide or clear state during interaction.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// macOS-wide accessibility entry point: allows reading system-level focused elements.
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe extern "C" {
|
||||
fn AXUIElementCreateSystemWide() -> *mut objc2_application_services::AXUIElement;
|
||||
}
|
||||
|
||||
/// Read the selected text of the frontmost application (without using the clipboard).
|
||||
/// macOS only. Returns `None` when the frontmost app is Coco to avoid false empties.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read_selected_text() -> Option<String> {
|
||||
use objc2_app_kit::NSWorkspace;
|
||||
use objc2_application_services::{AXError, AXUIElement};
|
||||
use objc2_core_foundation::{CFRetained, CFString, CFType};
|
||||
use std::ptr::NonNull;
|
||||
|
||||
// Prefer system-wide focused element; if unavailable, fall back to app/window focused element.
|
||||
let mut focused_ui_ptr: *const CFType = std::ptr::null();
|
||||
let focused_attr = CFString::from_static_str("AXFocusedUIElement");
|
||||
|
||||
// System-wide focused UI element.
|
||||
let system_elem = unsafe { AXUIElementCreateSystemWide() };
|
||||
if !system_elem.is_null() {
|
||||
let system_elem_retained: CFRetained<AXUIElement> =
|
||||
unsafe { CFRetained::from_raw(NonNull::new(system_elem).unwrap()) };
|
||||
let err = unsafe {
|
||||
system_elem_retained
|
||||
.copy_attribute_value(&focused_attr, NonNull::new(&mut focused_ui_ptr).unwrap())
|
||||
};
|
||||
if err != AXError::Success {
|
||||
focused_ui_ptr = std::ptr::null();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to the frontmost app's focused/window element.
|
||||
if focused_ui_ptr.is_null() {
|
||||
let workspace = unsafe { NSWorkspace::sharedWorkspace() };
|
||||
let frontmost_app = unsafe { workspace.frontmostApplication() }?;
|
||||
let pid = unsafe { frontmost_app.processIdentifier() };
|
||||
|
||||
// Skip if frontmost is Coco (this process).
|
||||
let my_pid = std::process::id() as i32;
|
||||
if pid == my_pid {
|
||||
return None;
|
||||
}
|
||||
|
||||
let app_element = unsafe { AXUIElement::new_application(pid) };
|
||||
let err = unsafe {
|
||||
app_element
|
||||
.copy_attribute_value(&focused_attr, NonNull::new(&mut focused_ui_ptr).unwrap())
|
||||
};
|
||||
if err != AXError::Success || focused_ui_ptr.is_null() {
|
||||
// Try `AXFocusedWindow` as a lightweight fallback.
|
||||
let mut focused_window_ptr: *const CFType = std::ptr::null();
|
||||
let focused_window_attr = CFString::from_static_str("AXFocusedWindow");
|
||||
let w_err = unsafe {
|
||||
app_element.copy_attribute_value(
|
||||
&focused_window_attr,
|
||||
NonNull::new(&mut focused_window_ptr).unwrap(),
|
||||
)
|
||||
};
|
||||
if w_err != AXError::Success || focused_window_ptr.is_null() {
|
||||
return None;
|
||||
}
|
||||
focused_ui_ptr = focused_window_ptr;
|
||||
}
|
||||
}
|
||||
|
||||
let focused_ui_elem: *mut AXUIElement = focused_ui_ptr.cast::<AXUIElement>().cast_mut();
|
||||
let focused_ui: CFRetained<AXUIElement> =
|
||||
unsafe { CFRetained::from_raw(NonNull::new(focused_ui_elem).unwrap()) };
|
||||
|
||||
// Prefer `AXSelectedText`; otherwise return None (can be extended to read ranges).
|
||||
let mut selected_text_ptr: *const CFType = std::ptr::null();
|
||||
let selected_text_attr = CFString::from_static_str("AXSelectedText");
|
||||
let err = unsafe {
|
||||
focused_ui.copy_attribute_value(
|
||||
&selected_text_attr,
|
||||
NonNull::new(&mut selected_text_ptr).unwrap(),
|
||||
)
|
||||
};
|
||||
if err != AXError::Success || selected_text_ptr.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// CFString → Rust String
|
||||
let selected_cfstr: CFRetained<CFString> = unsafe {
|
||||
CFRetained::from_raw(NonNull::new(selected_text_ptr.cast::<CFString>().cast_mut()).unwrap())
|
||||
};
|
||||
|
||||
Some(selected_cfstr.to_string())
|
||||
}
|
||||
|
||||
/// Read selected text with lightweight retries to handle transient AX focus instability.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read_selected_text_with_retries(retries: u32, delay_ms: u64) -> Option<String> {
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
for attempt in 0..=retries {
|
||||
if let Some(text) = read_selected_text() {
|
||||
if !text.is_empty() {
|
||||
if attempt > 0 {
|
||||
log::info!(
|
||||
"read_selected_text: 第{}次重试成功,获取到选中文本",
|
||||
attempt
|
||||
);
|
||||
}
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
if attempt < retries {
|
||||
thread::sleep(Duration::from_millis(delay_ms));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -107,6 +107,12 @@ pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String)
|
||||
})
|
||||
.expect("failed to run this closure on the main thread");
|
||||
|
||||
// Start system-wide selection monitor (macOS-only currently)
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
crate::selection_monitor::start_selection_monitor(tauri_app_handle.clone());
|
||||
}
|
||||
|
||||
crate::init(&tauri_app_handle).await;
|
||||
|
||||
if let Err(err) = crate::extension::init_extensions(&tauri_app_handle).await {
|
||||
|
||||
@@ -78,6 +78,29 @@
|
||||
"state": "active",
|
||||
"radius": 7
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "selection",
|
||||
"title": "Selection",
|
||||
"alwaysOnTop": true,
|
||||
"shadow": false,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"closable": true,
|
||||
"minimizable": false,
|
||||
"maximizable": false,
|
||||
"dragDropEnabled": false,
|
||||
"resizable": false,
|
||||
"center": false,
|
||||
"url": "/ui/selection",
|
||||
"hiddenTitle": true,
|
||||
"visible": false,
|
||||
"acceptFirstMouse": true,
|
||||
"windowEffects": {
|
||||
"effects": [],
|
||||
"state": "active",
|
||||
"radius": 7
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -25,7 +25,8 @@ export const AssistantFetcher = ({
|
||||
query?: string;
|
||||
}) => {
|
||||
try {
|
||||
if (await unrequitable()) {
|
||||
// Only gate by current window service when no explicit serverId provided.
|
||||
if (!params.serverId && (await unrequitable())) {
|
||||
return {
|
||||
total: 0,
|
||||
list: [],
|
||||
|
||||
@@ -47,6 +47,10 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
const setAskAiAssistantId = useSearchStore((state) => {
|
||||
return state.setAskAiAssistantId;
|
||||
});
|
||||
const targetAssistantId = useSearchStore((state) => state.targetAssistantId);
|
||||
const setTargetAssistantId = useSearchStore((state) => {
|
||||
return state.setTargetAssistantId;
|
||||
});
|
||||
|
||||
const { fetchAssistant } = AssistantFetcher({
|
||||
debounceKeyword,
|
||||
@@ -81,17 +85,22 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||||
const [isKeyboardActive, setIsKeyboardActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!askAiAssistantId || assistantList.length === 0) return;
|
||||
|
||||
const matched = assistantList.find((item) => {
|
||||
return item._id === askAiAssistantId;
|
||||
});
|
||||
const targetId = askAiAssistantId ?? targetAssistantId;
|
||||
if (!targetId || assistantList.length === 0) return;
|
||||
|
||||
const matched = assistantList.find((item) => item._id === targetId);
|
||||
if (!matched) return;
|
||||
|
||||
setCurrentAssistant(matched);
|
||||
setAskAiAssistantId(void 0);
|
||||
}, [assistantList, askAiAssistantId]);
|
||||
if (currentAssistant?._id !== matched._id) {
|
||||
setCurrentAssistant(matched);
|
||||
}
|
||||
|
||||
if (askAiAssistantId) {
|
||||
setAskAiAssistantId(void 0);
|
||||
} else if (targetAssistantId) {
|
||||
setTargetAssistantId(void 0);
|
||||
}
|
||||
}, [assistantList, askAiAssistantId, targetAssistantId]);
|
||||
|
||||
useKeyPress(
|
||||
["uparrow", "downarrow", "enter"],
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import { useMount, useMutationObserver } from "ahooks";
|
||||
import { debounce } from "lodash-es";
|
||||
|
||||
import Search from "@/components/Search/Search";
|
||||
import InputBox from "@/components/Search/InputBox";
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
import { useTauriFocus } from "@/hooks/useTauriFocus";
|
||||
import { POPOVER_PANEL_SELECTOR } from "@/constants";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { debounce } from "lodash-es";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
|
||||
interface SearchChatProps {
|
||||
isTauri?: boolean;
|
||||
@@ -314,6 +315,43 @@ function SearchChat({
|
||||
|
||||
const { normalOpacity, blurOpacity } = useAppearanceStore();
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenAsk = platformAdapter.listenEvent("selection-ask-ai", ({ payload }: any) => {
|
||||
const value = typeof payload === "string" ? payload : String(payload?.text ?? "");
|
||||
dispatch({ type: "SET_CHAT_MODE", payload: true });
|
||||
dispatch({ type: "SET_INPUT", payload: value });
|
||||
platformAdapter.showWindow();
|
||||
});
|
||||
|
||||
const unlistenAction = platformAdapter.listenEvent("selection-action", ({ payload }: any) => {
|
||||
const { action, text, assistantId } = payload || {};
|
||||
const value = String(text ?? "");
|
||||
if (action === "search") {
|
||||
dispatch({ type: "SET_CHAT_MODE", payload: false });
|
||||
dispatch({ type: "SET_INPUT", payload: value });
|
||||
const { setSearchValue } = useSearchStore.getState();
|
||||
setSearchValue(value);
|
||||
platformAdapter.showWindow();
|
||||
} else if (action === "chat") {
|
||||
dispatch({ type: "SET_CHAT_MODE", payload: true });
|
||||
dispatch({ type: "SET_INPUT", payload: value });
|
||||
|
||||
const { assistantList } = useConnectStore.getState();
|
||||
const assistant = assistantList.find((item) => item._source?.id === assistantId);
|
||||
if (assistant) {
|
||||
const { setTargetAssistantId } = useSearchStore.getState();
|
||||
setTargetAssistantId(assistant._id);
|
||||
}
|
||||
platformAdapter.showWindow();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenAsk.then((fn) => fn());
|
||||
unlistenAction.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTauri) {
|
||||
changeMode(defaultStartupWindow === "chatMode");
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
GripVertical,
|
||||
Bot,
|
||||
Copy,
|
||||
Languages,
|
||||
Search,
|
||||
Volume2,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
|
||||
import { setCurrentWindowService } from "@/commands/windowService";
|
||||
|
||||
type ActionType =
|
||||
| "search"
|
||||
| "ask_ai"
|
||||
| "translate"
|
||||
| "summary"
|
||||
| "copy"
|
||||
| "speak"
|
||||
| "custom";
|
||||
|
||||
type LucideIconName =
|
||||
| "Search"
|
||||
| "Bot"
|
||||
| "Languages"
|
||||
| "FileText"
|
||||
| "Copy"
|
||||
| "Volume2";
|
||||
|
||||
type IconConfig =
|
||||
| { type: "lucide"; name: LucideIconName; color?: string }
|
||||
| { type: "custom"; dataUrl: string; color?: string };
|
||||
|
||||
export type ButtonConfig = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: IconConfig;
|
||||
action: {
|
||||
type: ActionType;
|
||||
assistantId?: string;
|
||||
assistantServerId?: string;
|
||||
eventName?: string;
|
||||
};
|
||||
labelKey?: string;
|
||||
};
|
||||
|
||||
const LUCIDE_ICON_MAP: Record<LucideIconName, any> = {
|
||||
Search,
|
||||
Bot,
|
||||
Languages,
|
||||
FileText,
|
||||
Copy,
|
||||
Volume2,
|
||||
};
|
||||
|
||||
const ASSISTANT_CACHE_KEY = "assistant_list_cache";
|
||||
|
||||
type AssistantCacheItem = {
|
||||
list: any[];
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
function loadAssistantCache(): Record<string, AssistantCacheItem> {
|
||||
try {
|
||||
const raw = localStorage.getItem(ASSISTANT_CACHE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") return parsed;
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveAssistantCache(cache: Record<string, AssistantCacheItem>) {
|
||||
try {
|
||||
localStorage.setItem(ASSISTANT_CACHE_KEY, JSON.stringify(cache));
|
||||
} catch (e) {
|
||||
console.error("Persist assistant cache failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
type ButtonsListProps = {
|
||||
buttons: ButtonConfig[];
|
||||
setButtons: React.Dispatch<React.SetStateAction<ButtonConfig[]>>;
|
||||
serverList: any[];
|
||||
};
|
||||
|
||||
const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { fetchAssistant } = AssistantFetcher({});
|
||||
|
||||
const [assistantByServer, setAssistantByServer] = useState<Record<string, any[]>>({});
|
||||
const [assistantLoadingByServer, setAssistantLoadingByServer] = useState<Record<string, boolean>>({});
|
||||
const [assistantCache, setAssistantCacheState] = useState<Record<string, AssistantCacheItem>>(() => loadAssistantCache());
|
||||
|
||||
const dragIndexRef = useRef<number | null>(null);
|
||||
const initializedServiceRef = useRef<boolean>(false);
|
||||
|
||||
const onDragStart = (index: number) => {
|
||||
dragIndexRef.current = index;
|
||||
};
|
||||
|
||||
const onDrop = (index: number) => {
|
||||
const from = dragIndexRef.current;
|
||||
dragIndexRef.current = null;
|
||||
if (from === null || from === index) return;
|
||||
setButtons((prev) => {
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(index, 0, moved);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateAction = (id: string, patch: Partial<ButtonConfig["action"]>) => {
|
||||
setButtons((prev) => prev.map((b) => (b.id === id ? { ...b, action: { ...b.action, ...patch } } : b)));
|
||||
};
|
||||
|
||||
const handleAssistantSelect = (btn: ButtonConfig, value: string) => {
|
||||
const id = value || undefined;
|
||||
updateAction(btn.id, { assistantId: id });
|
||||
};
|
||||
|
||||
const handleServerSelect = async (btn: ButtonConfig, serverId: string) => {
|
||||
const sid = serverId || undefined;
|
||||
try {
|
||||
const target = serverList.find((s: any) => s.id === sid);
|
||||
if (target) {
|
||||
await setCurrentWindowService(target);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("setCurrentWindowService failed:", e);
|
||||
}
|
||||
updateAction(btn.id, { assistantServerId: sid, assistantId: undefined });
|
||||
if (!sid) return;
|
||||
|
||||
const cached = assistantCache[sid];
|
||||
if (cached && Array.isArray(cached.list)) {
|
||||
setAssistantByServer((prev) => ({ ...prev, [sid]: cached.list }));
|
||||
}
|
||||
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true }));
|
||||
try {
|
||||
const data = await fetchAssistant({ current: 1, pageSize: 1000, serverId: sid });
|
||||
const list = data.list || [];
|
||||
setAssistantByServer((prev) => ({ ...prev, [sid]: list }));
|
||||
const nextCache = { ...assistantCache, [sid]: { list, updatedAt: Date.now() } };
|
||||
setAssistantCacheState(nextCache);
|
||||
saveAssistantCache(nextCache);
|
||||
} catch (err) {
|
||||
console.error("Fetch assistants for server failed:", err);
|
||||
setAssistantByServer((prev) => ({ ...prev, [sid]: [] }));
|
||||
} finally {
|
||||
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (initializedServiceRef.current) return;
|
||||
initializedServiceRef.current = true;
|
||||
|
||||
const preferredSid =
|
||||
buttons.find((b) => b.action.assistantServerId)?.action.assistantServerId ||
|
||||
Object.keys(assistantCache)[0];
|
||||
|
||||
if (!preferredSid) return;
|
||||
const target = serverList.find((s: any) => s.id === preferredSid);
|
||||
if (!target) return;
|
||||
|
||||
setCurrentWindowService(target).catch((e) => {
|
||||
console.error("init setCurrentWindowService failed:", e);
|
||||
});
|
||||
}, [serverList, buttons]);
|
||||
|
||||
useEffect(() => {
|
||||
const uniqueServerIds = Array.from(
|
||||
new Set(
|
||||
buttons
|
||||
.map((b) => b.action.assistantServerId)
|
||||
.filter((sid): sid is string => Boolean(sid))
|
||||
)
|
||||
);
|
||||
|
||||
uniqueServerIds.forEach(async (sid) => {
|
||||
if (!sid) return;
|
||||
const cached = assistantCache[sid];
|
||||
if (cached && Array.isArray(cached.list)) {
|
||||
setAssistantByServer((prev) => ({ ...prev, [sid]: cached.list }));
|
||||
}
|
||||
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: true }));
|
||||
try {
|
||||
const data = await fetchAssistant({ current: 1, pageSize: 1000, serverId: sid });
|
||||
const list = data.list || [];
|
||||
setAssistantByServer((prev) => ({ ...prev, [sid]: list }));
|
||||
const nextCache = { ...assistantCache, [sid]: { list, updatedAt: Date.now() } };
|
||||
setAssistantCacheState(nextCache);
|
||||
saveAssistantCache(nextCache);
|
||||
} catch (err) {
|
||||
console.error("Prefetch assistants for stored server failed:", err);
|
||||
} finally {
|
||||
setAssistantLoadingByServer((prev) => ({ ...prev, [sid]: false }));
|
||||
}
|
||||
});
|
||||
}, [buttons]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{buttons.map((btn, index) => {
|
||||
const IconComp = btn.icon.type === "lucide" ? LUCIDE_ICON_MAP[btn.icon.name] : null;
|
||||
const isChat = ["ask_ai", "translate", "summary"].includes(btn.action.type);
|
||||
const visualType: "Chat" | "Search" | "Tool" = isChat ? "Chat" : btn.action.type === "search" ? "Search" : "Tool";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={btn.id}
|
||||
className={clsx(
|
||||
"rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-white dark:bg-[#0B1220] shadow-sm",
|
||||
"p-3"
|
||||
)}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(index)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => onDrop(index)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical className="size-4 text-[#64748B] shrink-0" />
|
||||
{IconComp ? (
|
||||
<IconComp className="size-4 shrink-0" style={{ color: btn.icon.color || "#6B7280" }} />
|
||||
) : (
|
||||
<img src={(btn.icon as any).dataUrl} alt="icon" className="w-4 h-4 rounded shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{btn.labelKey ? t(btn.labelKey) : btn.label}</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"ml-2 inline-flex items-center rounded px-2 py-0.5 text-xs",
|
||||
visualType === "Chat"
|
||||
? "bg-[#0287FF]/10 text-[#0287FF]"
|
||||
: visualType === "Search"
|
||||
? "bg-[#6366F1]/10 text-[#6366F1]"
|
||||
: "bg-[#64748B]/10 text-[#64748B]"
|
||||
)}
|
||||
>
|
||||
{visualType}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{isChat && (
|
||||
<>
|
||||
<select
|
||||
className="rounded-md border px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
|
||||
value={btn.action.assistantServerId || ""}
|
||||
onChange={(e) => handleServerSelect(btn, e.target.value)}
|
||||
title={t("selection.bind.service")}
|
||||
>
|
||||
<option value="">{t("selection.bind.defaultService")}</option>
|
||||
{serverList.map((s: any) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name || s.endpoint || s.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{(() => {
|
||||
const sid = btn.action.assistantServerId;
|
||||
const list = (sid && assistantByServer[sid]) || [];
|
||||
const loading = !!(sid && assistantLoadingByServer[sid]);
|
||||
return (
|
||||
<select
|
||||
className="rounded-md border px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
|
||||
value={btn.action.assistantId || ""}
|
||||
onChange={(e) => handleAssistantSelect(btn, e.target.value)}
|
||||
title={t("selection.bind.assistant")}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">{t("selection.bind.defaultAssistant")}</option>
|
||||
{loading && (
|
||||
<option value="" disabled>
|
||||
加载中...
|
||||
</option>
|
||||
)}
|
||||
{list.map((a: any) => (
|
||||
<option key={a._id} value={a._id}>
|
||||
{a._source?.name || a._id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonsList;
|
||||
202
src/components/Settings/Advanced/components/Selection/index.tsx
Normal file
202
src/components/Settings/Advanced/components/Selection/index.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useSelectionStore } from "@/stores/selectionStore";
|
||||
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
||||
import SettingsItem from "@/components/Settings/SettingsItem";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useEnabledServers } from "@/hooks/useEnabledServers";
|
||||
import ButtonsList from "./ButtonsList";
|
||||
|
||||
/**
|
||||
* Selection toolbar button config types
|
||||
*/
|
||||
type IconConfig =
|
||||
| { type: "lucide"; name: LucideIconName; color?: string }
|
||||
| { type: "custom"; dataUrl: string; color?: string };
|
||||
|
||||
type ActionType =
|
||||
| "search"
|
||||
| "ask_ai"
|
||||
| "translate"
|
||||
| "summary"
|
||||
| "copy"
|
||||
| "speak"
|
||||
| "custom";
|
||||
|
||||
type ButtonConfig = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: IconConfig;
|
||||
action: {
|
||||
type: ActionType;
|
||||
assistantId?: string;
|
||||
assistantServerId?: string;
|
||||
eventName?: string;
|
||||
};
|
||||
// i18n key for built-in labels; if present, render by t(labelKey)
|
||||
labelKey?: string;
|
||||
};
|
||||
|
||||
type LucideIconName =
|
||||
| "Search"
|
||||
| "Bot"
|
||||
| "Languages"
|
||||
| "FileText"
|
||||
| "Copy"
|
||||
| "Volume2";
|
||||
|
||||
|
||||
const DEFAULT_CONFIG: ButtonConfig[] = [
|
||||
{
|
||||
id: "search",
|
||||
label: "搜索",
|
||||
labelKey: "selection.actions.search",
|
||||
icon: { type: "lucide", name: "Search", color: "#6366F1" },
|
||||
action: { type: "search" },
|
||||
},
|
||||
{
|
||||
id: "ask_ai",
|
||||
label: "问答",
|
||||
labelKey: "selection.actions.ask_ai",
|
||||
icon: { type: "lucide", name: "Bot", color: "#0287FF" },
|
||||
action: { type: "ask_ai" },
|
||||
},
|
||||
{
|
||||
id: "translate",
|
||||
label: "翻译",
|
||||
labelKey: "selection.actions.translate",
|
||||
icon: { type: "lucide", name: "Languages", color: "#14B8A6" },
|
||||
action: { type: "translate" },
|
||||
},
|
||||
{
|
||||
id: "summary",
|
||||
label: "总结",
|
||||
labelKey: "selection.actions.summary",
|
||||
icon: { type: "lucide", name: "FileText", color: "#0EA5E9" },
|
||||
action: { type: "summary" },
|
||||
},
|
||||
{
|
||||
id: "copy",
|
||||
label: "复制",
|
||||
labelKey: "selection.actions.copy",
|
||||
icon: { type: "lucide", name: "Copy", color: "#64748B" },
|
||||
action: { type: "copy" },
|
||||
},
|
||||
{
|
||||
id: "speak",
|
||||
label: "朗读",
|
||||
labelKey: "selection.actions.speak",
|
||||
icon: { type: "lucide", name: "Volume2", color: "#F59E0B" },
|
||||
action: { type: "speak" },
|
||||
},
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "selection_toolbar_config";
|
||||
|
||||
/**
|
||||
* Utilities: load/save local toolbar config
|
||||
*/
|
||||
function loadToolbarConfig(): ButtonConfig[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_CONFIG;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.length > 0)
|
||||
return parsed as ButtonConfig[];
|
||||
return DEFAULT_CONFIG;
|
||||
} catch {
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
}
|
||||
function saveToolbarConfig(cfg: ButtonConfig[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection settings panel: toolbar buttons with sorting and assistant mapping
|
||||
*/
|
||||
const SelectionSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
// Reactive service and assistant list
|
||||
const { enabledServers: serverList } = useEnabledServers();
|
||||
|
||||
const selectionEnabled = useSelectionStore((state) => state.selectionEnabled);
|
||||
const iconsOnly = useSelectionStore((state) => state.iconsOnly);
|
||||
const setIconsOnly = useSelectionStore((state) => state.setIconsOnly);
|
||||
|
||||
useEffect(() => {
|
||||
useSelectionStore.getState().initSync();
|
||||
}, []);
|
||||
|
||||
// Initialize from global store; write back on change for multi-window sync
|
||||
const toolbarConfig = useSelectionStore((s) => s.toolbarConfig);
|
||||
const setToolbarConfig = useSelectionStore((s) => s.setToolbarConfig);
|
||||
|
||||
const [buttons, setButtons] = useState<ButtonConfig[]>(() =>
|
||||
loadToolbarConfig()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// prefer store config if present
|
||||
if (Array.isArray(toolbarConfig) && toolbarConfig.length > 0) {
|
||||
setButtons(toolbarConfig as ButtonConfig[]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
saveToolbarConfig(buttons);
|
||||
setToolbarConfig(buttons); // push to store for multi-window
|
||||
}, [buttons]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("selection.title")}</h2>
|
||||
</div>
|
||||
|
||||
<SettingsItem
|
||||
icon={Sparkles}
|
||||
title={t("settings.ai.title")}
|
||||
description={t("settings.ai.description")}
|
||||
>
|
||||
<SettingsToggle
|
||||
checked={selectionEnabled}
|
||||
onChange={async (value) => {
|
||||
try {
|
||||
await platformAdapter.invokeBackend("set_selection_enabled", {
|
||||
enabled: value,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("set_selection_enabled invoke failed:", e);
|
||||
}
|
||||
}}
|
||||
label={t("settings.ai.toggle")}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{selectionEnabled && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<SettingsItem
|
||||
icon={Sparkles}
|
||||
title={t("selection.display.title")}
|
||||
description={t("selection.display.iconsOnlyDesc")}
|
||||
>
|
||||
<SettingsToggle
|
||||
checked={iconsOnly}
|
||||
onChange={async (value) => {
|
||||
// Update local store
|
||||
setIconsOnly(value);
|
||||
}}
|
||||
label={t("selection.display.iconsOnlyLabel")}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<ButtonsList buttons={buttons} setButtons={setButtons} serverList={serverList} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectionSettings;
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Unplug,
|
||||
} from "lucide-react";
|
||||
import { useMount } from "ahooks";
|
||||
import { isNil } from "lodash-es";
|
||||
|
||||
import Shortcuts from "./components/Shortcuts";
|
||||
import SettingsItem from "../SettingsItem";
|
||||
@@ -20,7 +21,7 @@ import SettingsInput from "@/components//Settings/SettingsInput";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import UpdateSettings from "./components/UpdateSettings";
|
||||
import SettingsToggle from "../SettingsToggle";
|
||||
import { isNil } from "lodash-es";
|
||||
import SelectionSettings from "./components/Selection";
|
||||
|
||||
const Advanced = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -189,6 +190,8 @@ const Advanced = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<SelectionSettings />
|
||||
|
||||
<Shortcuts />
|
||||
|
||||
<Appearance />
|
||||
|
||||
@@ -18,6 +18,7 @@ import { isTauri } from "@tauri-apps/api/core";
|
||||
import { isEnabled } from "@tauri-apps/plugin-autostart";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCreation } from "ahooks";
|
||||
import clsx from "clsx";
|
||||
|
||||
import SettingsItem from "./SettingsItem";
|
||||
import SettingsToggle from "./SettingsToggle";
|
||||
@@ -34,7 +35,6 @@ import {
|
||||
unregister_shortcut,
|
||||
} from "@/commands";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import clsx from "clsx";
|
||||
import { useAppearanceStore, WindowMode } from "@/stores/appearanceStore";
|
||||
|
||||
export function ThemeOption({
|
||||
@@ -83,6 +83,8 @@ export default function GeneralSettings() {
|
||||
const { showTooltip, setShowTooltip, language, setLanguage } = useAppStore();
|
||||
const { windowMode, setWindowMode } = useAppearanceStore();
|
||||
|
||||
|
||||
|
||||
const fetchAutoStartStatus = async () => {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
@@ -305,6 +307,8 @@ export default function GeneralSettings() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<SettingsItem
|
||||
icon={Globe}
|
||||
title={t("settings.language.title")}
|
||||
|
||||
29
src/components/ui/separator.tsx
Normal file
29
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
21
src/hooks/useEnabledServers.ts
Normal file
21
src/hooks/useEnabledServers.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { getEnabledServers } from "@/utils/servers";
|
||||
import { useServers } from "./useServers";
|
||||
|
||||
/**
|
||||
* Hook: returns enabled & available servers, plus refresh function.
|
||||
*/
|
||||
export function useEnabledServers() {
|
||||
const serverList = useConnectStore((s) => s.serverList);
|
||||
const { refreshServerList } = useServers();
|
||||
|
||||
const enabledServers = useMemo(() => {
|
||||
const list = getEnabledServers(serverList);
|
||||
// Further filter to public servers or those with user profile (logged-in)
|
||||
return list.filter((s) => s.public || s.profile);
|
||||
}, [serverList]);
|
||||
|
||||
return { enabledServers, refreshServerList };
|
||||
}
|
||||
77
src/hooks/useSelectionPanel.ts
Normal file
77
src/hooks/useSelectionPanel.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { debounce } from "lodash-es";
|
||||
|
||||
export interface SelectionState {
|
||||
text: string;
|
||||
rect: DOMRect | null;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export function useSelectionPanel() {
|
||||
const [state, setState] = useState<SelectionState>({
|
||||
text: "",
|
||||
rect: null,
|
||||
visible: false,
|
||||
});
|
||||
|
||||
const latestTextRef = useRef<string>("");
|
||||
|
||||
const computeRect = useCallback((): DOMRect | null => {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (!rect || rect.width === 0 || rect.height === 0) return null;
|
||||
return rect;
|
||||
}, []);
|
||||
|
||||
const updateSelection = useCallback(() => {
|
||||
const sel = window.getSelection();
|
||||
const text = sel?.toString().trim() ?? "";
|
||||
const rect = computeRect();
|
||||
|
||||
if (!text || !rect) {
|
||||
setState((prev) => ({ ...prev, visible: false }));
|
||||
latestTextRef.current = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Suppress duplicates to avoid flicker and needless IPC
|
||||
if (text === latestTextRef.current && state.visible) {
|
||||
// Only reposition on scroll/resize
|
||||
setState((prev) => ({ ...prev, rect }));
|
||||
return;
|
||||
}
|
||||
|
||||
latestTextRef.current = text;
|
||||
setState({ text, rect, visible: true });
|
||||
}, [computeRect, state.visible]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseUp = debounce(updateSelection, 50);
|
||||
const onSelectionChange = debounce(updateSelection, 80);
|
||||
|
||||
document.addEventListener("selectionchange", onSelectionChange);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
window.addEventListener("scroll", onSelectionChange, { passive: true });
|
||||
window.addEventListener("resize", onSelectionChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("selectionchange", onSelectionChange);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
window.removeEventListener("scroll", onSelectionChange);
|
||||
window.removeEventListener("resize", onSelectionChange);
|
||||
};
|
||||
}, [updateSelection]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setState({ text: "", rect: null, visible: false });
|
||||
latestTextRef.current = "";
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
close,
|
||||
};
|
||||
}
|
||||
138
src/hooks/useSelectionWindow.ts
Normal file
138
src/hooks/useSelectionWindow.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useSelectionStore } from "@/stores/selectionStore";
|
||||
import { useSelectionPanel } from "@/hooks/useSelectionPanel";
|
||||
|
||||
export function useSelectionWindow() {
|
||||
const { state: panelState, close: onClose } = useSelectionPanel();
|
||||
const lastWidthRef = useRef<number | null>(null);
|
||||
|
||||
// Subscribe to store for reactive updates
|
||||
const iconsOnly = useSelectionStore((s) => s.iconsOnly);
|
||||
const selectionEnabled = useSelectionStore((s) => s.selectionEnabled);
|
||||
|
||||
const WIDTH_ICONS_ONLY = 250;
|
||||
const WIDTH_FULL = 470;
|
||||
const HEIGHT = 32;
|
||||
const getSelectionWidth = (isIconsOnly: boolean) =>
|
||||
isIconsOnly ? WIDTH_ICONS_ONLY : WIDTH_FULL;
|
||||
|
||||
useEffect(() => {
|
||||
const openSelectionWindow = async (payload: any) => {
|
||||
// console.log("[selection] openSelectionWindow payload", payload);
|
||||
// when selection is disabled, hide the existing window and return
|
||||
if (!selectionEnabled) {
|
||||
const existing = await platformAdapter.getWindowByLabel("selection");
|
||||
if (existing) {
|
||||
await existing.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const label = "selection";
|
||||
const width = getSelectionWidth(iconsOnly);
|
||||
const height = HEIGHT;
|
||||
|
||||
const options: any = {
|
||||
label,
|
||||
title: "Selection",
|
||||
width,
|
||||
height,
|
||||
alwaysOnTop: true,
|
||||
shadow: false,
|
||||
decorations: false,
|
||||
transparent: true,
|
||||
closable: true,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
dragDropEnabled: false,
|
||||
resizable: false,
|
||||
center: false,
|
||||
url: "/ui/selection",
|
||||
windowEffects: {
|
||||
effects: [],
|
||||
state: "active",
|
||||
radius: 7,
|
||||
},
|
||||
hiddenTitle: true,
|
||||
visible: false,
|
||||
acceptFirstMouse: true,
|
||||
data: { timestamp: Date.now() },
|
||||
};
|
||||
|
||||
const raw = typeof payload === "string" ? payload : String(payload?.text ?? "");
|
||||
const text = raw.trim();
|
||||
|
||||
// Receive backend "top-left origin + logical coordinates (Quartz point)" directly, no need for dpr conversion
|
||||
const xLogical = Math.round(Number(payload?.x ?? 0));
|
||||
const yLogical = Math.round(Number(payload?.y ?? 0));
|
||||
|
||||
const existingWindow = await platformAdapter.getWindowByLabel(label);
|
||||
|
||||
// Empty text: hide existing window and emit empty event
|
||||
if (!text) {
|
||||
if (existingWindow) {
|
||||
await existingWindow.hide();
|
||||
}
|
||||
await platformAdapter.emitEvent("selection-text", "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existingWindow) {
|
||||
await platformAdapter.createWindow(label, options);
|
||||
}
|
||||
const win = await platformAdapter.getWindowByLabel(label);
|
||||
if (!win) return;
|
||||
|
||||
// Set window size to fixed width and height
|
||||
// Avoid redundant setSize calls if width is unchanged
|
||||
if (lastWidthRef.current !== width) {
|
||||
// @ts-ignore
|
||||
await win.setSize({ type: "Logical", width, height });
|
||||
lastWidthRef.current = width;
|
||||
}
|
||||
|
||||
await win.show();
|
||||
|
||||
// Position window based on "top-left origin + logical coordinates" directly
|
||||
// X offset 0, Y offset -40px (not subtracting window height)
|
||||
if (xLogical > 0 || yLogical > 0) {
|
||||
const offsetX = 0;
|
||||
const offsetY = 40;
|
||||
const targetX = Math.max(0, xLogical + offsetX);
|
||||
const targetY = Math.max(0, yLogical - offsetY);
|
||||
// @ts-ignore
|
||||
await win.setPosition({ type: "Logical", x: targetX, y: targetY });
|
||||
}
|
||||
|
||||
await platformAdapter.emitEvent("selection-text", text);
|
||||
};
|
||||
|
||||
// DOM fallback: when panel is visible and has text, use its position
|
||||
if (panelState?.visible && panelState?.text) {
|
||||
const rect = panelState.rect || null;
|
||||
const screenX = window.screenX || 0; // CSS pixel (logical coordinate)
|
||||
const screenY = window.screenY || 0; // CSS pixel (logical coordinate)
|
||||
const xLogical = rect ? Math.round(screenX + rect.left) : 0;
|
||||
const yLogical = rect ? Math.round(screenY + rect.top) : 0;
|
||||
console.log("[selection] DOM fallback logical", { screenX, screenY, rect, xLogical, yLogical });
|
||||
|
||||
openSelectionWindow({ text: panelState.text, x: xLogical, y: yLogical });
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
// Listen to selection-detected event from backend
|
||||
const unlistenSelection = platformAdapter.listenEvent(
|
||||
"selection-detected",
|
||||
async (event: any) => {
|
||||
const payload = event?.payload;
|
||||
await openSelectionWindow(payload);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unlistenSelection.then((fn) => fn());
|
||||
};
|
||||
}, [panelState?.visible, panelState?.text, onClose, iconsOnly, selectionEnabled]);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { isMac } from "@/utils/platform";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { show_coco, show_settings, show_check } from "@/commands";
|
||||
import { useSelectionStore } from "@/stores/selectionStore";
|
||||
|
||||
const TRAY_ID = "COCO_TRAY";
|
||||
|
||||
@@ -16,11 +17,13 @@ export const useTray = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const showCocoShortcuts = useAppStore((state) => state.showCocoShortcuts);
|
||||
|
||||
const selectionEnabled = useSelectionStore((state) => state.selectionEnabled);
|
||||
|
||||
useUpdateEffect(() => {
|
||||
if (showCocoShortcuts.length === 0) return;
|
||||
|
||||
updateTrayMenu();
|
||||
}, [i18n.language, showCocoShortcuts]);
|
||||
}, [i18n.language, showCocoShortcuts, selectionEnabled]);
|
||||
|
||||
const getTrayById = () => {
|
||||
return TrayIcon.getById(TRAY_ID);
|
||||
@@ -56,6 +59,18 @@ export const useTray = () => {
|
||||
},
|
||||
}),
|
||||
PredefinedMenuItem.new({ item: "Separator" }),
|
||||
MenuItem.new({
|
||||
text: selectionEnabled
|
||||
? t("tray.selectionDisable")
|
||||
: t("tray.selectionEnable"),
|
||||
action: async () => {
|
||||
try {
|
||||
await platformAdapter.invokeBackend("set_selection_enabled", { enabled: !selectionEnabled });
|
||||
} catch (e) {
|
||||
console.error("set_selection_enabled invoke failed:", e);
|
||||
}
|
||||
},
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: t("tray.settings"),
|
||||
// accelerator: "CommandOrControl+,",
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
"default": "Default",
|
||||
"compact": "Compact"
|
||||
},
|
||||
"ai": {
|
||||
"title": "Selection Toolbar",
|
||||
"description": "Show selection toolbar after text selection",
|
||||
"toggle": "Enable selection toolbar"
|
||||
},
|
||||
"language": {
|
||||
"title": "Language",
|
||||
"description": "Choose your preferred language",
|
||||
@@ -534,6 +539,8 @@
|
||||
},
|
||||
"tray": {
|
||||
"showCoco": "Show Coco",
|
||||
"selectionDisable": "Disable Selection Toolbar",
|
||||
"selectionEnable": "Enable Selection Toolbar",
|
||||
"settings": "Settings",
|
||||
"quitCoco": "Quit Coco",
|
||||
"checkUpdate": "Check for Updates"
|
||||
@@ -628,5 +635,42 @@
|
||||
"buttons": {
|
||||
"login": "Login"
|
||||
}
|
||||
},
|
||||
"selection": {
|
||||
"title": "Selection Toolbar Settings",
|
||||
"actions": {
|
||||
"search": "Search",
|
||||
"ask_ai": "Q&A",
|
||||
"translate": "Translate",
|
||||
"summary": "Summary",
|
||||
"copy": "Copy",
|
||||
"speak": "Read Aloud"
|
||||
},
|
||||
"noText": "No text detected",
|
||||
"copied": "Copied",
|
||||
"speak": {
|
||||
"stopTitle": "Stop speaking",
|
||||
"stopAria": "Stop speaking",
|
||||
"stopLabel": "Stop",
|
||||
"pauseTitle": "Pause speaking",
|
||||
"pauseAria": "Pause speaking",
|
||||
"pauseLabel": "Pause",
|
||||
"resumeTitle": "Resume speaking",
|
||||
"resumeAria": "Resume speaking",
|
||||
"resumeLabel": "Resume",
|
||||
"volumeSr": "Volume",
|
||||
"volumeAria": "Speech volume"
|
||||
},
|
||||
"display": {
|
||||
"title": "Display Style",
|
||||
"iconsOnlyDesc": "Show icons only (hide text labels)",
|
||||
"iconsOnlyLabel": "Icons only"
|
||||
},
|
||||
"bind": {
|
||||
"service": "Select Service",
|
||||
"defaultService": "Default service",
|
||||
"assistant": "Bind Assistant (Chat only)",
|
||||
"defaultAssistant": "Default assistant"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
"default": "默认",
|
||||
"compact": "紧凑"
|
||||
},
|
||||
"ai": {
|
||||
"title": "划词工具栏",
|
||||
"description": "选择文本后显示独立可操作窗口",
|
||||
"toggle": "启用划词工具栏"
|
||||
},
|
||||
"language": {
|
||||
"title": "语言",
|
||||
"description": "选择您的首选语言",
|
||||
@@ -534,6 +539,8 @@
|
||||
},
|
||||
"tray": {
|
||||
"showCoco": "显示 Coco",
|
||||
"selectionDisable": "禁用划词工具栏",
|
||||
"selectionEnable": "启用划词工具栏",
|
||||
"settings": "偏好设置",
|
||||
"quitCoco": "退出 Coco",
|
||||
"checkUpdate": "检查更新"
|
||||
@@ -627,5 +634,42 @@
|
||||
"buttons": {
|
||||
"login": "登录"
|
||||
}
|
||||
},
|
||||
"selection": {
|
||||
"title": "划词工具栏设置",
|
||||
"actions": {
|
||||
"search": "搜索",
|
||||
"ask_ai": "问答",
|
||||
"translate": "翻译",
|
||||
"summary": "总结",
|
||||
"copy": "复制",
|
||||
"speak": "朗读"
|
||||
},
|
||||
"noText": "未检测到文本",
|
||||
"copied": "已复制",
|
||||
"speak": {
|
||||
"stopTitle": "停止朗读",
|
||||
"stopAria": "停止朗读",
|
||||
"stopLabel": "停止",
|
||||
"pauseTitle": "暂停朗读",
|
||||
"pauseAria": "暂停朗读",
|
||||
"pauseLabel": "暂停",
|
||||
"resumeTitle": "继续朗读",
|
||||
"resumeAria": "继续朗读",
|
||||
"resumeLabel": "继续",
|
||||
"volumeSr": "音量",
|
||||
"volumeAria": "朗读音量"
|
||||
},
|
||||
"display": {
|
||||
"title": "显示样式",
|
||||
"iconsOnlyDesc": "仅显示图标(隐藏文字标签)",
|
||||
"iconsOnlyLabel": "仅显示图标"
|
||||
},
|
||||
"bind": {
|
||||
"service": "选择服务",
|
||||
"defaultService": "默认服务",
|
||||
"assistant": "绑定小助手(仅聊天)",
|
||||
"defaultAssistant": "默认小助手"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
518
src/pages/selection/index.tsx
Normal file
518
src/pages/selection/index.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Bot,
|
||||
Copy,
|
||||
Languages,
|
||||
Search,
|
||||
X,
|
||||
Volume2,
|
||||
Pause,
|
||||
Play,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Separator } from "@radix-ui/react-separator";
|
||||
|
||||
import { useSelectionStore } from "@/stores/selectionStore";
|
||||
import { copyToClipboard } from "@/utils";
|
||||
import cocoLogoImg from "@/assets/app-icon.png";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
// Simple animated selection window content
|
||||
export default function SelectionWindow() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [text, setText] = useState("");
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [animatingOut, setAnimatingOut] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
|
||||
const voicesRef = useRef<SpeechSynthesisVoice[]>([]);
|
||||
const textRef = useRef<string>("");
|
||||
|
||||
const AUTO_HIDE_KEY = "selection_auto_hide_ms";
|
||||
const autoHideMs = useMemo(() => {
|
||||
const v = Number(localStorage.getItem(AUTO_HIDE_KEY));
|
||||
return Number.isFinite(v) && v > 0 ? v : 5000;
|
||||
}, []);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
const scheduleAutoHide = () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
close();
|
||||
}, autoHideMs);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const updateVoices = () => {
|
||||
voicesRef.current = window.speechSynthesis.getVoices();
|
||||
};
|
||||
updateVoices();
|
||||
window.speechSynthesis.onvoiceschanged = updateVoices;
|
||||
} catch {}
|
||||
|
||||
const unlistenPromise = platformAdapter.listenEvent(
|
||||
"selection-text",
|
||||
async ({ payload }: any) => {
|
||||
const incoming =
|
||||
typeof payload === "string" ? payload : String(payload?.text ?? "");
|
||||
const trimmed = incoming.trim();
|
||||
|
||||
const getCurrentWinSafe = async () => {
|
||||
try {
|
||||
return await platformAdapter.getCurrentWebviewWindow();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!useSelectionStore.getState().selectionEnabled) {
|
||||
setVisible(false);
|
||||
const win = await getCurrentWinSafe();
|
||||
win?.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmed) {
|
||||
setText("");
|
||||
textRef.current = ""; // sync ref immediately to avoid stale value
|
||||
setVisible(false);
|
||||
const win = await getCurrentWinSafe();
|
||||
win?.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
setText(incoming);
|
||||
textRef.current = incoming; // sync ref immediately to avoid relying on render
|
||||
setAnimatingOut(false);
|
||||
setVisible(true);
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
scheduleAutoHide();
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unlistenPromise
|
||||
.then((fn) => {
|
||||
try {
|
||||
fn();
|
||||
} catch {}
|
||||
})
|
||||
.catch(() => {});
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoHideMs]);
|
||||
|
||||
useEffect(() => {
|
||||
useSelectionStore.getState().initSync();
|
||||
}, []);
|
||||
|
||||
const close = async () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
try {
|
||||
window.speechSynthesis.cancel();
|
||||
} catch {}
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
setAnimatingOut(true);
|
||||
setTimeout(async () => {
|
||||
setVisible(false);
|
||||
const win = await platformAdapter.getCurrentWebviewWindow();
|
||||
win?.hide();
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const openMain = async () => {
|
||||
try {
|
||||
await platformAdapter.commands("show_coco");
|
||||
} catch {
|
||||
await platformAdapter.emitEvent("show-coco");
|
||||
await platformAdapter.showWindow();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChatAction = useCallback(
|
||||
async (assistantId?: string) => {
|
||||
const payloadText = (textRef.current || "").trim();
|
||||
if (!payloadText) return;
|
||||
|
||||
await openMain();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
|
||||
await platformAdapter.emitEvent("selection-action", {
|
||||
action: "chat",
|
||||
text: payloadText,
|
||||
assistantId,
|
||||
});
|
||||
|
||||
if (!isSpeaking) {
|
||||
await close();
|
||||
}
|
||||
},
|
||||
[openMain, isSpeaking, close]
|
||||
);
|
||||
|
||||
const searchMain = useCallback(async () => {
|
||||
const payloadText = (textRef.current || "").trim();
|
||||
console.log("searchMain payload", payloadText);
|
||||
if (!payloadText) return;
|
||||
|
||||
await openMain();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
await platformAdapter.emitEvent("selection-action", {
|
||||
action: "search",
|
||||
text: payloadText,
|
||||
});
|
||||
if (!isSpeaking) {
|
||||
await close();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopSpeak = () => {
|
||||
window.speechSynthesis.cancel();
|
||||
utteranceRef.current = null;
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
};
|
||||
|
||||
const speak = useCallback(async () => {
|
||||
try {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
if (isSpeaking && !isPaused) {
|
||||
window.speechSynthesis.pause();
|
||||
setIsPaused(true);
|
||||
return;
|
||||
}
|
||||
if (isSpeaking && isPaused) {
|
||||
window.speechSynthesis.resume();
|
||||
setIsPaused(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(trimmed);
|
||||
const zhVoice =
|
||||
voicesRef.current.find((v) => /zh|cn/i.test(v.lang)) ||
|
||||
window.speechSynthesis.getVoices().find((v) => /zh|cn/i.test(v.lang));
|
||||
if (zhVoice) utterance.voice = zhVoice;
|
||||
utterance.rate = 1;
|
||||
utterance.volume = volume;
|
||||
|
||||
// pause auto-hide while speaking
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
utterance.onend = () => {
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
utteranceRef.current = null;
|
||||
scheduleAutoHide();
|
||||
};
|
||||
utterance.onerror = () => {
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
utteranceRef.current = null;
|
||||
scheduleAutoHide();
|
||||
};
|
||||
|
||||
utteranceRef.current = utterance;
|
||||
window.speechSynthesis.cancel();
|
||||
window.speechSynthesis.speak(utterance);
|
||||
setIsSpeaking(true);
|
||||
setIsPaused(false);
|
||||
} catch (e) {
|
||||
console.error("TTS 播放失败", e);
|
||||
stopSpeak();
|
||||
scheduleAutoHide();
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const payloadText = (textRef.current || "").trim();
|
||||
if (!payloadText) return;
|
||||
|
||||
try {
|
||||
await copyToClipboard(payloadText, true);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (e) {
|
||||
console.warn("Copy failed:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getActionHandler = (type: string, assistantId?: string) => {
|
||||
switch (type) {
|
||||
case "ask_ai":
|
||||
case "translate":
|
||||
case "summary":
|
||||
return () => handleChatAction(assistantId);
|
||||
case "copy":
|
||||
return handleCopy;
|
||||
case "search":
|
||||
return searchMain;
|
||||
case "speak":
|
||||
return speak;
|
||||
default:
|
||||
return () => {};
|
||||
}
|
||||
};
|
||||
|
||||
// Render buttons from store; hide ones requiring assistant without assistantId
|
||||
const toolbarConfig = useSelectionStore((s) => s.toolbarConfig);
|
||||
const iconsOnly = useSelectionStore((s) => s.iconsOnly);
|
||||
|
||||
const requiresAssistant = (type?: string) =>
|
||||
type === "ask_ai" || type === "translate" || type === "summary";
|
||||
|
||||
const visibleButtons = useMemo(
|
||||
() =>
|
||||
(Array.isArray(toolbarConfig) ? toolbarConfig : []).filter((btn: any) => {
|
||||
const type = btn?.action?.type;
|
||||
if (requiresAssistant(type)) {
|
||||
return Boolean(btn?.action?.assistantId);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[toolbarConfig]
|
||||
);
|
||||
|
||||
// Lucide icon map for dynamic rendering
|
||||
const LUCIDE_ICON_MAP: Record<string, any> = {
|
||||
Search,
|
||||
Bot,
|
||||
Languages,
|
||||
FileText,
|
||||
Copy,
|
||||
Volume2,
|
||||
};
|
||||
|
||||
// Component: render icon (lucide or custom)
|
||||
const IconRenderer = ({ icon }: { icon?: any }) => {
|
||||
// Support lucide icon or custom image
|
||||
if (icon?.type === "lucide") {
|
||||
const Icon =
|
||||
LUCIDE_ICON_MAP[icon?.name as string] || LUCIDE_ICON_MAP.Search;
|
||||
return (
|
||||
<Icon
|
||||
className="size-4 transition-transform duration-150"
|
||||
style={icon?.color ? { color: icon.color } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (icon?.type === "custom" && icon?.dataUrl) {
|
||||
return (
|
||||
<img
|
||||
src={icon.dataUrl}
|
||||
className="size-4 rounded"
|
||||
alt=""
|
||||
style={
|
||||
icon?.color
|
||||
? { filter: `drop-shadow(0 0 0 ${icon.color})` }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// default
|
||||
return <Search className="size-4 text-[#6366F1]" />;
|
||||
};
|
||||
|
||||
// Component: single toolbar button
|
||||
const ToolbarButton = ({
|
||||
btn,
|
||||
onClick,
|
||||
}: {
|
||||
btn: any;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
const label = btn?.labelKey ? t(btn.labelKey) : btn?.label || btn?.id || "";
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-1 p-1 rounded-md cursor-pointer whitespace-nowrap transition-all duration-150"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
>
|
||||
<IconRenderer icon={btn?.icon} />
|
||||
{!iconsOnly && (
|
||||
<span className="text-[12px] transition-opacity duration-150">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Component: header logo
|
||||
const HeaderLogo = () => {
|
||||
return (
|
||||
<img
|
||||
src={cocoLogoImg}
|
||||
alt="Coco Logo"
|
||||
className="w-6 h-6"
|
||||
onClick={openMain}
|
||||
onError={(e) => {
|
||||
try {
|
||||
(e.target as HTMLImageElement).src = "/src-tauri/assets/logo.png";
|
||||
} catch {}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Component: selected text preview
|
||||
const TextPreview = ({ text }: { text: string }) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
data-tauri-drag-region="false"
|
||||
className="rounded-md bg-black/5 dark:bg-white/5 px-2 py-1 leading-4 text-[12px] text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
{text || t("selection.noText")}
|
||||
</div>
|
||||
{copied && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-start pl-2">
|
||||
<span className="px-2 py-1 rounded bg-black/75 text-white text-[12px]">
|
||||
{t("selection.copied")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Component: speak controls
|
||||
const SpeakControls = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="flex items-center gap-1 p-1 rounded-md cursor-pointer whitespace-nowrap transition-all duration-150"
|
||||
onClick={stopSpeak}
|
||||
title={t("selection.speak.stopTitle")}
|
||||
aria-label={t("selection.speak.stopAria")}
|
||||
>
|
||||
<X className="size-4 transition-transform duration-150" />
|
||||
{!iconsOnly && (
|
||||
<span className="text-[12px] transition-opacity duration-150">
|
||||
{t("selection.speak.stopLabel")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="hidden items-center gap-1 p-1 rounded-md cursor-pointer whitespace-nowrap transition-all duration-150"
|
||||
onClick={speak}
|
||||
title={
|
||||
isPaused
|
||||
? t("selection.speak.resumeTitle")
|
||||
: t("selection.speak.pauseTitle")
|
||||
}
|
||||
aria-pressed={isPaused}
|
||||
aria-label={
|
||||
isPaused
|
||||
? t("selection.speak.resumeAria")
|
||||
: t("selection.speak.pauseAria")
|
||||
}
|
||||
>
|
||||
{isPaused ? (
|
||||
<Play className="size-4 transition-transform duration-150" />
|
||||
) : (
|
||||
<Pause className="size-4 transition-transform duration-150" />
|
||||
)}
|
||||
{!iconsOnly && (
|
||||
<span className="text-[12px] transition-opacity duration-150">
|
||||
{isPaused
|
||||
? t("selection.speak.resumeLabel")
|
||||
: t("selection.speak.pauseLabel")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<label className="hidden items-center gap-1 text-[12px]">
|
||||
<span className="sr-only">{t("selection.speak.volumeSr")}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(Number(e.target.value))}
|
||||
aria-label={t("selection.speak.volumeAria")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === containerRef.current && !isSpeaking) {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
"m-0 p-0 w-full h-full overflow-hidden",
|
||||
"text-[13px] select-none",
|
||||
"bg-white dark:bg-[#1E293B]",
|
||||
"text-[#111] dark:text-[#ddd]",
|
||||
"rounded-xl",
|
||||
"transition-all duration-150",
|
||||
{
|
||||
"translate-y-0": visible && !animatingOut,
|
||||
"translate-y-1": !visible || animatingOut,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="hidden px-2 pt-1">
|
||||
<TextPreview text={text} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-tauri-drag-region="false"
|
||||
className="flex items-center gap-1 p-1 flex-nowrap overflow-hidden"
|
||||
>
|
||||
<HeaderLogo />
|
||||
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
decorative
|
||||
className="mx-2 h-4 w-px bg-gray-300 dark:bg-white/30 shrink-0"
|
||||
/>
|
||||
|
||||
{visibleButtons.map((btn: any) => {
|
||||
const { type, assistantId } = btn?.action;
|
||||
return (
|
||||
<ToolbarButton
|
||||
key={btn.id}
|
||||
btn={btn}
|
||||
onClick={getActionHandler(type, assistantId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{isSpeaking && <SpeakControls />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import SettingsPage from "@/pages/settings/index";
|
||||
import StandaloneChat from "@/pages/chat/index";
|
||||
import WebPage from "@/pages/web/index";
|
||||
import CheckPage from "@/pages/check/index";
|
||||
import SelectionWindow from "@/pages/selection/index";
|
||||
|
||||
const routerOptions = {
|
||||
basename: "/",
|
||||
@@ -27,6 +28,7 @@ export const router = createBrowserRouter(
|
||||
{ path: "/ui/settings", element: <SettingsPage /> },
|
||||
{ path: "/ui/chat", element: <StandaloneChat /> },
|
||||
{ path: "/ui/check", element: <CheckPage /> },
|
||||
{ path: "/ui/selection", element: <SelectionWindow /> },
|
||||
{ path: "/web", element: <WebPage /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -17,6 +17,8 @@ import { Extension } from "@/components/Settings/Extensions";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import { useServers } from "@/hooks/useServers";
|
||||
import { useDeepLinkManager } from "@/hooks/useDeepLinkManager";
|
||||
import { useSelectionWindow } from "../hooks/useSelectionWindow";
|
||||
import { useSelectionStore } from "@/stores/selectionStore";
|
||||
|
||||
export default function LayoutOutlet() {
|
||||
const location = useLocation();
|
||||
@@ -30,6 +32,27 @@ export default function LayoutOutlet() {
|
||||
// init deep link manager
|
||||
useDeepLinkManager();
|
||||
|
||||
// --- Selection state: init + subscribe backend as SSOT ---
|
||||
useMount(async () => {
|
||||
try {
|
||||
const enabled = await platformAdapter.invokeBackend<boolean>("get_selection_enabled");
|
||||
useSelectionStore.getState().setSelectionEnabled(!!enabled);
|
||||
} catch (e) {
|
||||
console.error("get_selection_enabled failed:", e);
|
||||
}
|
||||
|
||||
const unlisten = await platformAdapter.listenEvent(
|
||||
"selection-enabled",
|
||||
({ payload }: any) => {
|
||||
useSelectionStore.getState().setSelectionEnabled(!!payload?.enabled);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unlisten && unlisten();
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language);
|
||||
}, [language]);
|
||||
@@ -119,6 +142,9 @@ export default function LayoutOutlet() {
|
||||
setDisabledExtensions(disabledExtensions.map((item) => item.id));
|
||||
});
|
||||
|
||||
// --- Selection window ---
|
||||
useSelectionWindow();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
|
||||
@@ -44,6 +44,8 @@ export type ISearchStore = {
|
||||
setEnabledAiOverview: (enabledAiOverview: boolean) => void;
|
||||
askAiAssistantId?: string;
|
||||
setAskAiAssistantId: (askAiAssistantId?: string) => void;
|
||||
targetAssistantId?: string;
|
||||
setTargetAssistantId: (targetAssistantId?: string) => void;
|
||||
visibleExtensionStore: boolean;
|
||||
setVisibleExtensionStore: (visibleExtensionStore: boolean) => void;
|
||||
searchValue: string;
|
||||
@@ -102,6 +104,9 @@ export const useSearchStore = create<ISearchStore>()(
|
||||
setAskAiAssistantId: (askAiAssistantId) => {
|
||||
return set({ askAiAssistantId });
|
||||
},
|
||||
setTargetAssistantId: (targetAssistantId) => {
|
||||
return set({ targetAssistantId });
|
||||
},
|
||||
visibleExtensionStore: false,
|
||||
setVisibleExtensionStore: (visibleExtensionStore) => {
|
||||
return set({ visibleExtensionStore });
|
||||
|
||||
65
src/stores/selectionStore.ts
Normal file
65
src/stores/selectionStore.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, subscribeWithSelector } from "zustand/middleware";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
export type ISelectionStore = {
|
||||
// whether selection is enabled
|
||||
selectionEnabled: boolean;
|
||||
setSelectionEnabled: (selectionEnabled: boolean) => void;
|
||||
// toolbar buttons configuration for selection window
|
||||
toolbarConfig: any[];
|
||||
setToolbarConfig: (toolbarConfig: any[]) => void;
|
||||
// whether to show icons only (hide labels) in selection window
|
||||
iconsOnly: boolean;
|
||||
setIconsOnly: (iconsOnly: boolean) => void;
|
||||
// initialize cross-window sync listeners once
|
||||
initSync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const useSelectionStore = create<ISelectionStore>()(
|
||||
subscribeWithSelector(
|
||||
persist(
|
||||
(set) => ({
|
||||
selectionEnabled: false,
|
||||
setSelectionEnabled(selectionEnabled) {
|
||||
set({ selectionEnabled });
|
||||
},
|
||||
toolbarConfig: [],
|
||||
setToolbarConfig(toolbarConfig) {
|
||||
return set({ toolbarConfig });
|
||||
},
|
||||
iconsOnly: false,
|
||||
setIconsOnly(iconsOnly) {
|
||||
set({ iconsOnly });
|
||||
// broadcast to other windows
|
||||
try {
|
||||
platformAdapter.emitEvent("selection-icons-only", { value: iconsOnly });
|
||||
} catch {}
|
||||
},
|
||||
initSync: async () => {
|
||||
// ensure listener only initialized once per window context
|
||||
const hasInit = (window as any).__selectionIconsOnlyInit__;
|
||||
if (hasInit) return;
|
||||
(window as any).__selectionIconsOnlyInit__ = true;
|
||||
try {
|
||||
await platformAdapter.listenEvent(
|
||||
"selection-icons-only",
|
||||
({ payload }: any) => {
|
||||
const next = Boolean(payload?.value);
|
||||
// apply without re-broadcast to avoid echo
|
||||
set({ iconsOnly: next });
|
||||
}
|
||||
);
|
||||
} catch {}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "selection-store",
|
||||
partialize: (state) => ({
|
||||
toolbarConfig: state.toolbarConfig,
|
||||
iconsOnly: state.iconsOnly,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -51,6 +51,15 @@ export interface EventPayloads {
|
||||
extension_install_success: any;
|
||||
open_view_extension: ViewExtensionOpened;
|
||||
"server-list-changed": Server[];
|
||||
"selection-text": string;
|
||||
"selection-ask-ai": any;
|
||||
"selection-action": {
|
||||
action: "translate" | "search" | "copy" | "summary";
|
||||
text: string;
|
||||
};
|
||||
"selection-detected": string;
|
||||
"selection-enabled": boolean;
|
||||
"selection-icons-only": { value: boolean };
|
||||
}
|
||||
|
||||
// Window operation interface
|
||||
@@ -65,6 +74,7 @@ export interface WindowOperations {
|
||||
setFocus: () => Promise<void>;
|
||||
center: () => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
hide: () => Promise<void>;
|
||||
} | null>;
|
||||
createWindow: (label: string, options: any) => Promise<void>;
|
||||
createWebviewWindow: (label: string, options: any) => Promise<any>;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { getCurrentWindowService } from "@/commands/windowService";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import i18next from "i18next";
|
||||
|
||||
export async function copyToClipboard(text: string) {
|
||||
export async function copyToClipboard(text: string, noTip = false) {
|
||||
const addError = useAppStore.getState().addError;
|
||||
const language = useAppStore.getState().language;
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function copyToClipboard(text: string) {
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
addError(language === "zh" ? "复制成功" : "Copy Success", "info");
|
||||
!noTip && addError(language === "zh" ? "复制成功" : "Copy Success", "info");
|
||||
}
|
||||
|
||||
// 2
|
||||
|
||||
9
src/utils/servers.ts
Normal file
9
src/utils/servers.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { Server } from "@/types/server";
|
||||
|
||||
/**
|
||||
* Return enabled and available servers from a server list.
|
||||
*/
|
||||
export function getEnabledServers(list: Server[] | any[]): Server[] {
|
||||
if (!Array.isArray(list)) return [] as Server[];
|
||||
return (list as Server[]).filter((s) => s.enabled && s.available);
|
||||
}
|
||||
Reference in New Issue
Block a user