diff --git a/README.md b/README.md
index 0beb1b6..6e118d9 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ npm run dist # 打包 NSIS 安装包到 dist/
## 主要功能
- 悬浮窗无边框、可拖拽;标题栏一键切换「始终置顶」
-- 分组管理:右键重命名 / 删除 / 清空,分组与短语都可拖动重排
+- 分组管理:右键重命名 / 删除 / 清空,分组与短语都可拖动重排,把短语拖到左侧分组即可换组
- 一键粘贴:点击 → 写剪贴板 → 自动 Ctrl+V
- 收藏 / 最近 / 使用计数;「最近」列表与使用计数都能在设置面板一键清空
- 实时模糊搜索,命中片段高亮
@@ -84,7 +84,7 @@ npm run dist # 打包 NSIS 安装包到 dist/
部分 Electron / Chrome 内核应用为安全考虑忽略 SendKeys。普通应用都没问题。
**`Ctrl+Shift+V` 不起作用?**
-可能被其它程序占用了。注册失败时主进程只会在终端打一行 warn,应用照常运行——改用托盘图标唤出即可。要换快捷键改 `main.js` 的 `GLOBAL_TOGGLE_SHORTCUT`。
+可能被其它程序占用了。注册失败时主进程只会在终端打一行 warn,应用照常运行——改用托盘图标唤出即可。要换快捷键:设置面板 → 快捷键 → 录制。
**拖到副屏的窗口下次启动不见了?**
不会丢。启动时会按窗口中心点找最近的显示器,把窗口整体夹回该显示器的工作区;副屏拔掉 / 分辨率改了也能正常恢复。
diff --git a/main.js b/main.js
index a6f1eb5..e36aa90 100644
--- a/main.js
+++ b/main.js
@@ -102,9 +102,9 @@ const isDev = !app.isPackaged;
// 注意:userData 路径依赖 app 初始化状态,在 whenReady 里再解析更稳妥
let userDataPath = null;
-// 构建标记:用户跑应用时看到这个 banner 就知道是含本次搜索框修复的最新代码。
-// 调整搜索框「无法输入」相关问题时同步修改此字符串。
-const BUILD_TAG = 'always-on-top-toggle-2026-09-11';
+// 构建标记:用户跑应用时看到这个 banner 就知道是含本次「快捷键自定义」的最新代码。
+// 调整 settings 面板里的快捷键录制 / 恢复默认相关问题时同步修改此字符串。
+const BUILD_TAG = 'shortcut-customize-2026-09-16';
console.log(`[quick-paste] build: ${BUILD_TAG}`);
let mainWindow = null;
@@ -116,6 +116,13 @@ let isQuitting = false;
// 但渲染进程保存的偏好才是用户期望的,启动时用它覆盖回去。
let alwaysOnTop = true;
+// 开机自启动触发的本次启动:默认 false。
+// `app.whenReady` 里会调 app.getLoginItemSettings() 检测本次启动是否来自登录项,
+// 如果是(wasOpenedAtLogin / wasOpenedAsHidden 任一为真),
+// 窗口就不在 ready-to-show 自动弹出,保持隐藏在托盘里——
+// 避免用户每次登录 Windows 都被迫看到一个悬浮窗跳出来。
+let startedAtLogin = false;
+
// 上次前台窗口句柄(用于粘贴前主动切回)
let lastFgHwnd = null;
let ourThreadId = 0;
@@ -288,6 +295,10 @@ function applyAlwaysOnTop() {
// 改为:缓存最近一次由 data:save 写入的完整数据快照,更新窗口位置时直接复用这份
// 快照,绝不再从盘读。
let cachedDataSnapshot = null;
+// 启动期一次性读盘快照:createWindow 读 alwaysOnTop、whenReady 读 savedAcc
+// 原本各调一次 loadData —— 整份 JSON 解析 + 字段容错走两遍,对几百条短语的小库
+// 都能感知到。合到一处,createWindow/applyGlobalShortcut 直接读这里的字段。
+let bootDataSnapshot = null;
let saveBoundsTimer = null;
function scheduleSaveBounds() {
if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
@@ -329,12 +340,14 @@ function createWindow() {
};
// 启动时把磁盘上的 alwaysOnTop 偏好读进来;
- // 旧数据没这个字段时 loadData 已经容错处理,settings 至少是 {},所以取默认值 true 即可。
- try {
- const persisted = loadData();
- const savedAlways = persisted && persisted.settings && persisted.settings.alwaysOnTop;
+ // 复用 whenReady 里已经读过的 bootDataSnapshot,避免再 loadData 一遍(几百条短语
+ // 时 JSON 解析走两遍会感知到;旧数据没这个字段时 loadData 已经容错,bootDataSnapshot
+ // 至少有 settings = {},取默认值 true 即可)。
+ if (bootDataSnapshot) {
+ const savedAlways = bootDataSnapshot.settings && bootDataSnapshot.settings.alwaysOnTop;
alwaysOnTop = savedAlways === undefined ? true : !!savedAlways;
- } catch {
+ } else {
+ // 兜底:bootDataSnapshot 没拿到(loadData 抛错被吞)→ 默认开启
alwaysOnTop = true;
}
@@ -380,7 +393,9 @@ function createWindow() {
mainWindow.on('resize', scheduleSaveBounds);
mainWindow.once('ready-to-show', () => {
- showFloating();
+ // 开机自启动触发的启动 → 窗口保持隐藏在托盘里,
+ // 用户按 Ctrl+Shift+V 或点托盘图标才会显示,避免每次登录都跳出来。
+ if (!startedAtLogin) showFloating();
});
// 关闭按钮 → 最小化到托盘,而不是退出
@@ -646,10 +661,10 @@ ipcMain.handle('data:save', (_, data) => {
if (res && res.ok) {
// 维护一份最近一次写入的全量数据快照。scheduleSaveBounds 用它来同步窗口位置,
// 不再从盘读 —— 避免和渲染层的并发保存产生覆盖。
- // 浅拷贝即可:data 来自渲染进程 IPC structured clone、withOwnedSettings 也已经
- // 浅拷贝过 settings,本进程内部无须再 JSON.parse(JSON.stringify(...)) 深拷贝一次。
- // 后续 scheduleSaveBounds 会改写 settings.windowBounds,那块新对象也独立。
- cachedDataSnapshot = { ...enriched, settings: { ...enriched.settings } };
+ // enriched 是 withOwnedSettings 返回的新对象(顶层 + settings 都已是新对象),
+ // 直接复用即可;后续 scheduleSaveBounds 会改写 cachedDataSnapshot.settings.windowBounds
+ // 为新对象,不会影响 enriched。
+ cachedDataSnapshot = enriched;
// data:save 刚刚成功落盘,待处理的窗口位置合并任务无意义了 —— 下一次
// move/resize 会重新 schedule。提前清掉避免它再用一份过期快照覆盖磁盘。
if (saveBoundsTimer) { clearTimeout(saveBoundsTimer); saveBoundsTimer = null; }
@@ -661,7 +676,7 @@ ipcMain.on('data:saveSync', (e, data) => {
const enriched = withOwnedSettings(data);
const res = saveData(enriched);
if (res && res.ok) {
- cachedDataSnapshot = { ...enriched, settings: { ...enriched.settings } };
+ cachedDataSnapshot = enriched;
if (saveBoundsTimer) { clearTimeout(saveBoundsTimer); saveBoundsTimer = null; }
}
e.returnValue = res;
@@ -767,6 +782,45 @@ ipcMain.handle('window:setOpacity', (_, value) => {
});
ipcMain.handle('app:quit', () => { isQuitting = true; app.quit(); });
+// 开机自启动:读写由 app.setLoginItemSettings / getLoginItemSettings 代理。
+// Windows:写到 HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run,
+// openAsHidden=true 时执行参数追加 --hidden;
+// macOS:注册到「系统设置 → 通用 → 登录项」;
+// Linux:在 ~/.config/autostart/ 下放 .desktop 文件(仅支持 openAtLogin)。
+//
+// 启动后用 getLoginItemSettings().wasOpenedAsHidden / wasOpenedAtLogin 判断本次是否被登录项拉起,
+// 是的话窗口留在托盘里不弹(见 ready-to-show 分支)。
+ipcMain.handle('app:getAutoLaunch', () => {
+ try {
+ const ls = app.getLoginItemSettings();
+ return { ok: true, enabled: !!(ls && ls.openAtLogin) };
+ } catch (e) {
+ // 拿不到 OS 状态时回退到 false,渲染层展示关闭态;用户点过开关后下次刷新就会以 OS 为准。
+ console.error('getAutoLaunch error:', e);
+ return { ok: false, enabled: false, error: e.message || String(e) };
+ }
+});
+
+ipcMain.handle('app:setAutoLaunch', (_, enabled) => {
+ const want = !!enabled;
+ try {
+ app.setLoginItemSettings({
+ openAtLogin: want,
+ // Windows 会把这个值翻译成执行参数里的 --hidden,启动时被 getLoginItemSettings 读到;
+ // macOS 会让应用在登录时以隐藏状态启动。
+ // Linux 忽略此字段,但写一个稳定的值对 API 一致性无害。
+ openAsHidden: true,
+ });
+ return { ok: true, enabled: want };
+ } catch (e) {
+ // dev 模式下(isPackaged=false)调用此 API 经常失败:electron.exe 路径在不同机器上不一致,
+ // 注册表项会写到错位置;不过 `npm start` 启动的用户本来也不会真的想开机自启。
+ // 把异常吞掉 + 返回错误,让渲染层弹 toast 提示。
+ console.error('setAutoLaunch error:', e);
+ return { ok: false, enabled: false, error: e.message || String(e) };
+ }
+});
+
// 渲染进程要打字了(打开编辑弹窗)→ 临时让窗口获得焦点;
// 关掉弹窗后只把焦点还给原来的目标窗口,窗口本身仍保持可聚焦
// (搜索框需要持续可用;如果这里 setNoActivate(true) 会让搜索框跟着失能)。
@@ -847,28 +901,129 @@ ipcMain.handle('data:export', async () => {
}
});
+// 渲染层获取当前已注册的全局快捷键(设置面板首次打开时显示)
+ipcMain.handle('shortcuts:getGlobalToggle', () => currentGlobalShortcut);
+
+// 修改全局快捷键:渲染层在设置面板里点「录制」拿到新组合键后调用。
+// 1. 校验格式:必须是非空字符串且至少有一个 `+`(modifier + key 的最小形态)
+// 2. 落盘:复用 withOwnedSettings → saveData 链路,确保 windowBounds 跟着更新;
+// settings.shortcuts 是新字段,withOwnedSettings 只改 windowBounds、不动其它字段,
+// 所以 spread 后透传即可。
+// 3. 注册新值到系统:失败时回滚旧值;注册成功才更新 currentGlobalShortcut。
+ipcMain.handle('shortcuts:setGlobalToggle', (_, accelerator) => {
+ if (typeof accelerator !== 'string' || !accelerator.trim() || !accelerator.includes('+')) {
+ return { ok: false, error: '快捷键格式无效' };
+ }
+ const acc = accelerator.trim();
+ // 与当前一致:直接返回成功,避免无意义的 unregister/register 抖动
+ if (acc === currentGlobalShortcut) return { ok: true, accelerator: acc, unchanged: true };
+
+ // 落盘先于 register:注册失败时数据已存,但当前快捷键仍是旧值。
+ // 用户重启后会从 settings.shortcuts.globalToggle 再尝试一次(虽然大概率仍失败)。
+ // 如果反过来:register 成功但落盘失败,重启后快捷键回到旧值——这条更糟糕,
+ // 用户明明看到界面变了,重启又被回滚。所以先写盘、再注册。
+ try {
+ const current = loadData();
+ const newSettings = {
+ ...(current.settings || {}),
+ shortcuts: { ...((current.settings && current.settings.shortcuts) || {}), globalToggle: acc },
+ };
+ const enriched = withOwnedSettings({ ...current, settings: newSettings });
+ const res = saveData(enriched);
+ if (!res || !res.ok) return { ok: false, error: (res && res.error) || '保存失败' };
+ cachedDataSnapshot = enriched;
+ if (saveBoundsTimer) { clearTimeout(saveBoundsTimer); saveBoundsTimer = null; }
+ } catch (e) {
+ console.error('save shortcut error:', e);
+ return { ok: false, error: e.message || String(e) };
+ }
+
+ // 尝试注册到系统。失败 → 数据已写盘但快捷键未切换;返回失败让 UI 提示用户,
+ // 但用户重启时会再次尝试(此时如果那个占用程序退了就成功了)。
+ const ok = applyGlobalShortcut(acc);
+ if (!ok) {
+ return { ok: false, error: '快捷键已被其它应用占用,请换一个', accelerator: acc };
+ }
+ return { ok: true, accelerator: acc };
+});
+
+/* ==================== 全局快捷键 ==================== */
+// 默认唤出 / 收起快捷键。注册来源不再写死:读 data.settings.shortcuts.globalToggle,
+// 没有或格式异常时回落到这里。用户在设置面板改了之后通过 IPC 重注册。
+const DEFAULT_GLOBAL_TOGGLE = 'CommandOrControl+Shift+V';
+// 跟踪「当前已注册到系统」的那一个。change 时需要先 unregister 掉旧的再 register 新的,
+// 否则旧组合会一直占着直到进程退出。启动时会被真实注册值覆盖。
+let currentGlobalShortcut = DEFAULT_GLOBAL_TOGGLE;
+
+// 重新注册全局快捷键:
+// 1. 注销旧的(可能是默认值,也可能是用户改过的),避免旧组合一直占着系统
+// 2. register 新值;失败(被其它程序占用)时回滚到旧值,保证至少有一个组合能用
+// 3. 整个流程不抛错——注册失败时主进程只 warn,应用照常运行(README 86 行约定)
+function applyGlobalShortcut(accelerator) {
+ // 注销旧值:可能从来没注册成功过,catch 兜底
+ if (currentGlobalShortcut && currentGlobalShortcut !== accelerator) {
+ try { globalShortcut.unregister(currentGlobalShortcut); } catch {}
+ }
+ // 兜底:unregisterAll 清场,避免旧注册残留;本应用目前只有这一条全局快捷键,安全
+ try { globalShortcut.unregisterAll(); } catch {}
+
+ try {
+ const ok = globalShortcut.register(accelerator, () => toggleWindow());
+ if (!ok) {
+ console.warn('全局快捷键注册失败,可能被其它应用占用:', accelerator);
+ // 回滚:旧值能注册就还原
+ try { globalShortcut.register(currentGlobalShortcut, () => toggleWindow()); } catch {}
+ return false;
+ }
+ currentGlobalShortcut = accelerator;
+ return true;
+ } catch (e) {
+ console.warn('globalShortcut.register error:', e);
+ try { globalShortcut.register(currentGlobalShortcut, () => toggleWindow()); } catch {}
+ return false;
+ }
+}
+
/* ==================== 生命周期 ==================== */
-// 全局快捷键:Ctrl+Shift+V 在系统任何地方都能唤出 / 收起悬浮窗
-// README / 帮助面板里都列着这条快捷键,缺了等于核心交互路径断了
-const GLOBAL_TOGGLE_SHORTCUT = 'CommandOrControl+Shift+V';
app.whenReady().then(() => {
userDataPath = path.join(app.getPath('userData'), 'data.json');
+
+ // 启动期一次性读盘:createWindow 读 alwaysOnTop、applyGlobalShortcut 读 savedAcc,
+ // 共用这一份解析过的数据,避免 JSON 解析 + 字段容错走两遍。
+ try {
+ bootDataSnapshot = loadData();
+ } catch {
+ bootDataSnapshot = null;
+ }
+
+ // 检测本次启动是否由「开机自启动」触发:
+ // wasOpenedAsHidden 表示 setLoginItemSettings({openAsHidden:true}) 给执行参数加了 --hidden;
+ // wasOpenedAtLogin 是更广义的判断(macOS 等)。
+ // 二者任一为真都说明我们是被登录项拉起来的,此时不要自动弹出悬浮窗。
+ try {
+ const ls = app.getLoginItemSettings();
+ startedAtLogin = !!(ls && (ls.wasOpenedAtLogin || ls.wasOpenedAsHidden));
+ } catch {}
+
createWindow();
createTray();
// 启动前台窗口跟踪
startForegroundTracking();
- // 注册全局唤出快捷键。register 失败通常是别的程序占了这个组合键
- // (比如某些截图工具也用 Ctrl+Shift+V)——失败就静默退化到只通过托盘点击唤出,
- // 不阻塞应用启动。
- try {
- const ok = globalShortcut.register(GLOBAL_TOGGLE_SHORTCUT, () => toggleWindow());
- if (!ok) console.warn('全局快捷键注册失败,可能被其它应用占用:', GLOBAL_TOGGLE_SHORTCUT);
- } catch (e) {
- console.warn('globalShortcut.register error:', e);
+ // 注册全局唤出快捷键:优先用用户改过的值,没有或格式异常时用默认值。
+ // 复用 bootDataSnapshot,省一次 loadData。
+ let savedAcc = DEFAULT_GLOBAL_TOGGLE;
+ if (bootDataSnapshot) {
+ const fromSettings = bootDataSnapshot.settings
+ && bootDataSnapshot.settings.shortcuts
+ && bootDataSnapshot.settings.shortcuts.globalToggle;
+ if (typeof fromSettings === 'string' && fromSettings.trim() && fromSettings.includes('+')) {
+ savedAcc = fromSettings;
+ }
}
+ applyGlobalShortcut(savedAcc);
});
app.on('will-quit', () => {
diff --git a/preload.js b/preload.js
index 168c9a6..1fbc463 100644
--- a/preload.js
+++ b/preload.js
@@ -38,4 +38,13 @@ contextBridge.exposeInMainWorld('api', {
// 导出(保存对话框需要主进程;导入走隐藏 file input,无需 IPC)
exportData: () => ipcRenderer.invoke('data:export'),
+
+ // 开机自启动:读写由主进程代理 app.setLoginItemSettings / getLoginItemSettings。
+ // Windows 上对应注册表 Run 项,macOS 对应「登录项」。
+ setAutoLaunch: (enabled) => ipcRenderer.invoke('app:setAutoLaunch', !!enabled),
+ getAutoLaunch: () => ipcRenderer.invoke('app:getAutoLaunch'),
+
+ // 全局快捷键:读当前 / 改写并重注册(设置面板录制按钮调用)
+ getGlobalToggle: () => ipcRenderer.invoke('shortcuts:getGlobalToggle'),
+ setGlobalToggle: (acc) => ipcRenderer.invoke('shortcuts:setGlobalToggle', acc),
});
\ No newline at end of file
diff --git a/renderer/index.html b/renderer/index.html
index 7d0483b..c80f934 100644
--- a/renderer/index.html
+++ b/renderer/index.html
@@ -325,6 +325,37 @@
+
+
通用
+
+
+
+
开机自启动
+
登录 Windows 时自动启动(窗口默认隐藏在托盘)
+
+
+
+
+
+
+
快捷键
+
+
+
+
全局唤出 / 收起
+
在系统任意位置唤出或收起悬浮窗;与其它软件冲突时可改
+
+
+ Ctrl+Shift+V
+
+
+
+
+
+
外观
diff --git a/renderer/renderer.js b/renderer/renderer.js
index eb942e8..d9d14f9 100644
--- a/renderer/renderer.js
+++ b/renderer/renderer.js
@@ -11,6 +11,11 @@ let selectedPhraseIndex = -1;
let currentFilter = 'all';
let dragSrcId = null;
let dragSrcKind = null;
+// 全局快捷键录制态:setupKeyboardShortcuts 顶部优先检查 isRecordingShortcut,
+// 是的话所有 keydown 都走录制流程,不再处理其它快捷键。recordInvalidShown 保证
+// 「需要修饰键」提示每个录制会话只弹一次,按 5 个字母只看到 1 条 toast。
+let isRecordingShortcut = false;
+let recordInvalidShown = false;
// 拖拽过程中的 rect 缓存:dragstart 时一次性算出所有 li 的 bounding rect,
// 避免 dragover 每像素 mousemove 都调 getBoundingClientRect 强制 reflow。
let dragRectCache = null;
@@ -21,6 +26,9 @@ let lastDropTarget = null;
let lastDropAbove = null;
// 上一帧高亮的 li:dragover 从 A 切到 B 时,A 上的 class 要清掉否则留下残影。
let prevDropTarget = null;
+// 拖拽短语时悬停的目标分组:拖到分组上 ≠ 拖到短语间的缝隙,
+// 用 phraseDropTargetGroup 单独追踪,避免与 drop-above/below 互相打架。
+let phraseDropTargetGroup = null;
function cacheDragRects(selector) {
dragRectCache = new Map();
@@ -61,6 +69,23 @@ function clearDropIndicators() {
lastDropAbove = null;
prevDropTarget = null;
dropRafPending = false;
+ // 同步把「拖到分组」的悬停高亮也清掉,避免上一轮 dragend 没走到清理分支
+ clearPhraseDropTarget();
+}
+
+// 拖短语到分组栏时给目标分组挂一个高亮 class,区别于上下插入线(drop-above/below)。
+// 用 dragover 持续刷新目标,而不是依赖 dragleave —— dragover 每像素 mousemove 都会触发,
+// 跨过 li 边界时新 dragover 自然会清掉旧的高亮,不需要单独的 dragleave 处理。
+function schedulePhraseDropTarget(groupItem) {
+ if (phraseDropTargetGroup === groupItem) return;
+ if (phraseDropTargetGroup) phraseDropTargetGroup.classList.remove('phrase-drop-target');
+ phraseDropTargetGroup = groupItem;
+ if (phraseDropTargetGroup) phraseDropTargetGroup.classList.add('phrase-drop-target');
+}
+
+function clearPhraseDropTarget() {
+ if (phraseDropTargetGroup) phraseDropTargetGroup.classList.remove('phrase-drop-target');
+ phraseDropTargetGroup = null;
}
function uid(prefix = 'id') {
@@ -83,6 +108,39 @@ function escapeHtml(str) {
}[c]));
}
+// 把 Electron accelerator 字符串转成展示用的「人类可读」形式:
+// CommandOrControl → Ctrl(Windows 永远显示 Ctrl)
+// Cmd / Command → Ctrl(同上;本应用只在 Windows 上跑,留 Cmd 也无妨)
+// 其余(Shift / Alt / F1 / Up 等)原样保留。
+function formatAcceleratorForDisplay(acc) {
+ if (!acc) return '';
+ return String(acc).replace(/CommandOrControl|Cmd|Command/g, 'Ctrl');
+}
+
+// 把 keydown 事件转成 Electron accelerator 字符串:
+// - 必须含至少一个修饰键(Ctrl/Alt/Shift/Meta 任一);单独字母返回 ''(让 UI 提示)
+// - 修饰键单独按下时 key 是 'Control'/'Shift' 等,返回 null(让用户继续按主键)
+// - 字母统一大写;方向键 / 空格 / Enter / Esc 映射成 Electron 名字(F1~F24 同名不用映射)
+function keyEventToAccelerator(e) {
+ const modifiers = [];
+ if (e.ctrlKey || e.metaKey) modifiers.push('CommandOrControl');
+ if (e.altKey) modifiers.push('Alt');
+ if (e.shiftKey) modifiers.push('Shift');
+ if (modifiers.length === 0) return '';
+
+ let key = e.key;
+ // Electron 的键名与 DOM key 略有差异,统一映射
+ const map = {
+ 'ArrowUp': 'Up', 'ArrowDown': 'Down', 'ArrowLeft': 'Left', 'ArrowRight': 'Right',
+ 'Enter': 'Return', 'Escape': 'Esc',
+ ' ': 'Space',
+ };
+ if (map[key]) key = map[key];
+ else if (key.length === 1) key = key.toUpperCase();
+ // 功能键(F1~F24)DOM 直接给 'F1' 这种,Electron 接受同名,无需转换
+ return [...modifiers, key].join('+');
+}
+
function debounce(fn, wait) {
let timer = null;
let lastArgs = null;
@@ -364,6 +422,22 @@ function renderGroups() {
dragRectCache = null;
});
li.addEventListener('dragover', (e) => {
+ // 拖短语到分组:把当前分组高亮成「落点」,drop 时整条移过去。
+ // dragSrcKind 是 phrase 时也要 preventDefault + dropEffect=move,否则浏览器会显示
+ // 「禁止落下」光标且 drop 不触发。
+ if (dragSrcKind === 'phrase') {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ // 拖回原分组没有意义(movePhraseToGroup 内部会 no-op),不要亮起来骗用户。
+ // 通过 phraseIndex 反查源分组,避免每帧都遍历全库。
+ const owner = phraseIndex.get(dragSrcId);
+ if (!owner || owner.group.id !== g.id) {
+ schedulePhraseDropTarget(li);
+ } else {
+ schedulePhraseDropTarget(null);
+ }
+ return;
+ }
if (dragSrcKind !== 'group' || dragSrcId === g.id) return;
e.preventDefault();
const rect = dragRectCache && dragRectCache.get(li);
@@ -375,6 +449,11 @@ function renderGroups() {
// 残留半边 drop-above/drop-below 状态)。这里只清自己——dragend 会兜底。
li.addEventListener('drop', (e) => {
e.preventDefault();
+ // 拖短语到分组:把该短语移动到目标分组(顶部插入,与新建短语行为一致)。
+ if (dragSrcKind === 'phrase' && dragSrcId) {
+ movePhraseToGroup(dragSrcId, g.id);
+ return;
+ }
const rect = li.getBoundingClientRect();
const above = (e.clientY - rect.top) < rect.height / 2;
reorderGroups(dragSrcId, g.id, above);
@@ -626,6 +705,32 @@ function reorderPhrases(srcId, targetId, above) {
renderPhrases();
}
+// 把短语从所在分组挪到目标分组。短语拖动目前只在「全部」视图下启用(renderPhrases 里
+// draggable = currentFilter === 'all' && !searchQuery),所以源分组一定等于当前活动分组;
+// 但仍走 findPhraseOwner 兜底,避免以后放开拖拽限制(比如允许收藏视图也拖)时漏改。
+// 选中态不修正:跨组移动后原列表里这条短语已经不存在,selectedPhraseIndex 重新渲染时会
+// 自然失效(指向新索引),下次 moveSelection 会再校正。
+function movePhraseToGroup(srcPhraseId, targetGroupId) {
+ const owner = findPhraseOwner(srcPhraseId);
+ if (!owner) return;
+ // 拖回自己所在的分组:无操作(drop 仍然触发,只是不会改变数据)。
+ if (owner.group.id === targetGroupId) return;
+ const target = data.groups.find((g) => g.id === targetGroupId);
+ if (!target) return;
+ owner.group.phrases.splice(owner.index, 1);
+ // 顶部插入:与「新建短语」位置策略一致,用户挪完立刻就能在最上方看到。
+ target.phrases.unshift(owner.phrase);
+ // 当前活动分组里少了一条短语时,索引可能越界,重置选中更安全
+ if (selectedPhraseIndex >= 0 && data.activeGroupId === owner.group.id) {
+ const visible = visiblePhrases();
+ if (selectedPhraseIndex >= visible.length) selectedPhraseIndex = -1;
+ }
+ rebuildPhraseIndex();
+ persist();
+ renderAll();
+ showToast(`已移动到「${target.name}」`, 'success');
+}
+
function renderAll() {
renderGroups();
renderPhrases();
@@ -659,6 +764,11 @@ function toggleQuickFilter(filter) {
const renderPhrasesDebounced = debounce(() => renderPhrases(), 80);
+// 严格白名单:data.json 被外部脚本写脏("Paste" / "paste " / "xxx")时落到 paste
+// 是安全的,但显式白名单比"未知值降级到 paste"读起来更明确。提到模块顶层避免
+// actOnSelected 每次调用都重新创建数组。
+const ALLOWED_CLICK_ACTIONS = ['paste', 'copy'];
+
// 搜索 query 的唯一写入入口:IME 上屏、英文输入、点清空按钮都走这里,
// 避免散落在多处的赋值 / 隐藏 toggle 出现状态不一致。
function setSearchQuery(q) {
@@ -706,9 +816,6 @@ function actOnSelected() {
item.classList.add('fired');
setTimeout(() => item.classList.remove('fired'), 700);
}
- // 严格白名单:data.json 被外部脚本写脏("Paste" / "paste " / "xxx")时落到 paste
- // 是安全的,但显式白名单比"未知值降级到 paste"读起来更明确。
- const ALLOWED_CLICK_ACTIONS = ['paste', 'copy'];
const act = ALLOWED_CLICK_ACTIONS.includes(data.settings && data.settings.clickAction)
? data.settings.clickAction : 'paste';
if (act === 'copy') copyPhrase(p);
@@ -736,8 +843,16 @@ const persist = (() => {
if (!pending || !window.api.saveDataSync) return;
const snapshot = pending;
pending = null;
- try { window.api.saveDataSync(snapshot); }
- catch (e) { console.error('saveDataSync failed:', e); }
+ // pagehide / beforeunload 时 showToast 不会展示(DOM 即将销毁),
+ // 但主进程返回的错误至少要在控制台留下诊断信息,方便事后排障。
+ try {
+ const r = window.api.saveDataSync(snapshot);
+ if (r && r.ok === false) {
+ console.error('saveDataSync failed:', r.error || 'unknown');
+ }
+ } catch (e) {
+ console.error('saveDataSync failed:', e);
+ }
};
const fn = (next) => {
// IPC invoke 已经走 structured clone,主进程拿到的就是独立副本。
@@ -762,23 +877,20 @@ function recordRecent(phraseId) {
// 用 phraseIndex 索引,O(1) 拿到 group / phrase 对象,避免每个粘贴都遍历全库。
const hit = phraseIndex.get(phraseId);
const target = hit ? hit.phrase : null;
- if (!target) return false;
+ if (!target) return;
// useCount 与「最近」列表是独立的统计概念:即使关闭了最近记录,
// 「已用 N 次」徽章也应当照常增长。早期实现把这两件事绑在一起,
// 关掉最近记录后徽章也跟着停了,对用户来说是隐藏行为变化。
- const before = target.useCount || 0;
- target.useCount = before + 1;
+ target.useCount = (target.useCount || 0) + 1;
// 「最近」列表的更新受用户偏好控制。
- if (data.settings && data.settings.recordRecent === false) return before === 0;
+ if (data.settings && data.settings.recordRecent === false) return;
const idx = data.recent.findIndex((r) => r.id === phraseId);
if (idx >= 0) data.recent.splice(idx, 1);
data.recent.unshift({ id: phraseId, ts: Date.now() });
if (data.recent.length > 50) data.recent.length = 50;
- // 0 -> 1 是触发「已用 N 次」徽章首次出现的临界态,调用方据此判断要不要重渲染。
- return before === 0;
}
async function pastePhrase(phrase) {
@@ -1135,6 +1247,11 @@ async function addGroup() {
data.groups.push(g);
data.activeGroupId = g.id;
currentFilter = 'all';
+ // 清空搜索:新建的分组必然是空的,旧搜索词会让 empty-hint 走「没有匹配」分支,
+ // 隐藏「添加第一条短语」按钮,用户得手动清搜索框才能继续 —— 体验断裂。
+ const sInput = $('search-input');
+ if (sInput) sInput.value = '';
+ setSearchQuery('');
selectedPhraseIndex = -1;
persist();
renderAll();
@@ -1171,6 +1288,11 @@ async function deleteGroup(target = null) {
// 只在被删的是当前激活分组时才切换激活分组,避免误改上下文
if (data.activeGroupId === group.id) {
data.activeGroupId = data.groups[0].id;
+ // 清空搜索:切换激活分组是上下文变化,旧搜索词可能让新激活的分组
+ // 显示「没有匹配」而不是正常列表,用户得手动清搜索框才能继续。
+ const sInput = $('search-input');
+ if (sInput) sInput.value = '';
+ setSearchQuery('');
selectedPhraseIndex = -1;
}
rebuildPhraseIndex();
@@ -1347,8 +1469,21 @@ async function applyImportedData(incoming) {
'success'
);
}
+ // 兜底校验:合并模式下用户的 activeGroupId 仍指向旧分组(merge 不会删除),
+ // 替换模式理论上 groups[0]?.id 已指向新数据,但万一导入数据本身有问题
+ // (id 类型异常等)也兜底回到第一个分组。
+ if (!data.groups.find((g) => g.id === data.activeGroupId)) {
+ data.activeGroupId = data.groups[0]?.id || null;
+ }
selectedPhraseIndex = -1;
currentFilter = 'all';
+ // 替换模式下清空搜索:旧搜索词在全新数据里大概率 0 命中,挡住正常浏览;
+ // 合并模式下保留搜索:用户大概率是想用搜索词找刚合并进来的新短语。
+ if (mode === '替换') {
+ const sInput = $('search-input');
+ if (sInput) sInput.value = '';
+ setSearchQuery('');
+ }
// 导入后整张短语库都变了,索引必须重建
rebuildPhraseIndex();
// 导入必须同步刷盘,原因有两个:
@@ -1356,8 +1491,14 @@ async function applyImportedData(incoming) {
// 走异步 persist() → 200ms 防抖窗口里 scheduleSaveBounds 仍可能用旧快照覆盖磁盘
// 2. beforeunload 时已经走过 flushSync,再走异步 persist 不会更快落盘
if (window.api.saveDataSync) {
- try { window.api.saveDataSync(data); }
- catch (e) { console.error('saveDataSync failed:', e); }
+ try {
+ const r = window.api.saveDataSync(data);
+ if (r && r.ok === false) {
+ showToast(`导入后保存失败:${r.error || '未知错误'}`, 'error');
+ }
+ } catch (e) {
+ showToast(`导入后保存失败:${e.message || e}`, 'error');
+ }
} else {
persist();
}
@@ -1373,6 +1514,10 @@ function togglePanel(id, show) {
if (window.api.setTyping) window.api.setTyping(true);
if (id === 'settings-panel') refreshSettingsUI();
} else {
+ // 关闭设置面板时若还在录制态 → 一并退出,避免面板隐藏后用户看不到录制 UI、
+ // 不知道按了什么键其实被记录了。stopRecordShortcut 是 function declaration,
+ // 写在文件后面也能正常 hoist 引用。
+ if (id === 'settings-panel' && isRecordingShortcut) stopRecordShortcut();
if (isAnyPanelOpen()) return;
if (window.api.setTyping) window.api.setTyping(false);
}
@@ -1416,6 +1561,24 @@ function refreshSettingsUI() {
if (pathEl && window.api.getDataPath) {
window.api.getDataPath().then((p) => { if (p) pathEl.textContent = p; });
}
+ // 开机自启动:以操作系统状态(注册表 / macOS 登录项)为唯一真相。
+ // 不依赖 data.settings —— 外部修改(用户手动改注册表、卸载时勾选等)也能即时反映。
+ // OS 调用失败时回退到 data.settings.autoLaunch,仍然是个有意义的 UI 缓存。
+ // 注意:失败时主进程会返回 { ok: false, enabled: false },不能用 typeof === 'boolean'
+ // 来判断 —— 否则会把 failure 的 enabled:false 当真,把用户上次保存的开启状态覆盖掉。
+ const autoLaunch = $('auto-launch-toggle');
+ if (autoLaunch && window.api.getAutoLaunch) {
+ window.api.getAutoLaunch().then((res) => {
+ if (res && res.ok === true && typeof res.enabled === 'boolean') {
+ autoLaunch.checked = res.enabled;
+ } else if (s.autoLaunch != null) {
+ autoLaunch.checked = !!s.autoLaunch;
+ }
+ });
+ }
+ // 全局快捷键:每次打开面板都拉一次最新值(异步 IPC,成本可忽略)。
+ // 拉到后写进
;HTML 里硬编码的占位「Ctrl+Shift+V」只是兜底。
+ refreshShortcutDisplay();
}
function refreshRecentDesc() {
@@ -1445,6 +1608,97 @@ function refreshUseCountDesc() {
else desc.textContent = `已累计 ${total} 次使用(${countedPhrases} 条短语)`;
}
+// 把主进程的当前全局快捷键值刷到设置面板的 上。
+// IPC 是异步的,IPC 返回前 HTML 占位符「Ctrl+Shift+V」会被用户看到,
+// 几乎无感知;这样避免在 HTML 里假设默认值导致主进程没启动时显示错。
+function refreshShortcutDisplay() {
+ if (!window.api.getGlobalToggle) return;
+ window.api.getGlobalToggle().then((acc) => {
+ const display = $('shortcut-display');
+ if (display && acc) display.textContent = formatAcceleratorForDisplay(acc);
+ });
+}
+
+/* ==================== 全局快捷键录制 ==================== */
+// 录制流程的状态机:
+// startRecordShortcut() → 进入录制态(按钮高亮、kbd 脉冲)
+// stopRecordShortcut() → 退出录制态(恢复原 UI)
+// applyRecordedShortcut() / resetShortcut() → IPC 写盘 + 重注册,更新
+//
+// 实际的 keydown 处理在 setupKeyboardShortcuts 顶部(优先级最高)。
+// 单独抽函数是因为按钮点击和 keydown 都要触发停止,逻辑共享更稳。
+
+function startRecordShortcut() {
+ if (isRecordingShortcut) {
+ // 再点一次「录制」按钮 → 等价于按 Esc 取消
+ stopRecordShortcut();
+ return;
+ }
+ isRecordingShortcut = true;
+ recordInvalidShown = false;
+ const display = document.querySelector('.shortcut-display');
+ if (display) display.classList.add('recording');
+ const btn = $('btn-record-shortcut');
+ if (btn) {
+ btn.classList.add('recording');
+ btn.textContent = '按 Esc 取消';
+ }
+}
+
+function stopRecordShortcut() {
+ isRecordingShortcut = false;
+ const display = document.querySelector('.shortcut-display');
+ if (display) display.classList.remove('recording');
+ const btn = $('btn-record-shortcut');
+ if (btn) {
+ btn.classList.remove('recording');
+ btn.textContent = '录制';
+ }
+}
+
+async function applyRecordedShortcut(acc) {
+ try {
+ const res = await window.api.setGlobalToggle(acc);
+ if (res && res.ok) {
+ const shown = formatAcceleratorForDisplay(res.accelerator || acc);
+ const display = $('shortcut-display');
+ if (display) display.textContent = shown;
+ // 同步本地 data.settings.shortcuts:主进程已经把新值写盘,但渲染层 data 对象
+ // 是后保存的引用,不主动更新的话下次 persist() 会把陈旧的 settings 整体覆盖回去,
+ // 快捷键这条改动就丢了。和 autoLaunch 处理方式一致。
+ if (!data.settings) data.settings = {};
+ if (!data.settings.shortcuts) data.settings.shortcuts = {};
+ data.settings.shortcuts.globalToggle = res.accelerator || acc;
+ showToast(res.unchanged ? '已是当前快捷键' : `已更新:${shown}`, 'success');
+ } else {
+ showToast(`快捷键更新失败:${(res && res.error) || '未知错误'}`, 'error');
+ }
+ } catch (err) {
+ showToast(`快捷键更新失败:${err.message || err}`, 'error');
+ }
+}
+
+async function resetShortcut() {
+ // 默认值与 main.js 的 DEFAULT_GLOBAL_TOGGLE 保持一致:
+ // 通过 IPC 让主进程统一处理写盘 + 重注册,渲染层只负责 UI 反馈
+ try {
+ const res = await window.api.setGlobalToggle('CommandOrControl+Shift+V');
+ if (res && res.ok) {
+ const display = $('shortcut-display');
+ if (display) display.textContent = 'Ctrl+Shift+V';
+ // 同 applyRecordedShortcut:本地缓存要同步,否则下次 persist 会回滚
+ if (!data.settings) data.settings = {};
+ if (!data.settings.shortcuts) data.settings.shortcuts = {};
+ data.settings.shortcuts.globalToggle = 'CommandOrControl+Shift+V';
+ showToast('已恢复默认', 'success');
+ } else {
+ showToast(`恢复默认失败:${(res && res.error) || '未知错误'}`, 'error');
+ }
+ } catch (err) {
+ showToast(`恢复默认失败:${err.message || err}`, 'error');
+ }
+}
+
function initSettings() {
$$('#theme-segmented .seg-btn').forEach((btn) => {
btn.addEventListener('click', () => {
@@ -1542,6 +1796,38 @@ function initSettings() {
showToast('已清空使用统计', 'success');
});
}
+ // 开机自启动:开关状态由 OS 写入(注册表 / macOS 登录项),
+ // 这里只负责发 IPC + 把 OS 返回值持久化到 data.settings 作为下次启动的 UI 回退。
+ // OS 写入失败要把 UI 翻转回原值,避免显示与实际状态不一致。
+ const autoLaunch = $('auto-launch-toggle');
+ if (autoLaunch) {
+ autoLaunch.addEventListener('change', async () => {
+ const want = autoLaunch.checked;
+ if (!window.api.setAutoLaunch) return;
+ let res;
+ try {
+ res = await window.api.setAutoLaunch(want);
+ } catch (e) {
+ res = { ok: false, error: e.message || String(e) };
+ }
+ if (!res || res.ok !== true) {
+ autoLaunch.checked = !want;
+ showToast(`设置开机自启动失败:${(res && res.error) || '未知错误'}`, 'error');
+ return;
+ }
+ if (!data.settings) data.settings = {};
+ data.settings.autoLaunch = res.enabled;
+ persist();
+ showToast(res.enabled ? '已开启:开机自启动' : '已关闭:开机自启动', 'info');
+ });
+ }
+ // 全局快捷键录制 / 恢复默认。
+ // 录制按钮在点第二次时走 startRecordShortcut 内部的 toggle 分支退出录制。
+ // 恢复默认直接发默认 accelerator,IPC 那边会做去重 + 重注册。
+ const recordBtn = $('btn-record-shortcut');
+ if (recordBtn) recordBtn.addEventListener('click', startRecordShortcut);
+ const resetShortcutBtn = $('btn-reset-shortcut');
+ if (resetShortcutBtn) resetShortcutBtn.addEventListener('click', resetShortcut);
}
/* ==================== 全局键盘快捷键 ====================
@@ -1557,6 +1843,34 @@ function initSettings() {
// 通过 visiblePhrases() 拿到的列表,搜索 / 过滤器都会影响它。 */
function setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
+ /* ====== 0. 录制全局快捷键(最高优先级) ======
+ 放在所有分支最前面:录制期间用户的按键不能被任何弹窗逻辑吞掉,
+ 也不能让 Ctrl+字母被现有 Ctrl+N/G/B/F/, 系列吃掉。 */
+ if (isRecordingShortcut) {
+ // Esc 取消录制
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ stopRecordShortcut();
+ return;
+ }
+ // 修饰键单独按下 → 继续等待主键
+ if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return;
+ // 吞掉默认行为:方向键滚动、F1 帮助、Tab 切焦点 等在录制态下都不该触发
+ e.preventDefault();
+ const acc = keyEventToAccelerator(e);
+ if (!acc) {
+ // 没有修饰键(单按字母 / 数字 / 符号)→ 一次性 toast 提示,避免每按一个键都刷
+ if (!recordInvalidShown) {
+ showToast('需要包含至少一个修饰键(Ctrl / Alt / Shift)', 'info');
+ recordInvalidShown = true;
+ }
+ return;
+ }
+ stopRecordShortcut();
+ applyRecordedShortcut(acc);
+ return;
+ }
+
const modalOpen = !$('modal').classList.contains('hidden');
const promptOpen = !$('prompt-modal').classList.contains('hidden');
const settingsOpen = !$('settings-panel').classList.contains('hidden');
diff --git a/renderer/styles.css b/renderer/styles.css
index df6bc4c..53fdd74 100644
--- a/renderer/styles.css
+++ b/renderer/styles.css
@@ -601,6 +601,16 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
box-shadow: 0 2px 0 var(--accent);
}
+/* 把短语拖到分组上时整条高亮,区别于 drop-above/below 的「上下插入线」。
+ * 视觉上沿用 .active 的着色但多一层 inset 描边,让用户立刻意识到「整条分组是落点」,
+ * 而不是「在这里上下插入」。 */
+.group-item.phrase-drop-target {
+ background: var(--accent-subtle);
+ border-color: var(--accent);
+ color: var(--text-primary);
+ box-shadow: inset 0 0 0 1px var(--accent);
+}
+
.group-item .name {
flex: 1;
overflow: hidden;
@@ -1779,6 +1789,60 @@ body[data-theme="light"] .panel-backdrop {
gap: 4px;
}
+/* ===== 快捷键录制行 ===== */
+/* 视觉上与 .setting-actions 同宽,但 kbd 用衬底框表现「键帽」感,
+ 两个按钮紧跟在后,整体一行不换。 */
+.shortcut-display {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+/* 模仿 VSCode 等 IDE 里的键帽样式:
+ - 等宽字体 + 浅灰底 + 圆角,让「Ctrl+Shift+V」一眼可读
+ - 不设置固定 min-width:组合键长度不一(Ctrl+Shift+V vs F1),硬编码会留空 */
+kbd {
+ display: inline-block;
+ padding: 3px 8px;
+ font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ background: var(--bg-input);
+ border: 1px solid var(--border-default);
+ border-radius: 4px;
+ line-height: 1.4;
+ white-space: nowrap;
+ user-select: none;
+ transition: background var(--duration-fast) var(--ease),
+ border-color var(--duration-fast) var(--ease);
+}
+
+/* 录制态:kbd 底色变 accent,提示「现在按的键会被接收」,
+ 再叠加一个轻微脉冲,让用户意识到等待状态中。 */
+.shortcut-display.recording kbd {
+ background: var(--accent-subtle);
+ border-color: var(--accent);
+ color: var(--accent);
+ animation: kbd-pulse 1.4s var(--ease) infinite;
+}
+
+@keyframes kbd-pulse {
+ 0%, 100% { box-shadow: 0 0 0 0 var(--accent-subtle); }
+ 50% { box-shadow: 0 0 0 4px var(--accent-subtle); }
+}
+
+/* 录制按钮在录制态下变成「取消」语义:accent 底 + 白字 */
+.text-btn.recording {
+ background: var(--accent);
+ color: #fff;
+}
+
+.text-btn.recording:hover {
+ background: var(--accent-hover);
+ color: #fff;
+}
+
/* 设置面板里的可点击链接(关于区开发者链接等):
取消默认下划线,用 accent 色 + hover 强化,避免和段落正文混在一起 */
.setting-link {