This commit is contained in:
2026-09-20 23:35:03 +08:00
parent 941ce4baab
commit 388c700996
19 changed files with 393 additions and 71 deletions

10
.gitignore vendored
View File

@@ -1,2 +1,10 @@
node_modules/
dist/
dist/
data/
*.log
.superpowers/
docs/superpowers/
.playwright-mcp/
.remember/
final-hover-card.png
*.pyc

View File

@@ -1,4 +1,4 @@
# 剪贴板
# Clipboard
基于 Electron 的轻量剪贴板管理器,灵感来自 [Ditto](https://github.com/sabrogden/Ditto)。

View File

@@ -102,7 +102,9 @@ function get(db, favId) {
function getImage(db, favId) {
const row = stmts(db).getImage.get(favId);
if (!row || !row.image) return null;
return Buffer.from(row.image).toString('base64');
// row.image 原生就是 Buffer(better-sqlite3 BLOB 列)—— 与 main.js
// clips:get-image 同一优化:Buffer.from 是一次无谓的 2MB 拷贝。
return row.image.toString('base64');
}
// 原始行(含 image BLOB),仅供主进程"复制收藏到系统剪贴板"用。

View File

@@ -70,6 +70,15 @@ class KeyHelper {
this.ps.stderr.on('data', (chunk) => {
console.error('[keyhelper stderr]', chunk.toString());
});
// spawn 失败(powershell.exe 缺失 / 被策略禁用)走 'error' 事件而不是
// 抛异常 —— 不挂监听的话 Node 把它升级成 uncaughtException 打挂主进程。
// 'error' 之后 'exit' 可能不再触发,重连也要在这里做;
// _reconnect 有 _reconnectTimer 去重,两个事件都到也只重连一次。
this.ps.on('error', (err) => {
console.error('[keyhelper] powershell spawn error:', err.message);
this._rejectAll(err);
this._reconnect();
});
this.ps.on('exit', (code) => {
console.error('[keyhelper] powershell exited with code', code);
this._rejectAll(new Error('keyhelper exited'));

View File

@@ -21,7 +21,7 @@ function capImage(buf) {
/**
* 把图片 buffer 按 SOFT_CAP_BYTES 收纳:超出的图压成 RESIZE_WIDTH 宽。
* 三处共用:pollClipboard / bringFavoriteIntoHistory。
* 两处共用:pollClipboard / bringFavoriteIntoHistory(见 main.js)。
*
* 返回 { buf, preview },preview 由 capImage 给出(无图/小图 = null),
* 给数据库的 preview 列用。

63
main.js
View File

@@ -41,7 +41,7 @@ const { createEchoSuppress } = require('./lib/echo-suppress');
// ---------- 配置 ----------
const APP_NAME = '剪贴板';
const APP_NAME = 'Clipboard';
let appConfig = configMod.loadConfig();
let MAX_ITEMS = appConfig.maxItems; // 运行时可变
@@ -102,7 +102,10 @@ const listCols = (p = '') =>
const orderBy = (p = '') => `${p}created_at DESC, ${p}id DESC`;
function openDb() {
fs.mkdirSync(appConfig.dataDir, { recursive: true });
// 必须 mkdir effectiveDataDir()(含 session 兜底),不是 appConfig.dataDir:
// 用户配置的目录不可用(盘符缺失)时启动已切到 sessionDataDir,这里再去
// mkdir 那个坏目录会直接抛 ENOENT —— 兜底逻辑被这一行废掉,照样弹"启动失败"。
fs.mkdirSync(effectiveDataDir(), { recursive: true });
ftsAvailable = null; // 换库后能力可能不同,重新探测
clipCount = null; // 行数缓存绑定旧库,换库后必须回源重查
db = new Database(getDbPath());
@@ -144,7 +147,6 @@ function openDb() {
stmt.getMeta = db.prepare(`SELECT ${listCols()} FROM clips WHERE id = ?`);
stmt.getImage = db.prepare(`SELECT image FROM clips WHERE id = ?`);
stmt.moveToTop = db.prepare(`UPDATE clips SET top_at = ?, created_at = ? WHERE id = ?`);
stmt.clearTop = db.prepare(`UPDATE clips SET top_at = NULL WHERE id = ?`);
stmt.delete = db.prepare(`DELETE FROM clips WHERE id = ?`);
stmt.clearNonTop = db.prepare(`DELETE FROM clips WHERE top_at IS NULL`);
// 之前每次入库都现场 prepare 一遍,热路径上白白编译
@@ -167,7 +169,7 @@ function openDb() {
}
function openFavDb() {
fs.mkdirSync(appConfig.dataDir, { recursive: true });
fs.mkdirSync(effectiveDataDir(), { recursive: true }); // 同 openDb:兜底目录
favStmtCache.clear(); // 语句绑定在旧连接上,换库后必须丢掉
favDb = favorites.open(getFavDbPath());
}
@@ -287,15 +289,12 @@ function pollClipboard() {
}
// 文本变化检测(规则见 lib/echo-suppress.js)。
// readText 抛错时(text === null)**不调 observe** —— 否则 last 会被改成
// '' 触发 'empty' 直接退出,但剪贴板上可能有图片;图片分支被一并跳过,
// 用户刚复制的那张图就静默丢失。等下次 tick readText 恢复时再正常观察。
// readText 抛错时(text === null)**不调 observe** —— 一次读取失败不等于
// 剪贴板被清空:把基线 last 改成 '' 后,下个 tick 读回原来的文本会判
// 'new' 把旧内容重复入一次库。跳过本次观察,等 readText 恢复再正常分发;
// 图片分支不受影响,照常探测。
if (text !== null) {
const verdict = textSuppress.observe(text);
if (verdict === 'empty') {
// 剪贴板被清空或原本就空,跳过(不消化回声标记)
return;
}
if (verdict === 'suppressed') {
devLog('[poll] suppressed (was our own paste)');
return;
@@ -318,6 +317,12 @@ function pollClipboard() {
}
return;
}
// verdict === 'empty':剪贴板上没有文本 —— 可能被清空,也可能上面是
// 一张图(readText 读不到图片)。之前这里直接 return:从「复制文本」
// 切到「复制图片」时,图片要晚一个 tick 才入库(等下一次 observe('')
// 判 unchanged 才轮到图片分支)。落空继续走图片分支即可 —— 真为空时
// shouldReadImage 守卫零开销拦掉。「empty 不消化回声标记」的规则不变
// (由 lib/echo-suppress.js 内部保证),这里只是不再提前退出。
// verdict === 'unchanged' —— 文本没变,继续看图片分支
} else {
devLog('[poll] readText skipped (last read failed), still probing image branch');
@@ -455,7 +460,7 @@ function applyPollMs(ms) {
startPolling(); // startPolling 内部会先 stopPolling
}
// 窗口置顶:不落盘,重启回到默认(开启)——与本功能开发前的行为一致
// 窗口置顶:落盘持久化,重启后保持用户的选择(与其他设置项一致)。
let alwaysOnTop = true;
function applyAlwaysOnTop(on) {
@@ -464,6 +469,14 @@ function applyAlwaysOnTop(on) {
try { mainWindow.setAlwaysOnTop(alwaysOnTop); }
catch (e) { devWarn('[window] setAlwaysOnTop failed:', e.message); }
}
// 值真的变了才写盘:启动时的同步调用(applyAlwaysOnTop(appConfig.alwaysOnTop))
// 值与磁盘一致,靠这个判断避免每次启动都白写一次 config.json。
// 写盘失败只打日志 —— 置顶本会话已生效,重启还原成旧值,比设置项失败轻。
if (appConfig.alwaysOnTop !== alwaysOnTop) {
appConfig.alwaysOnTop = alwaysOnTop;
try { configMod.saveConfig(appConfig); }
catch (e) { console.error('[window] save alwaysOnTop failed:', e.message); }
}
return alwaysOnTop;
}
@@ -485,7 +498,10 @@ function createFramelessWindow({ width, height, file, onClose, minimizable = fal
// 窗口又变回置顶,而按钮状态仍显示"已关闭"。
alwaysOnTop,
// 背景色跟随主题:之前写死 #FFFFFF,5/6 的主题首帧会闪白。
backgroundColor: configMod.themeBgColor(appConfig.theme),
// 必须用 effectiveTheme 而不是 raw theme —— 「跟随系统」开着时 raw 主题
// 和实际显示的不一致(如 raw 是暗色但系统是浅色),首帧会闪一下错色。
backgroundColor: configMod.themeBgColor(
configMod.effectiveTheme(appConfig, nativeTheme.shouldUseDarkColors)),
// OS 级窗口图标:alt-tab、任务管理器进程图标、Win+Tab 任务视图。
// 工厂被主窗口和设置窗口共用,一行设置两窗生效。
icon: path.join(__dirname, 'icon.ico'),
@@ -1116,7 +1132,13 @@ function registerIpc() {
return applyThemeChange({ followSystem: followSystem === true });
});
ipcMain.handle('settings:get', () => ({ ...appConfig }));
ipcMain.handle('settings:get', () => ({
...appConfig,
// 实际在用的数据目录(含 session 兜底)。settings:open-dir 开的就是它;
// 设置页只显示 appConfig.dataDir 的话,兜底状态下用户看到的目录和
// 「打开数据目录」进去的目录对不上。
dataDirEffective: effectiveDataDir(),
}));
ipcMain.handle('settings:set', (_e, { key, value }) => {
appConfig = applyPatch(appConfig, { key, value });
@@ -1393,13 +1415,9 @@ if (!gotLock) {
registerIpc();
devLog('[main] ipc registered');
// 启动时把模块级 alwaysOnTop 同步到磁盘配置 —— 之前硬编码 true,
// 用户在主窗口点了图钉关闭置顶之后重启,配置里若有 ever-set 的
// false 状态也会被无视。应用层只暴露主窗口图钉一个入口,
// 所以这个字段大多停留在默认值;只是把"读 config、推模块变量"
// 的链路补上,避免和磁盘分叉。
// 必须在 createMainWindow 之前调用:模块级 alwaysOnTop 默认 true,
// 窗口构造时会读它,晚了就要先建后改(多一次 setAlwaysOnTop 调用)。
// 启动时从磁盘配置恢复置顶开关(applyAlwaysOnTop 在切换时已落盘,
// 见该函数注释)。必须在 createMainWindow 之前调用:窗口构造时会
// 读模块级 alwaysOnTop,晚了就要先建后改(多一次 setAlwaysOnTop)。
applyAlwaysOnTop(appConfig.alwaysOnTop);
createMainWindow(); // 内部已经挂好自适应轮询的 show/hide 监听
@@ -1436,6 +1454,9 @@ if (!gotLock) {
? '\n\n可能是数据目录不可用(盘符缺失、权限不足等)。\n请到「设置 → 通用 → 数据保存位置」换一个位置,或检查该盘符/文件夹是否可访问。'
: '';
dialog.showErrorBox(APP_NAME, '启动失败:' + e.message + hint);
// 启动失败时窗口 / 托盘 / 轮询全没起来,window-all-closed 又拦着不退
// —— 不主动退出的话进程变成无界面僵尸,只能去任务管理器里杀。
app.quit();
}
});

View File

@@ -1,7 +1,7 @@
{
"name": "clipboard-app",
"version": "1.0.0",
"description": "剪贴板 — 简洁的剪贴板管理器(Electron 实现)",
"description": "Clipboard — 简洁的剪贴板管理器(Electron 实现)",
"main": "main.js",
"scripts": {
"start": "set \"ELECTRON_RUN_AS_NODE=\" && electron .",
@@ -23,7 +23,7 @@
},
"build": {
"appId": "com.clipboard.app",
"productName": "剪贴板",
"productName": "Clipboard",
"files": [
"main.js",
"config.js",

View File

@@ -3,9 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" />
<title>剪贴板历史</title>
<link rel="stylesheet" href="themes/fonts.css" />
<title>Clipboard</title>
<!-- tokens 必须在 fonts 之前:两者都定义 --font-ui/--font-mono,同特异性
后加载的赢。fonts 在后才能让 Inter/JetBrains Mono 进字体栈(tokens
注释「fonts.css 会覆盖这两个」的前提);fonts 加载失败时 tokens 的
系统字体栈自然兜底。 -->
<link rel="stylesheet" href="themes/tokens.css" />
<link rel="stylesheet" href="themes/fonts.css" />
<link rel="stylesheet" href="themes/themes.css" />
<link rel="stylesheet" href="themes/accents.css" />
<link rel="stylesheet" href="styles.css" />
@@ -16,7 +20,7 @@
<!-- 拖动区 -->
<header id="drag-bar">
<img id="app-icon" alt="" />
<h1 id="title">剪贴板历史</h1>
<h1 id="title">Clipboard</h1>
<span id="ipc-status" class="ipc-dot" title="IPC 订阅状态" aria-label="IPC 状态"></span>
<span class="spacer"></span>
<button id="refresh-btn" class="icon-btn" title="刷新 (F5)" aria-label="刷新">
@@ -96,6 +100,11 @@
</footer>
</div>
<!-- 图片大图预览:点缩略图弹出,Esc / 点击任意处关闭 -->
<div id="lightbox" class="hidden" role="dialog" aria-modal="true" aria-label="图片大图预览" tabindex="-1">
<img id="lightbox-img" alt="图片大图预览" />
</div>
<!-- 单条卡片模板 -->
<template id="card-tpl">
<li class="card" tabindex="0">

View File

@@ -41,6 +41,8 @@ const $ipcDot = document.getElementById('ipc-status');
const $tabs = document.getElementById('tabs');
const $historyCount = document.getElementById('history-count');
const $favCount = document.getElementById('fav-count');
const $lightbox = document.getElementById('lightbox');
const $lightboxImg = document.getElementById('lightbox-img');
// ---------- 状态 ----------
@@ -131,6 +133,46 @@ async function loadImageInto(el) {
}
}
// ---------- 图片大图预览 ----------
// 数据复用懒加载的 FIFO 缓存:缩略图显示过就一定在缓存里,弹窗大多零 IPC;
// 没命中(缓存被挤出)才再走一次 getImage/favGetImage 拉取。
let lightboxReturnFocus = null; // 打开前的焦点元素,关闭后还回去
async function openLightbox(imgEl) {
const id = Number(imgEl.dataset.imgId);
const fromFav = imgEl.dataset.imgFav === '1';
const key = (fromFav ? 'f' : 'c') + id;
let b64 = fifoCache.get(key);
if (b64 === undefined) {
try {
b64 = await (fromFav ? favGetImage(id) : getImage(id));
if (b64) cacheImage(key, b64);
} catch (e) {
console.error('[renderer] lightbox getImage failed:', e);
return;
}
}
if (!b64) return; // 数据缺失就不弹一个空框
lightboxReturnFocus = document.activeElement;
$lightboxImg.src = `data:image/png;base64,${b64}`;
$lightbox.classList.remove('hidden');
$lightbox.focus(); // 焦点上遮罩:Esc 有着落,也防 Tab 掉进背后列表
}
function closeLightbox() {
$lightbox.classList.add('hidden');
// 清掉 src 释放解码后的大图内存;缓存里那份 base64 还在,重开零 IPC
$lightboxImg.removeAttribute('src');
if (lightboxReturnFocus && lightboxReturnFocus.isConnected) {
lightboxReturnFocus.focus();
}
lightboxReturnFocus = null;
}
// 点遮罩任意处(含大图本身)关闭
$lightbox.addEventListener('click', closeLightbox);
// ---------- 渲染 ----------
// 单张卡片:纯 DOM 构造,任何状态都不在这里改。
@@ -195,15 +237,36 @@ function buildCard(c, i) {
if (c.type === 'text') {
textEl.textContent = c.text || c.preview || '';
imgEl.classList.add('hidden');
} else {
} else if (c.hasImage) {
textEl.classList.add('hidden');
imgEl.classList.remove('hidden');
imgEl.removeAttribute('src');
if (c.hasImage) {
imgEl.dataset.imgId = String(c.id);
imgEl.dataset.imgFav = currentTab === 'favorites' ? '1' : '0';
imgObserver.observe(imgEl);
}
imgEl.dataset.imgId = String(c.id);
imgEl.dataset.imgFav = currentTab === 'favorites' ? '1' : '0';
imgObserver.observe(imgEl);
// 缩略图可点击/可聚焦:单击或 Enter/空格弹出大图预览。
// stopPropagation 防冒泡到卡片 onclick(选中)和全局 keydown(复制)。
imgEl.tabIndex = 0;
imgEl.setAttribute('role', 'button');
imgEl.title = '点击放大预览';
imgEl.alt = '图片预览(点击放大)';
imgEl.addEventListener('click', (e) => { e.stopPropagation(); openLightbox(imgEl); });
// dblclick 也会冒泡到卡片的 ondblclick(复制并写剪贴板)—— 双击缩略图
// 的用户意图是放大,不能顺手把剪贴板覆盖了。click 的 stopPropagation
// 只管 click 事件,拦不住 dblclick,得单独拦。
imgEl.addEventListener('dblclick', (e) => e.stopPropagation());
imgEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
openLightbox(imgEl);
}
});
} else {
// 图片条目但数据缺失(DB 里 image 列为 NULL 的极端情况)—— 退回文本
// 预览('[图片]' / '[图片] 800×600'),别让卡片渲染成一个空的破图框。
textEl.textContent = c.preview || '[图片]';
imgEl.classList.add('hidden');
}
// 点击选中按下标 —— 但必须点击时再从 DOM 现取:prependClip 增量插入
@@ -290,13 +353,20 @@ function prependClip(clip) {
const newCard = buildCard(clip, 0);
$list.insertBefore(newCard, $cards[0] || null); // null = 追加到末尾
$cards.unshift(newCard);
// Slot badge 重排:每次 prepend 之后,只有原来第 8 位(现在第 9 位)的卡需要
// 从「显示 9」变成「隐藏」,其它卡(0-8 已可见、9+ 已隐藏)都不动。
// 全表 1000 张卡都跑一遍是浪费。
const boundary = $cards[9]; // 第 10 位(下标 9):需要被隐藏
if (boundary) {
const slotEl = boundary.querySelector('.slot');
slotEl.classList.add('hidden');
// Slot badge 重排:新卡插到最顶后,原来第 1-9 位的卡整体后移一位,
// 徽标数字必须跟着 +1 —— 只藏第 10 位会留下两个「1」,且数字键 1-9
// 按实际下标复制(见 keydown 的 /^[1-9]$/ 分支),显示序号与按键错位。
// 受影响的只有前 10 张卡(下标 1-8 改数字、下标 9 隐藏),最多 9 次
// DOM 更新,不需要全表重扫。新卡(下标 0)的「1」由 buildCard(clip, 0) 写好。
const renumberEnd = Math.min($cards.length, 10);
for (let i = 1; i < renumberEnd; i++) {
const slotEl = $cards[i].querySelector('.slot');
if (i < 9) {
slotEl.textContent = String(i + 1);
slotEl.classList.remove('hidden');
} else {
slotEl.classList.add('hidden');
}
}
// 选中态:强制选中第一条(最新复制的条目),并滚到顶部让 Enter / 数字键 1 复制它。
// select() 内部会遍历 $cards 把旧 'selected' 清掉、把 $cards[0] 点亮,
@@ -462,6 +532,16 @@ document.addEventListener('keydown', (e) => {
reload();
return;
}
// 大图预览打开时模态拦截:Esc 关弹窗,其余键全部吞掉 —— 不然
// Enter/数字键会穿过弹窗触发列表复制,方向键会在背后移动选中项。
// (放在 F5 之后:刷新是全局的,弹窗开着也允许;Ctrl+L 则被拦下。)
if (!$lightbox.classList.contains('hidden')) {
if (e.key !== 'Escape') return; // 吞掉 Esc 之外的所有键
e.preventDefault();
closeLightbox();
return;
}
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'l') {
// Ctrl+L 聚焦搜索框。组合键完整匹配避免吃掉 Ctrl+Shift+L 之类。
e.preventDefault();

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" />
<title>设置</title>
<link rel="stylesheet" href="themes/fonts.css" />
<link rel="stylesheet" href="themes/tokens.css" />
<link rel="stylesheet" href="themes/fonts.css" />
<link rel="stylesheet" href="themes/themes.css" />
<link rel="stylesheet" href="themes/accents.css" />
<link rel="stylesheet" href="styles.css" />
@@ -61,7 +61,7 @@
<path d="M9 11h6M9 14.5h6M9 18h3"/>
</svg>
</div>
<div class="about-name">剪贴板</div>
<div class="about-name">Clipboard</div>
<div class="about-tagline">简洁的剪贴板管理器</div>
</div>
<div class="setting-row column">

View File

@@ -40,7 +40,10 @@
const { get, set, clearHistory, openDir, chooseDir, openExternal } = window.settingsApi;
const $auto=document.getElementById('opt-autolaunch'),$hotkey=document.getElementById('opt-hotkey'),$max=document.getElementById('opt-maxitems'),$display=document.getElementById('opt-displaylimit'),$poll=document.getElementById('opt-pollms'),$dir=document.getElementById('opt-datadir'),$change=document.getElementById('btn-change-dir'),$open=document.getElementById('btn-open-dir'),$clear=document.getElementById('btn-clear'),$close=document.getElementById('close-btn'),$status=document.getElementById('status-left');
function flash(msg){$status.textContent=msg;setTimeout(()=>$status.textContent='就绪',2000)}
// 连发两条提示时,第一条的 2 秒定时器会把第二条提前刷回「就绪」——
// 每次 flash 先清掉上一个定时器,重置时间以最后一条消息为准。
let flashTimer=null;
function flash(msg){$status.textContent=msg;clearTimeout(flashTimer);flashTimer=setTimeout(()=>$status.textContent='就绪',2000)}
// settings:set 在主进程先改运行时状态、后写盘;写盘失败(磁盘满/只读/杀软锁文件)
// 会带 saved:false 回来 —— 本次会话已生效但重启还原,必须明确提示,
// 不能让用户以为设置已经保存。
@@ -48,7 +51,19 @@ function flashIfNotSaved(res){
if(res&&res.saved===false){flash('⚠ 设置未保存到磁盘,重启后还原');return true}
return false
}
async function refresh(){const c=await get();$auto.checked=!!c.autoLaunch;$hotkey.value=c.hotkey||'';$max.value=String(c.maxItems);$display.value=String(c.displayLimit);$poll.value=String(c.pollMs);$dir.textContent=c.dataDir}
async function refresh(){
const c=await get();
$auto.checked=!!c.autoLaunch;$hotkey.value=c.hotkey||'';$max.value=String(c.maxItems);$display.value=String(c.displayLimit);$poll.value=String(c.pollMs);
// 显示实际在用的目录(dataDirEffective,含 session 兜底);与配置目录不
// 一致时(配置位置暂不可用)两个都给出来,并解释当前数据在哪。
if(c.dataDirEffective&&c.dataDirEffective!==c.dataDir){
$dir.textContent=c.dataDirEffective;
$dir.title='配置位置 '+c.dataDir+' 当前不可用,本会话暂用此目录(重启后若恢复会自动切回)';
}else{
$dir.textContent=c.dataDir;
$dir.title='';
}
}
$auto.addEventListener('change',async()=>{const res=await set('autoLaunch',$auto.checked);if(!flashIfNotSaved(res))flash('已'+($auto.checked?'开启':'关闭')+'开机自启动')});
$max.addEventListener('change',async()=>{const res=await set('maxItems',parseInt($max.value,10));if(!flashIfNotSaved(res))flash('最大条数已设为 '+$max.value)});
$display.addEventListener('change',async()=>{const res=await set('displayLimit',parseInt($display.value,10));if(!flashIfNotSaved(res))flash('界面显示条数已设为 '+$display.value)});
@@ -117,6 +132,10 @@ function renderThemes(currentTheme) {
const card = document.createElement('div');
card.className = 'theme-card' + (t.id === currentTheme ? ' active' : '');
card.dataset.theme = t.id;
// div + click 对键盘不可达:补 button 语义。Enter/Space 触发与鼠标一致的 click。
card.tabIndex = 0;
card.setAttribute('role', 'button');
card.setAttribute('aria-pressed', String(t.id === currentTheme));
card.innerHTML = `
<div class="theme-preview" style="background:${t.bg};color:${t.text}">
<span class="theme-preview-text">Aa</span>
@@ -127,7 +146,14 @@ function renderThemes(currentTheme) {
<div class="theme-desc">${t.desc}</div>
</div>
`;
card.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); card.click(); }
});
card.addEventListener('click', async () => {
// renderThemes 会销毁全部旧卡片:焦点卡没了焦点就掉到 body,键盘用户
// 迷失位置。重建后把焦点还到同名新卡(Chromium 会沿用 focus-visible 状态,
// 鼠标点击不会因此多出焦点框)。
const hadFocus = document.activeElement === card;
const res = await themeSet(t.id);
if (res) {
const eff = res.effectiveTheme || res.theme;
@@ -145,6 +171,10 @@ function renderThemes(currentTheme) {
} catch (_) {}
document.getElementById('opt-follow-system').checked = false;
renderThemes(eff);
if (hadFocus) {
const next = grid.querySelector(`[data-theme="${t.id}"]`);
if (next) next.focus();
}
flashIfNotSaved(res);
}
});

View File

@@ -117,14 +117,14 @@ html, body {
/* 置顶开启:图标上色 + 轻微背景,关闭时回到 muted */
#pin-btn[aria-pressed="true"] {
color: var(--accent);
color: var(--accent-fg);
background: var(--accent-soft);
}
#pin-btn[aria-pressed="true"]:hover {
/* 开启态下 hover 不要洗掉上色,只把背景加深一点 */
background: var(--accent-hover);
color: var(--accent);
color: var(--accent-fg);
}
#refresh-btn.spinning svg {
@@ -191,8 +191,8 @@ html, body {
}
#tabs .tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
color: var(--accent-fg);
border-bottom-color: var(--accent-fg);
}
.tab-count {
@@ -213,7 +213,7 @@ html, body {
#tabs .tab.active .tab-count {
background: var(--accent-soft);
color: var(--accent);
color: var(--accent-fg);
}
/* ---------- 搜索 ---------- */
@@ -258,9 +258,11 @@ html, body {
box-shadow: 0 0 0 3px var(--accent-soft);
}
#search:focus + #search-clear,
/* 只有搜索图标随聚焦高亮。之前还有一条 #search:focus + #search-clear,
* 会让清空按钮在输入全程保持 accent 色 —— 一个次要的 × 按钮不该比输入框
* 本身更抢眼,且与 #search-clear 自身的 muted 设计矛盾,已移除。 */
#search-wrap:focus-within #search-icon {
color: var(--accent);
color: var(--accent-fg);
}
#search::placeholder {
@@ -360,6 +362,12 @@ html, body {
border-color: var(--accent);
}
/* .card 设过 outline:none,必须用等效焦点指示补回(卡片本身 tabindex=0) */
.card:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.card-head {
display: flex;
align-items: center;
@@ -378,7 +386,9 @@ html, body {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10.5px;
/* 徽标含中文(文本/图片):tokens 自定标准 --fs-xs=11px 是 CJK 清晰下限,
* 之前 10.5px 与自己的标准矛盾,小字中文发糊。 */
font-size: var(--fs-xs);
color: var(--text-soft);
background: var(--bg-hover);
border-radius: 4px;
@@ -395,11 +405,11 @@ html, body {
}
.badge-image {
color: var(--accent);
color: var(--accent-fg);
}
.badge-image svg {
color: var(--accent);
color: var(--accent-fg);
opacity: 0.85;
}
@@ -433,8 +443,12 @@ html, body {
border-radius: 6px;
cursor: pointer;
opacity: 0;
/* 只设 opacity:0 的按钮「看不见但点得着」:点卡片头部想选中时会误触
* 透明的 ★/删除;键盘 Tab 也会聚焦到隐形按钮。visibility 进 transition
* 是离散动画:出现时立即可见 + opacity 淡入,消失时等淡出结束才隐藏。 */
visibility: hidden;
line-height: 1;
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast), visibility var(--transition-fast);
}
.action svg {
@@ -443,8 +457,15 @@ html, body {
}
.card:hover .action,
.card.selected .action {
.card.selected .action,
.action:focus-visible {
opacity: 1;
visibility: visible;
}
.action:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.action:hover {
@@ -453,12 +474,12 @@ html, body {
}
.action.copy {
color: var(--accent);
color: var(--accent-fg);
padding-right: 10px;
}
.action.copy:hover {
color: var(--accent);
color: var(--accent-fg);
background: var(--accent-hover);
}
@@ -507,7 +528,9 @@ html, body {
color: var(--text);
/* 中文正文行高 ≥1.55 才不会出现笔画"黏连",这里设 1.6 让 13px 中文清爽 */
line-height: 1.6;
word-break: break-all;
/* 超长无空格串(URL/路径)能断行,但不像 word-break:break-all 那样把
* 普通英文单词从中间生硬截断;中文本身任意位置可断,不受影响。 */
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -529,6 +552,53 @@ html, body {
linear-gradient(-45deg, transparent 75%, var(--bg-soft) 75%);
background-size: 12px 12px;
background-position: 0 0, 0 6px, 6px -6px, -6px 0;
cursor: zoom-in;
}
/* 缩略图可聚焦(buildCard 里设了 role=button + tabindex),补键盘焦点指示 */
.image-preview:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* ---------- 图片大图预览 ---------- */
#lightbox {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.72);
cursor: zoom-out;
}
/* 焦点放在遮罩上只为接 Esc,不需要焦点描边 */
#lightbox:focus { outline: none; }
#lightbox:not(.hidden) {
animation: lightbox-in var(--transition-fast);
}
@keyframes lightbox-in {
from { opacity: 0; }
}
#lightbox-img {
max-width: 92vw;
max-height: 88vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
/* 与 .image-preview 同款棋盘格底;深色遮罩上换成浅色格子,透明 PNG 才看得清 */
background:
linear-gradient(45deg, rgba(255, 255, 255, 0.14) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255, 255, 255, 0.14) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.14) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.14) 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
}
/* ---------- 空状态 ---------- */
@@ -652,14 +722,17 @@ kbd {
cursor: pointer;
line-height: 1;
margin-bottom: -1px;
transition: color 0.12s ease, border-color 0.12s ease;
/* 与全局动效 token 统一(--transition-fast = 100ms ease-out),不再散写 0.12s ease */
transition: color var(--transition-fast), border-color var(--transition-fast);
}
#tab-bar .tab:hover { color: var(--text); }
#tab-bar .tab[aria-selected="true"] {
color: var(--text);
border-bottom-color: var(--accent);
/* 与 #tabs .tab.active 一致:文字/下划线指示都走 --accent-fg,
* light 主题亮色系 accent 下划线才够清晰。 */
border-bottom-color: var(--accent-fg);
font-weight: var(--fw-semibold);
}
@@ -759,9 +832,11 @@ kbd {
.btn:hover { background: var(--bg-hover); border-color: var(--border-strong); }
.btn:active { background: var(--bg-soft); }
.btn-danger { color: var(--danger); border-color: var(--danger-bg); background: var(--danger-bg); }
.btn-danger:hover { background: var(--danger); color: var(--accent-text); border-color: var(--danger); }
/* hover 是「红底白字」:不能用 --accent-text(它随 accent 色系自适应,
* green/orange accent 下是深色字,红底深字对比度崩塌),这里恒为白。 */
.btn-danger:hover { background: var(--danger); color: #FFFFFF; border-color: var(--danger); }
.danger-zone { border-color: var(--danger-bg); }
.danger-zone:hover { border-color: rgba(239, 68, 68, 0.4); }
.danger-zone:hover { border-color: var(--danger-border); }
/* 开关 */
.switch { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
@@ -907,7 +982,11 @@ kbd {
width: 44px;
height: 44px;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
/* 立体渐变 = 实色 accent 上叠一层 白→黑 overlay(设计系统标准做法)。
* 之前渐变末端用 --accent-hover,那是个 20% 透明度的 hover 底色 token,
* 图标右下角几乎全透明、透出卡片底色,像褪了一块色。 */
background: var(--accent);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.16), rgba(0, 0, 0, 0.14)), var(--accent);
display: flex;
align-items: center;
justify-content: center;
@@ -943,10 +1022,28 @@ kbd {
.about-dev { font-size: var(--fs-base); color: var(--text); }
.ext-link {
font-size: var(--fs-sm);
color: var(--accent);
color: var(--accent-fg);
text-decoration: none;
word-break: break-all;
cursor: pointer;
transition: opacity var(--transition-fast);
}
.ext-link:hover { text-decoration: underline; opacity: 0.85; }
/* ---------- 键盘焦点可见性(:focus-visible) ----------
* 全局面控件统一的键盘焦点指示。:focus-visible 只在键盘导航时触发,
* 鼠标点击不出现描边,不影响现有观感。
* (.card / .action / #tab-bar .tab / .switch / select 已在各自区块定义。) */
.icon-btn:focus-visible,
#search-clear:focus-visible,
.btn:focus-visible,
#tabs .tab:focus-visible,
.theme-card:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* 圆形色板用双层 ring(与 .active 选中态同构,区别在 accent 色) */
.accent-swatch:focus-visible {
box-shadow: 0 0 0 2px var(--bg-base), 0 0 0 4px var(--accent);
}

View File

@@ -21,16 +21,30 @@
--accent: #10B981;
--accent-soft: rgba(16, 185, 129, 0.12);
--accent-hover: rgba(16, 185, 129, 0.20);
/* 亮绿底 + 白字只有 2.5:1(WCAG 需 4.5),slot 序号等 accent 底色文字改深色 → 5.8:1 */
--accent-text: #1F2937;
}
:root[data-accent="orange"] {
--accent: #F59E0B;
--accent-soft: rgba(245, 158, 11, 0.12);
--accent-hover: rgba(245, 158, 11, 0.20);
/* 同 green:亮橙底 + 白字 2.2:1 → 深色 6.8:1 */
--accent-text: #1F2937;
}
:root[data-accent="red"] {
--accent: #EF4444;
--accent-soft: rgba(239, 68, 68, 0.12);
--accent-hover: rgba(239, 68, 68, 0.20);
}
}
/* Light 主题下 accent 直接作文字色对比度不足(实测 orange 2.06 / green 2.43 /
* blue 3.52,WCAG 正文需 ≥4.5)—— 文字场景(--accent-fg)改用同色系深色变体,
* 全部 ≥4.6:1;色块填充 / 边框仍用 --accent 本身,品牌色观感不变。
* 深色主题各 accent 文字对比度 ≥3.2 且多为图标/强调短文本,维持原色。 */
:root[data-theme="light"][data-accent="purple"] { --accent-fg: #4F46E5; }
:root[data-theme="light"][data-accent="blue"] { --accent-fg: #2563EB; }
:root[data-theme="light"][data-accent="green"] { --accent-fg: #047857; }
:root[data-theme="light"][data-accent="orange"] { --accent-fg: #B45309; }
:root[data-theme="light"][data-accent="red"] { --accent-fg: #DC2626; }

View File

@@ -19,8 +19,8 @@
}
/* 字体变量赋值
* 中文优先:西文(Inter)排在中文兜底之后,App 里大部分文本是中文,
* 让 YaHei/PingFang 先匹配笔画渲染,CJK 之外再走 Inter 拿更好的西文观感。 */
* 西文优先:Inter 排最前拿更好的西文/数字观感;Inter 不含 CJK 字形,
* 中文会自动落到后面的 YaHei/PingFang 兜底,两段各取所长。 */
:root {
--font-ui: 'Inter', 'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC',
'Hiragino Sans GB', 'Segoe UI', system-ui, -apple-system,

View File

@@ -24,7 +24,13 @@
--accent: #5E6AD2;
--accent-soft: rgba(94, 106, 210, 0.12);
--accent-hover: rgba(94, 106, 210, 0.20);
/* accent 底色上的文字色:亮色系 accent(green/orange)白字对比度仅 ~2:1,
* accents.css 对这两个色系覆盖成深色文字。 */
--accent-text: #FFFFFF;
/* accent 作「文字色」用时的取值(Tab 激活、复制按钮、外链等)。
* 默认 = accent 本身;light 主题下亮色系 accent 对比度不足,
* accents.css 按 [data-theme="light"][data-accent="..."] 覆盖成深色变体。 */
--accent-fg: var(--accent);
/* 状态色 */
--success: #10B981;
@@ -32,6 +38,7 @@
--warn-bg: rgba(245, 158, 11, 0.12);
--danger: #EF4444;
--danger-bg: rgba(239, 68, 68, 0.12);
--danger-border: rgba(239, 68, 68, 0.4);
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);

View File

@@ -80,6 +80,18 @@ check('prependClip 熄灭空状态(#empty)', () => {
);
});
check('prependClip 重排 slot 徽标数字(后移的卡序号 +1)', () => {
// 新卡插到最顶后,原第 1-9 位的卡整体后移:只隐藏第 10 位会留下两个「1」,
// 数字键 1-9 按实际下标复制,显示序号与按键错位。曾被"优化"成只动边界卡,
// 这里守住:必须把后移卡的 textContent 改写成 i+1。
const prepend = rendererSrc.match(/function\s+prependClip\s*\([\s\S]*?\n\}/);
assert.ok(prepend, '找不到 prependClip()');
assert.ok(
/slotEl\.textContent\s*=\s*String\(\s*i\s*\+\s*1\s*\)/.test(prepend[0]),
'prependClip 没有改写后移卡片的 slot 数字 —— 序号会与实际下标错位'
);
});
check('buildCard 的点击选中按运行时下标,不捕获构建时 i', () => {
// prependClip 会把已有卡片整体后移,捕获构建时下标的闭包会选中错误的项。
assert.ok(

View File

@@ -136,6 +136,33 @@ check('defaultDataDir: D 盘可用时首选 D 盘(契约保留)', () => {
}
});
// ---------- main.js 源码级契约:兜底状态下 openDb/openFavDb 不能 mkdir 坏目录 ----------
// 启动时 dataDir 不可用会切到 sessionDataDir 兜底,但 openDb/openFavDb 如果
// mkdir 的是裸 appConfig.dataDir(坏目录),mkdirSync 直接抛 ENOENT ——
// 兜底逻辑被这一行废掉,照样弹「启动失败」。pin 住:两个函数都必须 mkdir
// effectiveDataDir()(含 session 兜底)。
{
const mainSrc = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
const openDbFn = mainSrc.match(/function openDb\(\) \{[\s\S]*?\n\}/);
const openFavDbFn = mainSrc.match(/function openFavDb\(\) \{[\s\S]*?\n\}/);
check('openDb mkdir effectiveDataDir()(含 session 兜底),不是裸 appConfig.dataDir', () => {
assert.ok(openDbFn, '找不到 openDb 函数');
assert.ok(
/mkdirSync\(\s*effectiveDataDir\(\s*\)/.test(openDbFn[0]),
'openDb 仍在 mkdir appConfig.dataDir —— 配置目录不可用时兜底失效,启动照样失败'
);
});
check('openFavDb mkdir effectiveDataDir()(含 session 兜底),不是裸 appConfig.dataDir', () => {
assert.ok(openFavDbFn, '找不到 openFavDb 函数');
assert.ok(
/mkdirSync\(\s*effectiveDataDir\(\s*\)/.test(openFavDbFn[0]),
'openFavDb 仍在 mkdir appConfig.dataDir —— 配置目录不可用时兜底失效,启动照样失败'
);
});
}
// 清理
fs.rmSync(tmpHome, { recursive: true, force: true });
console.log(`\n${passed} checks passed`);

View File

@@ -35,6 +35,9 @@ ipcMain.handle('theme:set-follow-system', () => ({ theme: cfg.theme, accent: cfg
// invoke 会抛错,渲染层的 catch 打 console.error,被下面的"无脚本错误"
// 断言当成 SyntaxError 一类的致命问题误杀。
ipcMain.handle('theme:subscribe', () => true);
// settings.js 启动时还会拉标题栏图标(window.api.getAppIcon -> app:get-icon),
// stub 缺这个 handler 同样打一条未捕获错误噪音;返回空串表示图标缺失(静默路径)。
ipcMain.handle('app:get-icon', () => '');
let failed = 0;
function check(name, fn) {

View File

@@ -25,6 +25,9 @@ ipcMain.handle('theme:set-follow-system', () => ({ theme: cfg.theme, accent: cfg
// stub 缺这个 handler 会往终端打一条未捕获错误(此测试不断言 console,
// 但留着噪音会掩盖真问题)。
ipcMain.handle('theme:subscribe', () => true);
// settings.js 启动时还会拉标题栏图标(window.api.getAppIcon -> app:get-icon),
// stub 缺这个 handler 同样打一条未捕获错误噪音;返回空串表示图标缺失(静默路径)。
ipcMain.handle('app:get-icon', () => '');
let passed = 0;
async function check(name, fn) {