This commit is contained in:
2026-09-20 23:49:24 +08:00
parent b27197505d
commit 66d75919bb
8 changed files with 283 additions and 59 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
node_modules/ node_modules/
dist/ dist/
# 本地 UI 走查截图(开发产物,不入库)
ui-check/

View File

@@ -110,7 +110,7 @@ npm run dist # 打包 NSIS 安装包到 dist/
└── README.md └── 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` 忽略。
## 许可证 ## 许可证

32
main.js
View File

@@ -98,13 +98,12 @@ const VK_CONTROL = 0x11;
const VK_V = 0x56; const VK_V = 0x56;
const KEYEVENTF_KEYUP = 0x02; const KEYEVENTF_KEYUP = 0x02;
const isDev = !app.isPackaged;
// 注意:userData 路径依赖 app 初始化状态,在 whenReady 里再解析更稳妥 // 注意:userData 路径依赖 app 初始化状态,在 whenReady 里再解析更稳妥
let userDataPath = null; let userDataPath = null;
// 构建标记:用户跑应用时看到这个 banner 就知道是含本次「快捷键自定义」的最新代码。 // 构建标记:用户跑应用时看到这个 banner 就知道是含本次「快捷键自定义」的最新代码。
// 调整 settings 面板里的快捷键录制 / 恢复默认相关问题时同步修改此字符串。 // 调整 settings 面板里的快捷键录制 / 恢复默认相关问题时同步修改此字符串。
const BUILD_TAG = 'shortcut-customize-2026-09-16'; const BUILD_TAG = 'shortcut-customize-2026-09-20';
console.log(`[quick-paste] build: ${BUILD_TAG}`); console.log(`[quick-paste] build: ${BUILD_TAG}`);
let mainWindow = null; let mainWindow = null;
@@ -126,7 +125,6 @@ let startedAtLogin = false;
// 上次前台窗口句柄(用于粘贴前主动切回) // 上次前台窗口句柄(用于粘贴前主动切回)
let lastFgHwnd = null; let lastFgHwnd = null;
let ourThreadId = 0; let ourThreadId = 0;
const ourPid = process.pid;
// 前台窗口轮询定时器 // 前台窗口轮询定时器
let fgTrackTimer = null; let fgTrackTimer = null;
@@ -370,7 +368,7 @@ function createWindow() {
minimizable: true, minimizable: true,
skipTaskbar: false, skipTaskbar: false,
backgroundColor: '#08090A', backgroundColor: '#08090A',
title: '快速粘贴', title: 'Quick Paste',
show: false, show: false,
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
@@ -434,7 +432,7 @@ function createTray() {
} }
tray = new Tray(trayIcon); tray = new Tray(trayIcon);
tray.setToolTip('快速粘贴'); tray.setToolTip('Quick Paste');
const menu = Menu.buildFromTemplate([ const menu = Menu.buildFromTemplate([
{ label: '显示 / 隐藏', click: () => toggleWindow() }, { label: '显示 / 隐藏', click: () => toggleWindow() },
@@ -564,9 +562,13 @@ function sendCtrlV() {
console.error('sendCtrlV error:', e); console.error('sendCtrlV error:', e);
} }
} else if (process.platform === 'darwin') { } 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 { } 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 }; 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(持久化), // 切换「始终置顶」。渲染进程负责把偏好写进 data.settings(持久化),
// 主进程只负责:1) 立即应用;2) 后续 showFloating / pasteByHiding 路径拿这个状态。 // 主进程只负责:1) 立即应用;2) 后续 showFloating / pasteByHiding 路径拿这个状态。
ipcMain.handle('window:setAlwaysOnTop', (_, on) => { ipcMain.handle('window:setAlwaysOnTop', (_, on) => {
@@ -904,6 +910,16 @@ ipcMain.handle('data:export', async () => {
// 渲染层获取当前已注册的全局快捷键(设置面板首次打开时显示) // 渲染层获取当前已注册的全局快捷键(设置面板首次打开时显示)
ipcMain.handle('shortcuts:getGlobalToggle', () => currentGlobalShortcut); 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 的最小形态) // 1. 校验格式:必须是非空字符串且至少有一个 `+`(modifier + key 的最小形态)
// 2. 落盘:复用 withOwnedSettings → saveData 链路,确保 windowBounds 跟着更新; // 2. 落盘:复用 withOwnedSettings → saveData 链路,确保 windowBounds 跟着更新;

View File

@@ -1,9 +1,10 @@
{ {
"name": "quick-paste", "name": "quick-paste",
"version": "1.0.0", "version": "1.0.0",
"description": "悬浮快速粘贴工具 — 分组管理常用句子,点击即粘贴到当前焦点窗口", "description": "Quick Paste — 悬浮粘贴工具,分组管理常用句子,点击即粘贴到当前焦点窗口",
"private": true,
"main": "main.js", "main": "main.js",
"author": "", "author": "关济寰",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"start": "node ./scripts/start.js", "start": "node ./scripts/start.js",
@@ -19,7 +20,7 @@
}, },
"build": { "build": {
"appId": "com.example.quickpaste", "appId": "com.example.quickpaste",
"productName": "快速粘贴", "productName": "Quick Paste",
"files": [ "files": [
"main.js", "main.js",
"preload.js", "preload.js",
@@ -44,7 +45,7 @@
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true, "createDesktopShortcut": true,
"createStartMenuShortcut": true, "createStartMenuShortcut": true,
"shortcutName": "快速粘贴" "shortcutName": "Quick Paste"
} }
} }
} }

View File

@@ -47,4 +47,7 @@ contextBridge.exposeInMainWorld('api', {
// 全局快捷键:读当前 / 改写并重注册(设置面板录制按钮调用) // 全局快捷键:读当前 / 改写并重注册(设置面板录制按钮调用)
getGlobalToggle: () => ipcRenderer.invoke('shortcuts:getGlobalToggle'), getGlobalToggle: () => ipcRenderer.invoke('shortcuts:getGlobalToggle'),
setGlobalToggle: (acc) => ipcRenderer.invoke('shortcuts:setGlobalToggle', acc), setGlobalToggle: (acc) => ipcRenderer.invoke('shortcuts:setGlobalToggle', acc),
}); // 录制期间临时挂起 / 恢复全局快捷键,避免想录入的按键被系统钩子截获去切窗口
suspendGlobalToggle: () => ipcRenderer.invoke('shortcuts:suspendGlobalToggle'),
resumeGlobalToggle: () => ipcRenderer.invoke('shortcuts:resumeGlobalToggle'),
});

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" <meta http-equiv="Content-Security-Policy"
content="default-src 'self' file:; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: file:;" /> content="default-src 'self' file:; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: file:;" />
<title>快速粘贴</title> <title>Quick Paste</title>
<!-- 浏览器标签页 / 收藏夹 / 桌面快捷方式都会用到的 favicon: <!-- 浏览器标签页 / 收藏夹 / 桌面快捷方式都会用到的 favicon:
用项目根的 icon.ico,Chromium 内核会自动挑合适尺寸。 --> 用项目根的 icon.ico,Chromium 内核会自动挑合适尺寸。 -->
<link rel="icon" type="image/x-icon" href="../icon.ico" /> <link rel="icon" type="image/x-icon" href="../icon.ico" />
@@ -122,9 +122,9 @@
<span class="logo"> <span class="logo">
<!-- 任务栏/Alt-Tab 已经在用 icon.ico,标题栏左上角的徽标也用同一份图标保持一致。 <!-- 任务栏/Alt-Tab 已经在用 icon.ico,标题栏左上角的徽标也用同一份图标保持一致。
ICO 内置多尺寸,Chromium 会按容器尺寸自动挑最接近的那一档渲染。 --> ICO 内置多尺寸,Chromium 会按容器尺寸自动挑最接近的那一档渲染。 -->
<img class="logo-img" src="../icon.ico" alt="快速粘贴" /> <img class="logo-img" src="../icon.ico" alt="Quick Paste" />
</span> </span>
<span class="title-name">快速粘贴</span> <span class="title-name">Quick Paste</span>
</div> </div>
<div class="title-actions"> <div class="title-actions">
<button id="btn-pin-top" class="icon-btn toggle-on" title="始终置顶(点击切换)" aria-label="切换始终置顶" aria-pressed="true"> <button id="btn-pin-top" class="icon-btn toggle-on" title="始终置顶(点击切换)" aria-label="切换始终置顶" aria-pressed="true">
@@ -133,7 +133,7 @@
<button id="btn-settings" class="icon-btn" title="设置"> <button id="btn-settings" class="icon-btn" title="设置">
<svg class="icon"><use href="#i-settings"/></svg> <svg class="icon"><use href="#i-settings"/></svg>
</button> </button>
<button id="btn-hide" class="icon-btn" title="关闭" aria-label="关闭(最小化到托盘)"> <button id="btn-hide" class="icon-btn" title="隐藏到托盘" aria-label="隐藏到托盘">
<svg class="icon"><use href="#i-x"/></svg> <svg class="icon"><use href="#i-x"/></svg>
</button> </button>
</div> </div>
@@ -186,7 +186,9 @@
<div class="search-bar"> <div class="search-bar">
<svg class="icon search-icon"><use href="#i-search"/></svg> <svg class="icon search-icon"><use href="#i-search"/></svg>
<input id="search-input" type="text" placeholder="搜索短语…" autocomplete="off" spellcheck="false" /> <input id="search-input" type="text" placeholder="搜索短语…" autocomplete="off" spellcheck="false" />
<button id="search-clear" class="search-clear hidden" title="清空"> <!-- 空搜索框时显示快捷键提示,有内容时让位给清空按钮(两者同位置互斥) -->
<kbd class="search-hint-kbd" id="search-hint-kbd">/</kbd>
<button id="search-clear" class="search-clear hidden" title="清空" aria-label="清空搜索">
<svg class="icon"><use href="#i-x"/></svg> <svg class="icon"><use href="#i-x"/></svg>
</button> </button>
</div> </div>
@@ -238,13 +240,13 @@
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<label class="field-label"> <label class="field-label" for="phrase-title">
<span>标题</span> <span>标题</span>
<span class="field-counter" id="title-counter">0 / 40</span> <span class="field-counter" id="title-counter">0 / 40</span>
</label> </label>
<input id="phrase-title" type="text" placeholder="比如:客服开场白" maxlength="40" /> <input id="phrase-title" type="text" placeholder="比如:客服开场白" maxlength="40" />
<label class="field-label"> <label class="field-label" for="phrase-content">
<span>内容</span> <span>内容</span>
<span class="field-counter" id="content-counter">0 / 2000</span> <span class="field-counter" id="content-counter">0 / 2000</span>
</label> </label>
@@ -320,7 +322,7 @@
<svg class="icon"><use href="#i-settings"/></svg> <svg class="icon"><use href="#i-settings"/></svg>
设置 设置
</span> </span>
<button class="icon-btn" data-close> <button class="icon-btn" data-close title="关闭" aria-label="关闭设置面板">
<svg class="icon"><use href="#i-x"/></svg> <svg class="icon"><use href="#i-x"/></svg>
</button> </button>
</div> </div>
@@ -402,7 +404,7 @@
<div class="setting-row"> <div class="setting-row">
<div class="setting-row-text"> <div class="setting-row-text">
<div class="setting-label">粘贴时隐藏悬浮窗</div> <div class="setting-label">粘贴时隐藏悬浮窗</div>
<div class="setting-desc">关闭后悬浮窗会立即重新显示</div> <div class="setting-desc">粘贴瞬间短暂隐藏,约 0.2 秒后自动重新显示</div>
</div> </div>
<label class="switch"> <label class="switch">
<input type="checkbox" id="hide-on-paste-toggle" /> <input type="checkbox" id="hide-on-paste-toggle" />

View File

@@ -29,6 +29,11 @@ let prevDropTarget = null;
// 拖拽短语时悬停的目标分组:拖到分组上 ≠ 拖到短语间的缝隙, // 拖拽短语时悬停的目标分组:拖到分组上 ≠ 拖到短语间的缝隙,
// 用 phraseDropTargetGroup 单独追踪,避免与 drop-above/below 互相打架。 // 用 phraseDropTargetGroup 单独追踪,避免与 drop-above/below 互相打架。
let phraseDropTargetGroup = null; let phraseDropTargetGroup = null;
// 拖拽期间列表可能滚动(拖到容器边缘时浏览器自动滚动、或用户滚轮),
// 缓存的 rect 是视口坐标会随滚动整体平移——记录缓存时的 scrollTop,
// dragover 里用滚动差值修正,不必重算 layout。
let dragScrollEl = null;
let dragScrollTopAtCache = 0;
function cacheDragRects(selector) { function cacheDragRects(selector) {
dragRectCache = new Map(); dragRectCache = new Map();
@@ -36,6 +41,18 @@ function cacheDragRects(selector) {
for (const el of list) { for (const el of list) {
dragRectCache.set(el, el.getBoundingClientRect()); 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) { function scheduleDropIndicator(target, above) {
@@ -74,8 +91,8 @@ function clearDropIndicators() {
} }
// 拖短语到分组栏时给目标分组挂一个高亮 class,区别于上下插入线(drop-above/below)。 // 拖短语到分组栏时给目标分组挂一个高亮 class,区别于上下插入线(drop-above/below)。
// 用 dragover 持续刷新目标,而不是依赖 dragleave —— dragover 每像素 mousemove 都会触发, // li 之间的目标切换靠 dragover 持续刷新(每像素 mousemove 都会触发,跨过 li 边界时
// 跨过 li 边界时新 dragover 自然会清掉旧的高亮,不需要单独的 dragleave 处理。 // 新 dragover 自然清掉旧高亮);拖出整个列表容器则由容器的 dragleave 兜底清理。
function schedulePhraseDropTarget(groupItem) { function schedulePhraseDropTarget(groupItem) {
if (phraseDropTargetGroup === groupItem) return; if (phraseDropTargetGroup === groupItem) return;
if (phraseDropTargetGroup) phraseDropTargetGroup.classList.remove('phrase-drop-target'); if (phraseDropTargetGroup) phraseDropTargetGroup.classList.remove('phrase-drop-target');
@@ -134,6 +151,10 @@ function keyEventToAccelerator(e) {
'ArrowUp': 'Up', 'ArrowDown': 'Down', 'ArrowLeft': 'Left', 'ArrowRight': 'Right', 'ArrowUp': 'Up', 'ArrowDown': 'Down', 'ArrowLeft': 'Left', 'ArrowRight': 'Right',
'Enter': 'Return', 'Escape': 'Esc', 'Enter': 'Return', 'Escape': 'Esc',
' ': 'Space', ' ': 'Space',
// '+' 是 Electron accelerator 的分隔符,直接拼会生成 "Ctrl+Shift++" 这种
// 非法字符串导致注册必败;Electron 用 'Plus' 表示这个键(标准美式键盘的 =/+ 键)。
// 同理 '=' 也统一映射为 Plus,避免不同键盘布局下注册行为不一致。
'+': 'Plus', '=': 'Plus',
}; };
if (map[key]) key = map[key]; if (map[key]) key = map[key];
else if (key.length === 1) key = key.toUpperCase(); else if (key.length === 1) key = key.toUpperCase();
@@ -407,9 +428,12 @@ function renderGroups() {
li.addEventListener('dragstart', (e) => { li.addEventListener('dragstart', (e) => {
dragSrcId = g.id; dragSrcId = g.id;
dragSrcKind = 'group'; dragSrcKind = 'group';
// 防御性重置:上一轮拖拽若因重渲染等原因没走到 dragend 清理,
// 这里把残留的指示状态清干净再开始新一轮。
clearDropIndicators();
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', g.id); e.dataTransfer.setData('text/plain', g.id);
// 拖拽过程中 list 不会重排,rect 在整个拖拽期间稳定。 // 拖拽过程中 list 不会重排,rect 在整个拖拽期间稳定(滚动由差值修正)。
// 在 dragstart 一次性算好所有 li 的 rect,dragover 直接读缓存, // 在 dragstart 一次性算好所有 li 的 rect,dragover 直接读缓存,
// 避免每个 mousemove 都触发 getBoundingClientRect 强制 layout。 // 避免每个 mousemove 都触发 getBoundingClientRect 强制 layout。
cacheDragRects('.group-item'); cacheDragRects('.group-item');
@@ -420,6 +444,7 @@ function renderGroups() {
clearDropIndicators(); clearDropIndicators();
dragSrcId = null; dragSrcKind = null; dragSrcId = null; dragSrcKind = null;
dragRectCache = null; dragRectCache = null;
dragScrollEl = null;
}); });
li.addEventListener('dragover', (e) => { li.addEventListener('dragover', (e) => {
// 拖短语到分组:把当前分组高亮成「落点」,drop 时整条移过去。 // 拖短语到分组:把当前分组高亮成「落点」,drop 时整条移过去。
@@ -428,6 +453,9 @@ function renderGroups() {
if (dragSrcKind === 'phrase') { if (dragSrcKind === 'phrase') {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move';
// 从短语列表拖进分组栏:短语侧的插入线要清掉,
// 否则分组高亮和短语插入线会同时亮着(两种指示互斥)。
scheduleDropIndicator(null);
// 拖回原分组没有意义(movePhraseToGroup 内部会 no-op),不要亮起来骗用户。 // 拖回原分组没有意义(movePhraseToGroup 内部会 no-op),不要亮起来骗用户。
// 通过 phraseIndex 反查源分组,避免每帧都遍历全库。 // 通过 phraseIndex 反查源分组,避免每帧都遍历全库。
const owner = phraseIndex.get(dragSrcId); const owner = phraseIndex.get(dragSrcId);
@@ -438,15 +466,20 @@ function renderGroups() {
} }
return; return;
} }
if (dragSrcKind !== 'group' || dragSrcId === g.id) return; if (dragSrcKind !== 'group') return;
// 悬停在拖拽源本身:清掉上一个目标的插入线,否则离开上一个目标后线条残留。
if (dragSrcId === g.id) {
scheduleDropIndicator(null);
return;
}
e.preventDefault(); e.preventDefault();
const rect = dragRectCache && dragRectCache.get(li); const rect = getDragRect(li);
if (!rect) return; if (!rect) return;
const above = (e.clientY - rect.top) < rect.height / 2; const above = (e.clientY - rect.top) < rect.height / 2;
scheduleDropIndicator(li, above); scheduleDropIndicator(li, above);
}); });
// drop-indicator 的清理统一在 dragend / dragleave 一起做(避免拖到 li 之间的缝隙时 // drop-indicator 的清理统一在 dragend / 容器 dragleave 一起做(拖到列表外的区域时
// 残留半边 drop-above/drop-below 状态)。这里只清自己——dragend 会兜底。 // 由 dragleave 兜底,离开窗口或松手时由 dragend 兜底)。
li.addEventListener('drop', (e) => { li.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
// 拖短语到分组:把该短语移动到目标分组(顶部插入,与新建短语行为一致)。 // 拖短语到分组:把该短语移动到目标分组(顶部插入,与新建短语行为一致)。
@@ -585,10 +618,14 @@ function renderPhrases() {
illu.classList.add('run-in'); illu.classList.add('run-in');
} }
empty.classList.remove('hidden'); empty.classList.remove('hidden');
// 空列表也要藏掉:#phrase-list 是 flex:1,空着仍会占一半高度,
// 把 empty-hint(同为 flex:1)挤到下半屏居中,上半屏留一块空白
list.classList.add('hidden');
selectedPhraseIndex = -1; selectedPhraseIndex = -1;
return; return;
} }
empty.classList.add('hidden'); empty.classList.add('hidden');
list.classList.remove('hidden');
if (selectedPhraseIndex >= phrases.length) selectedPhraseIndex = phrases.length - 1; if (selectedPhraseIndex >= phrases.length) selectedPhraseIndex = phrases.length - 1;
@@ -631,6 +668,8 @@ function renderPhrases() {
li.addEventListener('dragstart', (e) => { li.addEventListener('dragstart', (e) => {
dragSrcId = p.id; dragSrcId = p.id;
dragSrcKind = 'phrase'; dragSrcKind = 'phrase';
// 防御性重置:同分组 dragstart,清掉上一轮可能残留的指示状态
clearDropIndicators();
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', p.id); e.dataTransfer.setData('text/plain', p.id);
// 与分组拖拽共用 rect 缓存:避免每个 mousemove 都触发 layout // 与分组拖拽共用 rect 缓存:避免每个 mousemove 都触发 layout
@@ -642,11 +681,19 @@ function renderPhrases() {
clearDropIndicators(); clearDropIndicators();
dragSrcId = null; dragSrcKind = null; dragSrcId = null; dragSrcKind = null;
dragRectCache = null; dragRectCache = null;
dragScrollEl = null;
}); });
li.addEventListener('dragover', (e) => { 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(); e.preventDefault();
const rect = dragRectCache && dragRectCache.get(li); const rect = getDragRect(li);
if (!rect) return; if (!rect) return;
const above = (e.clientY - rect.top) < rect.height / 2; const above = (e.clientY - rect.top) < rect.height / 2;
scheduleDropIndicator(li, above); scheduleDropIndicator(li, above);
@@ -667,7 +714,7 @@ function renderPhrases() {
{ label: '粘贴', icon: 'i-paste-icon', onClick: () => pastePhrase(p) }, { label: '粘贴', icon: 'i-paste-icon', onClick: () => pastePhrase(p) },
{ label: '仅复制', icon: 'i-copy', onClick: () => copyPhrase(p) }, { label: '仅复制', icon: 'i-copy', onClick: () => copyPhrase(p) },
{ type: 'divider' }, { 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) }, { label: '编辑', icon: 'i-edit', onClick: () => openPhraseModal(p) },
{ type: 'divider' }, { type: 'divider' },
{ label: '删除', icon: 'i-trash', danger: true, onClick: () => deletePhrase(p) }, { label: '删除', icon: 'i-trash', danger: true, onClick: () => deletePhrase(p) },
@@ -769,6 +816,10 @@ const renderPhrasesDebounced = debounce(() => renderPhrases(), 80);
// actOnSelected 每次调用都重新创建数组。 // actOnSelected 每次调用都重新创建数组。
const ALLOWED_CLICK_ACTIONS = ['paste', 'copy']; const ALLOWED_CLICK_ACTIONS = ['paste', 'copy'];
// 与主进程 MAX_PHRASE_CONTENT_LENGTH 对齐:导入路径绕过弹窗的 maxlength=2000 限制,
// 不截断的话 >100KB 的短语能存进库,但粘贴/复制时又被主进程拒绝,行为不一致。
const MAX_PHRASE_CONTENT_LENGTH = 100 * 1024;
// 搜索 query 的唯一写入入口:IME 上屏、英文输入、点清空按钮都走这里, // 搜索 query 的唯一写入入口:IME 上屏、英文输入、点清空按钮都走这里,
// 避免散落在多处的赋值 / 隐藏 toggle 出现状态不一致。 // 避免散落在多处的赋值 / 隐藏 toggle 出现状态不一致。
function setSearchQuery(q) { function setSearchQuery(q) {
@@ -785,7 +836,11 @@ function setSearchQuery(q) {
// 而搜索匹配仍然按 trim 后的内容来。 // 而搜索匹配仍然按 trim 后的内容来。
function syncSearchClearVisibility() { function syncSearchClearVisibility() {
const input = $('search-input'); 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() { function isAnyPanelOpen() {
@@ -1326,6 +1381,9 @@ async function clearGroupPhrases(group) {
} }
async function exportDataNow() { async function exportDataNow() {
// 先把防抖队列里待保存的改动落盘:persist 有 200ms 防抖窗口,
// 不 flush 的话「刚改完立刻导出」会把最新改动漏出导出文件。
try { await persist.flush(); } catch {}
const res = await window.api.exportData(); const res = await window.api.exportData();
if (res && res.ok) showToast(`已导出到:${res.filePath}`, 'success'); if (res && res.ok) showToast(`已导出到:${res.filePath}`, 'success');
else if (res && res.canceled) return; else if (res && res.canceled) return;
@@ -1360,7 +1418,7 @@ function mergeImported(imported) {
if (existing) { if (existing) {
// 修复:导入数据没带 title/fav 时保留现值,避免覆盖为默认值 // 修复:导入数据没带 title/fav 时保留现值,避免覆盖为默认值
existing.title = typeof ip.title === 'string' ? ip.title : existing.title; 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; existing.fav = typeof ip.fav === 'boolean' ? ip.fav : existing.fav;
updatedPhrases++; updatedPhrases++;
} else { } else {
@@ -1369,7 +1427,7 @@ function mergeImported(imported) {
const newPhrase = { const newPhrase = {
id: newPhraseId, id: newPhraseId,
title: typeof ip.title === 'string' ? ip.title : '', title: typeof ip.title === 'string' ? ip.title : '',
content: ip.content, content: ip.content.slice(0, MAX_PHRASE_CONTENT_LENGTH),
fav: !!ip.fav, fav: !!ip.fav,
useCount: typeof ip.useCount === 'number' ? ip.useCount : 0, useCount: typeof ip.useCount === 'number' ? ip.useCount : 0,
}; };
@@ -1438,7 +1496,7 @@ async function applyImportedData(incoming) {
return { return {
id, id,
title: typeof p.title === 'string' ? p.title : '', title: typeof p.title === 'string' ? p.title : '',
content: p.content, content: p.content.slice(0, MAX_PHRASE_CONTENT_LENGTH),
fav: !!p.fav, fav: !!p.fav,
// 导入文件带 useCount 用导入的,否则从本地旧数据里找回(同 id 的累积计数) // 导入文件带 useCount 用导入的,否则从本地旧数据里找回(同 id 的累积计数)
useCount: typeof p.useCount === 'number' useCount: typeof p.useCount === 'number'
@@ -1636,6 +1694,11 @@ function startRecordShortcut() {
} }
isRecordingShortcut = true; isRecordingShortcut = true;
recordInvalidShown = false; recordInvalidShown = false;
// 挂起当前全局快捷键:否则想录入的组合恰好是已注册的那个时,
// 按键会被系统钩子截获去切换窗口,渲染层收不到 keydown,录制卡死。
if (window.api.suspendGlobalToggle) {
try { window.api.suspendGlobalToggle(); } catch {}
}
const display = document.querySelector('.shortcut-display'); const display = document.querySelector('.shortcut-display');
if (display) display.classList.add('recording'); if (display) display.classList.add('recording');
const btn = $('btn-record-shortcut'); const btn = $('btn-record-shortcut');
@@ -1646,7 +1709,13 @@ function startRecordShortcut() {
} }
function stopRecordShortcut() { function stopRecordShortcut() {
// 幂等:未在录制态时调用(如关闭设置面板的兜底路径)不重复发 IPC
if (!isRecordingShortcut) return;
isRecordingShortcut = false; isRecordingShortcut = false;
// 恢复录制前挂起的全局快捷键(重新注册 currentGlobalShortcut)
if (window.api.resumeGlobalToggle) {
try { window.api.resumeGlobalToggle(); } catch {}
}
const display = document.querySelector('.shortcut-display'); const display = document.querySelector('.shortcut-display');
if (display) display.classList.remove('recording'); if (display) display.classList.remove('recording');
const btn = $('btn-record-shortcut'); const btn = $('btn-record-shortcut');
@@ -2174,6 +2243,16 @@ async function init() {
$('phrase-list').addEventListener('click', onPhraseListClick); $('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) => { $$('.quick-item').forEach((el) => {

View File

@@ -1,5 +1,5 @@
/* ============================================================ /* ============================================================
快速粘贴 — 精致深色主题(基于 Linear/Notion 设计语言) Quick Paste — 精致深色主题(基于 Linear/Notion 设计语言)
============================================================ */ ============================================================ */
:root { :root {
@@ -216,9 +216,9 @@ input:focus, textarea:focus {
height: 24px; height: 24px;
border-radius: 6px; border-radius: 6px;
/* 图标本身已经有自己的配色和背景,不再叠渐变; /* 图标本身已经有自己的配色和背景,不再叠渐变;
浅薄边/阴影保留下来,确保浅色主题下图标轮廓也能看见。 */ 浅薄边用主题边框变量(rgba(255,255,255,0.08) 在亮色主题下完全不可见)。 */
background: transparent; 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)拉开一档, /* 不再铺满 logo 容器,缩小到 18px 留 3px 内边距:和标题栏文字字号(13px)拉开一档,
@@ -229,8 +229,9 @@ input:focus, textarea:focus {
width: 18px; width: 18px;
height: 18px; height: 18px;
display: block; display: block;
/* ICO 解码出来的位图默认走 nearest-neighbor 在缩小时会发糊,这里切到平滑。 */ /* 注意:不要写 image-rendering: -webkit-optimize-contrast ——
image-rendering: -webkit-optimize-contrast; 它在 Chromium 里等价于 crisp-edges(像素化),与直觉相反。
默认值 auto 就是平滑缩放,18px 渲染 ICO 内置位图无需额外干预。 */
} }
#titlebar .title-name { #titlebar .title-name {
@@ -291,8 +292,23 @@ input:focus, textarea:focus {
color: var(--accent-hover); color: var(--accent-hover);
} }
body[data-theme="light"] .icon-btn.toggle-on:hover { /* 键盘焦点可见性:button 全局 outline:none 后,Tab 焦点需要明确的视觉反馈(WCAG 2.4.7)。
background: var(--accent-hover-bg); :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; gap: 6px;
width: 100%; width: 100%;
padding: 6px 8px 6px 10px; padding: 6px 8px 6px 10px;
margin-bottom: 1px; /* 与 .group-item 的间距节奏保持一致(同为侧栏列表项) */
margin-bottom: 2px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
@@ -512,7 +529,8 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
.quick-item.active .count { .quick-item.active .count {
color: var(--text-secondary); color: var(--text-secondary);
background: rgba(255, 255, 255, 0.07); /* 同 .group-item.active .count:用主题变量,亮色主题下白 7% 不可见 */
background: var(--bg-active);
} }
.sidebar-section { .sidebar-section {
@@ -538,15 +556,22 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
flex: 1; flex: 1;
list-style: none; list-style: none;
overflow-y: auto; 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 { .group-item {
position: relative; position: relative;
padding: 6px 8px 6px 10px; 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); border-radius: var(--radius-md);
cursor: pointer; /* 与 .phrase-item 统一的拖拽光标:分组同样「点击选中 + 拖拽排序」,
抓手光标暗示可拖动(短语项已建立这个先例) */
cursor: grab;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
@@ -554,10 +579,15 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
align-items: center; align-items: center;
gap: 6px; gap: 6px;
transition: background var(--duration-fast) var(--ease), 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; border: 1px solid transparent;
} }
.group-item:active {
cursor: grabbing;
}
.group-item:hover { .group-item:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
@@ -572,7 +602,9 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
.group-item.active::before { .group-item.active::before {
content: ''; content: '';
position: absolute; position: absolute;
left: -6px; /* 不能小于 -4px:#group-list 的 padding-left 是 4px,overflow 在 padding box 处裁剪,
-6px 时 3px 宽的指示条有 2px 在裁剪区外,只显示 1px */
left: -4px;
top: 50%; top: 50%;
transform: translateY(-50%) scaleY(0.6); transform: translateY(-50%) scaleY(0.6);
width: 3px; width: 3px;
@@ -633,7 +665,8 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
.group-item.active .count { .group-item.active .count {
color: var(--text-secondary); color: var(--text-secondary);
background: rgba(255,255,255,0.07); /* 用主题变量而不是 rgba(255,255,255,0.07):亮色主题下白 7% 的胶囊底不可见 */
background: var(--bg-active);
} }
/* 侧栏底部:只放一个折叠按钮,靠右下对齐——尺寸与 add-bar 里的展开按钮一致 */ /* 侧栏底部:只放一个折叠按钮,靠右下对齐——尺寸与 add-bar 里的展开按钮一致 */
@@ -755,10 +788,25 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
.search-bar input { .search-bar input {
width: 100%; 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; 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 { .search-clear {
position: absolute; position: absolute;
right: 14px; right: 14px;
@@ -856,6 +904,16 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
box-shadow: 0 2px 0 var(--accent); 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 { .phrase-top {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -924,7 +982,10 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
} }
.phrase-item:hover .phrase-actions, .phrase-item:hover .phrase-actions,
.phrase-item.selected .phrase-actions { .phrase-item.selected .phrase-actions,
.phrase-actions:focus-within {
/* focus-within:按钮虽然平时透明,但仍可被 Tab 聚焦——
键盘焦点进入时必须显示,否则焦点落在不可见元素上 */
opacity: 1; opacity: 1;
} }
@@ -973,6 +1034,10 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
letter-spacing: -0.005em; letter-spacing: -0.005em;
} }
/* 搜索高亮:highlightText 同时用于标题和预览,两处样式必须一致——
只写 .phrase-preview 的话,标题里的 <mark> 会落到浏览器默认样式(黄底黑字),
深色主题下非常刺眼 */
.phrase-title mark,
.phrase-preview mark { .phrase-preview mark {
background: rgba(251, 191, 36, 0.18); background: rgba(251, 191, 36, 0.18);
color: var(--warning); color: var(--warning);
@@ -989,11 +1054,22 @@ body[data-theme="light"] .icon-btn.toggle-on:hover {
color: var(--text-tertiary); color: var(--text-tertiary);
} }
/* 收藏/最近视图下来源分组名可能很长(分组名最长 60 字符),
不截断会撑破短语卡片的右边界 */
.phrase-meta .dim {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.phrase-meta .use-count { .phrase-meta .use-count {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 3px; gap: 3px;
font-variant-numeric: tabular-nums; 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; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; /* safe center:空间够时居中;窗口最小高 360 时内容(约 230px)会超出剩余高度(约 177px),
普通 center 会从顶部裁掉插画、底部按钮也不可达——safe 让它退化为顶端对齐 + 可滚动 */
justify-content: safe center;
overflow-y: auto;
text-align: center; text-align: center;
padding: 40px 24px; padding: 24px 20px;
gap: 6px; gap: 6px;
} }
@@ -1145,11 +1224,18 @@ body[data-theme="light"] ::-webkit-scrollbar-thumb:hover {
.modal-card { .modal-card {
width: 360px; width: 360px;
/* 窗口最小 280px 宽:不限制 max-width 时卡片两边被视口裁掉,输入框贴边 */
max-width: calc(100vw - 24px);
background: var(--bg-overlay); background: var(--bg-overlay);
border: 1px solid var(--border-default); border: 1px solid var(--border-default);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
overflow: hidden; 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); animation: modal-pop var(--duration-base) var(--ease);
} }
@@ -1167,6 +1253,8 @@ body[data-theme="light"] .modal {
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
letter-spacing: -0.01em; letter-spacing: -0.01em;
/* .modal-card 是 flex 列容器:内容超高时头部不压缩,由 body 滚动 */
flex-shrink: 0;
} }
.modal-title-wrap { .modal-title-wrap {
@@ -1183,6 +1271,8 @@ body[data-theme="light"] .modal {
.modal-body { .modal-body {
padding: 16px; padding: 16px;
/* .modal-card 限高后的滚动区(textarea 拉高 / 长提示消息时兜底) */
overflow-y: auto;
} }
.field-label { .field-label {
@@ -1269,6 +1359,11 @@ body[data-theme="light"] .modal {
transform: rotate(-45deg) translate(1px, -1px); 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 { .field-label-inline .icon {
width: 12px; width: 12px;
height: 12px; height: 12px;
@@ -1364,6 +1459,13 @@ body[data-theme="light"] .modal {
color: var(--text-primary); 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 { .placeholder-hint .placeholder-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1466,7 +1568,8 @@ body[data-theme="light"] .modal {
gap: 8px; gap: 8px;
z-index: 200; z-index: 200;
pointer-events: none; pointer-events: none;
max-width: 320px; /* 窗口最小 280px 宽:固定 320px 会让右对齐的 toast 超出左边缘 */
max-width: min(320px, calc(100vw - 32px));
} }
.toast { .toast {
@@ -1680,6 +1783,8 @@ body[data-theme="light"] .panel-backdrop {
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
max-height: calc(100% - 32px); max-height: calc(100% - 32px);
/* 设置卡片宽 440px,窗口最小 280px —— 不限制宽度会在小窗口下两边被裁 */
max-width: calc(100vw - 24px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
animation: modal-pop var(--duration-base) var(--ease); animation: modal-pop var(--duration-base) var(--ease);
@@ -1741,11 +1846,19 @@ body[data-theme="light"] .panel-backdrop {
.setting-row { .setting-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 8px 16px;
padding: 10px 8px; padding: 10px 8px;
margin: 0 -8px; margin: 0 -8px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
transition: background var(--duration-fast) var(--ease); transition: background var(--duration-fast) var(--ease);
/* 窄窗口(设置卡片被 max-width 收窄到 ~256px)时右侧控件换到第二行,
而不是把左侧文字压成竖排单字列 */
flex-wrap: wrap;
}
/* 换行后控件仍然靠右,保持与宽屏一致的视觉锚点 */
.setting-row > :last-child {
margin-left: auto;
} }
.setting-row:hover { .setting-row:hover {
@@ -1791,11 +1904,13 @@ body[data-theme="light"] .panel-backdrop {
/* ===== 快捷键录制行 ===== */ /* ===== 快捷键录制行 ===== */
/* 视觉上与 .setting-actions 同宽,但 kbd 用衬底框表现「键帽」感, /* 视觉上与 .setting-actions 同宽,但 kbd 用衬底框表现「键帽」感,
两个按钮紧跟在后,整体一行不换。 */ 两个按钮紧跟在后;窄窗口下允许内部换行(按钮掉到 kbd 下一行)。 */
.shortcut-display { .shortcut-display {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
} }
/* 模仿 VSCode 等 IDE 里的键帽样式: /* 模仿 VSCode 等 IDE 里的键帽样式:
@@ -1950,6 +2065,12 @@ kbd {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15); 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 ===== */
.slider { .slider {
-webkit-appearance: none; -webkit-appearance: none;