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:
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user