update
This commit is contained in:
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(() => { /* 图标缺失不影响功能 */ });
|
||||
Reference in New Issue
Block a user