This commit is contained in:
2026-09-20 23:35:03 +08:00
parent 941ce4baab
commit 388c700996
19 changed files with 393 additions and 71 deletions

View File

@@ -41,6 +41,8 @@ const $ipcDot = document.getElementById('ipc-status');
const $tabs = document.getElementById('tabs');
const $historyCount = document.getElementById('history-count');
const $favCount = document.getElementById('fav-count');
const $lightbox = document.getElementById('lightbox');
const $lightboxImg = document.getElementById('lightbox-img');
// ---------- 状态 ----------
@@ -131,6 +133,46 @@ async function loadImageInto(el) {
}
}
// ---------- 图片大图预览 ----------
// 数据复用懒加载的 FIFO 缓存:缩略图显示过就一定在缓存里,弹窗大多零 IPC;
// 没命中(缓存被挤出)才再走一次 getImage/favGetImage 拉取。
let lightboxReturnFocus = null; // 打开前的焦点元素,关闭后还回去
async function openLightbox(imgEl) {
const id = Number(imgEl.dataset.imgId);
const fromFav = imgEl.dataset.imgFav === '1';
const key = (fromFav ? 'f' : 'c') + id;
let b64 = fifoCache.get(key);
if (b64 === undefined) {
try {
b64 = await (fromFav ? favGetImage(id) : getImage(id));
if (b64) cacheImage(key, b64);
} catch (e) {
console.error('[renderer] lightbox getImage failed:', e);
return;
}
}
if (!b64) return; // 数据缺失就不弹一个空框
lightboxReturnFocus = document.activeElement;
$lightboxImg.src = `data:image/png;base64,${b64}`;
$lightbox.classList.remove('hidden');
$lightbox.focus(); // 焦点上遮罩:Esc 有着落,也防 Tab 掉进背后列表
}
function closeLightbox() {
$lightbox.classList.add('hidden');
// 清掉 src 释放解码后的大图内存;缓存里那份 base64 还在,重开零 IPC
$lightboxImg.removeAttribute('src');
if (lightboxReturnFocus && lightboxReturnFocus.isConnected) {
lightboxReturnFocus.focus();
}
lightboxReturnFocus = null;
}
// 点遮罩任意处(含大图本身)关闭
$lightbox.addEventListener('click', closeLightbox);
// ---------- 渲染 ----------
// 单张卡片:纯 DOM 构造,任何状态都不在这里改。
@@ -195,15 +237,36 @@ function buildCard(c, i) {
if (c.type === 'text') {
textEl.textContent = c.text || c.preview || '';
imgEl.classList.add('hidden');
} else {
} else if (c.hasImage) {
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);
}
imgEl.dataset.imgId = String(c.id);
imgEl.dataset.imgFav = currentTab === 'favorites' ? '1' : '0';
imgObserver.observe(imgEl);
// 缩略图可点击/可聚焦:单击或 Enter/空格弹出大图预览。
// stopPropagation 防冒泡到卡片 onclick(选中)和全局 keydown(复制)。
imgEl.tabIndex = 0;
imgEl.setAttribute('role', 'button');
imgEl.title = '点击放大预览';
imgEl.alt = '图片预览(点击放大)';
imgEl.addEventListener('click', (e) => { e.stopPropagation(); openLightbox(imgEl); });
// dblclick 也会冒泡到卡片的 ondblclick(复制并写剪贴板)—— 双击缩略图
// 的用户意图是放大,不能顺手把剪贴板覆盖了。click 的 stopPropagation
// 只管 click 事件,拦不住 dblclick,得单独拦。
imgEl.addEventListener('dblclick', (e) => e.stopPropagation());
imgEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
openLightbox(imgEl);
}
});
} else {
// 图片条目但数据缺失(DB 里 image 列为 NULL 的极端情况)—— 退回文本
// 预览('[图片]' / '[图片] 800×600'),别让卡片渲染成一个空的破图框。
textEl.textContent = c.preview || '[图片]';
imgEl.classList.add('hidden');
}
// 点击选中按下标 —— 但必须点击时再从 DOM 现取:prependClip 增量插入
@@ -290,13 +353,20 @@ function prependClip(clip) {
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');
// Slot badge 重排:新卡插到最顶后,原来第 1-9 位的卡整体后移一位,
// 徽标数字必须跟着 +1 —— 只藏第 10 位会留下两个「1」,且数字键 1-9
// 按实际下标复制(见 keydown 的 /^[1-9]$/ 分支),显示序号与按键错位。
// 受影响的只有前 10 张卡(下标 1-8 改数字、下标 9 隐藏),最多 9 次
// DOM 更新,不需要全表重扫。新卡(下标 0)的「1」由 buildCard(clip, 0) 写好。
const renumberEnd = Math.min($cards.length, 10);
for (let i = 1; i < renumberEnd; i++) {
const slotEl = $cards[i].querySelector('.slot');
if (i < 9) {
slotEl.textContent = String(i + 1);
slotEl.classList.remove('hidden');
} else {
slotEl.classList.add('hidden');
}
}
// 选中态:强制选中第一条(最新复制的条目),并滚到顶部让 Enter / 数字键 1 复制它。
// select() 内部会遍历 $cards 把旧 'selected' 清掉、把 $cards[0] 点亮,
@@ -462,6 +532,16 @@ document.addEventListener('keydown', (e) => {
reload();
return;
}
// 大图预览打开时模态拦截:Esc 关弹窗,其余键全部吞掉 —— 不然
// Enter/数字键会穿过弹窗触发列表复制,方向键会在背后移动选中项。
// (放在 F5 之后:刷新是全局的,弹窗开着也允许;Ctrl+L 则被拦下。)
if (!$lightbox.classList.contains('hidden')) {
if (e.key !== 'Escape') return; // 吞掉 Esc 之外的所有键
e.preventDefault();
closeLightbox();
return;
}
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'l') {
// Ctrl+L 聚焦搜索框。组合键完整匹配避免吃掉 Ctrl+Shift+L 之类。
e.preventDefault();