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:
+36
-29
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user