/** * 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(() => { /* 图标缺失不影响功能 */ });