update
This commit is contained in:
134
renderer/index.html
Normal file
134
renderer/index.html
Normal file
@@ -0,0 +1,134 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<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" />
|
||||
<link rel="stylesheet" href="themes/tokens.css" />
|
||||
<link rel="stylesheet" href="themes/themes.css" />
|
||||
<link rel="stylesheet" href="themes/accents.css" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<script src="theme-boot.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<!-- 拖动区 -->
|
||||
<header id="drag-bar">
|
||||
<img id="app-icon" alt="" />
|
||||
<h1 id="title">剪贴板历史</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="刷新">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M14 8a6 6 0 0 0-10.5-4L2 5.5"/>
|
||||
<polyline points="2 2 2 5.5 5.5 5.5"/>
|
||||
<path d="M2 8a6 6 0 0 0 10.5 4L14 10.5"/>
|
||||
<polyline points="14 14 14 10.5 10.5 10.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="settings-btn" class="icon-btn" title="设置" aria-label="设置">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12.33 10.5L14.4 9.1L14.4 6.9L12.33 5.5L12.2 3L10.2 1.9L8 3L5.8 1.9L3.8 3L3.7 5.5L1.6 6.9L1.6 9.1L3.7 10.5L3.8 13L5.8 14.1L8 13L10.2 14.1L12.2 13Z"/>
|
||||
<circle cx="8" cy="8" r="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="pin-btn" class="icon-btn" title="窗口置顶" aria-label="窗口置顶" aria-pressed="true">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="8" y1="11.3" x2="8" y2="14.7"/>
|
||||
<path d="M3.3 11.3h9.4v-1.2a1.3 1.3 0 0 0-.7-1.2l-1.2-.6a1.3 1.3 0 0 1-.7-1.2V4a1.7 1.7 0 0 0-3.4 0v3.1a1.3 1.3 0 0 1-.7 1.2l-1.2.6a1.3 1.3 0 0 0-.7 1.2Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="close-btn" class="icon-btn" title="关闭 (Esc)" aria-label="关闭">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3.5 3.5l9 9M12.5 3.5l-9 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Tab 栏 -->
|
||||
<nav id="tabs">
|
||||
<button class="tab active" data-tab="history" type="button">历史<span class="tab-count" id="history-count">0</span></button>
|
||||
<button class="tab" data-tab="favorites" type="button">收藏<span class="tab-count" id="fav-count">0</span></button>
|
||||
</nav>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<div id="search-row">
|
||||
<div id="search-wrap">
|
||||
<svg id="search-icon" viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="7" cy="7" r="4.5"/>
|
||||
<path d="m13.5 13.5-3-3"/>
|
||||
</svg>
|
||||
<input id="search" type="text" placeholder="搜索剪贴板内容…" autocomplete="off" spellcheck="false" />
|
||||
<button id="search-clear" type="button" title="清空搜索" aria-label="清空搜索">
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="6"/>
|
||||
<path d="M5.5 5.5l5 5M10.5 5.5l-5 5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<main id="list-wrap">
|
||||
<ul id="list"></ul>
|
||||
<div id="empty" class="hidden">
|
||||
<svg class="empty-icon" viewBox="0 0 64 64" width="56" height="56" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="22" y="12" width="20" height="8" rx="2"/>
|
||||
<rect x="14" y="18" width="36" height="34" rx="4"/>
|
||||
<path d="M22 30h20M22 36h14M22 42h10"/>
|
||||
</svg>
|
||||
<p class="empty-title">还没有剪贴板记录</p>
|
||||
<p class="muted">复制一段文本或图片,就会自动出现在这里</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 状态栏 -->
|
||||
<footer id="status">
|
||||
<span id="status-left">就绪</span>
|
||||
<span class="shortcuts">
|
||||
<kbd>↑</kbd><kbd>↓</kbd> 选择
|
||||
<span class="dot">·</span>
|
||||
<kbd>1</kbd>–<kbd>9</kbd> / <kbd>Enter</kbd> 复制
|
||||
<span class="dot">·</span>
|
||||
<kbd>Esc</kbd> 关闭
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- 单条卡片模板 -->
|
||||
<template id="card-tpl">
|
||||
<li class="card" tabindex="0">
|
||||
<div class="card-head">
|
||||
<span class="slot hidden"></span>
|
||||
<span class="time"></span>
|
||||
<span class="badge type"></span>
|
||||
<span class="spacer"></span>
|
||||
<button class="action fav" title="收藏" aria-label="收藏">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M8 1.8l1.95 4.05 4.45.6-3.25 3.1.8 4.45L8 11.9l-3.95 2.1.8-4.45L1.6 6.45l4.45-.6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action copy" title="复制 (Enter)" aria-label="复制">
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="5" y="5" width="8" height="9" rx="1.5"/>
|
||||
<path d="M3 11V3.5A1.5 1.5 0 0 1 4.5 2H10"/>
|
||||
</svg>
|
||||
<span>复制</span>
|
||||
</button>
|
||||
<button class="action delete" title="删除" aria-label="删除">
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 4.5h10M6.5 4.5V3a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1.5M4.5 4.5l.6 9a1.5 1.5 0 0 0 1.5 1.4h2.8a1.5 1.5 0 0 0 1.5-1.4l.6-9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-preview"></p>
|
||||
<img class="image-preview hidden" alt="" />
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
713
renderer/renderer.js
Normal file
713
renderer/renderer.js
Normal file
@@ -0,0 +1,713 @@
|
||||
/**
|
||||
* renderer.js — 历史窗口 UI 逻辑
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 直接从 window.api 解构,避免 'api' 这个名字与 contextBridge 暴露的属性冲突
|
||||
const {
|
||||
list, count, movetop, remove, hide,
|
||||
subscribe, onAdded, onRefresh, openSettings,
|
||||
toggleAlwaysOnTop, getAlwaysOnTop, getImage,
|
||||
} = window.api;
|
||||
|
||||
const {
|
||||
add: favAdd,
|
||||
remove: favRemove,
|
||||
copy: favCopy,
|
||||
list: favList,
|
||||
count: favCountOf,
|
||||
getImage: favGetImage,
|
||||
subscribe: favSubscribe,
|
||||
onChanged: onFavoritesChanged,
|
||||
} = window.favoritesApi;
|
||||
|
||||
const { onDisplayLimitChanged, get: settingsGet } = window.settingsApi;
|
||||
|
||||
// ---------- DOM ----------
|
||||
|
||||
const $list = document.getElementById('list');
|
||||
const $listWrap = document.getElementById('list-wrap');
|
||||
const $empty = document.getElementById('empty');
|
||||
const $statusL = document.getElementById('status-left');
|
||||
const $search = document.getElementById('search');
|
||||
const $searchClear = document.getElementById('search-clear');
|
||||
const $tpl = document.getElementById('card-tpl');
|
||||
const $closeBtn = document.getElementById('close-btn');
|
||||
const $refreshBtn = document.getElementById('refresh-btn');
|
||||
const $settingsBtn = document.getElementById('settings-btn');
|
||||
const $pinBtn = document.getElementById('pin-btn');
|
||||
const $ipcDot = document.getElementById('ipc-status');
|
||||
const $tabs = document.getElementById('tabs');
|
||||
const $historyCount = document.getElementById('history-count');
|
||||
const $favCount = document.getElementById('fav-count');
|
||||
|
||||
// ---------- 状态 ----------
|
||||
|
||||
let clips = [];
|
||||
let selectedIdx = -1;
|
||||
let currentTab = 'history'; // 'history' | 'favorites'
|
||||
let displayLimit = 300; // 来自 config.displayLimit,预加载时拉一次,IPC 变更时刷新
|
||||
let $cards = [];
|
||||
|
||||
// ---------- 时间格式化 ----------
|
||||
// todayKey 由 render() / prependClip() 入口处刷新一次,避免每张卡片都
|
||||
// new Date() 一次(displayLimit=1000 时就是 1000 次 Date 构造 + toDateString)。
|
||||
let _todayKey = '';
|
||||
function refreshTodayKey() {
|
||||
_todayKey = new Date().toDateString();
|
||||
}
|
||||
|
||||
function fmtTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts * 1000);
|
||||
const sameDay = d.toDateString() === _todayKey;
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
if (sameDay) return `今天 ${hh}:${mm}`;
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day} ${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function setTab(name) {
|
||||
if (name !== 'history' && name !== 'favorites') return;
|
||||
currentTab = name;
|
||||
for (const t of $tabs.querySelectorAll('.tab')) {
|
||||
t.classList.toggle('active', t.dataset.tab === name);
|
||||
}
|
||||
selectedIdx = -1;
|
||||
// 切到 favorites 时先取真实总数(favorites:count IPC),列表本身只取 200 条,
|
||||
// 徽标反映真实值而不被截断。
|
||||
if (name === 'favorites') updateFavCount();
|
||||
reload();
|
||||
}
|
||||
|
||||
$tabs.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.tab');
|
||||
if (btn && btn.dataset.tab) setTab(btn.dataset.tab);
|
||||
});
|
||||
|
||||
// ---------- 图片懒加载 ----------
|
||||
// 列表只带 hasImage 标记,真正的图片数据滚到视口里才拉。
|
||||
// 这样打开窗口不再需要把几百 MB 的 base64 从主进程搬过来。
|
||||
|
||||
// FIFO 缓存 —— 满 60 条时删最早插入的那条。Map.keys() 按插入顺序排列,
|
||||
// 所以 delete(keys().next().value) 就够了;不要命名成「LRU」,那是按访问
|
||||
// 顺序淘汰,不是这里的行为。
|
||||
const fifoCache = new Map(); // id -> base64(上限 60 条,避免无限增长)
|
||||
|
||||
function cacheImage(key, data) {
|
||||
if (fifoCache.size >= 60) fifoCache.delete(fifoCache.keys().next().value);
|
||||
fifoCache.set(key, data);
|
||||
}
|
||||
|
||||
const imgObserver = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const el = entry.target;
|
||||
imgObserver.unobserve(el);
|
||||
loadImageInto(el);
|
||||
}
|
||||
}, { root: document.getElementById('list-wrap'), rootMargin: '200px' });
|
||||
|
||||
async function loadImageInto(el) {
|
||||
const id = Number(el.dataset.imgId);
|
||||
const fromFav = el.dataset.imgFav === '1';
|
||||
const key = (fromFav ? 'f' : 'c') + id;
|
||||
if (fifoCache.has(key)) {
|
||||
const cached = fifoCache.get(key);
|
||||
if (cached) el.src = `data:image/png;base64,${cached}`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const b64 = await (fromFav ? favGetImage(id) : getImage(id));
|
||||
if (!b64) return; // 没有数据就保持占位,不要设一个坏 src
|
||||
cacheImage(key, b64);
|
||||
el.src = `data:image/png;base64,${b64}`;
|
||||
} catch (e) {
|
||||
console.error('[renderer] getImage failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 渲染 ----------
|
||||
|
||||
// 单张卡片:纯 DOM 构造,任何状态都不在这里改。
|
||||
function buildCard(c, i) {
|
||||
const node = $tpl.content.firstElementChild.cloneNode(true);
|
||||
node.dataset.id = String(c.id);
|
||||
|
||||
node.querySelector('.time').textContent = fmtTime(c.createdAt);
|
||||
const typeBadge = node.querySelector('.badge.type');
|
||||
if (c.type === 'text') {
|
||||
typeBadge.innerHTML = '<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4.5h10M3 8h7M3 11.5h10"/></svg><span>文本</span>';
|
||||
typeBadge.classList.add('badge-text');
|
||||
} else {
|
||||
typeBadge.innerHTML = '<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="12" height="10" rx="1.5"/><circle cx="6" cy="6.5" r="0.8"/><path d="m3 12 3-3 3 3 4-4"/></svg><span>图片</span>';
|
||||
typeBadge.classList.add('badge-image');
|
||||
}
|
||||
|
||||
const slotEl = node.querySelector('.slot');
|
||||
if (i < 9) {
|
||||
slotEl.textContent = String(i + 1);
|
||||
slotEl.classList.remove('hidden');
|
||||
} else {
|
||||
slotEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
const favBtn = node.querySelector('.action.fav');
|
||||
if (currentTab === 'favorites' || c.favorited) {
|
||||
favBtn.classList.add('favorited');
|
||||
favBtn.title = currentTab === 'favorites' ? '取消收藏' : '已收藏(点击取消)';
|
||||
} else {
|
||||
favBtn.classList.remove('favorited');
|
||||
favBtn.title = '收藏';
|
||||
}
|
||||
favBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
// 捕获点击时刻的 Tab:回调进串行队列延迟执行,期间用户切了 Tab,
|
||||
// 若在执行时读全局 currentTab,历史 id 会被当 favId 发往
|
||||
// favorites:remove,静默删掉一条同号的无关收藏(两库自增撞号几乎必然)。
|
||||
toggleFavorite(c, currentTab);
|
||||
};
|
||||
|
||||
const pinBtn = node.querySelector('.action.copy');
|
||||
const delBtn = node.querySelector('.action.delete');
|
||||
// 「复制」按钮在两个 Tab 都用:历史 Tab 走 movetop(写剪贴板 + 置顶),
|
||||
// 收藏 Tab 走 favorites:copy(写收藏自己存的内容,favId 与历史 id 不同库)。
|
||||
pinBtn.classList.remove('hidden');
|
||||
if (currentTab === 'favorites') {
|
||||
// 收藏 Tab 没有"置顶"语义,也没"删除"按钮(收藏 Tab 用单独的取消收藏)
|
||||
delBtn.classList.add('hidden');
|
||||
pinBtn.title = '复制到剪贴板';
|
||||
pinBtn.onclick = (e) => { e.stopPropagation(); copyEntry(c); };
|
||||
} else {
|
||||
delBtn.classList.remove('hidden');
|
||||
pinBtn.title = '复制(按 Enter)';
|
||||
pinBtn.onclick = (e) => { e.stopPropagation(); moveToTop(c.id); };
|
||||
delBtn.onclick = (e) => { e.stopPropagation(); removeClip(c.id); };
|
||||
}
|
||||
|
||||
const textEl = node.querySelector('.text-preview');
|
||||
const imgEl = node.querySelector('.image-preview');
|
||||
|
||||
if (c.type === 'text') {
|
||||
textEl.textContent = c.text || c.preview || '';
|
||||
imgEl.classList.add('hidden');
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 点击选中按下标 —— 但必须点击时再从 DOM 现取:prependClip 增量插入
|
||||
// 会把已有卡片整体后移,构建时捕获的 i 会过期,选中错误的项。
|
||||
node.onclick = () => select($cards.indexOf(node));
|
||||
node.ondblclick = () => copyEntry(c);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
function render() {
|
||||
refreshTodayKey();
|
||||
imgObserver.disconnect();
|
||||
$list.innerHTML = '';
|
||||
if (clips.length === 0) {
|
||||
$empty.classList.remove('hidden');
|
||||
$statusL.textContent = '无内容';
|
||||
selectedIdx = -1;
|
||||
$cards = [];
|
||||
return;
|
||||
}
|
||||
$empty.classList.add('hidden');
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = 0; i < clips.length; i++) {
|
||||
frag.appendChild(buildCard(clips[i], i));
|
||||
}
|
||||
$list.appendChild(frag);
|
||||
$cards = Array.from($list.querySelectorAll('.card'));
|
||||
|
||||
// 选中态:reload() 现在统一走 select(0),所以这里 selectedIdx = -1,条件不成立。
|
||||
// 留下这段是为了万一将来 render() 被别处复用时还能保持选中态可视化。
|
||||
if (selectedIdx >= 0 && selectedIdx < $cards.length) {
|
||||
$cards[selectedIdx].classList.add('selected');
|
||||
}
|
||||
|
||||
$statusL.textContent = `显示 ${clips.length} 条`;
|
||||
}
|
||||
|
||||
// 滚到列表顶部($listWrap.scrollTop = 0)。两处调用:
|
||||
// - reload() 末尾:覆盖「窗口被隐藏又重新打开」路径 —— 此时 list-wrap 的
|
||||
// scrollTop 是上次关闭前的位置,innerHTML='' 重建 DOM 不会自动归零,
|
||||
// select(0) 配套的 scrollIntoView({block:'nearest'}) 又依赖可见性判定,
|
||||
// 不写死 0 可能在边界情况下漏滚。
|
||||
// - prependClip() 末尾:新复制的卡片插到 DOM 顶端,但 list-wrap.scrollTop
|
||||
// 还停在用户之前滚到的位置,新条目不可见。
|
||||
// 直接写 scrollTop 比依赖 scrollIntoView 更可控(后者行为依赖浏览器对
|
||||
// 可见性的判定,且会同时滚所有祖先滚动容器)。
|
||||
function scrollListToTop() {
|
||||
if ($listWrap) $listWrap.scrollTop = 0;
|
||||
}
|
||||
|
||||
// 把新记录增量插入列表。失败时由调用方 fallback 到 reload()。
|
||||
// 插入位 = 0:主进程顺序就是 created_at DESC(最新活跃在最顶),新插入的
|
||||
// 这条记录入库时 created_at = now,天然就是最顶上 —— 直接 unshift 即可。
|
||||
// 选中态:不再保留旧选中,统一在末尾用 select(0) 把新条目点亮并滚到顶部。
|
||||
function prependClip(clip) {
|
||||
refreshTodayKey();
|
||||
// 空列表时 render() 亮起了空状态;第一条增量进来必须熄掉,
|
||||
// 否则卡片和"暂无内容"会同时显示。
|
||||
$empty.classList.add('hidden');
|
||||
clips.unshift(clip);
|
||||
// 渲染端的二次裁剪 —— 主进程 SQL LIMIT 是配置里的 displayLimit,这里再守一道
|
||||
// 防止 IPC 之间收到 N 条新增时把数组无限撑大;超过上限的尾部从数组+DOM 一起裁掉。
|
||||
const cap = displayLimit;
|
||||
if (clips.length > cap) {
|
||||
clips.pop(); // 刚 unshift 的在最顶,被裁的永远是尾部的旧项
|
||||
const trimmedEl = $cards[$cards.length - 1];
|
||||
$cards.pop();
|
||||
// IntersectionObserver 持有已观察元素的强引用:不 unobserve,
|
||||
// 被裁的 <img> 和 buildCard 闭包会滞留到下一次整表 render 才能 GC。
|
||||
const img = trimmedEl.querySelector('.image-preview');
|
||||
if (img) imgObserver.unobserve(img);
|
||||
// FIFO 缓存里若有这个 id 的 base64,主动清掉;不依赖自然淘汰,
|
||||
// 否则会占着一个 slot 直到被新插入挤掉。
|
||||
if (img && img.dataset.imgId) {
|
||||
const fromFav = img.dataset.imgFav === '1';
|
||||
fifoCache.delete((fromFav ? 'f' : 'c') + img.dataset.imgId);
|
||||
}
|
||||
trimmedEl.remove();
|
||||
}
|
||||
// 选中态:所有已有卡片整体后移一位(之前的 +1 / clamp 是为了"保留旧选中"),
|
||||
// 现在策略改为"新复制 = 跳到第一条",旧选中下标没有意义了,直接交给下面的 select(0) 接管。
|
||||
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');
|
||||
}
|
||||
// 选中态:强制选中第一条(最新复制的条目),并滚到顶部让 Enter / 数字键 1 复制它。
|
||||
// select() 内部会遍历 $cards 把旧 'selected' 清掉、把 $cards[0] 点亮,
|
||||
// 然后 scrollIntoView({block:'nearest'}) —— 这里再调 scrollListToTop() 兜底,
|
||||
// 避免 nearest 把"已可见"误判导致不滚(参见 scrollListToTop 注释)。
|
||||
if (clips.length > 0) select(0);
|
||||
scrollListToTop();
|
||||
$statusL.textContent = `显示 ${clips.length} 条`;
|
||||
}
|
||||
|
||||
// ---------- 选择 ----------
|
||||
|
||||
function select(idx) {
|
||||
if (idx < 0 || idx >= clips.length) return;
|
||||
selectedIdx = idx;
|
||||
if (!$cards.length) return;
|
||||
$cards.forEach((el, i) => el.classList.toggle('selected', i === idx));
|
||||
const sel = $cards[idx];
|
||||
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function moveSelection(delta) {
|
||||
if (clips.length === 0) return;
|
||||
if (selectedIdx < 0) { select(0); return; }
|
||||
select(Math.max(0, Math.min(clips.length - 1, selectedIdx + delta)));
|
||||
}
|
||||
|
||||
// ---------- 操作 ----------
|
||||
|
||||
// 收藏 Tab 的复制:c.id 是 favId —— 与历史 id 分属两个库、自增数会撞号,
|
||||
// 绝不能走 moveToTop(clips:movetop 会按同号查到一条无关的历史记录)。
|
||||
// 走 favorites:copy,写收藏行自身存下的内容(源记录被清了也照样能复制)。
|
||||
async function copyFavorite(favId) {
|
||||
try {
|
||||
const result = await favCopy(favId);
|
||||
$statusL.textContent = result && result.copied
|
||||
? '已写入剪贴板(按 Ctrl+V 粘贴)'
|
||||
: '复制失败';
|
||||
} catch (e) {
|
||||
console.error('[renderer] favCopy failed:', e);
|
||||
$statusL.textContent = '复制失败:' + (e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// 「复制一条」的统一入口:历史 Tab 走 movetop(写剪贴板 + 置顶),
|
||||
// 收藏 Tab 走 favorites:copy。双击 / Enter / 数字键都收口到这里。
|
||||
function copyEntry(c) {
|
||||
if (currentTab === 'favorites') return copyFavorite(c.id);
|
||||
return moveToTop(c.id);
|
||||
}
|
||||
|
||||
async function moveToTop(id) {
|
||||
try {
|
||||
const result = await movetop(id);
|
||||
await reload();
|
||||
// 点选只写系统剪贴板(不自动粘贴):成功 → 提示按 Ctrl+V;失败 → 复制失败
|
||||
$statusL.textContent = result && result.pasted
|
||||
? '已粘贴'
|
||||
: (result && result.copied ? '已写入剪贴板(按 Ctrl+V 粘贴)' : '复制失败');
|
||||
} catch (e) {
|
||||
console.error('[renderer] movetop failed:', e);
|
||||
$statusL.textContent = '复制失败:' + (e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeClip(id) {
|
||||
if (!confirm('确定删除此条?此操作不可撤销。')) return;
|
||||
await remove(id);
|
||||
await reload();
|
||||
updateHistoryCount();
|
||||
}
|
||||
|
||||
// 并发守卫:reload 的触发方很多(搜索防抖 / IPC 推送 / 切 Tab / 复制回显),
|
||||
// 两次重叠时渲染结果必须由"最后发起的"决定,而不是"最后返回的" ——
|
||||
// 否则慢查询的旧结果会盖掉新查询(搜索框显示 ab、列表停在 a 的结果上)。
|
||||
let reloadSeq = 0;
|
||||
async function reload() {
|
||||
const seq = ++reloadSeq;
|
||||
const query = $search.value.trim();
|
||||
$refreshBtn.classList.add('spinning');
|
||||
// 先落局部变量:过期响应(seq 失配)在下面被丢弃时碰不到全局 clips,
|
||||
// 否则慢响应乱序到达会把 clips 数组污染成旧查询的结果(DOM 新、数组旧 → 分叉)。
|
||||
let rows;
|
||||
try {
|
||||
if (currentTab === 'history') {
|
||||
rows = await list(query);
|
||||
} else {
|
||||
// 收藏列表走配置里的 displayLimit(默认 300),避免一次建几千个 DOM 节点。
|
||||
// 徽标计数不能用 clips.length(被 displayLimit 截断会失真)——交给
|
||||
// updateFavCount()(favorites:count IPC)在每条 reload 路径上保证真实值。
|
||||
rows = await favList(query);
|
||||
updateFavCount();
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq !== reloadSeq) return; // 已有更新的请求:错误状态交给它处理
|
||||
console.error('[renderer] reload failed:', e);
|
||||
$statusL.textContent = '加载失败:' + (e.message || e);
|
||||
$refreshBtn.classList.remove('spinning');
|
||||
return;
|
||||
}
|
||||
if (seq !== reloadSeq) return; // 过期响应:丢弃,别拿旧查询的结果渲染
|
||||
clips = rows;
|
||||
selectedIdx = -1;
|
||||
render();
|
||||
// 窗口刚被唤起 / 切 Tab / 刷新列表 / 搜索结果更新:都强制选中第一条
|
||||
// 并滚到顶部,让最新内容成为焦点(按 1 / Enter 复制即拷贝最新条目)。
|
||||
// 之前用 prevId → restored 保留旧选中,但用户的旧选中此时通常已不在
|
||||
// 视口(scrollTop 仍是关闭前的位置),体验不连贯。
|
||||
if (clips.length > 0) select(0);
|
||||
scrollListToTop();
|
||||
$refreshBtn.classList.remove('spinning');
|
||||
}
|
||||
|
||||
let favPromise = Promise.resolve();
|
||||
async function toggleFavorite(c, tab) {
|
||||
favPromise = favPromise.then(async () => {
|
||||
try {
|
||||
// 全程用点击时捕获的 tab 分发 —— 不在执行时读全局 currentTab(见调用处注释)
|
||||
let willFavorite;
|
||||
if (tab === 'favorites') {
|
||||
// 收藏 Tab 的卡片 —— c.id 是 favId
|
||||
await favRemove(c.id);
|
||||
willFavorite = false;
|
||||
} else if (c.favorited) {
|
||||
await favRemove(c.favId);
|
||||
willFavorite = false;
|
||||
} else {
|
||||
// favAdd 通过 IPC 返回新 fav row,row.id 是 favId —— 必须存到 c 上,否则下次 ★ 无法 remove
|
||||
const row = await favAdd(c.id);
|
||||
if (row && row.id) c.favId = row.id;
|
||||
willFavorite = true;
|
||||
}
|
||||
if (tab === 'favorites') {
|
||||
// 收藏 Tab 取消后,重新加载列表(被取消的卡片需从列表消失)
|
||||
await reload();
|
||||
} else {
|
||||
// 历史 Tab:只更新当前卡片 DOM
|
||||
const node = $list.querySelector(`.card[data-id="${c.id}"]`);
|
||||
if (node) {
|
||||
const btn = node.querySelector('.action.fav');
|
||||
btn.classList.toggle('favorited', willFavorite);
|
||||
btn.title = willFavorite ? '已收藏(点击取消)' : '收藏';
|
||||
}
|
||||
// 同步状态对象
|
||||
c.favorited = willFavorite;
|
||||
if (!willFavorite) delete c.favId;
|
||||
}
|
||||
// 触发收藏徽标更新
|
||||
updateFavCount();
|
||||
} catch (e) {
|
||||
$statusL.textContent = '收藏操作失败:' + (e.message || e);
|
||||
}
|
||||
});
|
||||
return favPromise;
|
||||
}
|
||||
|
||||
// ---------- 键盘 ----------
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// 全局快捷键:不看焦点(在搜索框里也能 F5 / Ctrl+L)。
|
||||
if (e.key === 'F5') {
|
||||
e.preventDefault();
|
||||
reload();
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'l') {
|
||||
// Ctrl+L 聚焦搜索框。组合键完整匹配避免吃掉 Ctrl+Shift+L 之类。
|
||||
e.preventDefault();
|
||||
$search.focus();
|
||||
$search.select();
|
||||
return;
|
||||
}
|
||||
|
||||
// 搜索框聚焦时不抢键盘
|
||||
const inSearch = document.activeElement === $search;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
// 搜索框里按 Esc 先退出搜索,不要直接把窗口关掉。
|
||||
// (这个分支必须在无条件关闭之前,否则永远走不到。)
|
||||
if (inSearch) {
|
||||
clearSearch();
|
||||
$search.blur();
|
||||
return;
|
||||
}
|
||||
// 用户主动关闭:隐藏窗口而不是销毁它(应用留在托盘)。
|
||||
// 销毁后再唤出要走 BrowserWindow 冷路径,100~300ms 不可感延迟。
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!inSearch) {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); moveSelection(1); return; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); moveSelection(-1); return; }
|
||||
if (e.key === 'PageDown') { e.preventDefault(); moveSelection(5); return; }
|
||||
if (e.key === 'PageUp') { e.preventDefault(); moveSelection(-5); return; }
|
||||
if (e.key === 'Home') { e.preventDefault(); select(0); return; }
|
||||
if (e.key === 'End') { e.preventDefault(); select(clips.length - 1); return; }
|
||||
// 数字键 1..9 按当前位置复制(最顶上 = 1);历史 Tab 同时置顶
|
||||
if (/^[1-9]$/.test(e.key)) {
|
||||
e.preventDefault();
|
||||
const slot = parseInt(e.key, 10) - 1;
|
||||
if (slot < clips.length) copyEntry(clips[slot]);
|
||||
return;
|
||||
}
|
||||
// Enter:与"复制"按钮功能一致 —— 复制选中卡片(历史 Tab 同时置顶)
|
||||
// 当焦点在卡片上的某个按钮时,由按钮自身默认行为处理,避免重复触发
|
||||
if (e.key === 'Enter') {
|
||||
const active = document.activeElement;
|
||||
if (active && active.tagName === 'BUTTON') return;
|
||||
e.preventDefault();
|
||||
if (selectedIdx >= 0 && selectedIdx < clips.length) {
|
||||
copyEntry(clips[selectedIdx]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 搜索 ----------
|
||||
|
||||
let searchTimer = null;
|
||||
|
||||
function syncSearchClear() {
|
||||
// 输入框有任何字符就显示清空按钮;空了就藏起来。trim 前判断,
|
||||
// 让用户敲一两个空格也能用按钮清掉(reload 走 trim,不会真的去搜)。
|
||||
$searchClear.classList.toggle('visible', $search.value.length > 0);
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
if (!$search.value) return;
|
||||
$search.value = '';
|
||||
syncSearchClear();
|
||||
reload();
|
||||
}
|
||||
|
||||
$search.addEventListener('input', () => {
|
||||
syncSearchClear();
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(reload, 80);
|
||||
});
|
||||
$search.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
// Just select the first result; don't paste (Enter semantic conflict).
|
||||
if (clips.length > 0) select(0);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
$searchClear.addEventListener('click', () => {
|
||||
clearSearch();
|
||||
// 点完按钮焦点会落在按钮上 —— 拉回输入框,让用户能继续敲而不是按 Tab
|
||||
$search.focus();
|
||||
});
|
||||
|
||||
// 启动时同步一次(兜底:将来若有"恢复上次搜索"逻辑也能直接复用)
|
||||
syncSearchClear();
|
||||
|
||||
// ---------- 实时更新 ----------
|
||||
|
||||
subscribe()
|
||||
.then(() => {
|
||||
$ipcDot.classList.add('connected');
|
||||
$ipcDot.title = 'IPC 已连接';
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('[renderer] subscribe failed:', e);
|
||||
$ipcDot.classList.add('error');
|
||||
$ipcDot.title = 'IPC 失败: ' + (e.message || e);
|
||||
});
|
||||
onAdded((clip) => {
|
||||
// 收藏 Tab 走自己的 refresh;搜索时新项可能不匹配结果集,也走 reload。
|
||||
// 只在 history Tab + 搜索框为空时走增量插入,避免全表 innerHTML 重建。
|
||||
if (currentTab !== 'history' || $search.value.trim() !== '') {
|
||||
reload();
|
||||
updateHistoryCount();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
prependClip(clip);
|
||||
updateHistoryCount();
|
||||
} catch (e) {
|
||||
console.error('[renderer] prepend failed, falling back to reload:', e);
|
||||
reload();
|
||||
updateHistoryCount();
|
||||
}
|
||||
});
|
||||
onRefresh(() => { reload(); updateHistoryCount(); });
|
||||
|
||||
favSubscribe().catch((e) => console.error('[renderer] favSubscribe failed:', e));
|
||||
onFavoritesChanged(() => {
|
||||
if (currentTab === 'favorites') reload();
|
||||
else updateFavCount();
|
||||
});
|
||||
|
||||
async function updateFavCount() {
|
||||
try {
|
||||
// 只要一个数字:以前这里拉的是全部收藏(含图片 base64),只为读 .length
|
||||
$favCount.textContent = String(await favCountOf());
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function updateHistoryCount() {
|
||||
try {
|
||||
// clips:count 走主进程内存里的行数缓存(stmt.count 命中时不再 COUNT(*) 全表),
|
||||
// 每条新增 / 删除 / 清空历史都会实时同步,徽标总能反映真实总数。
|
||||
$historyCount.textContent = String(await count());
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ---------- 主题 ----------
|
||||
|
||||
const { get: themeGet, subscribe: themeSubscribe, onSystemChanged } = window.themeApi;
|
||||
|
||||
async function applyTheme() {
|
||||
try {
|
||||
const cfg = await themeGet();
|
||||
// 优先用 effectiveTheme:「跟随系统」开时 cfg.theme 已经是 effective,
|
||||
// 但要兼容旧主进程(旧版只发 raw theme),用 || 兜底。
|
||||
const theme = cfg.effectiveTheme || cfg.theme;
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('data-accent', cfg.accent);
|
||||
try {
|
||||
// 同时缓存 raw(用于下次启动 theme-boot 兜底)和 effective(用于
|
||||
// 「跟随系统」开时直接跳过 flash)。如果主进程旧版没给 rawTheme,
|
||||
// 就退回到 cfg.theme 字段本身。
|
||||
localStorage.setItem('clipboard-theme', JSON.stringify({
|
||||
theme: cfg.rawTheme || cfg.theme,
|
||||
effectiveTheme: cfg.effectiveTheme || cfg.theme,
|
||||
accent: cfg.accent,
|
||||
followSystem: cfg.followSystem,
|
||||
}));
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
console.error('[theme] applyTheme failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
onSystemChanged((payload) => {
|
||||
// 系统主题切换只更新 DOM,不写 localStorage:payload 里没有 rawTheme,
|
||||
// 写下去会让后续启动在「跟随系统」关掉时拿不到用户真正选的主题。
|
||||
// localStorage 由 applyTheme()(持有完整 rawTheme)统一维护。
|
||||
const theme = payload.effectiveTheme || payload.theme;
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('data-accent', payload.accent);
|
||||
});
|
||||
|
||||
// 不订阅的话,主进程的 theme:system-changed 根本不会发到这个窗口
|
||||
themeSubscribe().catch((e) => console.error('[renderer] themeSubscribe failed:', e));
|
||||
|
||||
applyTheme();
|
||||
|
||||
// ---------- 启动 ----------
|
||||
|
||||
// 关闭按钮:用户主动关闭走 hide(),不销毁窗口(理由见 Esc 分支)。
|
||||
$closeBtn.addEventListener('click', () => hide());
|
||||
|
||||
// 刷新按钮
|
||||
$refreshBtn.addEventListener('click', () => reload());
|
||||
|
||||
// 设置按钮
|
||||
$settingsBtn.addEventListener('click', () => openSettings());
|
||||
|
||||
// 置顶按钮:图标状态由 aria-pressed 表达(开启=true)
|
||||
function syncPinBtn(on) {
|
||||
$pinBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
$pinBtn.title = on ? '取消窗口置顶' : '窗口置顶';
|
||||
// 图标走 SVG,开/关靠 CSS 上色区分(accent vs muted)
|
||||
}
|
||||
|
||||
$pinBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const next = await toggleAlwaysOnTop(); // 不传 on → 翻转
|
||||
syncPinBtn(next === true);
|
||||
$statusL.textContent = next ? '已开启窗口置顶' : '已关闭窗口置顶';
|
||||
} catch (e) {
|
||||
console.error('[renderer] toggleAlwaysOnTop failed:', e);
|
||||
$statusL.textContent = '切换置顶失败:' + (e.message || e);
|
||||
}
|
||||
});
|
||||
|
||||
// 启动时同步主进程的初始状态(默认开启)
|
||||
getAlwaysOnTop()
|
||||
.then((on) => syncPinBtn(on === true))
|
||||
.catch((e) => console.error('[renderer] getAlwaysOnTop failed:', e));
|
||||
|
||||
// 启动时直接刷新两个 tab 的计数——不依赖当前 Tab
|
||||
updateHistoryCount();
|
||||
updateFavCount();
|
||||
reload();
|
||||
|
||||
// ---------- 界面显示条数(displayLimit) ----------
|
||||
// 启动时拉一次配置,让前端 displayLimit 跟主进程保持一致;
|
||||
// 设置窗口改了之后主进程会推 settings:display-limit-changed,所有窗口重拉。
|
||||
async function syncDisplayLimit() {
|
||||
try {
|
||||
const c = await settingsGet();
|
||||
if (c && c.displayLimit) displayLimit = c.displayLimit;
|
||||
} catch (_) {}
|
||||
}
|
||||
syncDisplayLimit();
|
||||
onDisplayLimitChanged(({ displayLimit: next }) => {
|
||||
if (typeof next === 'number' && next > 0) {
|
||||
displayLimit = next;
|
||||
reload();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 应用图标(标题栏左上角) ----------
|
||||
// 一次性从主进程拿 PNG data URL 喂给 <img>。主进程已缓存 .ico→PNG 的转换,
|
||||
// IPC 命中后基本零成本;图标缺失静默失败,不影响功能。
|
||||
window.api.getAppIcon().then((dataUrl) => {
|
||||
if (!dataUrl) return;
|
||||
const el = document.getElementById('app-icon');
|
||||
if (el) el.src = dataUrl;
|
||||
}).catch(() => { /* 图标缺失不影响功能 */ });
|
||||
80
renderer/settings.html
Normal file
80
renderer/settings.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<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" />
|
||||
<link rel="stylesheet" href="themes/tokens.css" />
|
||||
<link rel="stylesheet" href="themes/themes.css" />
|
||||
<link rel="stylesheet" href="themes/accents.css" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<script src="theme-boot.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<header id="drag-bar"><img id="app-icon" alt="" /><h1 id="title">设置</h1><span class="spacer"></span><button id="close-btn" class="icon-btn" title="关闭 (Esc)" aria-label="关闭">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3.5 3.5l9 9M12.5 3.5l-9 9"/>
|
||||
</svg>
|
||||
</button></header>
|
||||
<nav id="tab-bar" role="tablist" aria-label="设置分类">
|
||||
<button class="tab" role="tab" id="tab-appearance" aria-controls="panel-appearance" aria-selected="true" tabindex="0">外观</button>
|
||||
<button class="tab" role="tab" id="tab-general" aria-controls="panel-general" aria-selected="false" tabindex="-1">通用</button>
|
||||
<button class="tab" role="tab" id="tab-about" aria-controls="panel-about" aria-selected="false" tabindex="-1">关于</button>
|
||||
</nav>
|
||||
<main id="settings-body">
|
||||
<section class="tab-panel" role="tabpanel" id="panel-appearance" aria-labelledby="tab-appearance">
|
||||
<div class="setting-row column">
|
||||
<div class="setting-label">主题</div>
|
||||
<div id="theme-grid" class="theme-grid"></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label class="setting-label" for="opt-follow-system">跟随系统主题</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="opt-follow-system" />
|
||||
<span class="slider-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">强调色</div>
|
||||
<div id="accent-grid" class="accent-grid"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab-panel" role="tabpanel" id="panel-general" aria-labelledby="tab-general" hidden>
|
||||
<div class="setting-row"><label class="setting-label" for="opt-hotkey">唤起面板快捷键</label><select id="opt-hotkey"><option value="Ctrl+Alt+V">Ctrl + Alt + V</option><option value="Ctrl+Shift+V">Ctrl + Shift + V</option><option value="Alt+Shift+V">Alt + Shift + V</option><option value="Ctrl+Alt+C">Ctrl + Alt + C</option><option value="">禁用</option></select></div>
|
||||
<div class="setting-row"><label class="setting-label" for="opt-autolaunch">开机自启动</label><label class="switch"><input type="checkbox" id="opt-autolaunch" /><span class="slider-track"></span></label></div>
|
||||
<div class="setting-row"><label class="setting-label" for="opt-maxitems">最大保留条数</label><select id="opt-maxitems"><option value="0">不限制</option><option value="200">200</option><option value="500">500</option><option value="1000">1000</option></select></div>
|
||||
<div class="setting-row"><label class="setting-label" for="opt-displaylimit">界面显示条数</label><select id="opt-displaylimit"><option value="100">100</option><option value="300">300</option><option value="500">500</option><option value="1000">1000</option></select></div>
|
||||
<div class="setting-row"><label class="setting-label" for="opt-pollms">轮询间隔</label><select id="opt-pollms"><option value="500">500 ms</option><option value="1000">1000 ms</option><option value="2000">2000 ms</option></select></div>
|
||||
<div class="setting-row column"><div class="setting-label">数据保存位置</div><div class="path-box"><span id="opt-datadir" class="path-text"></span></div><div class="btn-row"><button id="btn-change-dir" class="btn">更改…</button><button id="btn-open-dir" class="btn">打开文件夹</button></div></div>
|
||||
<div class="setting-row column danger-zone"><div class="setting-label">清空历史(保留置顶)</div><button id="btn-clear" class="btn btn-danger">清空历史</button></div>
|
||||
</section>
|
||||
|
||||
<section class="tab-panel" role="tabpanel" id="panel-about" aria-labelledby="tab-about" hidden>
|
||||
<div class="about-head">
|
||||
<div class="about-icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="8" y="3" width="8" height="3" rx="1"/>
|
||||
<rect x="5" y="5" width="14" height="16" rx="2"/>
|
||||
<path d="M9 11h6M9 14.5h6M9 18h3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="about-name">剪贴板</div>
|
||||
<div class="about-tagline">简洁的剪贴板管理器</div>
|
||||
</div>
|
||||
<div class="setting-row column">
|
||||
<div class="setting-label">开发者</div>
|
||||
<div class="about-devline">
|
||||
<span class="about-dev">关济寰</span>
|
||||
<a class="ext-link" href="https://www.guanjihuan.com/about" target="_blank" rel="noopener">https://www.guanjihuan.com/about</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer id="status"><span id="status-left">就绪</span></footer>
|
||||
</div>
|
||||
<script src="settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
227
renderer/settings.js
Normal file
227
renderer/settings.js
Normal file
@@ -0,0 +1,227 @@
|
||||
'use strict';
|
||||
|
||||
// ---------- 标签页 ----------
|
||||
// 放在 IPC 解构之前:即使 preload 没注入,标签页也要能切换。
|
||||
|
||||
(() => {
|
||||
const bar = document.getElementById('tab-bar');
|
||||
if (!bar) return;
|
||||
const tabs = Array.from(bar.querySelectorAll('[role="tab"]'));
|
||||
if (!tabs.length) return;
|
||||
|
||||
function select(tab) {
|
||||
for (const t of tabs) {
|
||||
const on = t === tab;
|
||||
t.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
t.tabIndex = on ? 0 : -1;
|
||||
const panel = document.getElementById(t.getAttribute('aria-controls'));
|
||||
if (panel) panel.hidden = !on;
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of tabs) {
|
||||
t.addEventListener('click', () => { select(t); t.focus(); });
|
||||
}
|
||||
|
||||
bar.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
e.preventDefault();
|
||||
// 按「当前选中」而不是 document.activeElement 找位置:
|
||||
// 窗口未获焦点时 activeElement 不可靠,测试里也无法聚焦隐藏窗口。
|
||||
const i = tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true');
|
||||
const step = e.key === 'ArrowRight' ? 1 : tabs.length - 1;
|
||||
const next = tabs[((i < 0 ? 0 : i) + step) % tabs.length];
|
||||
select(next);
|
||||
next.focus();
|
||||
});
|
||||
|
||||
select(tabs[0]);
|
||||
})();
|
||||
|
||||
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)}
|
||||
// settings:set 在主进程先改运行时状态、后写盘;写盘失败(磁盘满/只读/杀软锁文件)
|
||||
// 会带 saved:false 回来 —— 本次会话已生效但重启还原,必须明确提示,
|
||||
// 不能让用户以为设置已经保存。
|
||||
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}
|
||||
$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)});
|
||||
$poll.addEventListener('change',async()=>{const res=await set('pollMs',parseInt($poll.value,10));if(!flashIfNotSaved(res))flash('轮询间隔已设为 '+$poll.value+' ms')});
|
||||
$hotkey.addEventListener('change',async()=>{
|
||||
const res=await set('hotkey',$hotkey.value);
|
||||
if(flashIfNotSaved(res))return;
|
||||
if($hotkey.value&&res&&res.hotkeyRegistered===false){
|
||||
// 主进程 register 返回 false:组合键被别的程序占用。配置照存,
|
||||
// 但必须告诉用户,否则他会以为快捷键坏了。
|
||||
flash('快捷键可能被其他程序占用,未生效');
|
||||
}else{
|
||||
flash($hotkey.value?'唤起快捷键已设为 '+$hotkey.value:'已禁用唤起快捷键');
|
||||
}
|
||||
});
|
||||
$open.addEventListener('click',()=>openDir());
|
||||
$clear.addEventListener('click',async()=>{if(!confirm('确定清空所有非置顶历史记录?此操作不可撤销。'))return;flash('已清除 '+await clearHistory()+' 条')});
|
||||
$change.addEventListener('click',async()=>{if(typeof chooseDir!=='function')return;const res=await chooseDir();if(res&&res.ok===false&&res.message)flash(res.message)});
|
||||
$close.addEventListener('click',()=>window.close());document.addEventListener('keydown',e=>{if(e.key==='Escape')window.close()});refresh();
|
||||
|
||||
// ---------- 应用图标(标题栏左上角)----------
|
||||
// 一次性从主进程拿 PNG data URL 喂给 <img>。与主窗口共用同一个 IPC,
|
||||
// 主进程缓存命中后零成本;图标缺失静默失败,不影响设置面板功能。
|
||||
window.api.getAppIcon().then((dataUrl) => {
|
||||
if (!dataUrl) return;
|
||||
const el = document.getElementById('app-icon');
|
||||
if (el) el.src = dataUrl;
|
||||
}).catch(() => { /* 图标缺失不影响功能 */ });
|
||||
|
||||
// ---------- 外链(关于页)----------
|
||||
// 窗口不能直接导航到外部 URL(会把设置页整个顶掉),点击统一拦下来
|
||||
// 走主进程 shell.openExternal 交给系统浏览器;scheme 白名单在主进程卡。
|
||||
for (const a of document.querySelectorAll('a.ext-link')) {
|
||||
a.addEventListener('click', (e) => { e.preventDefault(); if (typeof openExternal === 'function') openExternal(a.href); });
|
||||
}
|
||||
|
||||
// ---------- 主题 ----------
|
||||
|
||||
// 不要用 `const themeApi`:contextBridge 暴露的 window.themeApi 是不可配置属性,
|
||||
// 顶层同名 const 会让整个脚本在实例化阶段抛 SyntaxError(一行都不执行)。
|
||||
const themeIpc = window.themeApi;
|
||||
const { set: themeSet, setAccent, setFollowSystem, onSystemChanged } = themeIpc;
|
||||
|
||||
const THEMES = [
|
||||
{ id: 'linear', name: 'Linear Dark', desc: '极简暗色专业', bg: '#0E0E12', text: '#E6E6E9', accent: '#5E6AD2' },
|
||||
{ id: 'midnight', name: 'Midnight', desc: '极致暗色', bg: '#08090B', text: '#E8E8EA', accent: '#7C3AED' },
|
||||
{ id: 'carbon', name: 'Carbon', desc: 'VS Code 灰', bg: '#1A1D23', text: '#D4D4D4', accent: '#3B82F6' },
|
||||
{ id: 'solarized', name: 'Solarized', desc: '经典 Sola', bg: '#002B36', text: '#93A1A1', accent: '#268BD2' },
|
||||
{ id: 'github', name: 'GitHub Dark', desc: 'GitHub 风', bg: '#0D1117', text: '#C9D1D9', accent: '#8957E5' },
|
||||
{ id: 'light', name: 'Light', desc: '白天模式', bg: '#FAFAFA', text: '#111827', accent: '#5E6AD2' },
|
||||
];
|
||||
|
||||
const ACCENTS = [
|
||||
{ id: 'purple', color: '#5E6AD2' },
|
||||
{ id: 'blue', color: '#3B82F6' },
|
||||
{ id: 'green', color: '#10B981' },
|
||||
{ id: 'orange', color: '#F59E0B' },
|
||||
{ id: 'red', color: '#EF4444' },
|
||||
];
|
||||
|
||||
function renderThemes(currentTheme) {
|
||||
const grid = document.getElementById('theme-grid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '';
|
||||
for (const t of THEMES) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'theme-card' + (t.id === currentTheme ? ' active' : '');
|
||||
card.dataset.theme = t.id;
|
||||
card.innerHTML = `
|
||||
<div class="theme-preview" style="background:${t.bg};color:${t.text}">
|
||||
<span class="theme-preview-text">Aa</span>
|
||||
<span class="theme-preview-accent" style="background:${t.accent}"></span>
|
||||
</div>
|
||||
<div class="theme-info">
|
||||
<div class="theme-name">${t.name}</div>
|
||||
<div class="theme-desc">${t.desc}</div>
|
||||
</div>
|
||||
`;
|
||||
card.addEventListener('click', async () => {
|
||||
const res = await themeSet(t.id);
|
||||
if (res) {
|
||||
const eff = res.effectiveTheme || res.theme;
|
||||
document.documentElement.setAttribute('data-theme', eff);
|
||||
document.documentElement.setAttribute('data-accent', res.accent);
|
||||
try {
|
||||
// rawTheme 在 themeSet 路径上 = eff(顺手把 followSystem 关了),
|
||||
// 但旧主进程没发 rawTheme 字段,这里用 eff 兜底。
|
||||
localStorage.setItem('clipboard-theme', JSON.stringify({
|
||||
theme: res.rawTheme || eff,
|
||||
effectiveTheme: eff,
|
||||
accent: res.accent,
|
||||
followSystem: res.followSystem,
|
||||
}));
|
||||
} catch (_) {}
|
||||
document.getElementById('opt-follow-system').checked = false;
|
||||
renderThemes(eff);
|
||||
flashIfNotSaved(res);
|
||||
}
|
||||
});
|
||||
grid.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAccents(currentAccent) {
|
||||
const grid = document.getElementById('accent-grid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '';
|
||||
for (const a of ACCENTS) {
|
||||
const sw = document.createElement('button');
|
||||
sw.className = 'accent-swatch' + (a.id === currentAccent ? ' active' : '');
|
||||
sw.dataset.accent = a.id;
|
||||
sw.style.background = a.color;
|
||||
sw.title = a.id;
|
||||
sw.addEventListener('click', async () => {
|
||||
const res = await setAccent(a.id);
|
||||
if (res) {
|
||||
document.documentElement.setAttribute('data-accent', res.accent);
|
||||
try {
|
||||
localStorage.setItem('clipboard-theme', JSON.stringify({
|
||||
theme: res.rawTheme || res.theme,
|
||||
effectiveTheme: res.effectiveTheme || res.theme,
|
||||
accent: res.accent,
|
||||
followSystem: res.followSystem,
|
||||
}));
|
||||
} catch (_) {}
|
||||
renderAccents(res.accent);
|
||||
flashIfNotSaved(res);
|
||||
}
|
||||
});
|
||||
grid.appendChild(sw);
|
||||
}
|
||||
}
|
||||
|
||||
const $follow = document.getElementById('opt-follow-system');
|
||||
if ($follow) {
|
||||
$follow.addEventListener('change', async () => {
|
||||
const res = await setFollowSystem($follow.checked);
|
||||
if (res) {
|
||||
try {
|
||||
localStorage.setItem('clipboard-theme', JSON.stringify({
|
||||
theme: res.rawTheme || res.theme,
|
||||
effectiveTheme: res.effectiveTheme || res.theme,
|
||||
accent: res.accent,
|
||||
followSystem: res.followSystem,
|
||||
}));
|
||||
} catch (_) {}
|
||||
if (!flashIfNotSaved(res)) flash($follow.checked ? '已开启跟随系统' : '已关闭跟随系统');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSystemChanged((payload) => {
|
||||
// 同 renderer:只更新 DOM,不写 localStorage(payload 里没有 rawTheme)。
|
||||
const eff = payload.effectiveTheme || payload.theme;
|
||||
document.documentElement.setAttribute('data-theme', eff);
|
||||
document.documentElement.setAttribute('data-accent', payload.accent);
|
||||
renderThemes(eff);
|
||||
});
|
||||
|
||||
async function initTheme() {
|
||||
try {
|
||||
// 先订阅再拉当前值,否则跟随系统改主题时这个窗口收不到广播
|
||||
try { await themeIpc.subscribe(); }
|
||||
catch (e) { console.error('[theme] subscribe failed:', e.message); }
|
||||
const cfg = await themeIpc.get();
|
||||
const eff = cfg.effectiveTheme || cfg.theme;
|
||||
document.documentElement.setAttribute('data-theme', eff);
|
||||
document.documentElement.setAttribute('data-accent', cfg.accent);
|
||||
if ($follow) $follow.checked = cfg.followSystem;
|
||||
renderThemes(eff);
|
||||
renderAccents(cfg.accent);
|
||||
} catch (e) {
|
||||
console.error('[theme] initTheme failed:', e.message);
|
||||
}
|
||||
}
|
||||
initTheme();
|
||||
952
renderer/styles.css
Normal file
952
renderer/styles.css
Normal file
@@ -0,0 +1,952 @@
|
||||
/* 现代简约样式 —— 通过 token 系统接入 6 主题 / 5 强调色 */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg-base);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--fs-base);
|
||||
line-height: var(--lh-base);
|
||||
letter-spacing: var(--tracking-cn);
|
||||
/* Windows + CJK 小字清晰度关键设置:
|
||||
* - 默认 subpixel-antialiased 保留 RGB 子像素渲染,对中文笔画描边至关重要;
|
||||
* 之前强制 antialiased/grayscale 会让 12px 以下的汉字变成"雾蒙蒙"的灰块。
|
||||
* - text-rendering 用 auto,让浏览器按字号自适应:中文小字走 subpixel,
|
||||
* 英文大标题再走 optimizeLegibility 拿连字。
|
||||
* - font-feature 只在 ≥14px 的西文场景开启,避免给小字号增加渲染负担。 */
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
-moz-osx-font-smoothing: auto;
|
||||
text-rendering: auto;
|
||||
font-feature-settings: normal;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 大字号西文(标题、关于页)单独开启连字,小字号不参与 */
|
||||
#title, .about-name, .theme-preview-text {
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-feature-settings: "kern" 1, "liga" 1, "calt" 1;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------- 拖动区 ---------- */
|
||||
|
||||
#drag-bar {
|
||||
-webkit-app-region: drag;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 7px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
#title {
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text);
|
||||
letter-spacing: 0.2px;
|
||||
/* 原本 margin-left: 4px 用来撑开标题与左边框的距离;
|
||||
* 现在 #app-icon 在更左侧,由 #app-icon 的 margin-right 控制间隙更合理。 */
|
||||
margin-left: 0;
|
||||
/* 标题单行:body 的 line-height=1.6 在这里会撑出多余空白,
|
||||
* 关掉让标题跟按钮在同一基线。 */
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 标题栏左上角小图标:16×16 显示位,源是 32×32 PNG(主进程从 .ico resize
|
||||
* 而来),对常见 1.0/1.25/1.5/2x 缩放都锐利。
|
||||
* 父级 #drag-bar 已设 -webkit-app-region: drag,按住图标仍可拖动窗口。 */
|
||||
#app-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
margin-right: 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.icon-btn {
|
||||
-webkit-app-region: no-drag;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
/* SVG 图标:颜色继承 currentColor,让 aria-pressed 状态色直接生效 */
|
||||
.icon-btn svg {
|
||||
display: block;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#close-btn:hover {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* 置顶开启:图标上色 + 轻微背景,关闭时回到 muted */
|
||||
#pin-btn[aria-pressed="true"] {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
#pin-btn[aria-pressed="true"]:hover {
|
||||
/* 开启态下 hover 不要洗掉上色,只把背景加深一点 */
|
||||
background: var(--accent-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#refresh-btn.spinning svg {
|
||||
animation: spin 0.8s linear infinite;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.ipc-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: 0 4px 0 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
transition: background var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.ipc-dot.connected {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.ipc-dot.error {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
/* ---------- Tab 栏 ---------- */
|
||||
|
||||
#tabs {
|
||||
-webkit-app-region: no-drag;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-base);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
/* 历史窗口的 Tab —— 作用域限定到 #tabs,避免被下面 settings 面板的
|
||||
* 通用 .tab 规则覆盖(同一文件后写的同优先级规则会赢,导致 padding /
|
||||
* transition 跟设计稿不一致)。 */
|
||||
#tabs .tab {
|
||||
padding: 8px 10px 7px;
|
||||
border: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: transparent;
|
||||
color: var(--text-soft);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
/* 与 #title 一致:行高压成 1,文字基线跟标题对齐;不要靠 padding 顶。 */
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#tabs .tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#tabs .tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
padding: 0 6px;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 8px;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
line-height: 16px;
|
||||
height: 16px;
|
||||
font-weight: var(--fw-medium);
|
||||
vertical-align: 1px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
#tabs .tab.active .tab-count {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- 搜索 ---------- */
|
||||
|
||||
#search-row {
|
||||
padding: 8px 12px 10px;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
#search-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
#search {
|
||||
-webkit-app-region: no-drag;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 32px 0 32px;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-size: var(--fs-base);
|
||||
line-height: 1.4;
|
||||
outline: none;
|
||||
transition: background var(--transition-base), border-color var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
#search:focus {
|
||||
background: var(--bg-overlay);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
#search:focus + #search-clear,
|
||||
#search-wrap:focus-within #search-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#search::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
#search-clear {
|
||||
-webkit-app-region: no-drag;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
#search-clear.visible {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
#search-clear:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ---------- 列表 ---------- */
|
||||
|
||||
#list-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px 8px;
|
||||
background: var(--bg-base);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
#list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
#list-wrap::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
#list-wrap::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
#list-wrap::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
}
|
||||
#list-wrap::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ---------- 卡片 ---------- */
|
||||
|
||||
.card {
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 9px 12px 10px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-base), border-color var(--transition-base), box-shadow var(--transition-base), transform var(--transition-fast);
|
||||
outline: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: var(--bg-soft);
|
||||
border-color: var(--border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card.selected {
|
||||
background: var(--bg-overlay);
|
||||
border-color: var(--border-strong);
|
||||
border-left: 2px solid var(--accent);
|
||||
padding-left: 11px;
|
||||
}
|
||||
|
||||
.card.selected:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-soft);
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-soft);
|
||||
background: var(--bg-hover);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px 1px 5px;
|
||||
font-weight: var(--fw-medium);
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.badge svg {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.badge-image {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge-image svg {
|
||||
color: var(--accent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.slot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--accent-text);
|
||||
background: var(--accent);
|
||||
border-radius: 5px;
|
||||
letter-spacing: 0;
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.action {
|
||||
-webkit-app-region: no-drag;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
line-height: 1;
|
||||
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.action svg {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card:hover .action,
|
||||
.card.selected .action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.action.copy {
|
||||
color: var(--accent);
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.action.copy:hover {
|
||||
color: var(--accent);
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.action.fav {
|
||||
padding: 5px 7px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.action.fav svg {
|
||||
fill: none;
|
||||
transition: fill var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.action.fav.favorited {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.action.fav.favorited svg {
|
||||
fill: var(--warn);
|
||||
}
|
||||
|
||||
.action.fav:hover {
|
||||
color: var(--warn);
|
||||
background: var(--warn-bg);
|
||||
}
|
||||
|
||||
.action.delete {
|
||||
padding: 5px 7px;
|
||||
}
|
||||
|
||||
.action.delete:hover {
|
||||
color: var(--danger);
|
||||
background: var(--danger-bg);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.text-preview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: var(--fs-base);
|
||||
color: var(--text);
|
||||
/* 中文正文行高 ≥1.55 才不会出现笔画"黏连",这里设 1.6 让 13px 中文清爽 */
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
object-fit: contain;
|
||||
background:
|
||||
linear-gradient(45deg, var(--bg-soft) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, var(--bg-soft) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, var(--bg-soft) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, var(--bg-soft) 75%);
|
||||
background-size: 12px 12px;
|
||||
background-position: 0 0, 0 6px, 6px -6px, -6px 0;
|
||||
}
|
||||
|
||||
/* ---------- 空状态 ---------- */
|
||||
|
||||
#empty {
|
||||
text-align: center;
|
||||
padding: 64px 24px 56px;
|
||||
color: var(--text-soft);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.55;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-soft);
|
||||
font-weight: var(--fw-medium);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
#empty .muted {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ---------- 状态栏 ---------- */
|
||||
|
||||
#status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 7px 14px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
background: var(--bg-base);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#status-left {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shortcuts .dot {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--text-soft);
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* ---------- 设置面板 ---------- */
|
||||
#settings-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
background: var(--bg-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---------- 设置面板:标签栏 ---------- */
|
||||
/* 用 #tab-bar 前缀隔离:上面「历史/收藏」Tab 也用 .tab,但 padding/transition
|
||||
* 跟设置页 Tab 不一样。两份样式同特异性时位置靠后的赢,会把主窗口 Tab
|
||||
* 的 padding 从 5px 12px 改成 6px 10px 5px —— 显式框死作用域避免串味。 */
|
||||
|
||||
#tab-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-base);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
#tab-bar .tab {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 8px 10px 7px;
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-soft);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
#tab-bar .tab:hover { color: var(--text); }
|
||||
|
||||
#tab-bar .tab[aria-selected="true"] {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
#tab-bar .tab:focus-visible { outline: 1px solid var(--accent); outline-offset: -2px; }
|
||||
|
||||
.tab-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 面板滚动条 —— 跟主列表统一 */
|
||||
.tab-panel::-webkit-scrollbar { width: 8px; }
|
||||
.tab-panel::-webkit-scrollbar-track { background: transparent; }
|
||||
.tab-panel::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.tab-panel::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
.tab-panel[hidden] { display: none !important; }
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-base);
|
||||
line-height: 1.4;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.setting-row:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.setting-row.column {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
.setting-label { font-size: var(--fs-base); color: var(--text); font-weight: var(--fw-medium); }
|
||||
.setting-row select {
|
||||
height: 30px;
|
||||
padding: 0 28px 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-soft);
|
||||
color: var(--text);
|
||||
font-size: var(--fs-base);
|
||||
line-height: 1.4;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, var(--text-muted) 50%),
|
||||
linear-gradient(-45deg, transparent 50%, var(--text-muted) 50%);
|
||||
background-position: calc(100% - 13px) 12px, calc(100% - 8px) 12px;
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.setting-row select:hover { border-color: var(--border-strong); }
|
||||
.setting-row select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.path-box {
|
||||
margin-top: 4px;
|
||||
padding: 9px 11px;
|
||||
background: var(--bg-soft);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
}
|
||||
.path-text { font-size: var(--fs-sm); color: var(--text-soft); word-break: break-all; user-select: text; font-family: var(--font-mono); }
|
||||
.btn-row { display: flex; gap: 8px; margin-top: 4px; }
|
||||
.btn {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-base);
|
||||
color: var(--text);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
.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); }
|
||||
.danger-zone { border-color: var(--danger-bg); }
|
||||
.danger-zone:hover { border-color: rgba(239, 68, 68, 0.4); }
|
||||
|
||||
/* 开关 */
|
||||
.switch { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider-track {
|
||||
position: absolute; inset: 0; cursor: pointer;
|
||||
background: var(--border-strong); border-radius: 20px;
|
||||
transition: background var(--transition-base);
|
||||
}
|
||||
.slider-track::before {
|
||||
content: ""; position: absolute; height: 16px; width: 16px; left: 2px; top: 2px;
|
||||
background: var(--bg-overlay); border-radius: 50%;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
.switch input:checked + .slider-track { background: var(--accent); }
|
||||
.switch input:checked + .slider-track::before { transform: translateX(16px); }
|
||||
.switch input:focus-visible + .slider-track { box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
|
||||
/* 主题网格 */
|
||||
.theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--border-soft);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px;
|
||||
background: var(--bg-base);
|
||||
transition: border-color var(--transition-base), transform var(--transition-fast), box-shadow var(--transition-base);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.theme-card:hover {
|
||||
border-color: var(--border-strong);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.theme-card.active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent) inset;
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
position: relative;
|
||||
height: 56px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.theme-preview-text {
|
||||
font-family: var(--font-ui);
|
||||
font-weight: var(--fw-semibold);
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.theme-preview-accent {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.theme-info {
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.theme-name {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text);
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
|
||||
.theme-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* 强调色 */
|
||||
.accent-grid {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.accent-swatch {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border-soft);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.accent-swatch:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.accent-swatch.active {
|
||||
box-shadow: 0 0 0 2px var(--bg-base), 0 0 0 4px var(--text);
|
||||
}
|
||||
|
||||
.accent-swatch.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M3.5 8.5l3 3 6-6.5'/></svg>");
|
||||
background-size: 16px 16px;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* ---------- 设置面板:关于页 ---------- */
|
||||
|
||||
.about-head {
|
||||
text-align: center;
|
||||
padding: 18px 0 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.about-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent-text);
|
||||
margin-bottom: 6px;
|
||||
box-shadow: 0 2px 8px var(--accent-soft);
|
||||
}
|
||||
|
||||
.about-icon svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.about-name {
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.about-tagline {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.about-devline {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.about-dev { font-size: var(--fs-base); color: var(--text); }
|
||||
.ext-link {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.ext-link:hover { text-decoration: underline; opacity: 0.85; }
|
||||
21
renderer/theme-boot.js
Normal file
21
renderer/theme-boot.js
Normal file
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
/**
|
||||
* 首屏主题预应用:在首次绘制前从 localStorage 读取上次的主题/强调色,
|
||||
* 避免等 IPC 返回期间的白屏闪烁。
|
||||
*
|
||||
* 必须是「外部脚本」——页面 CSP 为 default-src 'self',未开 script-src 'unsafe-inline',
|
||||
* 内联 <script> 会被直接拒绝执行。
|
||||
*/
|
||||
try {
|
||||
const cached = JSON.parse(localStorage.getItem('clipboard-theme') || 'null');
|
||||
if (cached) {
|
||||
// 优先用 effectiveTheme:「跟随系统」开时它就是上次实际显示的主题,
|
||||
// 直接套上去能省一次闪。旧版缓存里只有 theme(旧版在 followSystem
|
||||
// 开时存的是 raw,会闪一下),这里向后兼容。
|
||||
const theme = cached.effectiveTheme || cached.theme;
|
||||
if (theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('data-accent', cached.accent || 'purple');
|
||||
}
|
||||
}
|
||||
} catch (e) { /* 静默 */ }
|
||||
36
renderer/themes/accents.css
Normal file
36
renderer/themes/accents.css
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* accents.css — 5 套强调色覆盖
|
||||
* 通过 <html data-accent="..."> 切换
|
||||
*/
|
||||
|
||||
/* 默认(兜底)—— Linear Purple */
|
||||
:root,
|
||||
:root[data-accent="purple"] {
|
||||
--accent: #5E6AD2;
|
||||
--accent-soft: rgba(94, 106, 210, 0.12);
|
||||
--accent-hover: rgba(94, 106, 210, 0.20);
|
||||
}
|
||||
|
||||
:root[data-accent="blue"] {
|
||||
--accent: #3B82F6;
|
||||
--accent-soft: rgba(59, 130, 246, 0.12);
|
||||
--accent-hover: rgba(59, 130, 246, 0.20);
|
||||
}
|
||||
|
||||
:root[data-accent="green"] {
|
||||
--accent: #10B981;
|
||||
--accent-soft: rgba(16, 185, 129, 0.12);
|
||||
--accent-hover: rgba(16, 185, 129, 0.20);
|
||||
}
|
||||
|
||||
:root[data-accent="orange"] {
|
||||
--accent: #F59E0B;
|
||||
--accent-soft: rgba(245, 158, 11, 0.12);
|
||||
--accent-hover: rgba(245, 158, 11, 0.20);
|
||||
}
|
||||
|
||||
:root[data-accent="red"] {
|
||||
--accent: #EF4444;
|
||||
--accent-soft: rgba(239, 68, 68, 0.12);
|
||||
--accent-hover: rgba(239, 68, 68, 0.20);
|
||||
}
|
||||
30
renderer/themes/fonts.css
Normal file
30
renderer/themes/fonts.css
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* fonts.css — Inter + JetBrains Mono 本地字体加载
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('./fonts/Inter-Variable.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('./fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* 字体变量赋值
|
||||
* 中文优先:西文(Inter)排在中文兜底之后,App 里大部分文本是中文,
|
||||
* 让 YaHei/PingFang 先匹配笔画渲染,CJK 之外再走 Inter 拿更好的西文观感。 */
|
||||
:root {
|
||||
--font-ui: 'Inter', 'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Segoe UI', system-ui, -apple-system,
|
||||
BlinkMacSystemFont, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'Consolas',
|
||||
'Microsoft YaHei', monospace;
|
||||
}
|
||||
BIN
renderer/themes/fonts/Inter-Variable.woff2
Normal file
BIN
renderer/themes/fonts/Inter-Variable.woff2
Normal file
Binary file not shown.
BIN
renderer/themes/fonts/JetBrainsMono-Regular.woff2
Normal file
BIN
renderer/themes/fonts/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
96
renderer/themes/themes.css
Normal file
96
renderer/themes/themes.css
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* themes.css — 6 套主题的颜色定义
|
||||
* 通过 <html data-theme="..."> 切换
|
||||
*/
|
||||
|
||||
/* 默认(兜底)—— Linear Dark */
|
||||
:root,
|
||||
:root[data-theme="linear"] {
|
||||
--bg-base: #0E0E12;
|
||||
--bg-soft: #16161B;
|
||||
--bg-overlay: #1F1F26;
|
||||
--bg-hover: #22222A;
|
||||
--border: #2A2A33;
|
||||
--border-soft: #1E1E25;
|
||||
--border-strong: #3A3A45;
|
||||
--text: #ECECEF;
|
||||
--text-soft: #B4B4BC;
|
||||
--text-muted: #95959F;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.30);
|
||||
}
|
||||
|
||||
/* Midnight */
|
||||
:root[data-theme="midnight"] {
|
||||
--bg-base: #08090B;
|
||||
--bg-soft: #0F1013;
|
||||
--bg-overlay: #18191E;
|
||||
--bg-hover: #1C1D22;
|
||||
--border: #26272D;
|
||||
--border-soft: #1B1C20;
|
||||
--border-strong: #34353C;
|
||||
--text: #ECECEE;
|
||||
--text-soft: #ACADB2;
|
||||
--text-muted: #8E8F96;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.40);
|
||||
}
|
||||
|
||||
/* Carbon */
|
||||
:root[data-theme="carbon"] {
|
||||
--bg-base: #1A1D23;
|
||||
--bg-soft: #22262D;
|
||||
--bg-overlay: #2A2F37;
|
||||
--bg-hover: #2E333B;
|
||||
--border: #353A42;
|
||||
--border-soft: #252930;
|
||||
--border-strong: #42474F;
|
||||
--text: #DCDCDC;
|
||||
--text-soft: #9DA3AC;
|
||||
--text-muted: #8A929C;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* Solarized Dark —— 原本 muted 在 #002B36 上只有 ~3.7:1,对中文小字会"雾蒙蒙",
|
||||
全部提亮一档让所有层级都达到 ≥5:1 的可读舒适线。 */
|
||||
:root[data-theme="solarized"] {
|
||||
--bg-base: #002B36;
|
||||
--bg-soft: #073642;
|
||||
--bg-overlay: #0A4554;
|
||||
--bg-hover: #0E5466;
|
||||
--border: #1F6779;
|
||||
--border-soft: #0A4554;
|
||||
--border-strong: #2680A5;
|
||||
--text: #B6CAD0;
|
||||
--text-soft: #9DB1B8;
|
||||
--text-muted: #88A0A8;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* GitHub Dark */
|
||||
:root[data-theme="github"] {
|
||||
--bg-base: #0D1117;
|
||||
--bg-soft: #161B22;
|
||||
--bg-overlay: #1F242C;
|
||||
--bg-hover: #22272E;
|
||||
--border: #30363D;
|
||||
--border-soft: #21262D;
|
||||
--border-strong: #444C56;
|
||||
--text: #DCE3EA;
|
||||
--text-soft: #9DA8B3;
|
||||
--text-muted: #8A95A0;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.30);
|
||||
}
|
||||
|
||||
/* Light Companion —— 把 muted 略压暗一档,避免浅底浅字反差不足。 */
|
||||
:root[data-theme="light"] {
|
||||
--bg-base: #FAFAFA;
|
||||
--bg-soft: #F4F4F5;
|
||||
--bg-overlay: #FFFFFF;
|
||||
--bg-hover: #ECECEE;
|
||||
--border: #E4E4E7;
|
||||
--border-soft: #F0F0F2;
|
||||
--border-strong: #D4D4D8;
|
||||
--text: #111827;
|
||||
--text-soft: #4B5563;
|
||||
--text-muted: #5C6573;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
71
renderer/themes/tokens.css
Normal file
71
renderer/themes/tokens.css
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* tokens.css — 颜色/间距/字号/圆角/阴影/字体语义化变量
|
||||
* 仅定义变量,具体值由 themes.css 和 accents.css 提供。
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* 背景层级(从底层到顶层) */
|
||||
--bg-base: #FFFFFF;
|
||||
--bg-soft: #F7F8FA;
|
||||
--bg-overlay: #FFFFFF;
|
||||
--bg-hover: #F3F4F6;
|
||||
|
||||
/* 边框 */
|
||||
--border: #E5E7EB;
|
||||
--border-soft: #F1F2F4;
|
||||
--border-strong: #D1D5DB;
|
||||
|
||||
/* 文字层级(muted 保持可读:对底色对比 ≥ 3:1,仅作 placeholder/辅助信息) */
|
||||
--text: #111827;
|
||||
--text-soft: #4B5563;
|
||||
--text-muted: #6B7280;
|
||||
|
||||
/* 强调色(被 accents.css 覆盖) */
|
||||
--accent: #5E6AD2;
|
||||
--accent-soft: rgba(94, 106, 210, 0.12);
|
||||
--accent-hover: rgba(94, 106, 210, 0.20);
|
||||
--accent-text: #FFFFFF;
|
||||
|
||||
/* 状态色 */
|
||||
--success: #10B981;
|
||||
--warn: #F59E0B;
|
||||
--warn-bg: rgba(245, 158, 11, 0.12);
|
||||
--danger: #EF4444;
|
||||
--danger-bg: rgba(239, 68, 68, 0.12);
|
||||
|
||||
/* 阴影 */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* 圆角 */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
/* 字号(中文偏小容易糊,整体往上提一档;列表正文单独压一档避免占空间) */
|
||||
--fs-xs: 11px; /* 时间戳/角标:再小就糊 */
|
||||
--fs-sm: 12px; /* Tab/状态栏/设置副文字 */
|
||||
--fs-base: 13px; /* 列表正文:清晰度优先 */
|
||||
--fs-md: 14px; /* 标题/按钮/设置项 */
|
||||
--fs-lg: 16px; /* 「关于」大标题 */
|
||||
|
||||
/* 行高(中文需要更大行高才不会笔画粘连;1.6 是 Windows 上 CJK 舒适区) */
|
||||
--lh-base: 1.6;
|
||||
|
||||
/* 字间距(提一档让小字笔画清晰;0.02em 在 13px 以上会显得字间距过大,收到 0.01em) */
|
||||
--tracking-cn: 0.01em;
|
||||
|
||||
/* 字体(fonts.css 会覆盖这两个) */
|
||||
--font-ui: 'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Segoe UI', system-ui, -apple-system,
|
||||
BlinkMacSystemFont, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'Consolas',
|
||||
'Microsoft YaHei', monospace;
|
||||
|
||||
/* 字重 */
|
||||
--fw-medium: 500;
|
||||
--fw-semibold: 600;
|
||||
|
||||
/* 动效 */
|
||||
--transition-fast: 100ms ease-out;
|
||||
--transition-base: 150ms ease-out;
|
||||
}
|
||||
Reference in New Issue
Block a user