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:
@@ -59,17 +59,20 @@ async function js(source) {
|
||||
return win.webContents.executeJavaScript(source);
|
||||
}
|
||||
|
||||
async function poll(name, predicate, timeout = 8000) {
|
||||
// soft=true 时超时不抛也不记失败,只返回 false,交给调用方自己断言,
|
||||
// 这样失败信息里能带上真实量到的状态而不是一句"等待超时"
|
||||
async function poll(name, predicate, timeout = 8000, soft = false) {
|
||||
const deadline = Date.now() + timeout;
|
||||
let lastError = null;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if (await predicate()) return;
|
||||
if (await predicate()) return true;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await wait(50);
|
||||
}
|
||||
if (soft) return false;
|
||||
const detail = lastError ? lastError.message : '等待超时';
|
||||
check(name, false, detail);
|
||||
throw new Error(`${name}: ${detail}`);
|
||||
@@ -159,6 +162,7 @@ app.whenReady().then(async () => {
|
||||
const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
|
||||
const annotations = require(path.join(ROOT, 'src', 'reader', 'annotations'));
|
||||
const noteAssets = require(path.join(ROOT, 'src', 'reader', 'note-assets'));
|
||||
const noteWindow = require(path.join(ROOT, 'src', 'reader', 'note-window'));
|
||||
const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
|
||||
const zlibAuth = require(path.join(ROOT, 'src', 'sources', 'zlib-auth'));
|
||||
const semanticKey = require(path.join(ROOT, 'src', 'sources', 'semantic-key'));
|
||||
@@ -481,13 +485,28 @@ app.whenReady().then(async () => {
|
||||
)) || null;
|
||||
return !!readerWindow;
|
||||
});
|
||||
await poll('封面打开的 PDF 在内置阅读器渲染', async () => (
|
||||
// 这本书的文件顺序是 [txt, pdf],封面走的是"第一个可阅读文件",
|
||||
// 也就是 txt(txt/md 同样能内置阅读,走 text-adapter 转 epub 渲染)。
|
||||
// 这里断言"渲染出正文",不要写死 PDF 画布:那样等于把
|
||||
// "txt 不可阅读所以退到 pdf" 这个旧缺陷当成期望行为锁死
|
||||
await poll('封面打开的书在内置阅读器渲染出正文', async () => (
|
||||
!readerWindow.isDestroyed()
|
||||
&& readerWindow.webContents.executeJavaScript(
|
||||
"document.querySelector('.pdfx-page[data-page=\"1\"] .pdfx-canvas')?.width > 0"
|
||||
)
|
||||
), 15000);
|
||||
&& readerWindow.webContents.executeJavaScript(`(() => {
|
||||
if (document.querySelector('.doc-overlay.err')) return false;
|
||||
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
|
||||
if (canvas && canvas.width > 0) return true;
|
||||
const frame = document.querySelector('.host-epub iframe');
|
||||
const body = frame && frame.contentDocument && frame.contentDocument.body;
|
||||
return !!(body && body.textContent.trim().length > 0);
|
||||
})()`)
|
||||
), 20000);
|
||||
check('点击可阅读图书封面直接打开内置阅读器', !!readerWindow);
|
||||
check(
|
||||
'封面打开的是第一个可阅读文件(txt 也算)',
|
||||
(await readerWindow.webContents.executeJavaScript(
|
||||
"new URLSearchParams(location.search).get('fileIndex')"
|
||||
)) === '0'
|
||||
);
|
||||
readerWindow.destroy();
|
||||
await wait(200);
|
||||
check(
|
||||
@@ -1589,6 +1608,292 @@ app.whenReady().then(async () => {
|
||||
);
|
||||
dialog.showOpenDialog = originalShowOpenDialog;
|
||||
|
||||
// --- 笔记独立窗口 ---
|
||||
await js(`document.querySelector('.tab[data-tab="notes"]').click()`);
|
||||
await pollJs('笔记页有可开窗的卡片', "document.querySelectorAll('#notesList .note-card').length > 0");
|
||||
const windowNote = readerStore.listNotes({}).find((note) => note.associated !== false);
|
||||
check('存在可用于开窗的笔记', !!windowNote);
|
||||
|
||||
// 只数笔记窗口。总窗口数会被阅读器窗口的开关干扰,
|
||||
// 之前用总数当基线,阅读器中途关掉就把「没多开窗」误判成失败
|
||||
const noteWindowCount = () => BrowserWindow.getAllWindows()
|
||||
.filter((item) => !item.isDestroyed() && String(item.webContents.getURL()).includes('note.html'))
|
||||
.length;
|
||||
check('开窗前没有笔记窗口', noteWindowCount() === 0, `笔记窗口数=${noteWindowCount()}`);
|
||||
// 按钮必须限定在目标笔记那张卡片内:笔记页此时有多张卡片,
|
||||
// 全局找「编辑」会命中别的卡片,断言就变成了自欺欺人
|
||||
const cardScript = (inner) => `(() => {
|
||||
const target = document.querySelector('#notesList .note-card[data-note-id=' +
|
||||
JSON.stringify(${JSON.stringify(String(windowNote.id))}) + ']');
|
||||
if (!target) throw new Error('找不到目标笔记卡片');
|
||||
${inner}
|
||||
})()`;
|
||||
const cardLabels = () => js(cardScript(
|
||||
'return [...target.querySelectorAll(".note-action")].map((b) => b.textContent).join(",");'
|
||||
));
|
||||
const clickCardAction = (label) => js(cardScript(`
|
||||
const button = [...target.querySelectorAll(".note-action")]
|
||||
.find((item) => item.textContent === ${JSON.stringify(label)});
|
||||
if (!button) throw new Error("找不到按钮:" + ${JSON.stringify(label)});
|
||||
button.click();
|
||||
return true;
|
||||
`));
|
||||
|
||||
await clickCardAction('独立窗口');
|
||||
// 窗口创建与 URL 就位之间有间隔,刚建好时 getURL() 还是空串,必须轮询
|
||||
const findNoteWindow = () => BrowserWindow.getAllWindows()
|
||||
.find((item) => !item.isDestroyed() && String(item.webContents.getURL()).includes('note.html'));
|
||||
await poll(
|
||||
'点击独立窗口后真的多出一个笔记窗口',
|
||||
async () => noteWindowCount() === 1 && !!findNoteWindow(),
|
||||
15000
|
||||
);
|
||||
const noteWin = findNoteWindow();
|
||||
check('新窗口加载的是笔记页面', !!noteWin);
|
||||
const noteJs = (source) => noteWin.webContents.executeJavaScript(source);
|
||||
// 多标签后表单是每个标签一份,查询必须限定在当前激活的那个视图内,
|
||||
// 否则回收/切换过程中会量到别的标签
|
||||
const activeScript = (inner) => `(() => {
|
||||
const view = [...document.querySelectorAll('.note-tab-view')]
|
||||
.find((item) => !item.classList.contains('inactive'));
|
||||
if (!view) throw new Error('没有激活的笔记标签');
|
||||
${inner}
|
||||
})()`;
|
||||
await poll(
|
||||
'笔记窗口标签就绪',
|
||||
() => noteJs(`(() => {
|
||||
const view = [...document.querySelectorAll('.note-tab-view')]
|
||||
.find((item) => !item.classList.contains('inactive'));
|
||||
return !!(view && view.querySelector('.note-window-title'));
|
||||
})()`),
|
||||
15000
|
||||
);
|
||||
check(
|
||||
'笔记窗口载入的是被点开的那一条',
|
||||
(await noteJs(activeScript("return view.querySelector('.note-window-title').value;")))
|
||||
=== String(windowNote.title || '')
|
||||
&& (await noteJs("document.getElementById('noteWindowError').textContent")) === '',
|
||||
await noteJs(activeScript("return view.querySelector('.note-window-title').value;"))
|
||||
);
|
||||
check(
|
||||
'开出的是一个标签',
|
||||
(await noteJs("document.querySelectorAll('.doctab').length")) === 1,
|
||||
`标签数=${await noteJs("document.querySelectorAll('.doctab').length")}`
|
||||
);
|
||||
|
||||
// 同一条笔记不允许开出第二个标签,否则两个编辑器会整条覆盖对方
|
||||
await clickCardAction('切到窗口');
|
||||
await wait(1200);
|
||||
check(
|
||||
'同一条笔记再次开窗只聚焦不新增窗口',
|
||||
noteWindowCount() === 1,
|
||||
`笔记窗口数=${noteWindowCount()}`
|
||||
);
|
||||
check(
|
||||
'同一条笔记再次开窗也不新增标签',
|
||||
(await noteJs("document.querySelectorAll('.doctab').length")) === 1,
|
||||
`标签数=${await noteJs("document.querySelectorAll('.doctab').length")}`
|
||||
);
|
||||
|
||||
// 已开窗时该卡片的按钮改为切窗,避免模态与窗口同时编辑同一条
|
||||
const openedLabels = await cardLabels();
|
||||
check(
|
||||
'已开窗后该笔记不再提供开模态的编辑按钮',
|
||||
openedLabels.includes('在窗口中编辑') && !openedLabels.split(',').includes('编辑'),
|
||||
openedLabels
|
||||
);
|
||||
await clickCardAction('在窗口中编辑');
|
||||
await wait(1000);
|
||||
check(
|
||||
'点「在窗口中编辑」不会打开模态',
|
||||
await js("document.getElementById('modal').classList.contains('hidden')")
|
||||
);
|
||||
|
||||
// 断言真正落盘的内容,而不是界面状态
|
||||
const editedTitle = `窗口改名 ${Date.now()}`;
|
||||
await noteJs(activeScript(`
|
||||
const title = view.querySelector('.note-window-title');
|
||||
title.value = ${JSON.stringify(editedTitle)};
|
||||
title.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const tags = view.querySelector('.note-window-tags-input');
|
||||
tags.value = '窗口标签';
|
||||
tags.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return true;
|
||||
`));
|
||||
// 有未保存修改时标签上要有脏标记,否则关闭前的二次确认无从触发
|
||||
await poll(
|
||||
'未保存的修改在标签上有脏标记',
|
||||
() => noteJs("document.querySelectorAll('.doctab-dirty').length === 1"),
|
||||
8000
|
||||
);
|
||||
check('未保存的修改在标签上有脏标记', true);
|
||||
await noteJs(activeScript("view.querySelector('.note-window-save').click(); return true;"));
|
||||
await poll(
|
||||
'笔记窗口的修改真正落盘',
|
||||
async () => {
|
||||
const stored = readerStore.listNotes({}).find((note) => note.id === windowNote.id);
|
||||
return !!stored && stored.title === editedTitle && stored.tags.includes('窗口标签');
|
||||
},
|
||||
12000
|
||||
);
|
||||
check('笔记窗口保存后落盘内容正确', true);
|
||||
|
||||
await poll(
|
||||
'笔记窗口的改动回流到主窗口列表',
|
||||
() => js(`Array.from(document.querySelectorAll('#notesList .note-title'))
|
||||
.some((node) => node.textContent.trim() === ${JSON.stringify(editedTitle)})`),
|
||||
12000
|
||||
);
|
||||
check('主窗口列表随笔记窗口保存刷新', true);
|
||||
await poll(
|
||||
'保存后脏标记清除',
|
||||
() => noteJs("document.querySelectorAll('.doctab-dirty').length === 0"),
|
||||
8000
|
||||
);
|
||||
check('保存后脏标记清除', true);
|
||||
|
||||
const noteWinErrors = [];
|
||||
noteWin.webContents.on('console-message', (event) => {
|
||||
if (event.level >= 2) noteWinErrors.push(event.message.slice(0, 120));
|
||||
});
|
||||
await wait(200);
|
||||
check('笔记窗口没有控制台错误', noteWinErrors.length === 0, noteWinErrors.slice(0, 2).join(' | '));
|
||||
|
||||
// 第二条笔记要进同一个窗口的新标签,而不是再开一个窗口
|
||||
const secondNote = readerStore.listNotes({})
|
||||
.find((note) => note.id !== windowNote.id && note.associated !== false);
|
||||
if (secondNote) {
|
||||
await js(`(() => {
|
||||
const target = document.querySelector('#notesList .note-card[data-note-id=' +
|
||||
JSON.stringify(${JSON.stringify(String(secondNote.id))}) + ']');
|
||||
if (!target) throw new Error('找不到第二条笔记卡片');
|
||||
const button = [...target.querySelectorAll('.note-action')]
|
||||
.find((item) => item.textContent === '独立窗口');
|
||||
if (!button) throw new Error('第二条笔记没有独立窗口按钮');
|
||||
button.click();
|
||||
return true;
|
||||
})()`);
|
||||
await poll(
|
||||
'第二条笔记进入同一窗口的新标签',
|
||||
async () => noteWindowCount() === 1
|
||||
&& (await noteJs("document.querySelectorAll('.doctab').length")) === 2,
|
||||
15000
|
||||
);
|
||||
check(
|
||||
'第二条笔记进入同一窗口的新标签',
|
||||
noteWindowCount() === 1,
|
||||
`笔记窗口数=${noteWindowCount()}`
|
||||
);
|
||||
check(
|
||||
'只有一个标签视图可见',
|
||||
(await noteJs(
|
||||
"[...document.querySelectorAll('.note-tab-view')].filter((v) => !v.classList.contains('inactive')).length"
|
||||
)) === 1
|
||||
);
|
||||
// 刚打开且只在编辑区点选、按方向键,不改内容,不能被判定为已修改。
|
||||
// 早前用 pointerdown/keydown 判脏时这里必然误报,一切标签都要求二次确认;
|
||||
// 画布还会因为 version 1→2 归一化在挂载时就"变更"一次
|
||||
await wait(1200);
|
||||
await noteJs(activeScript(`
|
||||
const host = view.querySelector('.note-window-editor');
|
||||
host.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
||||
host.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
|
||||
host.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }));
|
||||
host.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'ArrowRight' }));
|
||||
return true;
|
||||
`));
|
||||
await wait(900);
|
||||
check(
|
||||
'只点选不改内容不会被误判为已修改',
|
||||
(await noteJs("document.querySelectorAll('.doctab-dirty').length")) === 0,
|
||||
`脏标记数=${await noteJs("document.querySelectorAll('.doctab-dirty').length")}`
|
||||
);
|
||||
}
|
||||
|
||||
// 关窗前的未保存拦截:取消之后必须还能再次触发确认。
|
||||
// 主进程 closePending 不复位时第二次点关闭会被静默忽略,
|
||||
// 而看门狗十秒后仍会把带未保存内容的窗口销毁。
|
||||
// 此时激活的是第二条笔记的标签,必须先切回已保存过的那条,
|
||||
// 否则下面"改回原样"比对的是另一条笔记的基线
|
||||
await noteJs(`(() => {
|
||||
const tab = document.querySelector('.doctab[data-note-id=' +
|
||||
JSON.stringify(${JSON.stringify(String(windowNote.id))}) + ']');
|
||||
if (!tab) throw new Error('找不到目标标签');
|
||||
tab.click();
|
||||
return true;
|
||||
})()`);
|
||||
await poll(
|
||||
'切回已保存的那个标签',
|
||||
() => noteJs(activeScript(
|
||||
`return view.dataset.noteId === ${JSON.stringify(String(windowNote.id))};`
|
||||
)),
|
||||
10000
|
||||
);
|
||||
await noteJs(activeScript(`
|
||||
const title = view.querySelector('.note-window-title');
|
||||
title.value = '关窗前的未保存修改';
|
||||
title.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return true;
|
||||
`));
|
||||
await poll(
|
||||
'关窗前已置脏',
|
||||
() => noteJs("document.querySelectorAll('.doctab-dirty').length >= 1"),
|
||||
8000
|
||||
);
|
||||
await noteJs("document.getElementById('closeBtn').click()");
|
||||
await poll(
|
||||
'关窗被未保存确认拦下',
|
||||
() => noteJs("!document.getElementById('noteDirtyModal').classList.contains('hidden')"),
|
||||
10000
|
||||
);
|
||||
check('关窗被未保存确认拦下,窗口还在', !noteWin.isDestroyed());
|
||||
await noteJs("document.getElementById('noteDirtyCancelBtn').click()");
|
||||
await wait(1000);
|
||||
check('取消后窗口保留', !noteWin.isDestroyed());
|
||||
await noteJs("document.getElementById('closeBtn').click()");
|
||||
await poll(
|
||||
'取消之后再次关窗仍会弹确认',
|
||||
() => noteJs("!document.getElementById('noteDirtyModal').classList.contains('hidden')"),
|
||||
10000
|
||||
);
|
||||
check('取消之后再次关窗仍会弹确认', !noteWin.isDestroyed());
|
||||
await noteJs("document.getElementById('noteDirtyCancelBtn').click()");
|
||||
await wait(800);
|
||||
// 存盘收尾,避免未保存状态干扰后面的删除断言
|
||||
await noteJs(activeScript("view.querySelector('.note-window-save').click(); return true;"));
|
||||
await poll(
|
||||
'取消关闭后仍能正常保存',
|
||||
() => noteJs(`(() => {
|
||||
const tab = document.querySelector('.doctab[data-note-id=' +
|
||||
JSON.stringify(${JSON.stringify(String(windowNote.id))}) + ']');
|
||||
return !!tab && !tab.querySelector('.doctab-dirty');
|
||||
})()`),
|
||||
10000
|
||||
);
|
||||
check('取消关闭后仍能正常保存', true);
|
||||
|
||||
// 笔记被删除后只关掉它那个标签,窗口和别的标签要留着
|
||||
await js(`window.api.reader.removeNote(${JSON.stringify(windowNote.entryId)}, ${JSON.stringify(windowNote.id)})`);
|
||||
if (secondNote) {
|
||||
await poll(
|
||||
'删除笔记只关掉对应标签',
|
||||
async () => !noteWin.isDestroyed()
|
||||
&& (await noteJs("document.querySelectorAll('.doctab').length")) === 1,
|
||||
12000
|
||||
);
|
||||
check('删除笔记只关掉对应标签,窗口留着', !noteWin.isDestroyed());
|
||||
// 删掉最后一个标签,窗口才该退场
|
||||
const remainingId = await noteJs("document.querySelector('.doctab').dataset.noteId");
|
||||
const remaining = readerStore.listNotes({}).find((note) => note.id === remainingId);
|
||||
check('剩下的标签是另一条笔记', !!remaining && remaining.id !== windowNote.id, String(remainingId));
|
||||
if (remaining) {
|
||||
await js(`window.api.reader.removeNote(${JSON.stringify(remaining.entryId)}, ${JSON.stringify(remaining.id)})`);
|
||||
}
|
||||
}
|
||||
await poll('最后一个标签消失后窗口自动关闭', async () => noteWin.isDestroyed(), 12000);
|
||||
check('最后一个标签消失后窗口自动关闭', noteWin.isDestroyed());
|
||||
check('窗口关闭后主进程标签集清空', noteWindow.openIds().length === 0, JSON.stringify(noteWindow.openIds()));
|
||||
|
||||
await wait(300);
|
||||
check(
|
||||
'主渲染进程没有控制台错误',
|
||||
|
||||
Reference in New Issue
Block a user