update
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,2 +1,10 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
|
.superpowers/
|
||||||
|
docs/superpowers/
|
||||||
|
.playwright-mcp/
|
||||||
|
.remember/
|
||||||
|
final-hover-card.png
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 剪贴板
|
# Clipboard
|
||||||
|
|
||||||
基于 Electron 的轻量剪贴板管理器,灵感来自 [Ditto](https://github.com/sabrogden/Ditto)。
|
基于 Electron 的轻量剪贴板管理器,灵感来自 [Ditto](https://github.com/sabrogden/Ditto)。
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ function get(db, favId) {
|
|||||||
function getImage(db, favId) {
|
function getImage(db, favId) {
|
||||||
const row = stmts(db).getImage.get(favId);
|
const row = stmts(db).getImage.get(favId);
|
||||||
if (!row || !row.image) return null;
|
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),仅供主进程"复制收藏到系统剪贴板"用。
|
// 原始行(含 image BLOB),仅供主进程"复制收藏到系统剪贴板"用。
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ class KeyHelper {
|
|||||||
this.ps.stderr.on('data', (chunk) => {
|
this.ps.stderr.on('data', (chunk) => {
|
||||||
console.error('[keyhelper stderr]', chunk.toString());
|
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) => {
|
this.ps.on('exit', (code) => {
|
||||||
console.error('[keyhelper] powershell exited with code', code);
|
console.error('[keyhelper] powershell exited with code', code);
|
||||||
this._rejectAll(new Error('keyhelper exited'));
|
this._rejectAll(new Error('keyhelper exited'));
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function capImage(buf) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 把图片 buffer 按 SOFT_CAP_BYTES 收纳:超出的图压成 RESIZE_WIDTH 宽。
|
* 把图片 buffer 按 SOFT_CAP_BYTES 收纳:超出的图压成 RESIZE_WIDTH 宽。
|
||||||
* 三处共用:pollClipboard / bringFavoriteIntoHistory。
|
* 两处共用:pollClipboard / bringFavoriteIntoHistory(见 main.js)。
|
||||||
*
|
*
|
||||||
* 返回 { buf, preview },preview 由 capImage 给出(无图/小图 = null),
|
* 返回 { buf, preview },preview 由 capImage 给出(无图/小图 = null),
|
||||||
* 给数据库的 preview 列用。
|
* 给数据库的 preview 列用。
|
||||||
|
|||||||
63
main.js
63
main.js
@@ -41,7 +41,7 @@ const { createEchoSuppress } = require('./lib/echo-suppress');
|
|||||||
|
|
||||||
// ---------- 配置 ----------
|
// ---------- 配置 ----------
|
||||||
|
|
||||||
const APP_NAME = '剪贴板';
|
const APP_NAME = 'Clipboard';
|
||||||
|
|
||||||
let appConfig = configMod.loadConfig();
|
let appConfig = configMod.loadConfig();
|
||||||
let MAX_ITEMS = appConfig.maxItems; // 运行时可变
|
let MAX_ITEMS = appConfig.maxItems; // 运行时可变
|
||||||
@@ -102,7 +102,10 @@ const listCols = (p = '') =>
|
|||||||
const orderBy = (p = '') => `${p}created_at DESC, ${p}id DESC`;
|
const orderBy = (p = '') => `${p}created_at DESC, ${p}id DESC`;
|
||||||
|
|
||||||
function openDb() {
|
function openDb() {
|
||||||
fs.mkdirSync(appConfig.dataDir, { recursive: true });
|
// 必须 mkdir effectiveDataDir()(含 session 兜底),不是 appConfig.dataDir:
|
||||||
|
// 用户配置的目录不可用(盘符缺失)时启动已切到 sessionDataDir,这里再去
|
||||||
|
// mkdir 那个坏目录会直接抛 ENOENT —— 兜底逻辑被这一行废掉,照样弹"启动失败"。
|
||||||
|
fs.mkdirSync(effectiveDataDir(), { recursive: true });
|
||||||
ftsAvailable = null; // 换库后能力可能不同,重新探测
|
ftsAvailable = null; // 换库后能力可能不同,重新探测
|
||||||
clipCount = null; // 行数缓存绑定旧库,换库后必须回源重查
|
clipCount = null; // 行数缓存绑定旧库,换库后必须回源重查
|
||||||
db = new Database(getDbPath());
|
db = new Database(getDbPath());
|
||||||
@@ -144,7 +147,6 @@ function openDb() {
|
|||||||
stmt.getMeta = db.prepare(`SELECT ${listCols()} FROM clips WHERE id = ?`);
|
stmt.getMeta = db.prepare(`SELECT ${listCols()} FROM clips WHERE id = ?`);
|
||||||
stmt.getImage = db.prepare(`SELECT image 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.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.delete = db.prepare(`DELETE FROM clips WHERE id = ?`);
|
||||||
stmt.clearNonTop = db.prepare(`DELETE FROM clips WHERE top_at IS NULL`);
|
stmt.clearNonTop = db.prepare(`DELETE FROM clips WHERE top_at IS NULL`);
|
||||||
// 之前每次入库都现场 prepare 一遍,热路径上白白编译
|
// 之前每次入库都现场 prepare 一遍,热路径上白白编译
|
||||||
@@ -167,7 +169,7 @@ function openDb() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openFavDb() {
|
function openFavDb() {
|
||||||
fs.mkdirSync(appConfig.dataDir, { recursive: true });
|
fs.mkdirSync(effectiveDataDir(), { recursive: true }); // 同 openDb:兜底目录
|
||||||
favStmtCache.clear(); // 语句绑定在旧连接上,换库后必须丢掉
|
favStmtCache.clear(); // 语句绑定在旧连接上,换库后必须丢掉
|
||||||
favDb = favorites.open(getFavDbPath());
|
favDb = favorites.open(getFavDbPath());
|
||||||
}
|
}
|
||||||
@@ -287,15 +289,12 @@ function pollClipboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 文本变化检测(规则见 lib/echo-suppress.js)。
|
// 文本变化检测(规则见 lib/echo-suppress.js)。
|
||||||
// readText 抛错时(text === null)**不调 observe** —— 否则 last 会被改成
|
// readText 抛错时(text === null)**不调 observe** —— 一次读取失败不等于
|
||||||
// '' 触发 'empty' 直接退出,但剪贴板上可能有图片;图片分支被一并跳过,
|
// 剪贴板被清空:把基线 last 改成 '' 后,下个 tick 读回原来的文本会判
|
||||||
// 用户刚复制的那张图就静默丢失。等下次 tick readText 恢复时再正常观察。
|
// 'new' 把旧内容重复入一次库。跳过本次观察,等 readText 恢复再正常分发;
|
||||||
|
// 图片分支不受影响,照常探测。
|
||||||
if (text !== null) {
|
if (text !== null) {
|
||||||
const verdict = textSuppress.observe(text);
|
const verdict = textSuppress.observe(text);
|
||||||
if (verdict === 'empty') {
|
|
||||||
// 剪贴板被清空或原本就空,跳过(不消化回声标记)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (verdict === 'suppressed') {
|
if (verdict === 'suppressed') {
|
||||||
devLog('[poll] suppressed (was our own paste)');
|
devLog('[poll] suppressed (was our own paste)');
|
||||||
return;
|
return;
|
||||||
@@ -318,6 +317,12 @@ function pollClipboard() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// verdict === 'empty':剪贴板上没有文本 —— 可能被清空,也可能上面是
|
||||||
|
// 一张图(readText 读不到图片)。之前这里直接 return:从「复制文本」
|
||||||
|
// 切到「复制图片」时,图片要晚一个 tick 才入库(等下一次 observe('')
|
||||||
|
// 判 unchanged 才轮到图片分支)。落空继续走图片分支即可 —— 真为空时
|
||||||
|
// shouldReadImage 守卫零开销拦掉。「empty 不消化回声标记」的规则不变
|
||||||
|
// (由 lib/echo-suppress.js 内部保证),这里只是不再提前退出。
|
||||||
// verdict === 'unchanged' —— 文本没变,继续看图片分支
|
// verdict === 'unchanged' —— 文本没变,继续看图片分支
|
||||||
} else {
|
} else {
|
||||||
devLog('[poll] readText skipped (last read failed), still probing image branch');
|
devLog('[poll] readText skipped (last read failed), still probing image branch');
|
||||||
@@ -455,7 +460,7 @@ function applyPollMs(ms) {
|
|||||||
startPolling(); // startPolling 内部会先 stopPolling
|
startPolling(); // startPolling 内部会先 stopPolling
|
||||||
}
|
}
|
||||||
|
|
||||||
// 窗口置顶:不落盘,重启回到默认(开启)——与本功能开发前的行为一致
|
// 窗口置顶:落盘持久化,重启后保持用户的选择(与其他设置项一致)。
|
||||||
let alwaysOnTop = true;
|
let alwaysOnTop = true;
|
||||||
|
|
||||||
function applyAlwaysOnTop(on) {
|
function applyAlwaysOnTop(on) {
|
||||||
@@ -464,6 +469,14 @@ function applyAlwaysOnTop(on) {
|
|||||||
try { mainWindow.setAlwaysOnTop(alwaysOnTop); }
|
try { mainWindow.setAlwaysOnTop(alwaysOnTop); }
|
||||||
catch (e) { devWarn('[window] setAlwaysOnTop failed:', e.message); }
|
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;
|
return alwaysOnTop;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,7 +498,10 @@ function createFramelessWindow({ width, height, file, onClose, minimizable = fal
|
|||||||
// 窗口又变回置顶,而按钮状态仍显示"已关闭"。
|
// 窗口又变回置顶,而按钮状态仍显示"已关闭"。
|
||||||
alwaysOnTop,
|
alwaysOnTop,
|
||||||
// 背景色跟随主题:之前写死 #FFFFFF,5/6 的主题首帧会闪白。
|
// 背景色跟随主题:之前写死 #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 任务视图。
|
// OS 级窗口图标:alt-tab、任务管理器进程图标、Win+Tab 任务视图。
|
||||||
// 工厂被主窗口和设置窗口共用,一行设置两窗生效。
|
// 工厂被主窗口和设置窗口共用,一行设置两窗生效。
|
||||||
icon: path.join(__dirname, 'icon.ico'),
|
icon: path.join(__dirname, 'icon.ico'),
|
||||||
@@ -1116,7 +1132,13 @@ function registerIpc() {
|
|||||||
return applyThemeChange({ followSystem: followSystem === true });
|
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 }) => {
|
ipcMain.handle('settings:set', (_e, { key, value }) => {
|
||||||
appConfig = applyPatch(appConfig, { key, value });
|
appConfig = applyPatch(appConfig, { key, value });
|
||||||
@@ -1393,13 +1415,9 @@ if (!gotLock) {
|
|||||||
registerIpc();
|
registerIpc();
|
||||||
devLog('[main] ipc registered');
|
devLog('[main] ipc registered');
|
||||||
|
|
||||||
// 启动时把模块级 alwaysOnTop 同步到磁盘配置 —— 之前硬编码 true,
|
// 启动时从磁盘配置恢复置顶开关(applyAlwaysOnTop 在切换时已落盘,
|
||||||
// 用户在主窗口点了图钉关闭置顶之后重启,配置里若有 ever-set 的
|
// 见该函数注释)。必须在 createMainWindow 之前调用:窗口构造时会
|
||||||
// false 状态也会被无视。应用层只暴露主窗口图钉一个入口,
|
// 读模块级 alwaysOnTop,晚了就要先建后改(多一次 setAlwaysOnTop)。
|
||||||
// 所以这个字段大多停留在默认值;只是把"读 config、推模块变量"
|
|
||||||
// 的链路补上,避免和磁盘分叉。
|
|
||||||
// 必须在 createMainWindow 之前调用:模块级 alwaysOnTop 默认 true,
|
|
||||||
// 窗口构造时会读它,晚了就要先建后改(多一次 setAlwaysOnTop 调用)。
|
|
||||||
applyAlwaysOnTop(appConfig.alwaysOnTop);
|
applyAlwaysOnTop(appConfig.alwaysOnTop);
|
||||||
|
|
||||||
createMainWindow(); // 内部已经挂好自适应轮询的 show/hide 监听
|
createMainWindow(); // 内部已经挂好自适应轮询的 show/hide 监听
|
||||||
@@ -1436,6 +1454,9 @@ if (!gotLock) {
|
|||||||
? '\n\n可能是数据目录不可用(盘符缺失、权限不足等)。\n请到「设置 → 通用 → 数据保存位置」换一个位置,或检查该盘符/文件夹是否可访问。'
|
? '\n\n可能是数据目录不可用(盘符缺失、权限不足等)。\n请到「设置 → 通用 → 数据保存位置」换一个位置,或检查该盘符/文件夹是否可访问。'
|
||||||
: '';
|
: '';
|
||||||
dialog.showErrorBox(APP_NAME, '启动失败:' + e.message + hint);
|
dialog.showErrorBox(APP_NAME, '启动失败:' + e.message + hint);
|
||||||
|
// 启动失败时窗口 / 托盘 / 轮询全没起来,window-all-closed 又拦着不退
|
||||||
|
// —— 不主动退出的话进程变成无界面僵尸,只能去任务管理器里杀。
|
||||||
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "clipboard-app",
|
"name": "clipboard-app",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "剪贴板 — 简洁的剪贴板管理器(Electron 实现)",
|
"description": "Clipboard — 简洁的剪贴板管理器(Electron 实现)",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "set \"ELECTRON_RUN_AS_NODE=\" && electron .",
|
"start": "set \"ELECTRON_RUN_AS_NODE=\" && electron .",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.clipboard.app",
|
"appId": "com.clipboard.app",
|
||||||
"productName": "剪贴板",
|
"productName": "Clipboard",
|
||||||
"files": [
|
"files": [
|
||||||
"main.js",
|
"main.js",
|
||||||
"config.js",
|
"config.js",
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" />
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" />
|
||||||
<title>剪贴板历史</title>
|
<title>Clipboard</title>
|
||||||
<link rel="stylesheet" href="themes/fonts.css" />
|
<!-- 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/tokens.css" />
|
||||||
|
<link rel="stylesheet" href="themes/fonts.css" />
|
||||||
<link rel="stylesheet" href="themes/themes.css" />
|
<link rel="stylesheet" href="themes/themes.css" />
|
||||||
<link rel="stylesheet" href="themes/accents.css" />
|
<link rel="stylesheet" href="themes/accents.css" />
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
@@ -16,7 +20,7 @@
|
|||||||
<!-- 拖动区 -->
|
<!-- 拖动区 -->
|
||||||
<header id="drag-bar">
|
<header id="drag-bar">
|
||||||
<img id="app-icon" alt="" />
|
<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 id="ipc-status" class="ipc-dot" title="IPC 订阅状态" aria-label="IPC 状态"></span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button id="refresh-btn" class="icon-btn" title="刷新 (F5)" aria-label="刷新">
|
<button id="refresh-btn" class="icon-btn" title="刷新 (F5)" aria-label="刷新">
|
||||||
@@ -96,6 +100,11 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</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">
|
<template id="card-tpl">
|
||||||
<li class="card" tabindex="0">
|
<li class="card" tabindex="0">
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ const $ipcDot = document.getElementById('ipc-status');
|
|||||||
const $tabs = document.getElementById('tabs');
|
const $tabs = document.getElementById('tabs');
|
||||||
const $historyCount = document.getElementById('history-count');
|
const $historyCount = document.getElementById('history-count');
|
||||||
const $favCount = document.getElementById('fav-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 构造,任何状态都不在这里改。
|
// 单张卡片:纯 DOM 构造,任何状态都不在这里改。
|
||||||
@@ -195,15 +237,36 @@ function buildCard(c, i) {
|
|||||||
if (c.type === 'text') {
|
if (c.type === 'text') {
|
||||||
textEl.textContent = c.text || c.preview || '';
|
textEl.textContent = c.text || c.preview || '';
|
||||||
imgEl.classList.add('hidden');
|
imgEl.classList.add('hidden');
|
||||||
} else {
|
} else if (c.hasImage) {
|
||||||
textEl.classList.add('hidden');
|
textEl.classList.add('hidden');
|
||||||
imgEl.classList.remove('hidden');
|
imgEl.classList.remove('hidden');
|
||||||
imgEl.removeAttribute('src');
|
imgEl.removeAttribute('src');
|
||||||
if (c.hasImage) {
|
imgEl.dataset.imgId = String(c.id);
|
||||||
imgEl.dataset.imgId = String(c.id);
|
imgEl.dataset.imgFav = currentTab === 'favorites' ? '1' : '0';
|
||||||
imgEl.dataset.imgFav = currentTab === 'favorites' ? '1' : '0';
|
imgObserver.observe(imgEl);
|
||||||
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 增量插入
|
// 点击选中按下标 —— 但必须点击时再从 DOM 现取:prependClip 增量插入
|
||||||
@@ -290,13 +353,20 @@ function prependClip(clip) {
|
|||||||
const newCard = buildCard(clip, 0);
|
const newCard = buildCard(clip, 0);
|
||||||
$list.insertBefore(newCard, $cards[0] || null); // null = 追加到末尾
|
$list.insertBefore(newCard, $cards[0] || null); // null = 追加到末尾
|
||||||
$cards.unshift(newCard);
|
$cards.unshift(newCard);
|
||||||
// Slot badge 重排:每次 prepend 之后,只有原来第 8 位(现在第 9 位)的卡需要
|
// Slot badge 重排:新卡插到最顶后,原来第 1-9 位的卡整体后移一位,
|
||||||
// 从「显示 9」变成「隐藏」,其它卡(0-8 已可见、9+ 已隐藏)都不动。
|
// 徽标数字必须跟着 +1 —— 只藏第 10 位会留下两个「1」,且数字键 1-9
|
||||||
// 全表 1000 张卡都跑一遍是浪费。
|
// 按实际下标复制(见 keydown 的 /^[1-9]$/ 分支),显示序号与按键错位。
|
||||||
const boundary = $cards[9]; // 第 10 位(下标 9):需要被隐藏
|
// 受影响的只有前 10 张卡(下标 1-8 改数字、下标 9 隐藏),最多 9 次
|
||||||
if (boundary) {
|
// DOM 更新,不需要全表重扫。新卡(下标 0)的「1」由 buildCard(clip, 0) 写好。
|
||||||
const slotEl = boundary.querySelector('.slot');
|
const renumberEnd = Math.min($cards.length, 10);
|
||||||
slotEl.classList.add('hidden');
|
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 复制它。
|
// 选中态:强制选中第一条(最新复制的条目),并滚到顶部让 Enter / 数字键 1 复制它。
|
||||||
// select() 内部会遍历 $cards 把旧 'selected' 清掉、把 $cards[0] 点亮,
|
// select() 内部会遍历 $cards 把旧 'selected' 清掉、把 $cards[0] 点亮,
|
||||||
@@ -462,6 +532,16 @@ document.addEventListener('keydown', (e) => {
|
|||||||
reload();
|
reload();
|
||||||
return;
|
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') {
|
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'l') {
|
||||||
// Ctrl+L 聚焦搜索框。组合键完整匹配避免吃掉 Ctrl+Shift+L 之类。
|
// Ctrl+L 聚焦搜索框。组合键完整匹配避免吃掉 Ctrl+Shift+L 之类。
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" />
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" />
|
||||||
<title>设置</title>
|
<title>设置</title>
|
||||||
<link rel="stylesheet" href="themes/fonts.css" />
|
|
||||||
<link rel="stylesheet" href="themes/tokens.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/themes.css" />
|
||||||
<link rel="stylesheet" href="themes/accents.css" />
|
<link rel="stylesheet" href="themes/accents.css" />
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
<path d="M9 11h6M9 14.5h6M9 18h3"/>
|
<path d="M9 11h6M9 14.5h6M9 18h3"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="about-name">剪贴板</div>
|
<div class="about-name">Clipboard</div>
|
||||||
<div class="about-tagline">简洁的剪贴板管理器</div>
|
<div class="about-tagline">简洁的剪贴板管理器</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row column">
|
<div class="setting-row column">
|
||||||
|
|||||||
@@ -40,7 +40,10 @@
|
|||||||
|
|
||||||
const { get, set, clearHistory, openDir, chooseDir, openExternal } = window.settingsApi;
|
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');
|
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 在主进程先改运行时状态、后写盘;写盘失败(磁盘满/只读/杀软锁文件)
|
// settings:set 在主进程先改运行时状态、后写盘;写盘失败(磁盘满/只读/杀软锁文件)
|
||||||
// 会带 saved:false 回来 —— 本次会话已生效但重启还原,必须明确提示,
|
// 会带 saved:false 回来 —— 本次会话已生效但重启还原,必须明确提示,
|
||||||
// 不能让用户以为设置已经保存。
|
// 不能让用户以为设置已经保存。
|
||||||
@@ -48,7 +51,19 @@ function flashIfNotSaved(res){
|
|||||||
if(res&&res.saved===false){flash('⚠ 设置未保存到磁盘,重启后还原');return true}
|
if(res&&res.saved===false){flash('⚠ 设置未保存到磁盘,重启后还原');return true}
|
||||||
return false
|
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?'开启':'关闭')+'开机自启动')});
|
$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)});
|
$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)});
|
$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');
|
const card = document.createElement('div');
|
||||||
card.className = 'theme-card' + (t.id === currentTheme ? ' active' : '');
|
card.className = 'theme-card' + (t.id === currentTheme ? ' active' : '');
|
||||||
card.dataset.theme = t.id;
|
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 = `
|
card.innerHTML = `
|
||||||
<div class="theme-preview" style="background:${t.bg};color:${t.text}">
|
<div class="theme-preview" style="background:${t.bg};color:${t.text}">
|
||||||
<span class="theme-preview-text">Aa</span>
|
<span class="theme-preview-text">Aa</span>
|
||||||
@@ -127,7 +146,14 @@ function renderThemes(currentTheme) {
|
|||||||
<div class="theme-desc">${t.desc}</div>
|
<div class="theme-desc">${t.desc}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
card.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); card.click(); }
|
||||||
|
});
|
||||||
card.addEventListener('click', async () => {
|
card.addEventListener('click', async () => {
|
||||||
|
// renderThemes 会销毁全部旧卡片:焦点卡没了焦点就掉到 body,键盘用户
|
||||||
|
// 迷失位置。重建后把焦点还到同名新卡(Chromium 会沿用 focus-visible 状态,
|
||||||
|
// 鼠标点击不会因此多出焦点框)。
|
||||||
|
const hadFocus = document.activeElement === card;
|
||||||
const res = await themeSet(t.id);
|
const res = await themeSet(t.id);
|
||||||
if (res) {
|
if (res) {
|
||||||
const eff = res.effectiveTheme || res.theme;
|
const eff = res.effectiveTheme || res.theme;
|
||||||
@@ -145,6 +171,10 @@ function renderThemes(currentTheme) {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
document.getElementById('opt-follow-system').checked = false;
|
document.getElementById('opt-follow-system').checked = false;
|
||||||
renderThemes(eff);
|
renderThemes(eff);
|
||||||
|
if (hadFocus) {
|
||||||
|
const next = grid.querySelector(`[data-theme="${t.id}"]`);
|
||||||
|
if (next) next.focus();
|
||||||
|
}
|
||||||
flashIfNotSaved(res);
|
flashIfNotSaved(res);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,14 +117,14 @@ html, body {
|
|||||||
|
|
||||||
/* 置顶开启:图标上色 + 轻微背景,关闭时回到 muted */
|
/* 置顶开启:图标上色 + 轻微背景,关闭时回到 muted */
|
||||||
#pin-btn[aria-pressed="true"] {
|
#pin-btn[aria-pressed="true"] {
|
||||||
color: var(--accent);
|
color: var(--accent-fg);
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
#pin-btn[aria-pressed="true"]:hover {
|
#pin-btn[aria-pressed="true"]:hover {
|
||||||
/* 开启态下 hover 不要洗掉上色,只把背景加深一点 */
|
/* 开启态下 hover 不要洗掉上色,只把背景加深一点 */
|
||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
color: var(--accent);
|
color: var(--accent-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
#refresh-btn.spinning svg {
|
#refresh-btn.spinning svg {
|
||||||
@@ -191,8 +191,8 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#tabs .tab.active {
|
#tabs .tab.active {
|
||||||
color: var(--accent);
|
color: var(--accent-fg);
|
||||||
border-bottom-color: var(--accent);
|
border-bottom-color: var(--accent-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-count {
|
.tab-count {
|
||||||
@@ -213,7 +213,7 @@ html, body {
|
|||||||
|
|
||||||
#tabs .tab.active .tab-count {
|
#tabs .tab.active .tab-count {
|
||||||
background: var(--accent-soft);
|
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);
|
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 {
|
#search-wrap:focus-within #search-icon {
|
||||||
color: var(--accent);
|
color: var(--accent-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
#search::placeholder {
|
#search::placeholder {
|
||||||
@@ -360,6 +362,12 @@ html, body {
|
|||||||
border-color: var(--accent);
|
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 {
|
.card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -378,7 +386,9 @@ html, body {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
font-size: 10.5px;
|
/* 徽标含中文(文本/图片):tokens 自定标准 --fs-xs=11px 是 CJK 清晰下限,
|
||||||
|
* 之前 10.5px 与自己的标准矛盾,小字中文发糊。 */
|
||||||
|
font-size: var(--fs-xs);
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -395,11 +405,11 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge-image {
|
.badge-image {
|
||||||
color: var(--accent);
|
color: var(--accent-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-image svg {
|
.badge-image svg {
|
||||||
color: var(--accent);
|
color: var(--accent-fg);
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,8 +443,12 @@ html, body {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
/* 只设 opacity:0 的按钮「看不见但点得着」:点卡片头部想选中时会误触
|
||||||
|
* 透明的 ★/删除;键盘 Tab 也会聚焦到隐形按钮。visibility 进 transition
|
||||||
|
* 是离散动画:出现时立即可见 + opacity 淡入,消失时等淡出结束才隐藏。 */
|
||||||
|
visibility: hidden;
|
||||||
line-height: 1;
|
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 {
|
.action svg {
|
||||||
@@ -443,8 +457,15 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card:hover .action,
|
.card:hover .action,
|
||||||
.card.selected .action {
|
.card.selected .action,
|
||||||
|
.action:focus-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action:hover {
|
.action:hover {
|
||||||
@@ -453,12 +474,12 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action.copy {
|
.action.copy {
|
||||||
color: var(--accent);
|
color: var(--accent-fg);
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action.copy:hover {
|
.action.copy:hover {
|
||||||
color: var(--accent);
|
color: var(--accent-fg);
|
||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,7 +528,9 @@ html, body {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
/* 中文正文行高 ≥1.55 才不会出现笔画"黏连",这里设 1.6 让 13px 中文清爽 */
|
/* 中文正文行高 ≥1.55 才不会出现笔画"黏连",这里设 1.6 让 13px 中文清爽 */
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
word-break: break-all;
|
/* 超长无空格串(URL/路径)能断行,但不像 word-break:break-all 那样把
|
||||||
|
* 普通英文单词从中间生硬截断;中文本身任意位置可断,不受影响。 */
|
||||||
|
overflow-wrap: anywhere;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
@@ -529,6 +552,53 @@ html, body {
|
|||||||
linear-gradient(-45deg, transparent 75%, var(--bg-soft) 75%);
|
linear-gradient(-45deg, transparent 75%, var(--bg-soft) 75%);
|
||||||
background-size: 12px 12px;
|
background-size: 12px 12px;
|
||||||
background-position: 0 0, 0 6px, 6px -6px, -6px 0;
|
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;
|
cursor: pointer;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin-bottom: -1px;
|
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:hover { color: var(--text); }
|
||||||
|
|
||||||
#tab-bar .tab[aria-selected="true"] {
|
#tab-bar .tab[aria-selected="true"] {
|
||||||
color: var(--text);
|
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);
|
font-weight: var(--fw-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,9 +832,11 @@ kbd {
|
|||||||
.btn:hover { background: var(--bg-hover); border-color: var(--border-strong); }
|
.btn:hover { background: var(--bg-hover); border-color: var(--border-strong); }
|
||||||
.btn:active { background: var(--bg-soft); }
|
.btn:active { background: var(--bg-soft); }
|
||||||
.btn-danger { color: var(--danger); border-color: var(--danger-bg); background: var(--danger-bg); }
|
.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 { 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; }
|
.switch { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
|
||||||
@@ -907,7 +982,11 @@ kbd {
|
|||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border-radius: 10px;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -943,10 +1022,28 @@ kbd {
|
|||||||
.about-dev { font-size: var(--fs-base); color: var(--text); }
|
.about-dev { font-size: var(--fs-base); color: var(--text); }
|
||||||
.ext-link {
|
.ext-link {
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
color: var(--accent);
|
color: var(--accent-fg);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity var(--transition-fast);
|
transition: opacity var(--transition-fast);
|
||||||
}
|
}
|
||||||
.ext-link:hover { text-decoration: underline; opacity: 0.85; }
|
.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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,12 +21,16 @@
|
|||||||
--accent: #10B981;
|
--accent: #10B981;
|
||||||
--accent-soft: rgba(16, 185, 129, 0.12);
|
--accent-soft: rgba(16, 185, 129, 0.12);
|
||||||
--accent-hover: rgba(16, 185, 129, 0.20);
|
--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"] {
|
:root[data-accent="orange"] {
|
||||||
--accent: #F59E0B;
|
--accent: #F59E0B;
|
||||||
--accent-soft: rgba(245, 158, 11, 0.12);
|
--accent-soft: rgba(245, 158, 11, 0.12);
|
||||||
--accent-hover: rgba(245, 158, 11, 0.20);
|
--accent-hover: rgba(245, 158, 11, 0.20);
|
||||||
|
/* 同 green:亮橙底 + 白字 2.2:1 → 深色 6.8:1 */
|
||||||
|
--accent-text: #1F2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-accent="red"] {
|
:root[data-accent="red"] {
|
||||||
@@ -34,3 +38,13 @@
|
|||||||
--accent-soft: rgba(239, 68, 68, 0.12);
|
--accent-soft: rgba(239, 68, 68, 0.12);
|
||||||
--accent-hover: rgba(239, 68, 68, 0.20);
|
--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; }
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 字体变量赋值
|
/* 字体变量赋值
|
||||||
* 中文优先:西文(Inter)排在中文兜底之后,App 里大部分文本是中文,
|
* 西文优先:Inter 排最前拿更好的西文/数字观感;Inter 不含 CJK 字形,
|
||||||
* 让 YaHei/PingFang 先匹配笔画渲染,CJK 之外再走 Inter 拿更好的西文观感。 */
|
* 中文会自动落到后面的 YaHei/PingFang 兜底,两段各取所长。 */
|
||||||
:root {
|
:root {
|
||||||
--font-ui: 'Inter', 'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC',
|
--font-ui: 'Inter', 'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC',
|
||||||
'Hiragino Sans GB', 'Segoe UI', system-ui, -apple-system,
|
'Hiragino Sans GB', 'Segoe UI', system-ui, -apple-system,
|
||||||
|
|||||||
@@ -24,7 +24,13 @@
|
|||||||
--accent: #5E6AD2;
|
--accent: #5E6AD2;
|
||||||
--accent-soft: rgba(94, 106, 210, 0.12);
|
--accent-soft: rgba(94, 106, 210, 0.12);
|
||||||
--accent-hover: rgba(94, 106, 210, 0.20);
|
--accent-hover: rgba(94, 106, 210, 0.20);
|
||||||
|
/* accent 底色上的文字色:亮色系 accent(green/orange)白字对比度仅 ~2:1,
|
||||||
|
* accents.css 对这两个色系覆盖成深色文字。 */
|
||||||
--accent-text: #FFFFFF;
|
--accent-text: #FFFFFF;
|
||||||
|
/* accent 作「文字色」用时的取值(Tab 激活、复制按钮、外链等)。
|
||||||
|
* 默认 = accent 本身;light 主题下亮色系 accent 对比度不足,
|
||||||
|
* accents.css 按 [data-theme="light"][data-accent="..."] 覆盖成深色变体。 */
|
||||||
|
--accent-fg: var(--accent);
|
||||||
|
|
||||||
/* 状态色 */
|
/* 状态色 */
|
||||||
--success: #10B981;
|
--success: #10B981;
|
||||||
@@ -32,6 +38,7 @@
|
|||||||
--warn-bg: rgba(245, 158, 11, 0.12);
|
--warn-bg: rgba(245, 158, 11, 0.12);
|
||||||
--danger: #EF4444;
|
--danger: #EF4444;
|
||||||
--danger-bg: rgba(239, 68, 68, 0.12);
|
--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);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
|||||||
@@ -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', () => {
|
check('buildCard 的点击选中按运行时下标,不捕获构建时 i', () => {
|
||||||
// prependClip 会把已有卡片整体后移,捕获构建时下标的闭包会选中错误的项。
|
// prependClip 会把已有卡片整体后移,捕获构建时下标的闭包会选中错误的项。
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|||||||
@@ -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 });
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||||
console.log(`\n${passed} checks passed`);
|
console.log(`\n${passed} checks passed`);
|
||||||
@@ -35,6 +35,9 @@ ipcMain.handle('theme:set-follow-system', () => ({ theme: cfg.theme, accent: cfg
|
|||||||
// invoke 会抛错,渲染层的 catch 打 console.error,被下面的"无脚本错误"
|
// invoke 会抛错,渲染层的 catch 打 console.error,被下面的"无脚本错误"
|
||||||
// 断言当成 SyntaxError 一类的致命问题误杀。
|
// 断言当成 SyntaxError 一类的致命问题误杀。
|
||||||
ipcMain.handle('theme:subscribe', () => true);
|
ipcMain.handle('theme:subscribe', () => true);
|
||||||
|
// settings.js 启动时还会拉标题栏图标(window.api.getAppIcon -> app:get-icon),
|
||||||
|
// stub 缺这个 handler 同样打一条未捕获错误噪音;返回空串表示图标缺失(静默路径)。
|
||||||
|
ipcMain.handle('app:get-icon', () => '');
|
||||||
|
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
function check(name, fn) {
|
function check(name, fn) {
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ ipcMain.handle('theme:set-follow-system', () => ({ theme: cfg.theme, accent: cfg
|
|||||||
// stub 缺这个 handler 会往终端打一条未捕获错误(此测试不断言 console,
|
// stub 缺这个 handler 会往终端打一条未捕获错误(此测试不断言 console,
|
||||||
// 但留着噪音会掩盖真问题)。
|
// 但留着噪音会掩盖真问题)。
|
||||||
ipcMain.handle('theme:subscribe', () => true);
|
ipcMain.handle('theme:subscribe', () => true);
|
||||||
|
// settings.js 启动时还会拉标题栏图标(window.api.getAppIcon -> app:get-icon),
|
||||||
|
// stub 缺这个 handler 同样打一条未捕获错误噪音;返回空串表示图标缺失(静默路径)。
|
||||||
|
ipcMain.handle('app:get-icon', () => '');
|
||||||
|
|
||||||
let passed = 0;
|
let passed = 0;
|
||||||
async function check(name, fn) {
|
async function check(name, fn) {
|
||||||
|
|||||||
Reference in New Issue
Block a user