update
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
# 本地 UI 走查截图(开发产物,不入库)
|
||||||
|
ui-check/
|
||||||
|
|||||||
@@ -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
32
main.js
@@ -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 跟着更新;
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
});
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user