From 27136390efa62a03aa9813a6c8e07990593d06ef Mon Sep 17 00:00:00 2001
From: fly1d <309400591+fly1d@users.noreply.github.com>
Date: Thu, 20 Aug 2026 17:16:56 +0800
Subject: [PATCH] feat: add configurable selection shortcut
---
CHANGELOG.md | 1 +
README.md | 10 +-
docs/building-en.md | 11 +-
docs/building.md | 11 +-
site/en/index.html | 4 +-
site/index.html | 4 +-
src-tauri/src/selection.rs | 2 +-
src/App.tsx | 280 ++++++++++++++++++++++++++++----
src/platform.test.ts | 200 +++++++++++++++++++++++
src/platform.ts | 135 +++++++++++++--
src/shortcut.test.ts | 102 ++++++++++++
src/shortcut.ts | 204 +++++++++++++++++++++++
src/styles.css | 11 ++
tests/smoke/translation.spec.ts | 8 +-
vitest.config.ts | 2 +-
15 files changed, 908 insertions(+), 77 deletions(-)
create mode 100644 src/platform.test.ts
create mode 100644 src/shortcut.test.ts
create mode 100644 src/shortcut.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e513732..d0f8ec5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ All notable changes are documented here. Versions follow Semantic Versioning.
### Added
- macOS desktop shell with global selection translation shortcut.
+- A desktop setting to disable or customize the selection translation shortcut, defaulting to `Option + K`.
- GitHub pull request, CI, security scanning, dependency updates, and beta release workflows.
- Automated API and browser smoke coverage.
diff --git a/README.md b/README.md
index 75f0e05..ed0cef3 100644
--- a/README.md
+++ b/README.md
@@ -13,8 +13,8 @@
## 功能
-- macOS 中选中文字后按 `⌥ + T`,打开客户端并立即翻译
-- 自动、Ollama 本地模型、OpenAI 兼容 API、极速词典四种引擎
+- macOS 中选中文字后按默认快捷键 `⌥ + K`,打开客户端并立即翻译;可在设置中关闭或修改
+- 自动、Ollama、OpenAI 兼容 API、极速词典四种引擎
- 整段中文译文与逐词、数字、标点对齐结果
- TXT、Markdown、DOCX、PDF 文本提取
- 桌面与手机响应式 Web 界面
@@ -41,7 +41,7 @@ npm ci
npm run tauri dev
```
-全局划词需要在“系统设置 → 隐私与安全性 → 辅助功能”中允许逐词。客户端会优先通过 macOS Accessibility API 读取选区;不支持该属性的应用会使用保留剪贴板内容的复制回退。
+全局划词需要在“系统设置 → 隐私与安全性 → 辅助功能”中允许逐词。客户端会优先通过 macOS Accessibility API 读取选区;不支持该属性的应用会使用复制回退,尝试恢复原来的文本剪贴板,并可能请求“自动化 → System Events”权限。关闭选区快捷键只会取消全局快捷键注册,不会撤销已经授予的系统权限。
生成本地 `.app`:
@@ -52,7 +52,7 @@ npm run desktop:build:app
## 翻译引擎
- `自动选择`:优先使用可用的 Ollama;未检测到本地模型时使用已配置的云端 API。两者都不可用时会提示配置,不会把基础词典冒充成语境翻译。
-- `Ollama 本地`:内容不离开电脑,建议使用 `qwen3:4b` 或更大的 Qwen 模型。
+- `Ollama`:默认连接本机服务,建议使用 `qwen3:4b` 或更大的 Qwen 模型;改为远程地址时,内容会发送到该服务。
- `云端模型`:支持 OpenAI 及兼容 `/chat/completions` 的 API。
- `极速词典`:完全离线、即时返回,只用于逐词查义,不提供可靠的整句语境翻译。
@@ -71,7 +71,7 @@ OPENAI_BASE_URL=https://api.openai.com/v1 \
OPENAI_API_KEY=... OPENAI_MODEL=gpt-5-mini npm run dev
```
-API Key 不会写入浏览器存储。公共云端凭据不得编译到前端或桌面安装包中。
+API Key 不会写入浏览器存储。使用云端模型或远程 Ollama 地址时,选中文字、手动输入内容或提取出的文档文字会发送给用户配置的服务;使用默认本机 Ollama 地址或极速词典时不会发送到云端。公共云端凭据不得编译到前端或桌面安装包中。
Web 服务只会请求上述服务端环境变量配置的模型地址,页面中地址为只读;这可防止浏览器借用本地 API 访问其他内网服务。macOS 客户端直接在本机发起模型请求,仍允许用户在设置中修改兼容 API 地址。
## 质量门禁
diff --git a/docs/building-en.md b/docs/building-en.md
index a6713dc..b5d672a 100644
--- a/docs/building-en.md
+++ b/docs/building-en.md
@@ -49,9 +49,9 @@ Start the desktop app:
npm run tauri dev
```
-Wordwise opens as a native desktop window. Select English in another macOS app and press `Option + T` to capture it.
+Wordwise opens as a native desktop window. Select English in another macOS app and press the default `Option + K` shortcut to capture it. You can disable selection translation or record another key combination in Settings.
-The first selection capture requires permission in **System Settings -> Privacy & Security -> Accessibility**. Wordwise first tries the macOS Accessibility API. When an app does not expose its selection there, Wordwise uses a copy fallback and restores the previous clipboard contents.
+The first selection capture requires permission in **System Settings -> Privacy & Security -> Accessibility**. Wordwise first tries the macOS Accessibility API. When an app does not expose its selection there, Wordwise uses a copy fallback, attempts to restore the previous text clipboard, and may also request permission under **Automation -> System Events**. Disabling the shortcut only unregisters the global shortcut; remove Wordwise in macOS System Settings to revoke the permissions themselves.
## Configure a translation engine
@@ -66,15 +66,16 @@ Local mode needs no API key. Install and start Ollama from the [macOS download p
ollama pull qwen3:4b
```
-Return to Wordwise and keep **Automatic** selected, or choose **Local Ollama**. Select `What's under the hood?` in any app and press `Option + T`. A successful first run shows a full Chinese translation, token-aligned results, and the `Ollama · qwen3:4b` engine label.
+Return to Wordwise and keep `自动选择` selected, or choose `Ollama`. Select `What's under the hood?` in an app that supports text selection or copying, then press the default `Option + K` shortcut. A successful first run shows a full Chinese translation, token-aligned results, and the `Ollama · qwen3:4b` engine label.
Automatic mode uses an available Ollama model first, then a configured cloud API. It reports a setup error when neither is available. The offline dictionary is an explicit word-lookup mode and is not presented as contextual sentence translation.
-API keys remain in application memory and are not written to browser storage or committed to the repository. Never include keys, selected text, or private documents in a public GitHub issue.
+API keys remain in application memory and are not written to browser storage or committed to the repository. With a cloud model or remote Ollama URL, selected text, manual input, and extracted document text are sent to the configured service; the default localhost Ollama URL and dictionary mode do not send that content to the cloud. Never include keys, selected text, or private documents in a public GitHub issue.
## Common blockers
-- The shortcut cannot read the selection: check Wordwise under **System Settings -> Privacy & Security -> Accessibility**, then restart the development app.
+- The shortcut does not respond: confirm that selection translation is enabled in Settings and that another app has not reserved the key combination.
+- The shortcut cannot read the selection: check Wordwise under **System Settings -> Privacy & Security -> Accessibility**. The copy fallback also needs Wordwise to control System Events under **Automation**. Restart the development app after changing either permission.
- Automatic mode reports that no engine is configured: make sure Ollama is running and use `ollama list` to confirm that the model was downloaded.
- A development port is already in use: stop the existing Wordwise development process and try again.
diff --git a/docs/building.md b/docs/building.md
index 9c5f3b6..a104123 100644
--- a/docs/building.md
+++ b/docs/building.md
@@ -49,9 +49,9 @@ npm ci
npm run tauri dev
```
-逐词会以原生桌面窗口打开。在其他 macOS 应用中选中英文,按下 `Option + T` 即可读取选区。
+逐词会以原生桌面窗口打开。在其他 macOS 应用中选中英文,按下默认快捷键 `Option + K` 即可读取选区。可以在设置中关闭选区快捷键,或录入新的组合键。
-第一次划词时,需要在“系统设置 -> 隐私与安全性 -> 辅助功能”中允许逐词。客户端会优先使用 macOS Accessibility API;应用没有暴露选区时,才会使用复制回退,并在读取后恢复原来的剪贴板内容。
+第一次划词时,需要在“系统设置 -> 隐私与安全性 -> 辅助功能”中允许逐词。客户端会优先使用 macOS Accessibility API;应用没有暴露选区时,才会使用复制回退,尝试恢复原来的文本剪贴板,并可能要求在“自动化”中允许逐词控制 System Events。关闭选区快捷键只会取消全局快捷键注册;如需撤销读取权限,请在 macOS 系统设置中移除逐词。
## 配置翻译引擎
@@ -66,15 +66,16 @@ npm run tauri dev
ollama pull qwen3:4b
```
-回到逐词,保留“自动选择”或选择“Ollama 本地”。在任意应用中选中 `What's under the hood?`,按下 `Option + T`。首次成功时应同时看到完整中文译文、逐词结果,以及 `Ollama · qwen3:4b` 引擎标签。
+回到逐词,保留“自动选择”或选择“Ollama”。在支持文本选择或复制的应用中选中 `What's under the hood?`,按下默认快捷键 `Option + K`。首次成功时应同时看到完整中文译文、逐词结果,以及 `Ollama · qwen3:4b` 引擎标签。
自动模式会先使用可用的 Ollama,再使用已配置的云端 API。两者都不可用时,客户端会提示配置。离线词典是单独的查词模式,不会被冒充成语境整句翻译。
-API Key 只保存在应用运行内存中,不会写入浏览器存储或提交到仓库。不要在公开 GitHub Issue 中提交 API Key、选中文字或私有文档。
+API Key 只保存在应用运行内存中,不会写入浏览器存储或提交到仓库。选择云端模型或远程 Ollama 地址时,选中文字、手动输入内容或提取出的文档文字会发送给所配置的服务;使用默认本机 Ollama 地址或极速词典时不会发送到云端。不要在公开 GitHub Issue 中提交 API Key、选中文字或私有文档。
## 常见阻塞
-- 快捷键没有读到选区:检查“系统设置 -> 隐私与安全性 -> 辅助功能”中的逐词权限,然后重新启动开发客户端。
+- 快捷键没有响应:确认设置中的选区快捷键已启用且没有被其他应用占用。
+- 快捷键没有读到选区:检查“系统设置 -> 隐私与安全性”中逐词的“辅助功能”权限;复制回退还需要在“自动化”中允许逐词控制 System Events。修改后重新启动开发客户端。
- 自动模式提示没有引擎:确认 Ollama 应用正在运行,并用 `ollama list` 检查模型是否已经下载。
- 端口被占用:退出已有的逐词开发进程后重试。
diff --git a/site/en/index.html b/site/en/index.html
index f964550..3017f55 100644
--- a/site/en/index.html
+++ b/site/en/index.html
@@ -40,7 +40,7 @@
Source build available · Up to 15 signed-beta waitlist spots
Wordwise
Read technical English without losing the words.
- Select text in any macOS app and press ⌥ T to see a full Chinese translation and context-aware explanations for every word, number, and punctuation mark.
+ Select text in most macOS apps and press the default ⌥ K shortcut to see a full Chinese translation and context-aware explanations. The shortcut can be disabled or changed.
Build from source now
Join the signed-beta waitlist
@@ -106,7 +106,7 @@
The signed build is not ready. Validate the source build first.
Privacy boundary
We do not need to see what you translate.
- The private waitlist form collects the qualification and contact details needed for the beta plus coarse source and landing-page language metadata, never selected text, documents, API keys, or full model requests. Local-model content stays on your Mac; cloud mode sends content only to the provider you choose.
+ The private waitlist form collects the qualification and contact details needed for the beta plus coarse source and landing-page language metadata, never selected text, documents, API keys, or full model requests. On-device model content stays on your Mac; a remote Ollama URL or cloud model sends content only to the service you choose.
Read the public validation rules
diff --git a/site/index.html b/site/index.html
index f005bb9..df06a03 100644
--- a/site/index.html
+++ b/site/index.html
@@ -40,7 +40,7 @@
源码版可用 · 最多 15 个签名版候补名额
逐词 Wordwise
读技术英文,不只得到一句中文。
-
在 macOS 里选中文字,按下 ⌥ T ,同时查看完整译文与逐词语境解释。固定短语、冠词和标点都保持对齐。
+
在 macOS 里选中文字,按下默认快捷键 ⌥ K ,同时查看完整译文与逐词语境解释。快捷键可关闭或修改。
立即从源码试用
加入签名版候补
@@ -106,7 +106,7 @@
签名版未就绪,先从源码验证价值。
隐私边界
我们不需要看到你翻译了什么。
- 私密候补表单收集资格判断和联系所需的信息,以及粗粒度来源和落地页语言元数据;不收集选中文字、文档、API Key 或完整模型请求。本地模型内容留在电脑;云端模式只发送给你选择的模型服务。
+ 私密候补表单收集资格判断和联系所需的信息,以及粗粒度来源和落地页语言元数据;不收集选中文字、文档、API Key 或完整模型请求。使用本机模型时内容留在电脑;配置远程 Ollama 或云端模型时,内容只发送给你选择的服务。
查看公开验证规则
diff --git a/src-tauri/src/selection.rs b/src-tauri/src/selection.rs
index 41a7f29..629caa3 100644
--- a/src-tauri/src/selection.rs
+++ b/src-tauri/src/selection.rs
@@ -102,7 +102,7 @@ end try
if !output.status.success() {
return Err(
- "无法读取选中文字。请在“系统设置 → 隐私与安全性 → 辅助功能”中允许逐词。".into(),
+ "无法通过复制读取选中文字。请在“系统设置 → 隐私与安全性”中检查逐词的“辅助功能”和“自动化 → System Events”权限。".into(),
);
}
diff --git a/src/App.tsx b/src/App.tsx
index 435378c..c5f5f22 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -13,14 +13,23 @@ import {
X,
Zap,
} from "lucide-react";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { extractFileText } from "./documents";
import {
getEngineStatus,
isDesktopApp,
- registerSelectionShortcut,
+ configureSelectionShortcut,
requestTranslation,
+ SelectionShortcutConfigurationError,
} from "./platform";
+import {
+ DEFAULT_SELECTION_SHORTCUT,
+ formatSelectionShortcut,
+ readSelectionShortcutSettings,
+ saveSelectionShortcutSettings,
+ shortcutFromKeyboardEvent,
+} from "./shortcut";
+import type { SelectionShortcutSettings } from "./shortcut";
import type {
EngineSettings,
Provider,
@@ -41,11 +50,13 @@ const DEFAULT_SETTINGS: EngineSettings = {
const PROVIDER_LABELS: Record
= {
auto: "自动选择",
- ollama: "Ollama 本地",
+ ollama: "Ollama",
openai: "云端模型",
dictionary: "极速词典",
};
+type ShortcutRegistrationStatus = "registering" | "registered" | "disabled" | "failed";
+
function readSavedSettings(): EngineSettings {
try {
const saved = JSON.parse(localStorage.getItem("wordwise-settings") ?? "{}") as Partial;
@@ -76,6 +87,9 @@ export default function App() {
const [text, setText] = useState(SAMPLE);
const [result, setResult] = useState(null);
const [settings, setSettings] = useState(readSavedSettings);
+ const [selectionShortcut, setSelectionShortcut] = useState(
+ readSelectionShortcutSettings,
+ );
const [status, setStatus] = useState(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [loading, setLoading] = useState(false);
@@ -84,8 +98,17 @@ export default function App() {
const [fileName, setFileName] = useState("");
const [error, setError] = useState("");
const [copied, setCopied] = useState(false);
+ const [shortcutBusy, setShortcutBusy] = useState(false);
+ const [shortcutError, setShortcutError] = useState("");
+ const [shortcutRegistrationStatus, setShortcutRegistrationStatus] = useState(
+ selectionShortcut.enabled ? "registering" : "disabled",
+ );
const fileInputRef = useRef(null);
const settingsRef = useRef(settings);
+ const statusRef = useRef(status);
+ const selectionRequestInFlight = useRef(false);
+ const shortcutOperationInFlight = useRef(false);
+ const shortcutGlobalError = useRef("");
const tokenCount = useMemo(
() => text.match(/\p{L}+(?:['’]\p{L}+)*|\p{N}+(?:[.,]\p{N}+)*|[^\s]/gu)?.length ?? 0,
@@ -116,28 +139,63 @@ export default function App() {
}, [settings]);
useEffect(() => {
- let removeShortcut: () => void = () => undefined;
+ statusRef.current = status;
+ }, [status]);
+
+ const handleSelectedText = useCallback(async (selectedText: string) => {
+ if (selectionRequestInFlight.current) return;
+ selectionRequestInFlight.current = true;
+ setText(selectedText);
+ setFileName("");
+ setResult(null);
+
+ try {
+ const activeSettings = settingsRef.current;
+ if (needsModelConfiguration(activeSettings, statusRef.current)) {
+ setSettingsOpen(true);
+ return;
+ }
+ await translateValue(selectedText, activeSettings);
+ } finally {
+ selectionRequestInFlight.current = false;
+ }
+ }, []);
+
+ const handleSelectionError = useCallback((message: string) => {
+ shortcutGlobalError.current = "";
+ setError(message);
+ }, []);
+
+ useEffect(() => {
let cancelled = false;
- void registerSelectionShortcut(
- async (selectedText) => {
- if (cancelled) return;
- setText(selectedText);
- setFileName("");
- setResult(null);
- await translateValue(selectedText, settingsRef.current);
- },
- (message) => setError(message),
- ).then((remove) => {
- if (cancelled) remove();
- else removeShortcut = remove;
- }).catch((caught) => {
- setError(caught instanceof Error ? caught.message : "全局快捷键注册失败");
+ void configureSelectionShortcut(
+ selectionShortcut.enabled ? selectionShortcut.accelerator : null,
+ handleSelectedText,
+ handleSelectionError,
+ ).then(() => {
+ if (!cancelled) {
+ setShortcutRegistrationStatus(selectionShortcut.enabled ? "registered" : "disabled");
+ }
+ }).catch(() => {
+ if (!cancelled) {
+ setShortcutRegistrationStatus("failed");
+ const message = selectionShortcut.enabled
+ ? `无法注册 ${formatSelectionShortcut(selectionShortcut.accelerator)},可能已被其他应用占用`
+ : "无法关闭选区快捷键";
+ shortcutGlobalError.current = message;
+ setShortcutError(message);
+ setError(message);
+ }
});
return () => {
cancelled = true;
- removeShortcut();
+ void configureSelectionShortcut(
+ null,
+ handleSelectedText,
+ handleSelectionError,
+ ).catch(() => undefined);
};
}, []);
@@ -214,6 +272,69 @@ export default function App() {
setSettings((current) => ({ ...current, [key]: value }));
}
+ async function applySelectionShortcut(next: SelectionShortcutSettings) {
+ if (shortcutOperationInFlight.current) return;
+ shortcutOperationInFlight.current = true;
+ setShortcutBusy(true);
+ setShortcutError("");
+ setShortcutRegistrationStatus("registering");
+ try {
+ await configureSelectionShortcut(
+ next.enabled ? next.accelerator : null,
+ handleSelectedText,
+ handleSelectionError,
+ );
+ setSelectionShortcut(next);
+ setShortcutRegistrationStatus(next.enabled ? "registered" : "disabled");
+
+ const previousGlobalError = shortcutGlobalError.current;
+ shortcutGlobalError.current = "";
+ if (previousGlobalError) {
+ setError((current) => current === previousGlobalError ? "" : current);
+ }
+
+ try {
+ saveSelectionShortcutSettings(next);
+ } catch {
+ setShortcutError("快捷键已应用,但无法保存;下次启动将恢复原设置");
+ }
+ } catch (caught) {
+ const activeAccelerator = caught instanceof SelectionShortcutConfigurationError
+ ? caught.activeAccelerator
+ : selectionShortcut.enabled
+ ? selectionShortcut.accelerator
+ : null;
+ const previousStillActive = selectionShortcut.enabled
+ && activeAccelerator === selectionShortcut.accelerator;
+
+ setShortcutRegistrationStatus(
+ previousStillActive ? "registered" : activeAccelerator ? "failed" : selectionShortcut.enabled ? "failed" : "disabled",
+ );
+
+ let message: string;
+ if (previousStillActive) {
+ message = next.enabled
+ ? `无法注册 ${formatSelectionShortcut(next.accelerator)},已保留 ${formatSelectionShortcut(selectionShortcut.accelerator)}`
+ : `无法关闭 ${formatSelectionShortcut(selectionShortcut.accelerator)},原快捷键仍有效`;
+ } else if (!activeAccelerator) {
+ message = selectionShortcut.enabled
+ ? `无法注册 ${formatSelectionShortcut(next.accelerator)},原快捷键也未能恢复;请重新启用或更换`
+ : `无法注册 ${formatSelectionShortcut(next.accelerator)},选区快捷键仍为关闭状态`;
+ } else {
+ message = `快捷键配置未完成;当前仍为 ${formatSelectionShortcut(activeAccelerator)}`;
+ }
+ setShortcutError(message);
+
+ if (!previousStillActive && selectionShortcut.enabled) {
+ shortcutGlobalError.current = message;
+ setError(message);
+ }
+ } finally {
+ shortcutOperationInFlight.current = false;
+ setShortcutBusy(false);
+ }
+ }
+
function resetText() {
setText("");
setResult(null);
@@ -225,20 +346,23 @@ export default function App() {
const currentProviderStatus = (() => {
if (settings.provider === "dictionary") return "仅逐词查义";
- if (settings.provider === "ollama") return status?.ollama.available ? "本地已连接" : "等待本地模型";
+ if (settings.provider === "ollama") return status?.ollama.available ? "Ollama 可用" : "等待 Ollama";
if (settings.provider === "openai") {
return hasEnteredApiKey || status?.openaiConfigured ? "API 已配置" : "需要 API Key";
}
- if (status?.ollama.available) return "将使用本地模型";
+ if (status?.ollama.available) return "将使用 Ollama";
if (hasEnteredApiKey || status?.openaiConfigured) return "将使用云端模型";
return "需要配置引擎";
})();
- const needsModelSetup = status !== null
- && settings.provider === "auto"
- && !status.ollama.available
- && !hasEnteredApiKey
- && !status.openaiConfigured;
+ const needsModelSetup = needsModelConfiguration(settings, status);
+ const shortcutStatusLabel = shortcutRegistrationStatus === "registered"
+ ? "已注册"
+ : shortcutRegistrationStatus === "disabled"
+ ? "已关闭"
+ : shortcutRegistrationStatus === "registering"
+ ? "配置中"
+ : "注册失败";
return (
@@ -250,7 +374,7 @@ export default function App() {
{currentProviderStatus}
- setSettingsOpen(true)}>
+ setSettingsOpen(true)}>
@@ -282,7 +406,7 @@ export default function App() {
先连接一个语境翻译引擎
- Ollama 内容留在本机;云端 API 适合立即开始。极速词典只做逐词查义,不会冒充整句翻译。
+ 本机 Ollama 内容留在电脑;远程服务适合立即开始。极速词典只做逐词查义,不会冒充整句翻译。
setSettingsOpen(true)}>
配置引擎
@@ -434,7 +558,15 @@ export default function App() {
{isDesktopApp ? "桌面客户端" : "本地优先"}
- {isDesktopApp && ⌥ + T 翻译选中文字 }
+ {isDesktopApp && (
+
+ {shortcutRegistrationStatus === "failed"
+ ? "选区快捷键配置失败"
+ : selectionShortcut.enabled
+ ? `${formatSelectionShortcut(selectionShortcut.accelerator)} 翻译选中文字`
+ : "选区快捷键已关闭"}
+
+ )}
Ctrl / ⌘ + Enter 翻译
单次上限 20,000 字符
@@ -444,12 +576,82 @@ export default function App() {
event.stopPropagation()}>
-
翻译引擎
-
自动模式使用已配置的 Ollama 或云端 API,不会静默降级为基础词典。
+
设置
+
配置选区快捷键和翻译引擎。自动模式不会静默降级为基础词典。
setSettingsOpen(false)}>
+ {isDesktopApp && (
+
+
+ 选区快捷键
+
+ {shortcutStatusLabel}
+
+
+
+ void applySelectionShortcut({
+ ...selectionShortcut,
+ enabled: event.target.checked,
+ })}
+ />
+
+ 选中文字后用快捷键翻译
+ 关闭后不会注册任何选区翻译快捷键。
+
+
+
+
+ 快捷键
+ {
+ if (event.code === "Tab") return;
+ if (event.code === "Escape") {
+ event.currentTarget.blur();
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ const captured = shortcutFromKeyboardEvent(event);
+ if (captured.error) {
+ setShortcutError(captured.error);
+ } else if (captured.accelerator) {
+ void applySelectionShortcut({
+ enabled: selectionShortcut.enabled,
+ accelerator: captured.accelerator,
+ });
+ }
+ }}
+ />
+ 点按输入框后按新组合键;文字键和 F1-F12 需搭配 ⌘、⌃ 或 ⌥。
+
+ void applySelectionShortcut({
+ ...DEFAULT_SELECTION_SHORTCUT,
+ enabled: selectionShortcut.enabled,
+ })}
+ >
+
+
+
+ {shortcutError &&
{shortcutError}
}
+
+ )}
+
运行方式
{(Object.keys(PROVIDER_LABELS) as Provider[]).map((provider) => (
@@ -470,8 +672,8 @@ export default function App() {
{(settings.provider === "auto" || settings.provider === "ollama") && (
- Ollama 本地模型
- {status?.ollama.available ? "已连接" : "未连接"}
+ Ollama 模型
+ {status?.ollama.available ? "默认地址可用" : "默认地址未连接"}
服务地址
@@ -517,7 +719,7 @@ export default function App() {
)}
- API Key 不会写入浏览器存储
+ 设置保存在本机;API Key 不会写入浏览器存储
setSettingsOpen(false)}> 完成
@@ -530,8 +732,16 @@ export default function App() {
function providerDescription(provider: Provider, status: ServerStatus | null) {
switch (provider) {
case "auto": return "按可用性选择最佳引擎";
- case "ollama": return status?.ollama.available ? `${status.ollama.models.length} 个本地模型可用` : "数据不离开本机";
+ case "ollama": return status?.ollama.available ? `${status.ollama.models.length} 个默认地址模型可用` : "默认连接本机服务,可修改地址";
case "openai": return "语境质量高,按 API 用量计费";
case "dictionary": return "离线逐词查义,不提供可靠整句翻译";
}
}
+
+function needsModelConfiguration(settings: EngineSettings, status: ServerStatus | null) {
+ return status !== null
+ && settings.provider === "auto"
+ && !status.ollama.available
+ && !settings.openaiApiKey.trim()
+ && !status.openaiConfigured;
+}
diff --git a/src/platform.test.ts b/src/platform.test.ts
new file mode 100644
index 0000000..d8dd253
--- /dev/null
+++ b/src/platform.test.ts
@@ -0,0 +1,200 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ handlers: new Map Promise>(),
+ invoke: vi.fn(),
+ register: vi.fn(),
+ unregister: vi.fn(),
+ show: vi.fn(),
+ setFocus: vi.fn(),
+}));
+
+vi.mock("@tauri-apps/api/core", () => ({
+ invoke: mocks.invoke,
+ isTauri: () => true,
+}));
+
+vi.mock("@tauri-apps/api/window", () => ({
+ getCurrentWindow: () => ({ show: mocks.show, setFocus: mocks.setFocus }),
+}));
+
+vi.mock("@tauri-apps/plugin-global-shortcut", () => ({
+ register: mocks.register,
+ unregister: mocks.unregister,
+}));
+
+beforeEach(() => {
+ vi.resetModules();
+ mocks.handlers.clear();
+ mocks.invoke.mockReset();
+ mocks.register.mockReset();
+ mocks.unregister.mockReset();
+ mocks.show.mockReset().mockResolvedValue(undefined);
+ mocks.setFocus.mockReset().mockResolvedValue(undefined);
+ mocks.unregister.mockImplementation(async (shortcut: string) => {
+ mocks.handlers.delete(shortcut);
+ });
+ mocks.register.mockImplementation(async (
+ shortcut: string,
+ handler: (event: { state: "Released" | "Pressed" }) => Promise,
+ ) => {
+ mocks.handlers.set(shortcut, handler);
+ });
+});
+
+describe("selection shortcut registration", () => {
+ it("serially replaces and disables the active shortcut", async () => {
+ const { configureSelectionShortcut } = await import("./platform");
+ const onSelection = vi.fn();
+ const onError = vi.fn();
+
+ await configureSelectionShortcut("Alt+K", onSelection, onError);
+ await configureSelectionShortcut("Alt+J", onSelection, onError);
+ await configureSelectionShortcut(null, onSelection, onError);
+
+ expect(mocks.register.mock.calls.map(([shortcut]) => shortcut)).toEqual([
+ "Alt+K",
+ "Alt+J",
+ ]);
+ expect(mocks.unregister.mock.calls.map(([shortcut]) => shortcut)).toEqual([
+ "Alt+K",
+ "Alt+J",
+ ]);
+ });
+
+ it("restores the previous shortcut when a replacement fails", async () => {
+ const { configureSelectionShortcut } = await import("./platform");
+ const onSelection = vi.fn();
+ const onError = vi.fn();
+ mocks.register.mockImplementation(async (
+ shortcut: string,
+ handler: (event: { state: "Released" | "Pressed" }) => Promise,
+ ) => {
+ if (shortcut === "Alt+J") throw new Error("occupied");
+ mocks.handlers.set(shortcut, handler);
+ });
+
+ await configureSelectionShortcut("Alt+K", onSelection, onError);
+ await expect(
+ configureSelectionShortcut("Alt+J", onSelection, onError),
+ ).rejects.toThrow("occupied");
+
+ expect(mocks.register.mock.calls.map(([shortcut]) => shortcut)).toEqual([
+ "Alt+K",
+ "Alt+J",
+ "Alt+K",
+ ]);
+
+ mocks.invoke.mockResolvedValue("restored shortcut");
+ await mocks.handlers.get("Alt+K")?.({ state: "Pressed" });
+ expect(onSelection).toHaveBeenCalledWith("restored shortcut");
+ });
+
+ it("reports when both replacement and rollback registration fail", async () => {
+ const {
+ configureSelectionShortcut,
+ SelectionShortcutConfigurationError,
+ } = await import("./platform");
+ const onSelection = vi.fn();
+ const onError = vi.fn();
+ let initialRegistrationDone = false;
+ mocks.register.mockImplementation(async (
+ shortcut: string,
+ handler: (event: { state: "Released" | "Pressed" }) => Promise,
+ ) => {
+ if (shortcut === "Alt+K" && !initialRegistrationDone) {
+ initialRegistrationDone = true;
+ mocks.handlers.set(shortcut, handler);
+ return;
+ }
+ throw new Error("registration unavailable");
+ });
+
+ await configureSelectionShortcut("Alt+K", onSelection, onError);
+ const replacement = configureSelectionShortcut("Alt+J", onSelection, onError);
+
+ await expect(replacement).rejects.toBeInstanceOf(SelectionShortcutConfigurationError);
+ await expect(replacement).rejects.toMatchObject({ activeAccelerator: null });
+ });
+
+ it("captures selected text only for the pressed event", async () => {
+ const { configureSelectionShortcut } = await import("./platform");
+ const onSelection = vi.fn();
+ const onError = vi.fn();
+ mocks.invoke.mockResolvedValue("Selected API documentation");
+
+ await configureSelectionShortcut("Alt+K", onSelection, onError);
+ const handler = mocks.handlers.get("Alt+K");
+ await handler?.({ state: "Released" });
+ await handler?.({ state: "Pressed" });
+
+ expect(mocks.invoke).toHaveBeenCalledOnce();
+ expect(mocks.show).toHaveBeenCalledOnce();
+ expect(mocks.setFocus).toHaveBeenCalledOnce();
+ expect(onSelection).toHaveBeenCalledWith("Selected API documentation");
+ expect(onError).not.toHaveBeenCalled();
+ });
+
+ it("shows the app before reporting a selection error", async () => {
+ const { configureSelectionShortcut } = await import("./platform");
+ const onSelection = vi.fn();
+ const onError = vi.fn();
+ mocks.invoke.mockRejectedValue("没有检测到选中的文字");
+
+ await configureSelectionShortcut("Alt+K", onSelection, onError);
+ await mocks.handlers.get("Alt+K")?.({ state: "Pressed" });
+
+ expect(mocks.show).toHaveBeenCalledOnce();
+ expect(mocks.setFocus).toHaveBeenCalledOnce();
+ expect(onError).toHaveBeenCalledWith("没有检测到选中的文字");
+ expect(onSelection).not.toHaveBeenCalled();
+ });
+
+ it("prevents concurrent selection capture and translation", async () => {
+ const { configureSelectionShortcut } = await import("./platform");
+ const onSelection = vi.fn();
+ const onError = vi.fn();
+ let resolveCapture: (text: string) => void = () => undefined;
+ mocks.invoke.mockImplementation(() => new Promise((resolve) => {
+ resolveCapture = resolve;
+ }));
+
+ await configureSelectionShortcut("Alt+K", onSelection, onError);
+ const handler = mocks.handlers.get("Alt+K");
+ const firstPress = handler?.({ state: "Pressed" });
+ const secondPress = handler?.({ state: "Pressed" });
+
+ expect(mocks.invoke).toHaveBeenCalledOnce();
+ resolveCapture("one selection");
+ await Promise.all([firstPress, secondPress]);
+ expect(onSelection).toHaveBeenCalledOnce();
+ expect(onSelection).toHaveBeenCalledWith("one selection");
+ });
+
+ it("continues translation and reports when the app window cannot open", async () => {
+ const { configureSelectionShortcut } = await import("./platform");
+ const onSelection = vi.fn();
+ const onError = vi.fn();
+ mocks.invoke.mockResolvedValue("Selected API documentation");
+ mocks.show.mockRejectedValue(new Error("window unavailable"));
+
+ await configureSelectionShortcut("Alt+K", onSelection, onError);
+ await mocks.handlers.get("Alt+K")?.({ state: "Pressed" });
+
+ expect(mocks.setFocus).toHaveBeenCalledOnce();
+ expect(onSelection).toHaveBeenCalledWith("Selected API documentation");
+ expect(onError).toHaveBeenCalledWith("已读取选中文字,但无法打开逐词窗口");
+ });
+
+ it("converts selection callback failures into a visible error", async () => {
+ const { configureSelectionShortcut } = await import("./platform");
+ const onSelection = vi.fn().mockRejectedValue(new Error("render failed"));
+ const onError = vi.fn();
+ mocks.invoke.mockResolvedValue("Selected API documentation");
+
+ await configureSelectionShortcut("Alt+K", onSelection, onError);
+ await mocks.handlers.get("Alt+K")?.({ state: "Pressed" });
+
+ expect(onError).toHaveBeenCalledWith("无法处理选中的文字");
+ });
+});
diff --git a/src/platform.ts b/src/platform.ts
index 41886d4..f0a95e9 100644
--- a/src/platform.ts
+++ b/src/platform.ts
@@ -3,10 +3,33 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
import { register, unregister } from "@tauri-apps/plugin-global-shortcut";
import type { EngineSettings, ServerStatus, TranslationResult } from "./types";
-const SELECTION_SHORTCUT = "Alt+T";
-
export const isDesktopApp = isTauri();
+type SelectionShortcutRegistration = {
+ accelerator: string;
+ onSelection: (text: string) => void | Promise;
+ onError: (message: string) => void;
+};
+
+export class SelectionShortcutConfigurationError extends Error {
+ readonly activeAccelerator: string | null;
+
+ constructor(activeAccelerator: string | null, cause: unknown) {
+ const message = cause instanceof Error
+ ? cause.message
+ : typeof cause === "string"
+ ? cause
+ : "全局快捷键配置失败";
+ super(message, { cause });
+ this.name = "SelectionShortcutConfigurationError";
+ this.activeAccelerator = activeAccelerator;
+ }
+}
+
+let activeSelectionShortcut: SelectionShortcutRegistration | null = null;
+let selectionShortcutQueue = Promise.resolve();
+let selectionCaptureInFlight = false;
+
async function parseResponse(response: Response): Promise {
const body = (await response.json()) as T & { error?: string };
if (!response.ok) throw new Error(body.error || "请求失败");
@@ -33,28 +56,106 @@ export async function requestTranslation(text: string, settings: EngineSettings)
return parseResponse(response);
}
-export async function registerSelectionShortcut(
+export function configureSelectionShortcut(
+ accelerator: string | null,
onSelection: (text: string) => void | Promise,
onError: (message: string) => void,
) {
- if (!isDesktopApp) return () => undefined;
+ if (!isDesktopApp) return Promise.resolve();
+
+ const next = accelerator ? { accelerator, onSelection, onError } : null;
+ const operation = selectionShortcutQueue.then(async () => {
+ const previous = activeSelectionShortcut;
+ if (previous?.accelerator === next?.accelerator) {
+ activeSelectionShortcut = next;
+ return;
+ }
- await unregister(SELECTION_SHORTCUT).catch(() => undefined);
- await register(SELECTION_SHORTCUT, async (event) => {
- if (event.state !== "Pressed") return;
try {
- const selectedText = await invoke("capture_selected_text");
- const appWindow = getCurrentWindow();
- await appWindow.show();
- await appWindow.setFocus();
- await onSelection(selectedText);
- } catch (error) {
- const message = typeof error === "string" ? error : "无法读取选中的文字";
- onError(message);
+ if (previous) {
+ await unregister(previous.accelerator);
+ activeSelectionShortcut = null;
+ }
+ if (!next) return;
+
+ await register(next.accelerator, createSelectionShortcutHandler(next.accelerator));
+ activeSelectionShortcut = next;
+ } catch (cause) {
+ if (previous && activeSelectionShortcut === null) {
+ try {
+ await register(
+ previous.accelerator,
+ createSelectionShortcutHandler(previous.accelerator),
+ );
+ activeSelectionShortcut = previous;
+ } catch {
+ activeSelectionShortcut = null;
+ }
+ }
+ throw new SelectionShortcutConfigurationError(
+ activeSelectionShortcut?.accelerator ?? null,
+ cause,
+ );
}
});
- return () => {
- void unregister(SELECTION_SHORTCUT).catch(() => undefined);
+ selectionShortcutQueue = operation.catch(() => undefined);
+ return operation;
+}
+
+function createSelectionShortcutHandler(accelerator: string) {
+ return async (event: { state: "Released" | "Pressed" }) => {
+ if (event.state !== "Pressed" || selectionCaptureInFlight) return;
+ const registration = activeSelectionShortcut;
+ if (!registration || registration.accelerator !== accelerator) return;
+
+ selectionCaptureInFlight = true;
+ try {
+ let selectedText: string;
+ try {
+ selectedText = await invoke("capture_selected_text");
+ } catch (error) {
+ await showAndFocusAppWindow();
+ reportSelectionError(
+ registration,
+ typeof error === "string" ? error : "无法读取选中的文字",
+ );
+ return;
+ }
+
+ const windowReady = await showAndFocusAppWindow();
+ try {
+ await registration.onSelection(selectedText);
+ } catch {
+ reportSelectionError(registration, "无法处理选中的文字");
+ }
+ if (!windowReady) {
+ reportSelectionError(registration, "已读取选中文字,但无法打开逐词窗口");
+ }
+ } finally {
+ selectionCaptureInFlight = false;
+ }
};
}
+
+async function showAndFocusAppWindow() {
+ try {
+ const appWindow = getCurrentWindow();
+ const shown = await appWindow.show().then(() => true).catch(() => false);
+ const focused = await appWindow.setFocus().then(() => true).catch(() => false);
+ return shown && focused;
+ } catch {
+ return false;
+ }
+}
+
+function reportSelectionError(
+ registration: SelectionShortcutRegistration,
+ message: string,
+) {
+ try {
+ registration.onError(message);
+ } catch {
+ // A UI callback must not leave the global shortcut channel rejected.
+ }
+}
diff --git a/src/shortcut.test.ts b/src/shortcut.test.ts
new file mode 100644
index 0000000..9c83f23
--- /dev/null
+++ b/src/shortcut.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ DEFAULT_SELECTION_SHORTCUT,
+ formatSelectionShortcut,
+ readSelectionShortcutSettings,
+ saveSelectionShortcutSettings,
+ SELECTION_SHORTCUT_STORAGE_KEY,
+ shortcutFromKeyboardEvent,
+ validateSelectionShortcut,
+} from "./shortcut";
+
+describe("selection shortcut settings", () => {
+ it("uses the safe default when no preference is stored", () => {
+ expect(readSelectionShortcutSettings({ getItem: () => null })).toEqual(
+ DEFAULT_SELECTION_SHORTCUT,
+ );
+ });
+
+ it("uses the safe default when storage cannot be read", () => {
+ expect(readSelectionShortcutSettings({
+ getItem: () => { throw new DOMException("blocked", "SecurityError"); },
+ })).toEqual(DEFAULT_SELECTION_SHORTCUT);
+ });
+
+ it("keeps the enabled preference but rejects an unsafe stored key", () => {
+ const storage = {
+ getItem: () => JSON.stringify({ enabled: false, accelerator: "K" }),
+ };
+
+ expect(readSelectionShortcutSettings(storage)).toEqual({
+ enabled: false,
+ accelerator: "Alt+K",
+ });
+ });
+
+ it("persists only the shortcut preference", () => {
+ const setItem = vi.fn();
+ saveSelectionShortcutSettings(
+ { enabled: true, accelerator: "Command+Shift+J" },
+ { setItem },
+ );
+
+ expect(setItem).toHaveBeenCalledWith(
+ SELECTION_SHORTCUT_STORAGE_KEY,
+ JSON.stringify({ enabled: true, accelerator: "Command+Shift+J" }),
+ );
+ });
+});
+
+describe("shortcut capture", () => {
+ it("captures Option+K using the physical key code", () => {
+ expect(shortcutFromKeyboardEvent({
+ code: "KeyK",
+ metaKey: false,
+ ctrlKey: false,
+ altKey: true,
+ shiftKey: false,
+ })).toEqual({ accelerator: "Alt+K" });
+ });
+
+ it("waits while the user is holding only a modifier", () => {
+ expect(shortcutFromKeyboardEvent({
+ code: "AltLeft",
+ metaKey: false,
+ ctrlKey: false,
+ altKey: true,
+ shiftKey: false,
+ })).toEqual({});
+ });
+
+ it("rejects bare and Shift-only printable keys", () => {
+ expect(validateSelectionShortcut("K").error).toContain("Option");
+ expect(validateSelectionShortcut("Shift+K").error).toContain("Option");
+ });
+
+ it("rejects copy and other high-risk system shortcuts", () => {
+ expect(validateSelectionShortcut("Command+C").error).toContain("系统");
+ expect(validateSelectionShortcut("Alt+Command+C").error).toContain("系统");
+ expect(validateSelectionShortcut("Command+Q").error).toContain("系统");
+ expect(validateSelectionShortcut("Control+Space").error).toContain("系统");
+ });
+
+ it("normalizes modifier order", () => {
+ expect(validateSelectionShortcut("Shift+Alt+J")).toEqual({
+ accelerator: "Alt+Shift+J",
+ });
+ });
+
+ it("allows a bare function key", () => {
+ expect(validateSelectionShortcut("F13")).toEqual({ accelerator: "F13" });
+ });
+
+ it("requires a primary modifier for standard Mac function keys", () => {
+ expect(validateSelectionShortcut("F5").error).toContain("F1-F12");
+ expect(validateSelectionShortcut("Shift+F5").error).toContain("F1-F12");
+ expect(validateSelectionShortcut("Alt+F5")).toEqual({ accelerator: "Alt+F5" });
+ });
+
+ it("formats macOS modifiers for display", () => {
+ expect(formatSelectionShortcut("Command+Control+Alt+Shift+K")).toBe("⌘ ⌃ ⌥ ⇧ K");
+ });
+});
diff --git a/src/shortcut.ts b/src/shortcut.ts
new file mode 100644
index 0000000..7f3d9fd
--- /dev/null
+++ b/src/shortcut.ts
@@ -0,0 +1,204 @@
+export type SelectionShortcutSettings = {
+ enabled: boolean;
+ accelerator: string;
+};
+
+export type ShortcutKeyEvent = {
+ code: string;
+ metaKey: boolean;
+ ctrlKey: boolean;
+ altKey: boolean;
+ shiftKey: boolean;
+};
+
+export type ShortcutCapture = {
+ accelerator?: string;
+ error?: string;
+};
+
+type StorageReader = Pick;
+type StorageWriter = Pick;
+
+export const SELECTION_SHORTCUT_STORAGE_KEY = "wordwise-selection-shortcut";
+export const DEFAULT_SELECTION_SHORTCUT: SelectionShortcutSettings = {
+ enabled: true,
+ accelerator: "Alt+K",
+};
+
+const MODIFIER_CODES = new Set([
+ "AltLeft",
+ "AltRight",
+ "ControlLeft",
+ "ControlRight",
+ "MetaLeft",
+ "MetaRight",
+ "ShiftLeft",
+ "ShiftRight",
+]);
+
+const MODIFIER_ORDER = ["Command", "Control", "Alt", "Shift"] as const;
+const RESERVED_SHORTCUTS = new Set([
+ "Command+A",
+ "Command+F",
+ "Command+H",
+ "Command+M",
+ "Command+N",
+ "Command+O",
+ "Command+P",
+ "Command+Q",
+ "Command+S",
+ "Command+V",
+ "Command+W",
+ "Command+X",
+ "Command+Z",
+ "Command+Shift+Z",
+ "Command+Space",
+ "Control+C",
+ "Control+Space",
+]);
+
+const SPECIAL_KEYS: Record = {
+ Backquote: "Backquote",
+ Backslash: "Backslash",
+ BracketLeft: "BracketLeft",
+ BracketRight: "BracketRight",
+ Comma: "Comma",
+ Equal: "Equal",
+ Minus: "Minus",
+ Period: "Period",
+ Quote: "Quote",
+ Semicolon: "Semicolon",
+ Slash: "Slash",
+ Space: "Space",
+};
+
+const DISPLAY_KEYS: Record = {
+ Backquote: "`",
+ Backslash: "\\",
+ BracketLeft: "[",
+ BracketRight: "]",
+ Comma: ",",
+ Equal: "=",
+ Minus: "-",
+ Period: ".",
+ Quote: "'",
+ Semicolon: ";",
+ Slash: "/",
+ Space: "Space",
+};
+
+export function readSelectionShortcutSettings(
+ storage?: StorageReader,
+): SelectionShortcutSettings {
+ try {
+ const target = storage ?? localStorage;
+ const saved = JSON.parse(
+ target.getItem(SELECTION_SHORTCUT_STORAGE_KEY) ?? "{}",
+ ) as Partial;
+ const accelerator = typeof saved.accelerator === "string"
+ ? validateSelectionShortcut(saved.accelerator).accelerator
+ : undefined;
+
+ return {
+ enabled: typeof saved.enabled === "boolean"
+ ? saved.enabled
+ : DEFAULT_SELECTION_SHORTCUT.enabled,
+ accelerator: accelerator ?? DEFAULT_SELECTION_SHORTCUT.accelerator,
+ };
+ } catch {
+ return { ...DEFAULT_SELECTION_SHORTCUT };
+ }
+}
+
+export function saveSelectionShortcutSettings(
+ settings: SelectionShortcutSettings,
+ storage?: StorageWriter,
+) {
+ (storage ?? localStorage).setItem(
+ SELECTION_SHORTCUT_STORAGE_KEY,
+ JSON.stringify(settings),
+ );
+}
+
+export function shortcutFromKeyboardEvent(event: ShortcutKeyEvent): ShortcutCapture {
+ if (MODIFIER_CODES.has(event.code)) return {};
+
+ const key = keyFromCode(event.code);
+ if (!key) return { error: "这个按键不能用于全局快捷键" };
+
+ const modifiers = [
+ event.metaKey ? "Command" : "",
+ event.ctrlKey ? "Control" : "",
+ event.altKey ? "Alt" : "",
+ event.shiftKey ? "Shift" : "",
+ ].filter(Boolean);
+
+ return validateSelectionShortcut([...modifiers, key].join("+"));
+}
+
+export function validateSelectionShortcut(accelerator: string): ShortcutCapture {
+ const tokens = accelerator.split("+").map((token) => token.trim()).filter(Boolean);
+ if (tokens.length === 0) return { error: "请按下一个快捷键" };
+
+ const key = tokens.at(-1) ?? "";
+ const modifiers = tokens.slice(0, -1);
+ const allowedModifiers = new Set(MODIFIER_ORDER);
+ const uniqueModifiers = new Set(modifiers);
+ const normalizedKey = normalizeKey(key);
+
+ if (
+ uniqueModifiers.size !== modifiers.length
+ || modifiers.some((modifier) => !allowedModifiers.has(modifier))
+ || !normalizedKey
+ ) {
+ return { error: "这个快捷键格式无效" };
+ }
+
+ const allowsBareFunctionKey = /^F(?:1[3-9]|20)$/.test(normalizedKey);
+ const hasPrimaryModifier = modifiers.some((modifier) => (
+ modifier === "Command" || modifier === "Control" || modifier === "Alt"
+ ));
+ if (!allowsBareFunctionKey && !hasPrimaryModifier) {
+ return { error: "文字键和 F1-F12 必须搭配 Command、Control 或 Option" };
+ }
+
+ const normalizedModifiers = MODIFIER_ORDER.filter((modifier) => (
+ uniqueModifiers.has(modifier)
+ ));
+ const normalizedAccelerator = [...normalizedModifiers, normalizedKey].join("+");
+ if (
+ (normalizedKey === "C" && uniqueModifiers.has("Command"))
+ || RESERVED_SHORTCUTS.has(normalizedAccelerator)
+ ) {
+ return { error: "这个组合键用于系统或常用操作,请选择其他快捷键" };
+ }
+
+ return { accelerator: normalizedAccelerator };
+}
+
+export function formatSelectionShortcut(accelerator: string) {
+ const symbols: Record = {
+ Command: "⌘",
+ Control: "⌃",
+ Alt: "⌥",
+ Shift: "⇧",
+ };
+ return accelerator
+ .split("+")
+ .map((token) => symbols[token] ?? DISPLAY_KEYS[token] ?? token)
+ .join(" ");
+}
+
+function keyFromCode(code: string) {
+ if (/^Key[A-Z]$/.test(code)) return code.slice(3);
+ if (/^Digit[0-9]$/.test(code)) return code.slice(5);
+ if (/^F(?:[1-9]|1\d|20)$/.test(code)) return code;
+ return SPECIAL_KEYS[code];
+}
+
+function normalizeKey(key: string) {
+ if (/^[A-Za-z]$/.test(key)) return key.toUpperCase();
+ if (/^[0-9]$/.test(key)) return key;
+ if (/^F(?:[1-9]|1\d|20)$/.test(key.toUpperCase())) return key.toUpperCase();
+ return Object.hasOwn(SPECIAL_KEYS, key) ? key : undefined;
+}
diff --git a/src/styles.css b/src/styles.css
index c34f64c..cb28067 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -397,6 +397,17 @@ textarea::placeholder { color: #a1a8a3; }
.settings-group label:has(input[type="password"]) { grid-column: 1 / -1; }
.settings-group input { width: 100%; height: 36px; padding: 0 10px; border: 1px solid var(--line-strong); border-radius: 6px; background: #fff; color: var(--ink); font-size: 12px; }
+.shortcut-settings { grid-template-columns: 1fr; }
+.settings-group .shortcut-toggle { flex-direction: row; align-items: center; gap: 10px; color: var(--ink); }
+.settings-group .shortcut-toggle input { width: 16px; height: 16px; flex: 0 0 auto; padding: 0; accent-color: var(--green); }
+.shortcut-toggle > span { display: flex; flex-direction: column; gap: 3px; }
+.shortcut-toggle strong { font-size: 12px; }
+.shortcut-toggle small, .shortcut-recorder small { color: var(--muted); font-size: 10px; line-height: 1.4; }
+.shortcut-recorder { display: grid; grid-template-columns: minmax(0, 1fr) 36px; align-items: end; gap: 8px; }
+.shortcut-input { cursor: text; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
+.shortcut-input:disabled { cursor: not-allowed; background: var(--surface); color: var(--muted); }
+.shortcut-error { margin: 0; color: var(--red); font-size: 10px; line-height: 1.4; }
+
.dialog-footer { min-height: 64px; justify-content: space-between; gap: 16px; padding: 10px 22px; color: var(--muted); font-size: 10px; }
.dialog-footer .primary-button { min-width: 92px; }
diff --git a/tests/smoke/translation.spec.ts b/tests/smoke/translation.spec.ts
index 3ce86ee..cc240c3 100644
--- a/tests/smoke/translation.spec.ts
+++ b/tests/smoke/translation.spec.ts
@@ -21,7 +21,7 @@ test("translates every sample token and opens settings", async ({ page }) => {
await expect(page.getByText("先连接一个语境翻译引擎")).toBeVisible();
await expect(page.getByRole("button", { name: "配置引擎" })).toBeVisible();
await page.getByRole("button", { name: "配置后翻译" }).click();
- await expect(page.getByRole("dialog", { name: "翻译引擎" })).toBeVisible();
+ await expect(page.getByRole("dialog", { name: "设置" })).toBeVisible();
await page.getByRole("button", { name: "关闭设置" }).click();
await page.getByLabel("翻译引擎").selectOption("dictionary");
await page.getByRole("button", { name: "逐词翻译", exact: true }).click();
@@ -31,10 +31,10 @@ test("translates every sample token and opens settings", async ({ page }) => {
await expect(page.locator(".segment-row")).toHaveCount(52);
await expect(page.locator(".segment-row").filter({ hasText: "hood" })).toContainText("内部机制");
- await page.getByRole("button", { name: "翻译引擎设置" }).click();
- const dialog = page.getByRole("dialog", { name: "翻译引擎" });
+ await page.getByRole("button", { name: "设置" }).click();
+ const dialog = page.getByRole("dialog", { name: "设置" });
await expect(dialog).toBeVisible();
- await expect(dialog.getByRole("radio", { name: /Ollama 本地/ })).toBeVisible();
+ await expect(dialog.getByRole("radio", { name: /Ollama/ })).toBeVisible();
await expect(dialog.getByText("离线逐词查义,不提供可靠整句翻译")).toBeVisible();
});
diff --git a/vitest.config.ts b/vitest.config.ts
index 1b8d7f4..c9eecdf 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
- include: ["server/**/*.test.ts"],
+ include: ["server/**/*.test.ts", "src/**/*.test.ts"],
},
});