feat: 笔记独立窗口改为多标签,AI 多轮会话与 TXT/MD 阅读

笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致:
标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增,
否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并
LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与
关窗都做二次确认,取消关闭必须回报主进程复位 closePending,
否则窗口再也关不掉而看门狗仍会销毁未保存内容。

AI 助手支持多轮会话:会话独立落盘,先取历史再写提问,
历史只发文本不重发图像,失败与取消都保留已流出的残片。

新增 TXT/MD 内置阅读(转内存 EPUB 复用 epub 渲染管线),
补上渲染层遗漏的可阅读格式白名单:主进程本就放行 txt/md,
但渲染层另有两份白名单漏了,表现为卡片上没有「阅读」按钮。

书库卡片封面改用 contain 完整显示,留白由同图模糊层垫底,
修正不同比例封面被裁切程度不一导致的观感不一致;多选复选框
去掉衬底色块,恢复原生外观。

其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、
GitHub Pages 站点。
This commit is contained in:
lofyer
2026-08-04 16:19:06 +08:00
parent 522b0f74a5
commit 0fd7c59e08
68 changed files with 11721 additions and 301 deletions
+36 -29
View File
@@ -12,6 +12,12 @@ const Library = (() => {
const selectedIds = new Set();
let visibleIds = [];
// 必须与 main.js 的 READABLE_EXT 保持一致。漏掉格式不会报错,
// 只是「阅读」按钮和封面点击静默消失,看上去像阅读器打不开这类文件
const READABLE_RE = /\.(pdf|epub|mobi|azw|azw3|txt|md)$/i;
const isReadableFile = (file) => !!file && file.exists
&& READABLE_RE.test(file.path || file.name || '');
const SORTERS = {
recent: (a, b) => (
(b.lastReadAt || 0) - (a.lastReadAt || 0)
@@ -209,11 +215,9 @@ const Library = (() => {
return items.filter((item) => selectedIds.has(String(item.id)));
}
function cardHtml(it, noteCounts) {
function cardHtml(it, noteCounts, annotationCounts) {
const openable = (it.files || []).some((file) => file.exists);
const readable = (it.files || []).some((file) => (
file.exists && /\.(pdf|epub|mobi|azw|azw3)$/i.test(file.path || file.name || '')
));
const readable = (it.files || []).some(isReadableFile);
const badge = openable
? '<span class="card-badge">已下载</span>'
: ((it.files || []).length
@@ -223,8 +227,12 @@ const Library = (() => {
const noteBadge = noteCount > 0
? `<span class="card-badge note-count">笔记 ${noteCount}</span>`
: '';
const annotationCount = annotationCounts.get(String(it.id)) || 0;
const annotationBadge = annotationCount > 0
? `<span class="card-badge annotation-count">批注 ${annotationCount}</span>`
: '';
const tagBadges = (it.tags || []).slice(0, 3)
.map((tag) => `<span class="library-card-tag">${escapeHtml(tag)}</span>`)
.map((tag) => `<span class="library-card-tag" title="${escapeHtml(tag)}">${escapeHtml(tag)}</span>`)
.join('');
const selectBox = selectMode
? `<label class="card-select" title="选择"><input type="checkbox" aria-label="选择${escapeHtml(it.title)}" /></label>`
@@ -236,12 +244,13 @@ const Library = (() => {
${selectBox}
<div class="card-cover${readable ? ' readable' : ''}" style="${coverStyle(it.cover)}"
data-cover-state="${it.cover ? 'ready' : 'pending'}"
${coverActs ? 'data-act="read" role="button" tabindex="0" title="使用内置阅读器打开"' : ''}>${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}</div>
${coverActs ? 'data-act="read" role="button" tabindex="0" title="使用内置阅读器打开"' : ''}>${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}
${tagBadges ? `<div class="library-card-tags">${tagBadges}</div>` : ''}
<div class="card-cover-badges start">${badge}</div>
<div class="card-cover-badges end">${annotationBadge}${noteBadge}</div>
</div>
<div class="card-title" title="${escapeHtml(it.title)}">${escapeHtml(it.title)}</div>
${(it.authors && it.authors.length) ? `<div class="card-sub">${escapeHtml(it.authors.slice(0, 2).join(', '))}</div>` : ''}
${badge}
${noteBadge}
${tagBadges ? `<div class="library-card-tags">${tagBadges}</div>` : ''}
${selectMode ? '' : `<div class="lib-card-actions">
${readable ? cardAction('read', '阅读', true) : ''}
${cardAction('open', readable ? '外部打开' : '打开', !readable, !openable)}
@@ -281,14 +290,14 @@ const Library = (() => {
};
}
function reconcileCards(items, noteCounts) {
function reconcileCards(items, noteCounts, annotationCounts) {
const existing = new Map(
Array.from(grid.querySelectorAll(':scope > .card')).map((card) => [card.dataset.id, card])
);
const keep = new Set();
items.forEach((item, index) => {
const id = String(item.id);
const markup = cardHtml(item, noteCounts).trim();
const markup = cardHtml(item, noteCounts, annotationCounts).trim();
let card = existing.get(id);
if (!card || card.__peoplelibMarkup !== markup) {
const template = document.createElement('template');
@@ -314,9 +323,10 @@ const Library = (() => {
async function refresh(force) {
if (!force && !dirty) return;
const currentRefresh = ++refreshSeq;
const [res, noteCountResult, shelfResult, tagResult] = await Promise.all([
const [res, noteCountResult, annotationCountResult, shelfResult, tagResult] = await Promise.all([
window.api.library.list(),
window.api.reader.getNoteCounts().catch(() => null),
window.api.reader.getAnnotationCounts().catch(() => null),
window.api.library.listShelves(),
window.api.library.listTags()
]);
@@ -334,6 +344,7 @@ const Library = (() => {
if (selectedTag && !libraryTags.some((tag) => tag.name === selectedTag)) selectedTag = '';
renderOrganizationSidebar();
const noteCounts = noteCountsOf(noteCountResult);
const annotationCounts = noteCountsOf(annotationCountResult);
const allItems = res.data.slice();
const scopedItems = allItems.filter((item) => {
if (selectedShelf === '__uncategorized__' && item.shelfId) return false;
@@ -365,7 +376,7 @@ const Library = (() => {
syncSelectionUi();
return;
}
reconcileCards(items, noteCounts);
reconcileCards(items, noteCounts, annotationCounts);
syncSelectionUi();
}
@@ -659,8 +670,8 @@ const Library = (() => {
});
const shelfValue = $('libraryBulkShelf').value;
const errorEl = $('libraryBulkError');
const failures = [];
for (const item of targets) {
// 一次提交:逐条 update 会把整个书库索引重写 N 遍
const patches = targets.map((item) => {
const patch = {};
if (shelfValue !== '__keep__') patch.shelfId = shelfValue || null;
const kept = (item.tags || []).filter((tag) => !strip.includes(String(tag).toLocaleLowerCase()));
@@ -671,11 +682,11 @@ const Library = (() => {
}
});
patch.tags = merged;
const response = await window.api.library.update(item.id, patch);
if (!response || !response.ok) failures.push(item.title);
}
if (failures.length) {
errorEl.textContent = `${failures.length} 本未能保存:${failures.slice(0, 3).join('、')}`;
return { id: item.id, patch };
});
const response = await window.api.library.updateMany(patches);
if (!response || !response.ok) {
errorEl.textContent = (response && response.error) || '保存失败';
return false;
}
return true;
@@ -709,17 +720,13 @@ const Library = (() => {
}));
if (!choice) return;
const failures = [];
for (const item of targets) {
const removed = await window.api.library.remove(item.id, choice);
if (!removed || !removed.ok) failures.push(item.title);
}
const removed = await window.api.library.removeMany(targets.map((item) => item.id), choice);
setSelectMode(false);
if (failures.length) {
await confirmModal('部分移除失败', `${failures.length} 本未能移除:${failures.slice(0, 3).join('、')}`);
if (!removed || !removed.ok) {
await confirmModal('移除失败', (removed && removed.error) || '未知错误');
return;
}
statusEl.textContent = `已移除 ${targets.length}`;
statusEl.textContent = `已移除 ${(removed.data && removed.data.removed) || targets.length}`;
}
async function organizeBook(item) {
@@ -772,7 +779,7 @@ const Library = (() => {
const it = res.data;
if (act === 'read') {
const files = it.files || [];
const idx = files.findIndex((x) => x.exists && /\.(pdf|epub|mobi|azw|azw3)$/i.test(x.path || x.name || ''));
const idx = files.findIndex(isReadableFile);
const r = await window.api.reader.open(id, idx >= 0 ? idx : undefined);
if (!r.ok) await confirmModal('无法阅读', r.error || '打开阅读器失败');
} else if (act === 'open') {