feat: 内置阅读器、批注笔记与 AI 助手,发布 1.3.0

新增 PDF/EPUB/MOBI/AZW 内置阅读器,PDF 分段读取支持超大文件,
批注、读书与画布笔记、封面生成与本地导入。AI 助手支持三种协议、
图像上下文与安全 Markdown 渲染,上下文范围改为 选中/当前页/全文,
页面与全文无需选中文本即可发送,全文会提示可能超出模型限制。

便携版输出目录固定为 PeopleLib-windows-x64,不再随版本号变化,
避免升级后 data/ 被遗留在旧目录。

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-03 12:13:02 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent b8c8d24107
commit 3ccd044527
307 changed files with 98477 additions and 1148 deletions
+167
View File
@@ -0,0 +1,167 @@
// 阅读器独立窗口的生命周期管理。
// 全局只保留一个阅读器窗口,书籍通过窗口内标签切换,避免同一 PDF 出现两个并发编辑器。
const path = require('path');
const { pathToFileURL } = require('url');
const { BrowserWindow } = require('electron');
let readerWindow = null;
let rendererReady = false;
let pendingMessages = [];
let closeAllowed = false;
let closePending = false;
let closeTimer = null;
function alive(win) {
return !!win && !win.isDestroyed();
}
function get() {
if (alive(readerWindow)) return readerWindow;
readerWindow = null;
return null;
}
function sendEntry(win, channel, payload) {
if (!rendererReady) {
pendingMessages.push([channel, payload]);
return;
}
win.webContents.send(channel, payload);
}
function markReady(webContents) {
const win = get();
if (!win || win.webContents.id !== webContents.id) return false;
rendererReady = true;
const messages = pendingMessages;
pendingMessages = [];
for (const [channel, payload] of messages) {
if (alive(win)) win.webContents.send(channel, payload);
}
return true;
}
function isReady(win) {
return alive(win) && win === get() && rendererReady;
}
function open(entryId, rootDir, fileIndex, locator, uiTheme = 'dark') {
const existing = get();
if (existing) {
if (existing.isMinimized()) existing.restore();
const payload = {
entryId: String(entryId),
fileIndex: Number.isInteger(fileIndex) ? fileIndex : null
};
if (locator && typeof locator === 'object') payload.locator = locator;
sendEntry(existing, 'reader:openEntry', payload);
existing.focus();
return existing;
}
readerWindow = new BrowserWindow({
width: 1180,
height: 900,
minWidth: 760,
minHeight: 540,
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,
// 重排图书正文来自不可信来源,即使已净化也不给它任何 Node 能力
sandbox: false,
spellcheck: false
}
});
rendererReady = false;
pendingMessages = [];
closeAllowed = false;
closePending = false;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = null;
readerWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
readerWindow.webContents.on('will-navigate', (event, url) => {
const expected = pathToFileURL(path.join(rootDir, 'src', 'ui', 'reader.html')).href;
if (!String(url).startsWith(expected)) event.preventDefault();
});
const query = { entryId: String(entryId) };
if (Number.isInteger(fileIndex)) query.fileIndex = String(fileIndex);
if (locator && typeof locator === 'object') query.locator = JSON.stringify(locator);
readerWindow.loadFile(path.join(rootDir, 'src', 'ui', 'reader.html'), { query });
readerWindow.on('close', (event) => {
if (closeAllowed || !alive(readerWindow)) return;
event.preventDefault();
if (closePending) return;
closePending = true;
sendEntry(readerWindow, 'reader:prepareClose', null);
closeTimer = setTimeout(() => {
const win = get();
if (win) win.destroy();
}, 10000);
});
readerWindow.on('closed', () => {
readerWindow = null;
rendererReady = false;
pendingMessages = [];
closeAllowed = false;
closePending = false;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = null;
});
return readerWindow;
}
function closeFor(entryId) {
const win = get();
if (win) sendEntry(win, 'reader:closeEntry', String(entryId));
}
function purgeFor(entryId, requestId) {
const win = get();
if (win) {
sendEntry(win, 'reader:purgeEntry', {
entryId: String(entryId),
requestId: String(requestId)
});
}
return !!win;
}
function shutdownReady(webContents) {
const win = get();
if (!win || win.webContents.id !== webContents.id || !closePending) return false;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = null;
closeAllowed = true;
closePending = false;
win.close();
return true;
}
function fromWebContents(wc) {
const win = get();
return win && win.webContents.id === wc.id ? 'reader' : null;
}
function all() {
const win = get();
return win ? [win] : [];
}
module.exports = {
open, get, closeFor, purgeFor, fromWebContents, all,
markReady, isReady, shutdownReady
};