diff --git a/.gitignore b/.gitignore
index b947077..f48a701 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
node_modules/
dist/
+# 本地 UI 走查截图(开发产物,不入库)
+ui-check/
diff --git a/README.md b/README.md
index 6e118d9..ebfd903 100644
--- a/README.md
+++ b/README.md
@@ -110,7 +110,7 @@ npm run dist # 打包 NSIS 安装包到 dist/
└── README.md
```
-`npm run dist` 会用 `electron-builder` 走 NSIS 打包,产物落在 `dist/` 下(`快速粘贴 Setup x.y.z.exe` 安装包 + `win-unpacked/` 免安装目录),仓库已通过 `.gitignore` 忽略。
+`npm run dist` 会用 `electron-builder` 走 NSIS 打包,产物落在 `dist/` 下(`Quick Paste Setup x.y.z.exe` 安装包 + `win-unpacked/` 免安装目录),仓库已通过 `.gitignore` 忽略。
## 许可证
diff --git a/main.js b/main.js
index e36aa90..f780b51 100644
--- a/main.js
+++ b/main.js
@@ -98,13 +98,12 @@ const VK_CONTROL = 0x11;
const VK_V = 0x56;
const KEYEVENTF_KEYUP = 0x02;
-const isDev = !app.isPackaged;
// 注意:userData 路径依赖 app 初始化状态,在 whenReady 里再解析更稳妥
let userDataPath = null;
// 构建标记:用户跑应用时看到这个 banner 就知道是含本次「快捷键自定义」的最新代码。
// 调整 settings 面板里的快捷键录制 / 恢复默认相关问题时同步修改此字符串。
-const BUILD_TAG = 'shortcut-customize-2026-09-16';
+const BUILD_TAG = 'shortcut-customize-2026-09-20';
console.log(`[quick-paste] build: ${BUILD_TAG}`);
let mainWindow = null;
@@ -126,7 +125,6 @@ let startedAtLogin = false;
// 上次前台窗口句柄(用于粘贴前主动切回)
let lastFgHwnd = null;
let ourThreadId = 0;
-const ourPid = process.pid;
// 前台窗口轮询定时器
let fgTrackTimer = null;
@@ -370,7 +368,7 @@ function createWindow() {
minimizable: true,
skipTaskbar: false,
backgroundColor: '#08090A',
- title: '快速粘贴',
+ title: 'Quick Paste',
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
@@ -434,7 +432,7 @@ function createTray() {
}
tray = new Tray(trayIcon);
- tray.setToolTip('快速粘贴');
+ tray.setToolTip('Quick Paste');
const menu = Menu.buildFromTemplate([
{ label: '显示 / 隐藏', click: () => toggleWindow() },
@@ -564,9 +562,13 @@ function sendCtrlV() {
console.error('sendCtrlV error:', e);
}
} else if (process.platform === 'darwin') {
- spawn('osascript', ['-e', 'tell application "System Events" to keystroke "v" using command down']);
+ // spawn 失败(命令不存在等)会以异步 'error' 事件抛出,
+ // 没有监听器会变成未捕获异常把主进程拉崩,这里必须兜底。
+ spawn('osascript', ['-e', 'tell application "System Events" to keystroke "v" using command down'])
+ .on('error', (e) => console.error('sendCtrlV spawn error:', e));
} else {
- spawn('xdotool', ['key', 'ctrl+v']);
+ spawn('xdotool', ['key', 'ctrl+v'])
+ .on('error', (e) => console.error('sendCtrlV spawn error:', e));
}
}
@@ -757,7 +759,11 @@ ipcMain.handle('paste', (_, text, opts) => {
return { ok: true, hasTarget: true };
});
-ipcMain.handle('window:hide', () => { if (mainWindow) mainWindow.hide(); });
+ipcMain.handle('window:hide', () => {
+ // 与其他 handler 保持一致的防护:退出路径上窗口可能已销毁,
+ // hide() 会抛 "Object has been destroyed"
+ if (mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();
+});
// 切换「始终置顶」。渲染进程负责把偏好写进 data.settings(持久化),
// 主进程只负责:1) 立即应用;2) 后续 showFloating / pasteByHiding 路径拿这个状态。
ipcMain.handle('window:setAlwaysOnTop', (_, on) => {
@@ -904,6 +910,16 @@ ipcMain.handle('data:export', async () => {
// 渲染层获取当前已注册的全局快捷键(设置面板首次打开时显示)
ipcMain.handle('shortcuts:getGlobalToggle', () => currentGlobalShortcut);
+// 录制新快捷键期间临时挂起 / 恢复当前全局快捷键:
+// 用户想录入的组合如果恰好是「当前已注册的那个」(比如想重录 Ctrl+Shift+V),
+// 按键会被系统级钩子截获去切换窗口,渲染层永远收不到 keydown,录制 UI 卡死。
+// 进入录制态时 unregisterAll 挂起,退出录制态(含取消 / 关闭设置面板)时恢复注册。
+// 极端情况(录制中进程崩溃)也只是本次会话没有全局快捷键,重启后 whenReady 会重新注册。
+ipcMain.handle('shortcuts:suspendGlobalToggle', () => {
+ try { globalShortcut.unregisterAll(); return true; } catch { return false; }
+});
+ipcMain.handle('shortcuts:resumeGlobalToggle', () => applyGlobalShortcut(currentGlobalShortcut));
+
// 修改全局快捷键:渲染层在设置面板里点「录制」拿到新组合键后调用。
// 1. 校验格式:必须是非空字符串且至少有一个 `+`(modifier + key 的最小形态)
// 2. 落盘:复用 withOwnedSettings → saveData 链路,确保 windowBounds 跟着更新;
diff --git a/package.json b/package.json
index a0a0557..0739ca9 100644
--- a/package.json
+++ b/package.json
@@ -1,9 +1,10 @@
{
"name": "quick-paste",
"version": "1.0.0",
- "description": "悬浮快速粘贴工具 — 分组管理常用句子,点击即粘贴到当前焦点窗口",
+ "description": "Quick Paste — 悬浮粘贴工具,分组管理常用句子,点击即粘贴到当前焦点窗口",
+ "private": true,
"main": "main.js",
- "author": "",
+ "author": "关济寰",
"license": "MIT",
"scripts": {
"start": "node ./scripts/start.js",
@@ -19,7 +20,7 @@
},
"build": {
"appId": "com.example.quickpaste",
- "productName": "快速粘贴",
+ "productName": "Quick Paste",
"files": [
"main.js",
"preload.js",
@@ -44,7 +45,7 @@
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
- "shortcutName": "快速粘贴"
+ "shortcutName": "Quick Paste"
}
}
}
diff --git a/preload.js b/preload.js
index 1fbc463..fc3f85e 100644
--- a/preload.js
+++ b/preload.js
@@ -47,4 +47,7 @@ contextBridge.exposeInMainWorld('api', {
// 全局快捷键:读当前 / 改写并重注册(设置面板录制按钮调用)
getGlobalToggle: () => ipcRenderer.invoke('shortcuts:getGlobalToggle'),
setGlobalToggle: (acc) => ipcRenderer.invoke('shortcuts:setGlobalToggle', acc),
-});
\ No newline at end of file
+ // 录制期间临时挂起 / 恢复全局快捷键,避免想录入的按键被系统钩子截获去切窗口
+ suspendGlobalToggle: () => ipcRenderer.invoke('shortcuts:suspendGlobalToggle'),
+ resumeGlobalToggle: () => ipcRenderer.invoke('shortcuts:resumeGlobalToggle'),
+});
diff --git a/renderer/index.html b/renderer/index.html
index c80f934..c9c897b 100644
--- a/renderer/index.html
+++ b/renderer/index.html
@@ -4,7 +4,7 @@
-
快速粘贴
+ Quick Paste
@@ -122,9 +122,9 @@
-
+
- 快速粘贴
+ Quick Paste
@@ -186,7 +186,9 @@
-
+
+ /
+
@@ -238,13 +240,13 @@
-
@@ -402,7 +404,7 @@
粘贴时隐藏悬浮窗
-
关闭后悬浮窗会立即重新显示
+
粘贴瞬间短暂隐藏,约 0.2 秒后自动重新显示
diff --git a/renderer/renderer.js b/renderer/renderer.js
index d9d14f9..0afb16b 100644
--- a/renderer/renderer.js
+++ b/renderer/renderer.js
@@ -29,6 +29,11 @@ let prevDropTarget = null;
// 拖拽短语时悬停的目标分组:拖到分组上 ≠ 拖到短语间的缝隙,
// 用 phraseDropTargetGroup 单独追踪,避免与 drop-above/below 互相打架。
let phraseDropTargetGroup = null;
+// 拖拽期间列表可能滚动(拖到容器边缘时浏览器自动滚动、或用户滚轮),
+// 缓存的 rect 是视口坐标会随滚动整体平移——记录缓存时的 scrollTop,
+// dragover 里用滚动差值修正,不必重算 layout。
+let dragScrollEl = null;
+let dragScrollTopAtCache = 0;
function cacheDragRects(selector) {
dragRectCache = new Map();
@@ -36,6 +41,18 @@ function cacheDragRects(selector) {
for (const el of list) {
dragRectCache.set(el, el.getBoundingClientRect());
}
+ // li 是滚动容器 ul 的直接子元素,parentElement 即滚动容器
+ dragScrollEl = list.length ? list[0].parentElement : null;
+ dragScrollTopAtCache = dragScrollEl ? dragScrollEl.scrollTop : 0;
+}
+
+// 读缓存 rect 并按滚动差值修正:滚动 Δ 后元素在视口里整体上移 Δ,
+// 不修正的话插入线会高亮到错位的 li 上。
+function getDragRect(el) {
+ const rect = dragRectCache && dragRectCache.get(el);
+ if (!rect) return null;
+ const dy = dragScrollEl ? dragScrollEl.scrollTop - dragScrollTopAtCache : 0;
+ return dy ? { top: rect.top - dy, height: rect.height } : rect;
}
function scheduleDropIndicator(target, above) {
@@ -74,8 +91,8 @@ function clearDropIndicators() {
}
// 拖短语到分组栏时给目标分组挂一个高亮 class,区别于上下插入线(drop-above/below)。
-// 用 dragover 持续刷新目标,而不是依赖 dragleave —— dragover 每像素 mousemove 都会触发,
-// 跨过 li 边界时新 dragover 自然会清掉旧的高亮,不需要单独的 dragleave 处理。
+// li 之间的目标切换靠 dragover 持续刷新(每像素 mousemove 都会触发,跨过 li 边界时
+// 新 dragover 自然清掉旧高亮);拖出整个列表容器则由容器的 dragleave 兜底清理。
function schedulePhraseDropTarget(groupItem) {
if (phraseDropTargetGroup === groupItem) return;
if (phraseDropTargetGroup) phraseDropTargetGroup.classList.remove('phrase-drop-target');
@@ -134,6 +151,10 @@ function keyEventToAccelerator(e) {
'ArrowUp': 'Up', 'ArrowDown': 'Down', 'ArrowLeft': 'Left', 'ArrowRight': 'Right',
'Enter': 'Return', 'Escape': 'Esc',
' ': 'Space',
+ // '+' 是 Electron accelerator 的分隔符,直接拼会生成 "Ctrl+Shift++" 这种
+ // 非法字符串导致注册必败;Electron 用 'Plus' 表示这个键(标准美式键盘的 =/+ 键)。
+ // 同理 '=' 也统一映射为 Plus,避免不同键盘布局下注册行为不一致。
+ '+': 'Plus', '=': 'Plus',
};
if (map[key]) key = map[key];
else if (key.length === 1) key = key.toUpperCase();
@@ -407,9 +428,12 @@ function renderGroups() {
li.addEventListener('dragstart', (e) => {
dragSrcId = g.id;
dragSrcKind = 'group';
+ // 防御性重置:上一轮拖拽若因重渲染等原因没走到 dragend 清理,
+ // 这里把残留的指示状态清干净再开始新一轮。
+ clearDropIndicators();
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', g.id);
- // 拖拽过程中 list 不会重排,rect 在整个拖拽期间稳定。
+ // 拖拽过程中 list 不会重排,rect 在整个拖拽期间稳定(滚动由差值修正)。
// 在 dragstart 一次性算好所有 li 的 rect,dragover 直接读缓存,
// 避免每个 mousemove 都触发 getBoundingClientRect 强制 layout。
cacheDragRects('.group-item');
@@ -420,6 +444,7 @@ function renderGroups() {
clearDropIndicators();
dragSrcId = null; dragSrcKind = null;
dragRectCache = null;
+ dragScrollEl = null;
});
li.addEventListener('dragover', (e) => {
// 拖短语到分组:把当前分组高亮成「落点」,drop 时整条移过去。
@@ -428,6 +453,9 @@ function renderGroups() {
if (dragSrcKind === 'phrase') {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
+ // 从短语列表拖进分组栏:短语侧的插入线要清掉,
+ // 否则分组高亮和短语插入线会同时亮着(两种指示互斥)。
+ scheduleDropIndicator(null);
// 拖回原分组没有意义(movePhraseToGroup 内部会 no-op),不要亮起来骗用户。
// 通过 phraseIndex 反查源分组,避免每帧都遍历全库。
const owner = phraseIndex.get(dragSrcId);
@@ -438,15 +466,20 @@ function renderGroups() {
}
return;
}
- if (dragSrcKind !== 'group' || dragSrcId === g.id) return;
+ if (dragSrcKind !== 'group') return;
+ // 悬停在拖拽源本身:清掉上一个目标的插入线,否则离开上一个目标后线条残留。
+ if (dragSrcId === g.id) {
+ scheduleDropIndicator(null);
+ return;
+ }
e.preventDefault();
- const rect = dragRectCache && dragRectCache.get(li);
+ const rect = getDragRect(li);
if (!rect) return;
const above = (e.clientY - rect.top) < rect.height / 2;
scheduleDropIndicator(li, above);
});
- // drop-indicator 的清理统一在 dragend / dragleave 一起做(避免拖到 li 之间的缝隙时
- // 残留半边 drop-above/drop-below 状态)。这里只清自己——dragend 会兜底。
+ // drop-indicator 的清理统一在 dragend / 容器 dragleave 一起做(拖到列表外的区域时
+ // 由 dragleave 兜底,离开窗口或松手时由 dragend 兜底)。
li.addEventListener('drop', (e) => {
e.preventDefault();
// 拖短语到分组:把该短语移动到目标分组(顶部插入,与新建短语行为一致)。
@@ -585,10 +618,14 @@ function renderPhrases() {
illu.classList.add('run-in');
}
empty.classList.remove('hidden');
+ // 空列表也要藏掉:#phrase-list 是 flex:1,空着仍会占一半高度,
+ // 把 empty-hint(同为 flex:1)挤到下半屏居中,上半屏留一块空白
+ list.classList.add('hidden');
selectedPhraseIndex = -1;
return;
}
empty.classList.add('hidden');
+ list.classList.remove('hidden');
if (selectedPhraseIndex >= phrases.length) selectedPhraseIndex = phrases.length - 1;
@@ -631,6 +668,8 @@ function renderPhrases() {
li.addEventListener('dragstart', (e) => {
dragSrcId = p.id;
dragSrcKind = 'phrase';
+ // 防御性重置:同分组 dragstart,清掉上一轮可能残留的指示状态
+ clearDropIndicators();
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', p.id);
// 与分组拖拽共用 rect 缓存:避免每个 mousemove 都触发 layout
@@ -642,11 +681,19 @@ function renderPhrases() {
clearDropIndicators();
dragSrcId = null; dragSrcKind = null;
dragRectCache = null;
+ dragScrollEl = null;
});
li.addEventListener('dragover', (e) => {
- if (dragSrcKind !== 'phrase' || dragSrcId === p.id) return;
+ if (dragSrcKind !== 'phrase') return;
+ // 从分组栏拖回短语列表:分组高亮要清掉,避免与短语插入线同时显示。
+ clearPhraseDropTarget();
+ // 悬停在拖拽源本身:清掉上一个目标的插入线,否则线条残留。
+ if (dragSrcId === p.id) {
+ scheduleDropIndicator(null);
+ return;
+ }
e.preventDefault();
- const rect = dragRectCache && dragRectCache.get(li);
+ const rect = getDragRect(li);
if (!rect) return;
const above = (e.clientY - rect.top) < rect.height / 2;
scheduleDropIndicator(li, above);
@@ -667,7 +714,7 @@ function renderPhrases() {
{ label: '粘贴', icon: 'i-paste-icon', onClick: () => pastePhrase(p) },
{ label: '仅复制', icon: 'i-copy', onClick: () => copyPhrase(p) },
{ type: 'divider' },
- { label: p.fav ? '取消收藏' : '收藏', icon: 'i-pin', onClick: () => toggleFav(p) },
+ { label: p.fav ? '取消收藏' : '收藏', icon: 'i-star', onClick: () => toggleFav(p) },
{ label: '编辑', icon: 'i-edit', onClick: () => openPhraseModal(p) },
{ type: 'divider' },
{ label: '删除', icon: 'i-trash', danger: true, onClick: () => deletePhrase(p) },
@@ -769,6 +816,10 @@ const renderPhrasesDebounced = debounce(() => renderPhrases(), 80);
// actOnSelected 每次调用都重新创建数组。
const ALLOWED_CLICK_ACTIONS = ['paste', 'copy'];
+// 与主进程 MAX_PHRASE_CONTENT_LENGTH 对齐:导入路径绕过弹窗的 maxlength=2000 限制,
+// 不截断的话 >100KB 的短语能存进库,但粘贴/复制时又被主进程拒绝,行为不一致。
+const MAX_PHRASE_CONTENT_LENGTH = 100 * 1024;
+
// 搜索 query 的唯一写入入口:IME 上屏、英文输入、点清空按钮都走这里,
// 避免散落在多处的赋值 / 隐藏 toggle 出现状态不一致。
function setSearchQuery(q) {
@@ -785,7 +836,11 @@ function setSearchQuery(q) {
// 而搜索匹配仍然按 trim 后的内容来。
function syncSearchClearVisibility() {
const input = $('search-input');
- $('search-clear').classList.toggle('hidden', input.value.length === 0);
+ const hasText = input.value.length > 0;
+ $('search-clear').classList.toggle('hidden', !hasText);
+ // 快捷键提示(/)与清空按钮占据同一位置,互斥显示
+ const hint = $('search-hint-kbd');
+ if (hint) hint.classList.toggle('hidden', hasText);
}
function isAnyPanelOpen() {
@@ -1326,6 +1381,9 @@ async function clearGroupPhrases(group) {
}
async function exportDataNow() {
+ // 先把防抖队列里待保存的改动落盘:persist 有 200ms 防抖窗口,
+ // 不 flush 的话「刚改完立刻导出」会把最新改动漏出导出文件。
+ try { await persist.flush(); } catch {}
const res = await window.api.exportData();
if (res && res.ok) showToast(`已导出到:${res.filePath}`, 'success');
else if (res && res.canceled) return;
@@ -1360,7 +1418,7 @@ function mergeImported(imported) {
if (existing) {
// 修复:导入数据没带 title/fav 时保留现值,避免覆盖为默认值
existing.title = typeof ip.title === 'string' ? ip.title : existing.title;
- existing.content = ip.content;
+ existing.content = ip.content.slice(0, MAX_PHRASE_CONTENT_LENGTH);
existing.fav = typeof ip.fav === 'boolean' ? ip.fav : existing.fav;
updatedPhrases++;
} else {
@@ -1369,7 +1427,7 @@ function mergeImported(imported) {
const newPhrase = {
id: newPhraseId,
title: typeof ip.title === 'string' ? ip.title : '',
- content: ip.content,
+ content: ip.content.slice(0, MAX_PHRASE_CONTENT_LENGTH),
fav: !!ip.fav,
useCount: typeof ip.useCount === 'number' ? ip.useCount : 0,
};
@@ -1438,7 +1496,7 @@ async function applyImportedData(incoming) {
return {
id,
title: typeof p.title === 'string' ? p.title : '',
- content: p.content,
+ content: p.content.slice(0, MAX_PHRASE_CONTENT_LENGTH),
fav: !!p.fav,
// 导入文件带 useCount 用导入的,否则从本地旧数据里找回(同 id 的累积计数)
useCount: typeof p.useCount === 'number'
@@ -1636,6 +1694,11 @@ function startRecordShortcut() {
}
isRecordingShortcut = true;
recordInvalidShown = false;
+ // 挂起当前全局快捷键:否则想录入的组合恰好是已注册的那个时,
+ // 按键会被系统钩子截获去切换窗口,渲染层收不到 keydown,录制卡死。
+ if (window.api.suspendGlobalToggle) {
+ try { window.api.suspendGlobalToggle(); } catch {}
+ }
const display = document.querySelector('.shortcut-display');
if (display) display.classList.add('recording');
const btn = $('btn-record-shortcut');
@@ -1646,7 +1709,13 @@ function startRecordShortcut() {
}
function stopRecordShortcut() {
+ // 幂等:未在录制态时调用(如关闭设置面板的兜底路径)不重复发 IPC
+ if (!isRecordingShortcut) return;
isRecordingShortcut = false;
+ // 恢复录制前挂起的全局快捷键(重新注册 currentGlobalShortcut)
+ if (window.api.resumeGlobalToggle) {
+ try { window.api.resumeGlobalToggle(); } catch {}
+ }
const display = document.querySelector('.shortcut-display');
if (display) display.classList.remove('recording');
const btn = $('btn-record-shortcut');
@@ -2174,6 +2243,16 @@ async function init() {
$('phrase-list').addEventListener('click', onPhraseListClick);
+ // 拖出列表容器(移到容器 padding、列表外区域或窗口外)时清掉插入线和分组高亮,
+ // 否则最后一条指示会一直残留到 dragend。li 之间的移动 relatedTarget 仍在容器内,
+ // 不清——那种切换由 dragover 自己接力完成。
+ for (const listEl of [$('group-list'), $('phrase-list')]) {
+ listEl.addEventListener('dragleave', (e) => {
+ if (e.relatedTarget && listEl.contains(e.relatedTarget)) return;
+ clearDropIndicators();
+ });
+ }
+
// 侧栏顶部「收藏 / 最近」点击:切换过滤视图,
// 再点一次已激活的项则退回「全部」(侧栏没有「全部」按钮,需要这个出口)。
$$('.quick-item').forEach((el) => {
diff --git a/renderer/styles.css b/renderer/styles.css
index 53fdd74..aa1aab5 100644
--- a/renderer/styles.css
+++ b/renderer/styles.css
@@ -1,5 +1,5 @@
/* ============================================================
- 快速粘贴 — 精致深色主题(基于 Linear/Notion 设计语言)
+ Quick Paste — 精致深色主题(基于 Linear/Notion 设计语言)
============================================================ */
:root {
@@ -216,9 +216,9 @@ input:focus, textarea:focus {
height: 24px;
border-radius: 6px;
/* 图标本身已经有自己的配色和背景,不再叠渐变;
- 浅薄边/阴影保留下来,确保浅色主题下图标轮廓也能看见。 */
+ 浅薄边用主题边框变量(rgba(255,255,255,0.08) 在亮色主题下完全不可见)。 */
background: transparent;
- box-shadow: 0 0 0 1px rgba(255,255,255,0.08), 0 2px 8px var(--accent-glow);
+ box-shadow: 0 0 0 1px var(--border-default), 0 2px 8px var(--accent-glow);
}
/* 不再铺满 logo 容器,缩小到 18px 留 3px 内边距:和标题栏文字字号(13px)拉开一档,
@@ -229,8 +229,9 @@ input:focus, textarea:focus {
width: 18px;
height: 18px;
display: block;
- /* ICO 解码出来的位图默认走 nearest-neighbor 在缩小时会发糊,这里切到平滑。 */
- image-rendering: -webkit-optimize-contrast;
+ /* 注意:不要写 image-rendering: -webkit-optimize-contrast ——
+ 它在 Chromium 里等价于 crisp-edges(像素化),与直觉相反。
+ 默认值 auto 就是平滑缩放,18px 渲染 ICO 内置位图无需额外干预。 */
}
#titlebar .title-name {
@@ -291,8 +292,23 @@ input:focus, textarea:focus {
color: var(--accent-hover);
}
-body[data-theme="light"] .icon-btn.toggle-on:hover {
- background: var(--accent-hover-bg);
+/* 键盘焦点可见性:button 全局 outline:none 后,Tab 焦点需要明确的视觉反馈(WCAG 2.4.7)。
+ :focus-visible 只对键盘聚焦生效,鼠标点击不会出现多余的框。 */
+button:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+/* 链接(设置面板「关于」区的开发者链接等)同上面的理由 */
+a:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: var(--radius-sm);
+}
+
+/* 主题化选区颜色:输入框/文本域里选中文字时用 accent 色而不是浏览器默认蓝 */
+::selection {
+ background: var(--accent-mark-bg);
}
/* ===== 通用按钮 ===== */
@@ -451,7 +467,8 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
gap: 6px;
width: 100%;
padding: 6px 8px 6px 10px;
- margin-bottom: 1px;
+ /* 与 .group-item 的间距节奏保持一致(同为侧栏列表项) */
+ margin-bottom: 2px;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 13px;
@@ -512,7 +529,8 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
.quick-item.active .count {
color: var(--text-secondary);
- background: rgba(255, 255, 255, 0.07);
+ /* 同 .group-item.active .count:用主题变量,亮色主题下白 7% 不可见 */
+ background: var(--bg-active);
}
.sidebar-section {
@@ -538,15 +556,22 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
flex: 1;
list-style: none;
overflow-y: auto;
- padding: 0 4px 10px;
+ /* padding-top 至少留 2px:拖拽排序时第一条分组的 drop-above 插入线
+ * 是 box-shadow: 0 -2px,伸出 li 上边缘 2px,padding 为 0 会被 overflow 裁掉 */
+ padding: 4px 4px 10px;
}
.group-item {
position: relative;
padding: 6px 8px 6px 10px;
- margin-bottom: 1px;
+ /* margin-bottom 至少 2px:drop-below 插入线是 box-shadow: 0 2px,
+ * 间隙只有 1px 时线的下半截会被后绘制的兄弟项背景盖住,
+ * 导致 drop-below(1px) 比 drop-above(2px) 细一半 */
+ margin-bottom: 2px;
border-radius: var(--radius-md);
- cursor: pointer;
+ /* 与 .phrase-item 统一的拖拽光标:分组同样「点击选中 + 拖拽排序」,
+ 抓手光标暗示可拖动(短语项已建立这个先例) */
+ cursor: grab;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
@@ -554,10 +579,15 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
align-items: center;
gap: 6px;
transition: background var(--duration-fast) var(--ease),
- color var(--duration-fast) var(--ease);
+ color var(--duration-fast) var(--ease),
+ box-shadow var(--duration-fast) var(--ease);
border: 1px solid transparent;
}
+.group-item:active {
+ cursor: grabbing;
+}
+
.group-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
@@ -572,7 +602,9 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
.group-item.active::before {
content: '';
position: absolute;
- left: -6px;
+ /* 不能小于 -4px:#group-list 的 padding-left 是 4px,overflow 在 padding box 处裁剪,
+ -6px 时 3px 宽的指示条有 2px 在裁剪区外,只显示 1px */
+ left: -4px;
top: 50%;
transform: translateY(-50%) scaleY(0.6);
width: 3px;
@@ -633,7 +665,8 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
.group-item.active .count {
color: var(--text-secondary);
- background: rgba(255,255,255,0.07);
+ /* 用主题变量而不是 rgba(255,255,255,0.07):亮色主题下白 7% 的胶囊底不可见 */
+ background: var(--bg-active);
}
/* 侧栏底部:只放一个折叠按钮,靠右下对齐——尺寸与 add-bar 里的展开按钮一致 */
@@ -755,10 +788,25 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
.search-bar input {
width: 100%;
- padding: 6px 60px 6px 28px;
+ /* 右侧要避开同位置互斥的两个控件:清空按钮(18px)和「/」快捷键提示(约 22px),
+ 取较宽的提示计算:right:14px(距输入框右缘 2px)+ 22px + 8px 间隙 = 32px;
+ 之前的 60px 会让长文本提前 34px 开始横向滚动 */
+ padding: 6px 32px 6px 28px;
font-size: 12.5px;
}
+/* 空搜索框时显示在输入框右侧的快捷键提示,与清空按钮同位置互斥(JS 同步 hidden) */
+.search-hint-kbd {
+ position: absolute;
+ right: 14px;
+ top: 50%;
+ transform: translateY(-50%);
+ padding: 1px 7px;
+ font-size: 10.5px;
+ color: var(--text-tertiary);
+ pointer-events: none;
+}
+
.search-clear {
position: absolute;
right: 14px;
@@ -856,6 +904,16 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
box-shadow: 0 2px 0 var(--accent);
}
+/* 选中项作为放置目标时:box-shadow 不跨 class 叠加(后声明的 box-shadow 会整体覆盖),
+ * 需要显式把选中描边和插入线写在一起,否则拖过选中项时选中描边会闪没 */
+.phrase-item.selected.drop-above {
+ box-shadow: 0 -2px 0 var(--accent), 0 0 0 1px var(--accent-subtle);
+}
+
+.phrase-item.selected.drop-below {
+ box-shadow: 0 2px 0 var(--accent), 0 0 0 1px var(--accent-subtle);
+}
+
.phrase-top {
display: flex;
align-items: center;
@@ -924,7 +982,10 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
}
.phrase-item:hover .phrase-actions,
-.phrase-item.selected .phrase-actions {
+.phrase-item.selected .phrase-actions,
+.phrase-actions:focus-within {
+ /* focus-within:按钮虽然平时透明,但仍可被 Tab 聚焦——
+ 键盘焦点进入时必须显示,否则焦点落在不可见元素上 */
opacity: 1;
}
@@ -973,6 +1034,10 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
letter-spacing: -0.005em;
}
+/* 搜索高亮:highlightText 同时用于标题和预览,两处样式必须一致——
+ 只写 .phrase-preview 的话,标题里的 会落到浏览器默认样式(黄底黑字),
+ 深色主题下非常刺眼 */
+.phrase-title mark,
.phrase-preview mark {
background: rgba(251, 191, 36, 0.18);
color: var(--warning);
@@ -989,11 +1054,22 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
color: var(--text-tertiary);
}
+/* 收藏/最近视图下来源分组名可能很长(分组名最长 60 字符),
+ 不截断会撑破短语卡片的右边界 */
+.phrase-meta .dim {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
.phrase-meta .use-count {
display: inline-flex;
align-items: center;
gap: 3px;
font-variant-numeric: tabular-nums;
+ /* 空间不足时先截断分组名(.dim),「已用 N 次」始终保持完整 */
+ flex-shrink: 0;
}
/* ===== 空状态 ===== */
@@ -1002,9 +1078,12 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
display: flex;
flex-direction: column;
align-items: center;
- justify-content: center;
+ /* safe center:空间够时居中;窗口最小高 360 时内容(约 230px)会超出剩余高度(约 177px),
+ 普通 center 会从顶部裁掉插画、底部按钮也不可达——safe 让它退化为顶端对齐 + 可滚动 */
+ justify-content: safe center;
+ overflow-y: auto;
text-align: center;
- padding: 40px 24px;
+ padding: 24px 20px;
gap: 6px;
}
@@ -1145,11 +1224,18 @@ body[data-theme="light"] ::-webkit-scrollbar-thumb:hover {
.modal-card {
width: 360px;
+ /* 窗口最小 280px 宽:不限制 max-width 时卡片两边被视口裁掉,输入框贴边 */
+ max-width: calc(100vw - 24px);
background: var(--bg-overlay);
border: 1px solid var(--border-default);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
overflow: hidden;
+ /* 与 .panel-card 一致的高度约束:内容(如可纵向拉高的 textarea)超出窗口时
+ 由 .modal-body 滚动兜底,否则弹窗会被顶出窗口、底部按钮不可达 */
+ max-height: calc(100% - 32px);
+ display: flex;
+ flex-direction: column;
animation: modal-pop var(--duration-base) var(--ease);
}
@@ -1167,6 +1253,8 @@ body[data-theme="light"] .modal {
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.01em;
+ /* .modal-card 是 flex 列容器:内容超高时头部不压缩,由 body 滚动 */
+ flex-shrink: 0;
}
.modal-title-wrap {
@@ -1183,6 +1271,8 @@ body[data-theme="light"] .modal {
.modal-body {
padding: 16px;
+ /* .modal-card 限高后的滚动区(textarea 拉高 / 长提示消息时兜底) */
+ overflow-y: auto;
}
.field-label {
@@ -1269,6 +1359,11 @@ body[data-theme="light"] .modal {
transform: rotate(-45deg) translate(1px, -1px);
}
+.field-label-inline input[type="checkbox"]:focus-visible {
+ /* appearance:none 后默认焦点框也没了,用 accent 光晕补回键盘焦点反馈 */
+ box-shadow: 0 0 0 3px var(--accent-subtle);
+}
+
.field-label-inline .icon {
width: 12px;
height: 12px;
@@ -1364,6 +1459,13 @@ body[data-theme="light"] .modal {
color: var(--text-primary);
}
+.placeholder-hint summary:focus-visible {
+ /* summary 可键盘聚焦(Enter/Space 展开),list-style:none 后也要补焦点框 */
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: var(--radius-sm);
+}
+
.placeholder-hint .placeholder-list {
display: flex;
flex-direction: column;
@@ -1466,7 +1568,8 @@ body[data-theme="light"] .modal {
gap: 8px;
z-index: 200;
pointer-events: none;
- max-width: 320px;
+ /* 窗口最小 280px 宽:固定 320px 会让右对齐的 toast 超出左边缘 */
+ max-width: min(320px, calc(100vw - 32px));
}
.toast {
@@ -1680,6 +1783,8 @@ body[data-theme="light"] .panel-backdrop {
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
max-height: calc(100% - 32px);
+ /* 设置卡片宽 440px,窗口最小 280px —— 不限制宽度会在小窗口下两边被裁 */
+ max-width: calc(100vw - 24px);
display: flex;
flex-direction: column;
animation: modal-pop var(--duration-base) var(--ease);
@@ -1741,11 +1846,19 @@ body[data-theme="light"] .panel-backdrop {
.setting-row {
display: flex;
align-items: center;
- gap: 16px;
+ gap: 8px 16px;
padding: 10px 8px;
margin: 0 -8px;
border-radius: var(--radius-md);
transition: background var(--duration-fast) var(--ease);
+ /* 窄窗口(设置卡片被 max-width 收窄到 ~256px)时右侧控件换到第二行,
+ 而不是把左侧文字压成竖排单字列 */
+ flex-wrap: wrap;
+}
+
+/* 换行后控件仍然靠右,保持与宽屏一致的视觉锚点 */
+.setting-row > :last-child {
+ margin-left: auto;
}
.setting-row:hover {
@@ -1791,11 +1904,13 @@ body[data-theme="light"] .panel-backdrop {
/* ===== 快捷键录制行 ===== */
/* 视觉上与 .setting-actions 同宽,但 kbd 用衬底框表现「键帽」感,
- 两个按钮紧跟在后,整体一行不换。 */
+ 两个按钮紧跟在后;窄窗口下允许内部换行(按钮掉到 kbd 下一行)。 */
.shortcut-display {
display: inline-flex;
align-items: center;
gap: 6px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
}
/* 模仿 VSCode 等 IDE 里的键帽样式:
@@ -1950,6 +2065,12 @@ kbd {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
}
+.switch input:focus-visible + .switch-slider {
+ /* input 本体是 opacity:0 的,键盘焦点反馈要画在可视的滑块上 */
+ box-shadow: 0 0 0 3px var(--accent-subtle);
+ border-color: var(--accent);
+}
+
/* ===== Slider ===== */
.slider {
-webkit-appearance: none;