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

@@ -325,6 +325,37 @@
</button>
</div>
<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-title">外观</div>

View File

@@ -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;
// 上一帧高亮的 lidragover 从 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 → 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) {
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成本可忽略
// 拉到后写进 <kbd>HTML 里硬编码的占位「Ctrl+Shift+V」只是兜底。
refreshShortcutDisplay();
}
function refreshRecentDesc() {
@@ -1445,6 +1608,97 @@ function refreshUseCountDesc() {
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() {
$$('#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 分支退出录制。
// 恢复默认直接发默认 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() 拿到的列表,搜索 / 过滤器都会影响它。 */
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');

View File

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