/**
* 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 = '文本';
typeBadge.classList.add('badge-text');
} else {
typeBadge.innerHTML = '图片';
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,
// 被裁的
和 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 喂给
。主进程已缓存 .ico→PNG 的转换,
// IPC 命中后基本零成本;图标缺失静默失败,不影响功能。
window.api.getAppIcon().then((dataUrl) => {
if (!dataUrl) return;
const el = document.getElementById('app-icon');
if (el) el.src = dataUrl;
}).catch(() => { /* 图标缺失不影响功能 */ });