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
+250
View File
@@ -0,0 +1,250 @@
// 笔记独立窗口的生命周期管理。
// 单窗口多标签,形态与阅读器一致:窗口只有一个,一条笔记占一个标签。
//
// 「一标签一条」是数据安全约束,不是体验优化:`reader:updateNote` 是整条覆盖、
// 无版本校验,同一条笔记开两个编辑器时后保存者会把前者的内容整块吃掉。
//
// openNotes 是主进程侧的标签集镜像,由渲染层通过 `notes:tabsChanged` 上报。
// 它同时承担授权职责(见 ownsNote),所以不能只信渲染层:新增标签一律先过
// main.js 的 findNote() 用 listNotes() 对账。
const path = require('path');
const { pathToFileURL } = require('url');
const { BrowserWindow } = require('electron');
const CLOSE_TIMEOUT = 10000;
let win = null;
// noteId -> entryId。删除对账要按 entryId 找标签,所以存的是映射不是集合。
const openNotes = new Map();
let onChanged = null;
let closeAllowed = false;
let closePending = false;
let closeTimer = null;
function alive(target) {
return !!target && !target.isDestroyed();
}
function keyOf(noteId) {
return String(noteId == null ? '' : noteId);
}
function get() {
if (alive(win)) return win;
win = null;
return null;
}
function openIds() {
if (!get()) return [];
return [...openNotes.keys()];
}
function notifyChanged() {
if (typeof onChanged === 'function') onChanged(openIds());
}
function setChangeListener(fn) {
onChanged = typeof fn === 'function' ? fn : null;
}
function pageUrl(rootDir) {
return pathToFileURL(path.join(rootDir, 'src', 'ui', 'note.html')).href;
}
function isNoteSender(wc) {
const target = get();
return !!target && !!wc && target.webContents.id === wc.id;
}
function fromWebContents(wc) {
return isNoteSender(wc) ? 'note' : null;
}
// 笔记窗口只能读自己已经打开的那些标签。放宽成「只要是笔记窗口就给」
// 会让这个通道变成遍历全部笔记的后门。
function ownsNote(wc, noteId) {
if (!isNoteSender(wc)) return false;
return openNotes.has(keyOf(noteId));
}
// 渲染层只能"收窄"标签集(上报自己关掉了哪些),不能新增。
// 允许新增等于让渲染层自己扩权:谎报持有某条笔记,随后 notes:getOne 就放行了。
// 新增只能走 open(),那条路径在 main.js 里过 findNote() 对账。
function setTabs(wc, noteIds) {
if (!isNoteSender(wc)) return false;
const claimed = new Set();
for (const item of Array.isArray(noteIds) ? noteIds : []) {
const id = keyOf(item && item.noteId != null ? item.noteId : item);
if (id) claimed.add(id);
}
let changed = false;
for (const key of [...openNotes.keys()]) {
if (claimed.has(key)) continue;
openNotes.delete(key);
changed = true;
}
if (changed) notifyChanged();
return true;
}
function sendToWindow(channel, payload) {
const target = get();
if (!target) return false;
target.webContents.send(channel, payload);
return true;
}
function create(rootDir, uiTheme) {
closeAllowed = false;
closePending = false;
win = new BrowserWindow({
width: 1080,
height: 820,
minWidth: 720,
minHeight: 520,
frame: false,
backgroundColor: '#141414',
icon: path.join(
rootDir,
'icons',
'dist',
uiTheme === 'light' ? 'book-ai-light.ico' : 'book-ai-dark.ico'
),
title: 'PeopleLib',
webPreferences: {
preload: path.join(rootDir, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false
}
});
const created = win;
created.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
created.webContents.on('will-navigate', (event, url) => {
if (!String(url).startsWith(pageUrl(rootDir))) event.preventDefault();
});
// 未保存的编辑要在窗口消失之前问用户,所以必须先拦下 close 交给渲染层。
// 渲染层卡住时靠看门狗兜底,否则窗口永远关不掉。
created.on('close', (event) => {
if (closeAllowed || !alive(created)) return;
event.preventDefault();
if (closePending) return;
closePending = true;
created.webContents.send('notes:prepareClose', null);
closeTimer = setTimeout(() => {
if (alive(created)) {
closeAllowed = true;
created.destroy();
}
}, CLOSE_TIMEOUT);
});
created.on('closed', () => {
if (win === created) win = null;
openNotes.clear();
closeAllowed = false;
closePending = false;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = null;
notifyChanged();
});
return created;
}
function open(entryId, noteId, rootDir, uiTheme = 'dark') {
const key = keyOf(noteId);
const entry = String(entryId);
const existing = get();
if (existing) {
if (existing.isMinimized()) existing.restore();
existing.focus();
// 已经开着的标签由渲染层激活,不新建第二个编辑器
existing.webContents.send('notes:openTab', { entryId: entry, noteId: key });
if (!openNotes.has(key)) {
openNotes.set(key, entry);
notifyChanged();
}
return existing;
}
const created = create(rootDir, uiTheme);
openNotes.set(key, entry);
created.loadFile(path.join(rootDir, 'src', 'ui', 'note.html'), {
query: { entryId: entry, noteId: key }
});
notifyChanged();
return created;
}
// 笔记在别处被删除后标签必须自己退场,否则它下一次保存会把已删条目整条写回去
function closeFor(noteId) {
const key = keyOf(noteId);
if (!get() || !openNotes.has(key)) return false;
sendToWindow('notes:closeTab', { noteIds: [key] });
openNotes.delete(key);
notifyChanged();
return true;
}
function closeMany(noteIds) {
const keys = (Array.isArray(noteIds) ? noteIds : [])
.map((id) => keyOf(id))
.filter((id) => openNotes.has(id));
if (!get() || !keys.length) return 0;
sendToWindow('notes:closeTab', { noteIds: keys });
for (const key of keys) openNotes.delete(key);
notifyChanged();
return keys.length;
}
function closeForEntries(entryIds) {
const targets = new Set((Array.isArray(entryIds) ? entryIds : []).map((id) => String(id)));
if (!get() || !targets.size) return 0;
const keys = [];
for (const [key, entryId] of openNotes) {
if (targets.has(entryId)) keys.push(key);
}
if (!keys.length) return 0;
sendToWindow('notes:closeTab', { noteIds: keys });
for (const key of keys) openNotes.delete(key);
notifyChanged();
return keys.length;
}
// 渲染层处理完未保存提示后才真正放行关闭
function shutdownReady(wc) {
const target = get();
if (!target || !isNoteSender(wc) || !closePending) return false;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = null;
closeAllowed = true;
closePending = false;
target.close();
return true;
}
// 用户在未保存提示里选了取消。必须复位 closePending,否则下一次点关闭会被
// 「已在处理中」挡掉,窗口再也关不上;也必须撤掉看门狗,否则十秒后它会把
// 带着未保存内容的窗口直接销毁。
function cancelClose(wc) {
if (!isNoteSender(wc) || !closePending) return false;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = null;
closePending = false;
return true;
}
function all() {
const target = get();
return target ? [target] : [];
}
module.exports = {
open, get, all, openIds, closeFor, closeMany, closeForEntries,
fromWebContents, ownsNote, setTabs, shutdownReady, cancelClose, setChangeListener
};