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 {