update
This commit is contained in:
@@ -22,7 +22,7 @@ npm run dist # 打包 NSIS 安装包到 dist/
|
|||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
- 悬浮窗无边框、可拖拽;标题栏一键切换「始终置顶」
|
- 悬浮窗无边框、可拖拽;标题栏一键切换「始终置顶」
|
||||||
- 分组管理:右键重命名 / 删除 / 清空,分组与短语都可拖动重排
|
- 分组管理:右键重命名 / 删除 / 清空,分组与短语都可拖动重排,把短语拖到左侧分组即可换组
|
||||||
- 一键粘贴:点击 → 写剪贴板 → 自动 Ctrl+V
|
- 一键粘贴:点击 → 写剪贴板 → 自动 Ctrl+V
|
||||||
- 收藏 / 最近 / 使用计数;「最近」列表与使用计数都能在设置面板一键清空
|
- 收藏 / 最近 / 使用计数;「最近」列表与使用计数都能在设置面板一键清空
|
||||||
- 实时模糊搜索,命中片段高亮
|
- 实时模糊搜索,命中片段高亮
|
||||||
@@ -84,7 +84,7 @@ npm run dist # 打包 NSIS 安装包到 dist/
|
|||||||
部分 Electron / Chrome 内核应用为安全考虑忽略 SendKeys。普通应用都没问题。
|
部分 Electron / Chrome 内核应用为安全考虑忽略 SendKeys。普通应用都没问题。
|
||||||
|
|
||||||
**`Ctrl+Shift+V` 不起作用?**
|
**`Ctrl+Shift+V` 不起作用?**
|
||||||
可能被其它程序占用了。注册失败时主进程只会在终端打一行 warn,应用照常运行——改用托盘图标唤出即可。要换快捷键改 `main.js` 的 `GLOBAL_TOGGLE_SHORTCUT`。
|
可能被其它程序占用了。注册失败时主进程只会在终端打一行 warn,应用照常运行——改用托盘图标唤出即可。要换快捷键:设置面板 → 快捷键 → 录制。
|
||||||
|
|
||||||
**拖到副屏的窗口下次启动不见了?**
|
**拖到副屏的窗口下次启动不见了?**
|
||||||
不会丢。启动时会按窗口中心点找最近的显示器,把窗口整体夹回该显示器的工作区;副屏拔掉 / 分辨率改了也能正常恢复。
|
不会丢。启动时会按窗口中心点找最近的显示器,把窗口整体夹回该显示器的工作区;副屏拔掉 / 分辨率改了也能正常恢复。
|
||||||
|
|||||||
205
main.js
205
main.js
@@ -102,9 +102,9 @@ const isDev = !app.isPackaged;
|
|||||||
// 注意:userData 路径依赖 app 初始化状态,在 whenReady 里再解析更稳妥
|
// 注意:userData 路径依赖 app 初始化状态,在 whenReady 里再解析更稳妥
|
||||||
let userDataPath = null;
|
let userDataPath = null;
|
||||||
|
|
||||||
// 构建标记:用户跑应用时看到这个 banner 就知道是含本次搜索框修复的最新代码。
|
// 构建标记:用户跑应用时看到这个 banner 就知道是含本次「快捷键自定义」的最新代码。
|
||||||
// 调整搜索框「无法输入」相关问题时同步修改此字符串。
|
// 调整 settings 面板里的快捷键录制 / 恢复默认相关问题时同步修改此字符串。
|
||||||
const BUILD_TAG = 'always-on-top-toggle-2026-09-11';
|
const BUILD_TAG = 'shortcut-customize-2026-09-16';
|
||||||
console.log(`[quick-paste] build: ${BUILD_TAG}`);
|
console.log(`[quick-paste] build: ${BUILD_TAG}`);
|
||||||
|
|
||||||
let mainWindow = null;
|
let mainWindow = null;
|
||||||
@@ -116,6 +116,13 @@ let isQuitting = false;
|
|||||||
// 但渲染进程保存的偏好才是用户期望的,启动时用它覆盖回去。
|
// 但渲染进程保存的偏好才是用户期望的,启动时用它覆盖回去。
|
||||||
let alwaysOnTop = true;
|
let alwaysOnTop = true;
|
||||||
|
|
||||||
|
// 开机自启动触发的本次启动:默认 false。
|
||||||
|
// `app.whenReady` 里会调 app.getLoginItemSettings() 检测本次启动是否来自登录项,
|
||||||
|
// 如果是(wasOpenedAtLogin / wasOpenedAsHidden 任一为真),
|
||||||
|
// 窗口就不在 ready-to-show 自动弹出,保持隐藏在托盘里——
|
||||||
|
// 避免用户每次登录 Windows 都被迫看到一个悬浮窗跳出来。
|
||||||
|
let startedAtLogin = false;
|
||||||
|
|
||||||
// 上次前台窗口句柄(用于粘贴前主动切回)
|
// 上次前台窗口句柄(用于粘贴前主动切回)
|
||||||
let lastFgHwnd = null;
|
let lastFgHwnd = null;
|
||||||
let ourThreadId = 0;
|
let ourThreadId = 0;
|
||||||
@@ -288,6 +295,10 @@ function applyAlwaysOnTop() {
|
|||||||
// 改为:缓存最近一次由 data:save 写入的完整数据快照,更新窗口位置时直接复用这份
|
// 改为:缓存最近一次由 data:save 写入的完整数据快照,更新窗口位置时直接复用这份
|
||||||
// 快照,绝不再从盘读。
|
// 快照,绝不再从盘读。
|
||||||
let cachedDataSnapshot = null;
|
let cachedDataSnapshot = null;
|
||||||
|
// 启动期一次性读盘快照:createWindow 读 alwaysOnTop、whenReady 读 savedAcc
|
||||||
|
// 原本各调一次 loadData —— 整份 JSON 解析 + 字段容错走两遍,对几百条短语的小库
|
||||||
|
// 都能感知到。合到一处,createWindow/applyGlobalShortcut 直接读这里的字段。
|
||||||
|
let bootDataSnapshot = null;
|
||||||
let saveBoundsTimer = null;
|
let saveBoundsTimer = null;
|
||||||
function scheduleSaveBounds() {
|
function scheduleSaveBounds() {
|
||||||
if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
|
if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
|
||||||
@@ -329,12 +340,14 @@ function createWindow() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 启动时把磁盘上的 alwaysOnTop 偏好读进来;
|
// 启动时把磁盘上的 alwaysOnTop 偏好读进来;
|
||||||
// 旧数据没这个字段时 loadData 已经容错处理,settings 至少是 {},所以取默认值 true 即可。
|
// 复用 whenReady 里已经读过的 bootDataSnapshot,避免再 loadData 一遍(几百条短语
|
||||||
try {
|
// 时 JSON 解析走两遍会感知到;旧数据没这个字段时 loadData 已经容错,bootDataSnapshot
|
||||||
const persisted = loadData();
|
// 至少有 settings = {},取默认值 true 即可)。
|
||||||
const savedAlways = persisted && persisted.settings && persisted.settings.alwaysOnTop;
|
if (bootDataSnapshot) {
|
||||||
|
const savedAlways = bootDataSnapshot.settings && bootDataSnapshot.settings.alwaysOnTop;
|
||||||
alwaysOnTop = savedAlways === undefined ? true : !!savedAlways;
|
alwaysOnTop = savedAlways === undefined ? true : !!savedAlways;
|
||||||
} catch {
|
} else {
|
||||||
|
// 兜底:bootDataSnapshot 没拿到(loadData 抛错被吞)→ 默认开启
|
||||||
alwaysOnTop = true;
|
alwaysOnTop = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,7 +393,9 @@ function createWindow() {
|
|||||||
mainWindow.on('resize', scheduleSaveBounds);
|
mainWindow.on('resize', scheduleSaveBounds);
|
||||||
|
|
||||||
mainWindow.once('ready-to-show', () => {
|
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) {
|
if (res && res.ok) {
|
||||||
// 维护一份最近一次写入的全量数据快照。scheduleSaveBounds 用它来同步窗口位置,
|
// 维护一份最近一次写入的全量数据快照。scheduleSaveBounds 用它来同步窗口位置,
|
||||||
// 不再从盘读 —— 避免和渲染层的并发保存产生覆盖。
|
// 不再从盘读 —— 避免和渲染层的并发保存产生覆盖。
|
||||||
// 浅拷贝即可:data 来自渲染进程 IPC structured clone、withOwnedSettings 也已经
|
// enriched 是 withOwnedSettings 返回的新对象(顶层 + settings 都已是新对象),
|
||||||
// 浅拷贝过 settings,本进程内部无须再 JSON.parse(JSON.stringify(...)) 深拷贝一次。
|
// 直接复用即可;后续 scheduleSaveBounds 会改写 cachedDataSnapshot.settings.windowBounds
|
||||||
// 后续 scheduleSaveBounds 会改写 settings.windowBounds,那块新对象也独立。
|
// 为新对象,不会影响 enriched。
|
||||||
cachedDataSnapshot = { ...enriched, settings: { ...enriched.settings } };
|
cachedDataSnapshot = enriched;
|
||||||
// data:save 刚刚成功落盘,待处理的窗口位置合并任务无意义了 —— 下一次
|
// data:save 刚刚成功落盘,待处理的窗口位置合并任务无意义了 —— 下一次
|
||||||
// move/resize 会重新 schedule。提前清掉避免它再用一份过期快照覆盖磁盘。
|
// move/resize 会重新 schedule。提前清掉避免它再用一份过期快照覆盖磁盘。
|
||||||
if (saveBoundsTimer) { clearTimeout(saveBoundsTimer); saveBoundsTimer = null; }
|
if (saveBoundsTimer) { clearTimeout(saveBoundsTimer); saveBoundsTimer = null; }
|
||||||
@@ -661,7 +676,7 @@ ipcMain.on('data:saveSync', (e, data) => {
|
|||||||
const enriched = withOwnedSettings(data);
|
const enriched = withOwnedSettings(data);
|
||||||
const res = saveData(enriched);
|
const res = saveData(enriched);
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
cachedDataSnapshot = { ...enriched, settings: { ...enriched.settings } };
|
cachedDataSnapshot = enriched;
|
||||||
if (saveBoundsTimer) { clearTimeout(saveBoundsTimer); saveBoundsTimer = null; }
|
if (saveBoundsTimer) { clearTimeout(saveBoundsTimer); saveBoundsTimer = null; }
|
||||||
}
|
}
|
||||||
e.returnValue = res;
|
e.returnValue = res;
|
||||||
@@ -767,6 +782,45 @@ ipcMain.handle('window:setOpacity', (_, value) => {
|
|||||||
});
|
});
|
||||||
ipcMain.handle('app:quit', () => { isQuitting = true; app.quit(); });
|
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) 会让搜索框跟着失能)。
|
// (搜索框需要持续可用;如果这里 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(() => {
|
app.whenReady().then(() => {
|
||||||
userDataPath = path.join(app.getPath('userData'), 'data.json');
|
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();
|
createWindow();
|
||||||
createTray();
|
createTray();
|
||||||
|
|
||||||
// 启动前台窗口跟踪
|
// 启动前台窗口跟踪
|
||||||
startForegroundTracking();
|
startForegroundTracking();
|
||||||
|
|
||||||
// 注册全局唤出快捷键。register 失败通常是别的程序占了这个组合键
|
// 注册全局唤出快捷键:优先用用户改过的值,没有或格式异常时用默认值。
|
||||||
// (比如某些截图工具也用 Ctrl+Shift+V)——失败就静默退化到只通过托盘点击唤出,
|
// 复用 bootDataSnapshot,省一次 loadData。
|
||||||
// 不阻塞应用启动。
|
let savedAcc = DEFAULT_GLOBAL_TOGGLE;
|
||||||
try {
|
if (bootDataSnapshot) {
|
||||||
const ok = globalShortcut.register(GLOBAL_TOGGLE_SHORTCUT, () => toggleWindow());
|
const fromSettings = bootDataSnapshot.settings
|
||||||
if (!ok) console.warn('全局快捷键注册失败,可能被其它应用占用:', GLOBAL_TOGGLE_SHORTCUT);
|
&& bootDataSnapshot.settings.shortcuts
|
||||||
} catch (e) {
|
&& bootDataSnapshot.settings.shortcuts.globalToggle;
|
||||||
console.warn('globalShortcut.register error:', e);
|
if (typeof fromSettings === 'string' && fromSettings.trim() && fromSettings.includes('+')) {
|
||||||
|
savedAcc = fromSettings;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
applyGlobalShortcut(savedAcc);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('will-quit', () => {
|
app.on('will-quit', () => {
|
||||||
|
|||||||
@@ -38,4 +38,13 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
|
|
||||||
// 导出(保存对话框需要主进程;导入走隐藏 file input,无需 IPC)
|
// 导出(保存对话框需要主进程;导入走隐藏 file input,无需 IPC)
|
||||||
exportData: () => ipcRenderer.invoke('data:export'),
|
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),
|
||||||
});
|
});
|
||||||
@@ -325,6 +325,37 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
|
<div class="setting-group">
|
||||||
|
<div class="setting-group-title">通用</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-row-text">
|
||||||
|
<div class="setting-label">开机自启动</div>
|
||||||
|
<div class="setting-desc">登录 Windows 时自动启动(窗口默认隐藏在托盘)</div>
|
||||||
|
</div>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="auto-launch-toggle" />
|
||||||
|
<span class="switch-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<div class="setting-group-title">快捷键</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-row-text">
|
||||||
|
<div class="setting-label">全局唤出 / 收起</div>
|
||||||
|
<div class="setting-desc">在系统任意位置唤出或收起悬浮窗;与其它软件冲突时可改</div>
|
||||||
|
</div>
|
||||||
|
<div class="shortcut-display">
|
||||||
|
<kbd id="shortcut-display">Ctrl+Shift+V</kbd>
|
||||||
|
<button class="text-btn" id="btn-record-shortcut" title="录制新快捷键">录制</button>
|
||||||
|
<button class="text-btn" id="btn-reset-shortcut" title="恢复为 Ctrl+Shift+V">恢复默认</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<div class="setting-group-title">外观</div>
|
<div class="setting-group-title">外观</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ let selectedPhraseIndex = -1;
|
|||||||
let currentFilter = 'all';
|
let currentFilter = 'all';
|
||||||
let dragSrcId = null;
|
let dragSrcId = null;
|
||||||
let dragSrcKind = null;
|
let dragSrcKind = null;
|
||||||
|
// 全局快捷键录制态:setupKeyboardShortcuts 顶部优先检查 isRecordingShortcut,
|
||||||
|
// 是的话所有 keydown 都走录制流程,不再处理其它快捷键。recordInvalidShown 保证
|
||||||
|
// 「需要修饰键」提示每个录制会话只弹一次,按 5 个字母只看到 1 条 toast。
|
||||||
|
let isRecordingShortcut = false;
|
||||||
|
let recordInvalidShown = false;
|
||||||
// 拖拽过程中的 rect 缓存:dragstart 时一次性算出所有 li 的 bounding rect,
|
// 拖拽过程中的 rect 缓存:dragstart 时一次性算出所有 li 的 bounding rect,
|
||||||
// 避免 dragover 每像素 mousemove 都调 getBoundingClientRect 强制 reflow。
|
// 避免 dragover 每像素 mousemove 都调 getBoundingClientRect 强制 reflow。
|
||||||
let dragRectCache = null;
|
let dragRectCache = null;
|
||||||
@@ -21,6 +26,9 @@ let lastDropTarget = null;
|
|||||||
let lastDropAbove = null;
|
let lastDropAbove = null;
|
||||||
// 上一帧高亮的 li:dragover 从 A 切到 B 时,A 上的 class 要清掉否则留下残影。
|
// 上一帧高亮的 li:dragover 从 A 切到 B 时,A 上的 class 要清掉否则留下残影。
|
||||||
let prevDropTarget = null;
|
let prevDropTarget = null;
|
||||||
|
// 拖拽短语时悬停的目标分组:拖到分组上 ≠ 拖到短语间的缝隙,
|
||||||
|
// 用 phraseDropTargetGroup 单独追踪,避免与 drop-above/below 互相打架。
|
||||||
|
let phraseDropTargetGroup = null;
|
||||||
|
|
||||||
function cacheDragRects(selector) {
|
function cacheDragRects(selector) {
|
||||||
dragRectCache = new Map();
|
dragRectCache = new Map();
|
||||||
@@ -61,6 +69,23 @@ function clearDropIndicators() {
|
|||||||
lastDropAbove = null;
|
lastDropAbove = null;
|
||||||
prevDropTarget = null;
|
prevDropTarget = null;
|
||||||
dropRafPending = false;
|
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') {
|
function uid(prefix = 'id') {
|
||||||
@@ -83,6 +108,39 @@ function escapeHtml(str) {
|
|||||||
}[c]));
|
}[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) {
|
function debounce(fn, wait) {
|
||||||
let timer = null;
|
let timer = null;
|
||||||
let lastArgs = null;
|
let lastArgs = null;
|
||||||
@@ -364,6 +422,22 @@ function renderGroups() {
|
|||||||
dragRectCache = null;
|
dragRectCache = null;
|
||||||
});
|
});
|
||||||
li.addEventListener('dragover', (e) => {
|
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;
|
if (dragSrcKind !== 'group' || dragSrcId === g.id) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const rect = dragRectCache && dragRectCache.get(li);
|
const rect = dragRectCache && dragRectCache.get(li);
|
||||||
@@ -375,6 +449,11 @@ function renderGroups() {
|
|||||||
// 残留半边 drop-above/drop-below 状态)。这里只清自己——dragend 会兜底。
|
// 残留半边 drop-above/drop-below 状态)。这里只清自己——dragend 会兜底。
|
||||||
li.addEventListener('drop', (e) => {
|
li.addEventListener('drop', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// 拖短语到分组:把该短语移动到目标分组(顶部插入,与新建短语行为一致)。
|
||||||
|
if (dragSrcKind === 'phrase' && dragSrcId) {
|
||||||
|
movePhraseToGroup(dragSrcId, g.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const rect = li.getBoundingClientRect();
|
const rect = li.getBoundingClientRect();
|
||||||
const above = (e.clientY - rect.top) < rect.height / 2;
|
const above = (e.clientY - rect.top) < rect.height / 2;
|
||||||
reorderGroups(dragSrcId, g.id, above);
|
reorderGroups(dragSrcId, g.id, above);
|
||||||
@@ -626,6 +705,32 @@ function reorderPhrases(srcId, targetId, above) {
|
|||||||
renderPhrases();
|
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() {
|
function renderAll() {
|
||||||
renderGroups();
|
renderGroups();
|
||||||
renderPhrases();
|
renderPhrases();
|
||||||
@@ -659,6 +764,11 @@ function toggleQuickFilter(filter) {
|
|||||||
|
|
||||||
const renderPhrasesDebounced = debounce(() => renderPhrases(), 80);
|
const renderPhrasesDebounced = debounce(() => renderPhrases(), 80);
|
||||||
|
|
||||||
|
// 严格白名单:data.json 被外部脚本写脏("Paste" / "paste " / "xxx")时落到 paste
|
||||||
|
// 是安全的,但显式白名单比"未知值降级到 paste"读起来更明确。提到模块顶层避免
|
||||||
|
// actOnSelected 每次调用都重新创建数组。
|
||||||
|
const ALLOWED_CLICK_ACTIONS = ['paste', 'copy'];
|
||||||
|
|
||||||
// 搜索 query 的唯一写入入口:IME 上屏、英文输入、点清空按钮都走这里,
|
// 搜索 query 的唯一写入入口:IME 上屏、英文输入、点清空按钮都走这里,
|
||||||
// 避免散落在多处的赋值 / 隐藏 toggle 出现状态不一致。
|
// 避免散落在多处的赋值 / 隐藏 toggle 出现状态不一致。
|
||||||
function setSearchQuery(q) {
|
function setSearchQuery(q) {
|
||||||
@@ -706,9 +816,6 @@ function actOnSelected() {
|
|||||||
item.classList.add('fired');
|
item.classList.add('fired');
|
||||||
setTimeout(() => item.classList.remove('fired'), 700);
|
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)
|
const act = ALLOWED_CLICK_ACTIONS.includes(data.settings && data.settings.clickAction)
|
||||||
? data.settings.clickAction : 'paste';
|
? data.settings.clickAction : 'paste';
|
||||||
if (act === 'copy') copyPhrase(p);
|
if (act === 'copy') copyPhrase(p);
|
||||||
@@ -736,8 +843,16 @@ const persist = (() => {
|
|||||||
if (!pending || !window.api.saveDataSync) return;
|
if (!pending || !window.api.saveDataSync) return;
|
||||||
const snapshot = pending;
|
const snapshot = pending;
|
||||||
pending = null;
|
pending = null;
|
||||||
try { window.api.saveDataSync(snapshot); }
|
// pagehide / beforeunload 时 showToast 不会展示(DOM 即将销毁),
|
||||||
catch (e) { console.error('saveDataSync failed:', e); }
|
// 但主进程返回的错误至少要在控制台留下诊断信息,方便事后排障。
|
||||||
|
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) => {
|
const fn = (next) => {
|
||||||
// IPC invoke 已经走 structured clone,主进程拿到的就是独立副本。
|
// IPC invoke 已经走 structured clone,主进程拿到的就是独立副本。
|
||||||
@@ -762,23 +877,20 @@ function recordRecent(phraseId) {
|
|||||||
// 用 phraseIndex 索引,O(1) 拿到 group / phrase 对象,避免每个粘贴都遍历全库。
|
// 用 phraseIndex 索引,O(1) 拿到 group / phrase 对象,避免每个粘贴都遍历全库。
|
||||||
const hit = phraseIndex.get(phraseId);
|
const hit = phraseIndex.get(phraseId);
|
||||||
const target = hit ? hit.phrase : null;
|
const target = hit ? hit.phrase : null;
|
||||||
if (!target) return false;
|
if (!target) return;
|
||||||
|
|
||||||
// useCount 与「最近」列表是独立的统计概念:即使关闭了最近记录,
|
// useCount 与「最近」列表是独立的统计概念:即使关闭了最近记录,
|
||||||
// 「已用 N 次」徽章也应当照常增长。早期实现把这两件事绑在一起,
|
// 「已用 N 次」徽章也应当照常增长。早期实现把这两件事绑在一起,
|
||||||
// 关掉最近记录后徽章也跟着停了,对用户来说是隐藏行为变化。
|
// 关掉最近记录后徽章也跟着停了,对用户来说是隐藏行为变化。
|
||||||
const before = target.useCount || 0;
|
target.useCount = (target.useCount || 0) + 1;
|
||||||
target.useCount = before + 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);
|
const idx = data.recent.findIndex((r) => r.id === phraseId);
|
||||||
if (idx >= 0) data.recent.splice(idx, 1);
|
if (idx >= 0) data.recent.splice(idx, 1);
|
||||||
data.recent.unshift({ id: phraseId, ts: Date.now() });
|
data.recent.unshift({ id: phraseId, ts: Date.now() });
|
||||||
if (data.recent.length > 50) data.recent.length = 50;
|
if (data.recent.length > 50) data.recent.length = 50;
|
||||||
// 0 -> 1 是触发「已用 N 次」徽章首次出现的临界态,调用方据此判断要不要重渲染。
|
|
||||||
return before === 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pastePhrase(phrase) {
|
async function pastePhrase(phrase) {
|
||||||
@@ -1135,6 +1247,11 @@ async function addGroup() {
|
|||||||
data.groups.push(g);
|
data.groups.push(g);
|
||||||
data.activeGroupId = g.id;
|
data.activeGroupId = g.id;
|
||||||
currentFilter = 'all';
|
currentFilter = 'all';
|
||||||
|
// 清空搜索:新建的分组必然是空的,旧搜索词会让 empty-hint 走「没有匹配」分支,
|
||||||
|
// 隐藏「添加第一条短语」按钮,用户得手动清搜索框才能继续 —— 体验断裂。
|
||||||
|
const sInput = $('search-input');
|
||||||
|
if (sInput) sInput.value = '';
|
||||||
|
setSearchQuery('');
|
||||||
selectedPhraseIndex = -1;
|
selectedPhraseIndex = -1;
|
||||||
persist();
|
persist();
|
||||||
renderAll();
|
renderAll();
|
||||||
@@ -1171,6 +1288,11 @@ async function deleteGroup(target = null) {
|
|||||||
// 只在被删的是当前激活分组时才切换激活分组,避免误改上下文
|
// 只在被删的是当前激活分组时才切换激活分组,避免误改上下文
|
||||||
if (data.activeGroupId === group.id) {
|
if (data.activeGroupId === group.id) {
|
||||||
data.activeGroupId = data.groups[0].id;
|
data.activeGroupId = data.groups[0].id;
|
||||||
|
// 清空搜索:切换激活分组是上下文变化,旧搜索词可能让新激活的分组
|
||||||
|
// 显示「没有匹配」而不是正常列表,用户得手动清搜索框才能继续。
|
||||||
|
const sInput = $('search-input');
|
||||||
|
if (sInput) sInput.value = '';
|
||||||
|
setSearchQuery('');
|
||||||
selectedPhraseIndex = -1;
|
selectedPhraseIndex = -1;
|
||||||
}
|
}
|
||||||
rebuildPhraseIndex();
|
rebuildPhraseIndex();
|
||||||
@@ -1347,8 +1469,21 @@ async function applyImportedData(incoming) {
|
|||||||
'success'
|
'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;
|
selectedPhraseIndex = -1;
|
||||||
currentFilter = 'all';
|
currentFilter = 'all';
|
||||||
|
// 替换模式下清空搜索:旧搜索词在全新数据里大概率 0 命中,挡住正常浏览;
|
||||||
|
// 合并模式下保留搜索:用户大概率是想用搜索词找刚合并进来的新短语。
|
||||||
|
if (mode === '替换') {
|
||||||
|
const sInput = $('search-input');
|
||||||
|
if (sInput) sInput.value = '';
|
||||||
|
setSearchQuery('');
|
||||||
|
}
|
||||||
// 导入后整张短语库都变了,索引必须重建
|
// 导入后整张短语库都变了,索引必须重建
|
||||||
rebuildPhraseIndex();
|
rebuildPhraseIndex();
|
||||||
// 导入必须同步刷盘,原因有两个:
|
// 导入必须同步刷盘,原因有两个:
|
||||||
@@ -1356,8 +1491,14 @@ async function applyImportedData(incoming) {
|
|||||||
// 走异步 persist() → 200ms 防抖窗口里 scheduleSaveBounds 仍可能用旧快照覆盖磁盘
|
// 走异步 persist() → 200ms 防抖窗口里 scheduleSaveBounds 仍可能用旧快照覆盖磁盘
|
||||||
// 2. beforeunload 时已经走过 flushSync,再走异步 persist 不会更快落盘
|
// 2. beforeunload 时已经走过 flushSync,再走异步 persist 不会更快落盘
|
||||||
if (window.api.saveDataSync) {
|
if (window.api.saveDataSync) {
|
||||||
try { window.api.saveDataSync(data); }
|
try {
|
||||||
catch (e) { console.error('saveDataSync failed:', e); }
|
const r = window.api.saveDataSync(data);
|
||||||
|
if (r && r.ok === false) {
|
||||||
|
showToast(`导入后保存失败:${r.error || '未知错误'}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`导入后保存失败:${e.message || e}`, 'error');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
persist();
|
persist();
|
||||||
}
|
}
|
||||||
@@ -1373,6 +1514,10 @@ function togglePanel(id, show) {
|
|||||||
if (window.api.setTyping) window.api.setTyping(true);
|
if (window.api.setTyping) window.api.setTyping(true);
|
||||||
if (id === 'settings-panel') refreshSettingsUI();
|
if (id === 'settings-panel') refreshSettingsUI();
|
||||||
} else {
|
} else {
|
||||||
|
// 关闭设置面板时若还在录制态 → 一并退出,避免面板隐藏后用户看不到录制 UI、
|
||||||
|
// 不知道按了什么键其实被记录了。stopRecordShortcut 是 function declaration,
|
||||||
|
// 写在文件后面也能正常 hoist 引用。
|
||||||
|
if (id === 'settings-panel' && isRecordingShortcut) stopRecordShortcut();
|
||||||
if (isAnyPanelOpen()) return;
|
if (isAnyPanelOpen()) return;
|
||||||
if (window.api.setTyping) window.api.setTyping(false);
|
if (window.api.setTyping) window.api.setTyping(false);
|
||||||
}
|
}
|
||||||
@@ -1416,6 +1561,24 @@ function refreshSettingsUI() {
|
|||||||
if (pathEl && window.api.getDataPath) {
|
if (pathEl && window.api.getDataPath) {
|
||||||
window.api.getDataPath().then((p) => { if (p) pathEl.textContent = p; });
|
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,成本可忽略)。
|
||||||
|
// 拉到后写进 <kbd>;HTML 里硬编码的占位「Ctrl+Shift+V」只是兜底。
|
||||||
|
refreshShortcutDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshRecentDesc() {
|
function refreshRecentDesc() {
|
||||||
@@ -1445,6 +1608,97 @@ function refreshUseCountDesc() {
|
|||||||
else desc.textContent = `已累计 ${total} 次使用(${countedPhrases} 条短语)`;
|
else desc.textContent = `已累计 ${total} 次使用(${countedPhrases} 条短语)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 把主进程的当前全局快捷键值刷到设置面板的 <kbd> 上。
|
||||||
|
// 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 写盘 + 重注册,更新 <kbd>
|
||||||
|
//
|
||||||
|
// 实际的 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() {
|
function initSettings() {
|
||||||
$$('#theme-segmented .seg-btn').forEach((btn) => {
|
$$('#theme-segmented .seg-btn').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
@@ -1542,6 +1796,38 @@ function initSettings() {
|
|||||||
showToast('已清空使用统计', 'success');
|
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() 拿到的列表,搜索 / 过滤器都会影响它。 */
|
// 通过 visiblePhrases() 拿到的列表,搜索 / 过滤器都会影响它。 */
|
||||||
function setupKeyboardShortcuts() {
|
function setupKeyboardShortcuts() {
|
||||||
document.addEventListener('keydown', (e) => {
|
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 modalOpen = !$('modal').classList.contains('hidden');
|
||||||
const promptOpen = !$('prompt-modal').classList.contains('hidden');
|
const promptOpen = !$('prompt-modal').classList.contains('hidden');
|
||||||
const settingsOpen = !$('settings-panel').classList.contains('hidden');
|
const settingsOpen = !$('settings-panel').classList.contains('hidden');
|
||||||
|
|||||||
@@ -601,6 +601,16 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
|
|||||||
box-shadow: 0 2px 0 var(--accent);
|
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 {
|
.group-item .name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1779,6 +1789,60 @@ body[data-theme="light"] .panel-backdrop {
|
|||||||
gap: 4px;
|
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 强化,避免和段落正文混在一起 */
|
取消默认下划线,用 accent 色 + hover 强化,避免和段落正文混在一起 */
|
||||||
.setting-link {
|
.setting-link {
|
||||||
|
|||||||
Reference in New Issue
Block a user