This commit is contained in:
2026-09-16 21:19:32 +08:00
parent b178c86ac7
commit b27197505d
6 changed files with 613 additions and 40 deletions

View File

@@ -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
View File

@@ -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', () => {

View File

@@ -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),
}); });

View File

@@ -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>

View File

@@ -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;
// 上一帧高亮的 lidragover 从 A 切到 B 时A 上的 class 要清掉否则留下残影。 // 上一帧高亮的 lidragover 从 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 → CtrlWindows 永远显示 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~F24DOM 直接给 '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 分支退出录制。
// 恢复默认直接发默认 acceleratorIPC 那边会做去重 + 重注册。
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');

View File

@@ -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 {