Files
Clipboard/renderer/renderer.js
2026-09-12 13:59:12 +08:00

713 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 分属两个库、自增数会撞号,
// 绝不能走 moveToTopclips: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 rowrow.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不写 localStoragepayload 里没有 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(() => { /* 图标缺失不影响功能 */ });