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
+66 -1
View File
@@ -41,15 +41,80 @@ worker 选择策略在 `pdf-adapter.mjs`
改动这个 worker 后,用真实 PDF 对比三条路径的渲染结果(文本项数 + 非白像素数):官方 worker 全量数据 / 官方 worker range / 稀疏 worker range。三者应一致。
## PDF 渲染画质
- 底栏「画质」档位(1/2/3)经 `renderOpts` 下发为 `opts.renderQuality`,持久化在 `reader.pdfRenderQuality`
- 生效倍率是 `max(devicePixelRatio, renderQuality)`,**HiDPI 屏上不叠乘**。所以断言时不能写「backing 翻倍」,只能断言 `backing / CSS 宽度 == 期望倍率`;开发机 dpr 常见 1.75,写死 2 会假失败。
- 画布尺寸必须过 `clampCanvasSize()`Chromium 单边上限 16384px、面积上限 268435456px。超限时浏览器**静默给出不可用画布**,整页空白且不报错。大幅面图纸(2000pt 以上)在 scale 5 下必然踩中。
- 钳制后 backing 与 CSS 盒子的比例不再等于名义倍率,`pdfPage.render``transform` 必须用 `fit.scaleX` / `fit.scaleY`(两个方向分别算,`Math.floor` 的损失不同),否则画面错位或只画出一角。
- 倍率变化必须走 `epoch++` 整篇重建,只改新渲染的页会让同屏出现清晰度不一致。
- 集成测试里量画质前要先把视口滚回目标页:离屏页会被回收成空白占位,量到的是占位画布。画布尺寸在重建时立即变大,墨迹要等重绘完才落上去,断言墨迹必须轮询而不是只等比例。
## TXT 与 Markdown
- `text-adapter.mjs` 把 txt/md 转成内存 EPUB 再交给 `epub-adapter` 渲染,定位器 `kind``txt` / `md`,结构与 epub 同源(`chapter` + `offset`)。新增重排格式时记得同步 `REFLOW_KINDS`
- 格式白名单有六处,改一处不够:`main.js``READABLE_EXT``library/store.js``library/local-import.js``BOOK_EXT``main.js` 里选文件对话框的 `extensions`,以及**渲染层的两份** `READABLE_RE``ui/views/library.js``ui/reader/shell.mjs`)。单测有正则锁死并逐项比对渲染层与 `READABLE_EXT` 是否一致。
- 渲染层那两份漏掉格式**不会报错**:IPC 照样能打开,但卡片上的「阅读」按钮和封面点击入口静默消失,阅读器的「从书库打开」列表里也少掉这些书。用户看到的现象是"内置阅读器打不开 txt"。集成测试里直接调 `reader.open` 是查不出来的,必须从书库界面点「阅读」。
- 书库卡片的「阅读」取的是**第一个可阅读文件**。同一条书目既有 txt 又有 pdf 时,加进白名单后打开的文件会从 pdf 变成 txt,断言里不要写死 PDF 画布。
- Markdown 走 markdown-it`html: false`+ DOMPurify 双保险。裸 HTML 会被转义成文本,所以**净化断言必须按 DOM 查**(`querySelectorAll('script')``on*` 属性、`a[href]` 协议),用 `innerHTML` 匹配 `onerror` 会把转义后的字面量误判成漏网。
- 编码探测支持 UTF-8 / UTF-16LE / UTF-16BE(含 BOM)与 GB18030。带 BOM 的 UTF-16LE 是记事本另存的默认之一,探测错会整本乱码且用户无从修正。
## AI 助手
- 上下文范围:`selection | page | document | page-image | region-image`
- 只有 `selection` 需要选中文本;`page``document` 不依赖选区。
- `document` 无论多短都强制弹确认框,并提示可能超出模型上下文限制。
- 正文`ai-client.js``MAX_CHARS` 截断,保留首尾(结论常在末尾,只留开头会让模型答非所问)
- 正文**完整发送,不做本地截断**。模型窗口够不够由接口自己判断,超限时把报错转成中文提示(`isContextOverflow` / `overflowHint`)。曾经按 `MAX_CHARS = 12000` 挖空中间,实测 40 页文档只发出 8 页,而界面仍显示全文字数,属于静默数据丢失,已移除。`clipContext` 保留给有明确预算上限的场合显式调用
- 界面展示的字数必须等于真正外发的字数。任何"先按上限裁剪再发送"的改动都要同步改 `aiCost` 与确认框文案,否则用户会把答非所问归因于模型。
- 图像只用 JPEG 单一编码路径,1600px / 目标 400 KB。**不要加格式回退**(PNG 等),维护成本高于收益;视觉模型按像素图块计费,格式不影响费用。
- 三种协议:Anthropic `/messages`、OpenAI Responses `/responses`、兼容 Chat `/chat/completions`。图像负载格式各不相同,改动时三条都要验。
## AI 多轮会话
- 会话存在 `reader-ai-sessions/`,一个会话一个 JSON,外加可重建的 `index.json`。不进 `store.js`,避免整本阅读进度被聊天记录带着反复重写。
- `ai:run` 里顺序是硬约束:**先 `historyFor()``appendUser()`**。顺序反了,当前这轮提问会出现在自己的历史里,模型收到两遍同样的问题。
- 同一会话禁止并发生成(`ai:run` 里按 `sessionId``aiRuns`)。两轮同时写一个文件,后完成的那轮会把前一轮的消息覆盖掉。
- 消息正文存的是**用户看见的那句提问**(`aiTurnTitle`),整篇正文只在 `contextRef` 里留 scope / 字数 / 哈希。把正文当消息存会让重开后的气泡变成十几万字原文,还会被 `LIMITS.question` 截成一段无意义的残句。
- 失败和取消都要落盘,并且要把**已经流出来的残片**一起存(`streamed`)。只写空串的话,界面上明明显示着半截回答,一重开就消失。
- 历史只发文本:`stream()` 只取 `role``text`,图像一律不重发。历史里内联 base64 会让每轮费用随轮数线性上涨。
- 落盘失败不能把已经拿到的回答变成请求失败,`settleAiAssistant` 吞掉异常。
- 书籍删除、孤立对账都要带上会话(`aiSessions.forgetMany` / `orphanReport`),删完调 `collectAiImages()`。内容寻址的图片没有引用者就永远不会被回收。
- 集成测试里断言历史时**不能按固定下标取消息**:本轮提问固定在末尾,中间是历史。原来写死 `messages[1]` 的断言在多轮上线后会取到上一轮,表现为"图像尺寸无效"这种完全无关的报错。
## 笔记独立窗口
- `src/reader/note-window.js` 是**单窗口多标签**:一个笔记窗口,一条笔记一个标签,已开则切到该标签。同一条笔记两处编辑时 `reader:updateNote` 是整条覆盖、无版本校验,后保存者会把画布内容整块吃掉,所以「一条笔记只能有一个编辑器」是数据安全约束,不是体验优化。`openTab` 必须先 `tabOf()` 查重。
- 阅读器内的笔记模态**保留**,因此「模态 + 独立窗口」仍可能撞车。靠列表按钮避开:已开窗时「编辑」变成「在窗口中编辑」并转为聚焦窗口,不再开模态。
- 笔记消失时必须**关掉对应标签**(不是销毁整个窗口):`reader:removeNote``closeFor``library:removeMany``reader:purgeOrphans``closeForEntries`。留着标签,它下一次保存会把已删的笔记整条写回去。最后一个标签关掉后窗口才自行退场。
- 三处窗口广播(`notifyNotesChanged``applyWindowIcons``notifyUiThemeChanged`)都要带 `noteWindow.all()`,漏一处笔记窗口就收不到笔记变更或主题切换。单测有正则锁死。
- `notes:getOne` 要校验 `noteWindow.ownsNote(event.sender, noteId)`:笔记窗口只能读**自己已开标签**的那几条,否则这个通道就是遍历全部笔记的后门。开窗目标也必须过 `findNote()``listNotes()` 重新对账。
- `notes:tabsChanged``setTabs`)只能**收窄**标签集,即只允许 `openNotes.delete`,绝不能 `set`。允许渲染层往里加 ID 等于让它自己扩权:谎报持有某条笔记后 `notes:getOne` 立刻放行,授权集合就形同虚设。新增标签只能走 `open()`,那条路径过 `findNote()` 对账。
- 关窗拦截三件套必须配套:`close``preventDefault()` + `closePending` + 看门狗,渲染层处理完调 `notes:shutdownReady`**用户取消时必须调 `notes:cancelClose`**。少了取消回报,`closePending` 一直为真会让之后每次点关闭都被当成「正在处理」静默忽略,而看门狗仍会在十秒后把带未保存内容的窗口直接销毁。
- 笔记没有自动保存。切换标签只留在内存,LRU 回收前要把未保存内容序列化进 `pendingContent`,否则回收即丢改动。
- 脏判定必须比对**序列化后的内容**(`contentKey` 与挂载后取的 `baselineKey`),不能用 `pointerdown`/`keydown` 之类的交互事件:只点选不改字也会被判脏,每个标签关闭时都弹一次无谓的确认。基线要在编辑器 `ready()` 之后取(画布有 version 1→2 归一化,拿磁盘原值当基线会让刚打开就显示已修改),保存成功后基线要跟着前移。
- 笔记窗口**不**纳入 `isReaderSender`,不获得 AI 会话等阅读器权限。
- PDF 底版草稿按 sender 隔离(`resolveDrafts` / `readDraft`),草稿不能跨窗口交接,笔记窗口必须自己 stage。
- 编辑区吃满整窗要逐层 `min-height: 0`,缺一层 flex 子项就被内容顶高、画布溢出窗口。
- 笔记本下拉框要 `max-width` + `min-width: 0`。只给 `max-width` 不够:`select` 的 min-content 以最长选项为准,长书名/长笔记本名照样把整行顶宽(实测取消限宽后从 260px 涨到 553px)。
- 标题栏只放品牌名「笔记」,不要副标题。`note.html` 用的是 `style.css`,那里的 `.titlebar-left` **不是** flex(只有 `reader.css` 才是),把 `brand-sub` 放成 `.brand` 的同级会掉到下一行,把左侧块顶成 46px 而标题栏只有 44px。要加副标题只能像 `index.html` 那样塞进 `.brand` 内部。
- `note.html` 不加载 `reader.css`,所以标签条样式必须在 `note-window.css` 里**自带一份**,也不能用只在 `reader.css` 里定义的变量(如 `--hover-bg-soft`),否则静默失效。
- 集成测试四个坑:窗口刚建好时 `getURL()` 还是空串,找窗口必须轮询;断言窗口数量只能数**笔记窗口**,用总窗口数当基线会被阅读器窗口的开关搅乱;笔记页同时有多张卡片,找按钮必须限定在 `.note-card[data-note-id=...]` 内,全局找「编辑」会命中别的卡片;多标签后表单是每标签一份,查询要限定在当前激活的 `.note-tab-view` 内,或直接按 `[data-note-id]` 定位,否则量到的是别的标签。
- 删除笔记要走真实 IPC`reader:removeNote`),它内部已经调了 `closeFor`。测试里再手工补一次 `closeFor` 等于在验证自己造的假路径,还会因为重复处理而看到「标签没关掉」的假失败。
## 书库卡片封面
- 封面比例来源不一(内置生成 400x500,书源常见 0.65~0.75,还有方图和横图)。卡片盒子固定 `aspect-ratio: 3/4`,用 `background-size: cover` 会按各自比例裁掉不同的边,观感就是「预览大小不一致」,但**量盒子是量不出问题的**(每张都一样宽高),必须看截图或比对可见的封面内容。
- 现在用 `contain` 完整显示封面,留白由 `::before` 里同一张图放大模糊后垫底。注意 **`::before` 会盖在父元素自己的背景之上**,所以清晰的那层必须单独画在 `::after` 里,只调 `z-index` 是压不住父元素背景的;照这个顺序:`::before`(模糊,z-index 0)→ `::after`(清晰,z-index 1)→ `.card-cover > *`(角标文字,z-index 2)。两个伪元素都要 `pointer-events: none`,否则挡掉封面的阅读点击。
- 多选复选框不要加 `padding` + 背景色块。13px 的原生复选框套一圈衬底后看起来像加粗了边框,浅色封面上的可见性用 `filter: drop-shadow(...)` 解决。
## 笔记表单的选择器边界
`.note-edit-form` 里嵌着画布工具栏(`.canvas-note-root`)与富文本工具栏(`.ql-toolbar`),两者都有 `<select>`。因此 `.note-edit-form select { ... }` 这类**后代选择器会一路灌进工具栏**,必须显式 `:not(.canvas-note-root select):not(.ql-toolbar select)`
踩过的坑:表单控件的 `margin-top: 5px` 落到工具栏的粗细/纸张下拉上,分组高度变成 28 与 33 两种,工具栏从一行变两行(42px → 78px)。表现像是「工具栏没横排、需要 flex-wrap 调整」,改 `.canvas-note-tool-group` 的 wrap/shrink 只能把 78px 压到 46px,剩下的 4px 差和分组错位依然在,因为根因不在布局属性而在这条越界的样式。
诊断方法:量到子项全是 28px 而父分组是 33px 时,不要继续猜 flex 属性,直接遍历 `document.styleSheets` 找出 `el.matches(rule.selectorText)` 的全部规则,越界的那条会立刻现形。
## 测试
```bash