Readest 笔记气泡内联编辑(PR 5780)源码评审与实现解析
2026/9/21 16:31:55 网站建设 项目流程

Readest 笔记气泡内联编辑(PR #5780)源码评审与实现解析

【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest

导读

本文基于 Readest 仓库中关于 PR #5780(issue #4668)"从气泡弹窗(bubble popup)编辑笔记" 的完整评审记录,深入剖析该功能如何把笔记编辑能力从侧边栏下沉到阅读页的笔记气泡卡片上:包括AnnotationNoteItem中的 Edit / Save / Cancel 交互、从BooknoteItem中抽取的useInlineTextEditor/useSaveBooknoteNoteText/updateBooknoteNoteText三个复用单元,以及随之引入的浏览器端vi.mock严格 ESM 测试陷阱。阅读本文后,你将理解该功能的完整调用链、持久化与气泡重绘机制、移动端软键盘与竖排文本的处理,以及一次真实的跨平台(Chrome 与 Xiaomi 真机)验收流程。

一、功能背景:为什么要从气泡弹窗编辑笔记

在 Readest 中,选中文本后弹出的是"选区工具栏 + 笔记气泡"(note bubble)。PR #5780 之前,气泡卡片只能查看笔记内容;要编辑必须打开侧边栏的笔记列表(BooknoteItem)进行修改。用户从阅读上下文跳到侧边栏编辑,体验割裂。

PR #5780(作者 libbybar,fork 分支feature/inline-note-editing)的核心诉求(issue #4668)就是:在阅读页的笔记气泡上直接提供 Edit / Save / Cancel,让"写下笔记"这件事在任意入口(侧边栏或气泡)走同一套编辑器与同一套持久化逻辑。

该 PR 于 2026-08-20 以 squash 方式合入主分支(提交e83fec7f2),并在 2026-08-21 完成了针对 #5805(笔记气泡 Markdown 渲染)的 rebase 移植,最终以 clean/mergeable 状态合入。

二、PR 的三块核心抽取物

评审记录明确说明,该 PR 从BooknoteItem(侧边栏笔记项)中抽取出三块可复用逻辑,全部在合入后的主仓库源码中得到验证:

1.useInlineTextEditor—— 通用内联编辑状态机

文件:useInlineTextEditor.ts

该 hook 只负责 UI 状态,完全不知道自己在编辑什么、保存到哪里:

  • editorRefTextEditorRef引用,用于聚焦/操纵文本编辑器实例;
  • draftText/setDraftText:当前草稿文本;
  • inlineEditMode:编辑模式开关;
  • startEdit(initialText):用初始文本填充草稿并进入编辑模式;
  • cancelEdit():退出编辑模式,不触发保存;
  • save():退出编辑模式并调用外部传入的onSave(draftText)

源码注释明确写道:onSave是调用方自己的保存函数(例如来自useSaveBooknoteNoteText),因此同一个 hook 可以服务于任何内联编辑面(书签文本、笔记文本,后续的AnnotationNotes),内部不需要任何分支。这是一个典型的"UI 状态与持久化解耦"设计。

2.updateBooknoteNoteText—— 纯函数式的笔记文本更新

文件:updateBooknoteNoteText.ts

这是最底层的纯函数,返回值类型为:

export interface UpdateBooknoteNoteTextResult { booknotes: BookNote[]; updatedBooknote: BookNote; previousNoteText: string; }

关键行为(均有源码注释佐证):

  • id匹配且deletedAt为空的"存活"笔记,只按 id 匹配、对BookNote['type']无感——因此它同样能更新书签(bookmark)或摘录(excerpt)记录的note字段;只想处理注解的调用方需要自行按 type 过滤;
  • 空白/纯空格文本被规范化为空字符串;非空白文本原样存储、不做 trim
  • now由调用方传入,用于写updatedAt,保证函数确定、与运行时机无关;
  • 绝不修改原数组,返回全新数组(booknotes.map(...));
  • 若找不到匹配的存活记录(例如并发同步已将其 tombstone 删除)则返回null,让调用方可放弃保存,避免"复活"一条已删除的笔记。

3.useSaveBooknoteNoteText—— 保存 + 气泡重绘 + 落盘的一体化接线

文件:useSaveBooknoteNoteText.ts

export function useSaveBooknoteNoteText(bookKey: string) { // 返回 (booknoteId: string, noteText: string) => void }

它的执行顺序设计得非常严谨(源码注释点明"先写 store,成功后再重绘气泡与落盘"):

  1. getConfig(bookKey)读取当前书本配置,拿不到则直接返回;
  2. 调用updateBooknoteNoteText(config.booknotes ?? [], booknoteId, noteText, Date.now()),失败(返回null)则放弃;
  3. updateBooknotes(bookKey, result.booknotes)写入 store,store 拒绝则放弃——绝不让 store 拒绝的配置落盘
  4. decideNoteBubbleTransition(previousNoteText, updatedBooknote.note)决定气泡过渡类型;
  5. applyNoteBubbleTransition(getViewsById(...), updatedBooknote, transition)在所有已渲染视图上重绘气泡;
  6. 最后saveConfig(envConfig, bookKey, updatedConfig, settings)落盘/同步云端。

这样设计的目的:一次失败的 store 更新,绝不会在屏幕上留下陈旧气泡,也不会持久化 store 拒绝的配置

三、气泡过渡:decide + apply 两个函数

文件:annotatorUtil.ts

评审文档强调,正是这两个函数进入AnnotationPopup -> AnnotationNotes -> AnnotationNoteItem -> useSaveBooknoteNoteText的导入链,才引爆了浏览器测试的 ESM mock 问题(见第五节)。

export function decideNoteBubbleTransition(before: string, after: string): NoteBubbleTransition { const had = before.trim().length > 0; const has = after.trim().length > 0; if (!had && has) return 'add'; if (had && !has) return 'remove'; return 'none'; } export function applyNoteBubbleTransition(views: FoliateView[], note: BookNote, transition: NoteBubbleTransition): void { if (transition === 'none') return; for (const view of views) { view.addAnnotation({ ...note, value: `${NOTE_PREFIX}${note.cfi}` }, transition === 'remove'); } }

过渡语义(与removeBookNoteOverlays的 trim 规则保持一致):

  • 气泡的存在性取决于笔记正文是否非空(trim 后)
  • 从无到有 →'add',添加气泡;
  • 从有到无 →'remove',移除气泡(高亮本身保留,符合 unified-annotation 规则);
  • 纯内容变化 →'none',无需重绘,因为气泡本身不渲染正文文本。

applyNoteBubbleTransition对所有已渲染视图调用view.addAnnotation(..., remove),与Notebook.handleSaveNote的 overlay 调用方式对应。

四、气泡卡片交互实现:AnnotationNoteItem

文件:AnnotationNoteItem.tsx

气泡卡片本身是React.memo组件,其 props 中的onEdit被设计为"把笔记交给共享编辑器(桌面端为 popup 主体,手机端为 bottom sheet),而不是原地编辑",从而保证无论从哪个入口打开编辑器,写笔记的方式都完全一致。

关键实现细节:

  • Markdown 渲染noteHtml = useMemo(() => parseNoteMarkdown(note.note), [note.note]),与侧边栏共用同一解析器(含 sanitize,对应 #5785);使用prose prose-sm max-w-none渲染。因为 popup 每次重新定位都会重渲染,而解析长文本并不廉价,所以用useMemo缓存。这也是 rebase 时从 #5805 移植过来的渲染逻辑(原 PR 头是{note.note}直接输出)。
  • Edit 按钮常显而非 hover 才显示:气泡在触屏设备上使用,触屏没有 hover 态,因此按钮"Always visible, not hover-gated"。
  • handleEditClick必须event.stopPropagation():否则点击编辑会同时触发卡片的onClickhandleShowAnnotation),在编辑气泡下方把侧边栏打开。
  • 点击卡片主体handleShowAnnotation会收起 hover 状态、打开侧边栏并切到 annotations 标签页;在移动端会先onDismiss()关闭气泡。
  • 竖排支持isVertical时应用writing-vertical-rlfontFeatureSettings: "'vrt2' 1, 'vert' 1",卡片尺寸按popupHeight撑满。
  • 时间戳dayjs(note.createdAt).fromNow()相对时间。

卡片布局为 flex 上下结构:上部分是 Markdown 正文,下部分是"相对时间 + 编辑按钮"一行。

五、编辑器调度:桌面 popup 与移动端 bottom sheet

文件:AnnotationPopup.tsx、Annotator.tsx

Annotator.tsx负责调度三种 popup 主体,优先级是:noteEditor > notes 列表 > 高亮选项工具栏AnnotationPopup.tsx第 136-196 行):

  1. noteEditor非空 → 渲染AnnotationNoteEditor,高度为useResponsiveSize(180),够放下几行文本加上 Cancel/Save 行,而不是工具栏 44px 的矮高度;它锚定在 popup 三角形边缘并向远离三角形的方向生长,与AnnotationNotes一致。
  2. 否则若notes.length > 0→ 渲染AnnotationNotes(按updatedAt降序排序)。
  3. 否则若highlightOptionsVisible→ 渲染HighlightOptions

Annotator.tsx中相关的状态与回调:

  • noteEditorTargetuseState,第 191 行):记录当前正在编辑的笔记与 placeholder;当 target 消失时由 effect 清理removeNotePlaceholders(第 1593-1603 行),保证编辑器的清理逻辑不依赖单一 dismiss 路径;
  • handleSaveNotesaveBooknoteNoteText(annotationId, note)→ 清空 placeholder → 关闭编辑器与选区 popup;
  • handleCancelNote:关闭编辑器与 popup;
  • 移动端判定:window.innerWidth < 640 || window.innerHeight < 640时使用 bottom sheet(noteEditorInSheet),否则使用 popup(noteEditorInPopup);
  • popup 的onDismiss在编辑态下被替换为handleCancelNote,即Escape = 取消编辑并关闭 popup(评审记录确认这依赖Popup组件在 window 上挂载的useKeyDownActions,不是本次回归,维持原样)。

AnnotationPopup外层包装容器的 z-index 设计(z-[43])也值得注意:工具栏开在选区上,与文本选择器的拖拽手柄层(z-[44])重叠,手柄是抓取目标、优先占位,因此工具栏必须低于手柄层、但又高于段落/TTS 层(z-40)和脚注 popup(z-[42]);从工具栏弹出的所有次级 popup 保持z-50以上。同时外层使用absolute而非fixed,因为position位于书本单元格坐标系内(Annotator减去了#gridcell-<bookKey>的 rect),fixed会在侧边栏打开或分屏第二本书离开视口原点时,把 popup 错位cell.left像素。

六、浏览器测试的严格 ESM 陷阱(真实踩坑记录)

评审文档中最具工程价值的部分,是 CItest_web_app (1)的失败根因与修复方法:

  • 现象annotation-popup-layout.browser.test.tsxdoes not provide an export named ...
  • 根因:该测试用 factory 方式 mockannotatorUtil,而 factory 只导出了getHighlightColorLabel;PR 新增的调用链AnnotationPopup -> AnnotationNotes -> AnnotationNoteItem -> useSaveBooknoteNoteText会导入applyNoteBubbleTransition/decideNoteBubbleTransition。浏览器模式的vi.mock严格 ESM,mock factory 缺失具名导出会导致整个文件 import 失败;
  • 修复:在 factory 中补齐这两个 stub(本地复现并验证 3/3 通过)。

评审文档给出的通用排查建议("How to apply"):当 PR 只挂test_web_app (1)且报这个错时,去查测试文件的vi.mockfactory 是否缺具名导出,而不是查源码;优先用importOriginal展开或补 stub。

七、两个被证伪/发现的边界行为

1. "清空笔记留下空卡片" 是误报

CodeRabbit 曾提示"清空笔记会留下空卡片",但评审确认为误报:Annotator.tsx中约第 1185 行的 effect 会用config.booknotes过滤掉空白笔记后重建annotationNotes(该 effect 位于 Annotator.tsx,单次遍历当前章节候选,利用 location 桶化,即使书中有数千高亮也能保持高效)。因此清空后的笔记会卸载,气泡 popup 回退到工具栏。已在 Xiaomi 真机验证:清空 → 显示工具栏、气泡移除、高亮保留。

2. 预存缺陷:初始加载不绘制气泡

评审发现一个非本 PR 引入的预存 bug:笔记气泡在初始加载 / section 重渲染时不被绘制。onCreateOverlay(Annotator.tsx)只重新添加style注解;气泡只来自依赖progress的 effect(约第 1130 行),而该 effect 触发时 overlayer 尚不存在。Chrome 与 Xiaomi 上带已有笔记验证均可复现,气泡只有在 Notebook/popup 保存后才出现。截至评审记录时间,该问题尚未提交独立 issue。

八、验收流程与工程实践(真实验证记录)

评审记录包含一份跨平台真机验收清单,展示了该功能的完整验证路径:

  • Chrome(Web):用户库中的 Alice 书(笔记在验证后恢复为原始 "Chapter II"),气泡点击 → popup → Edit(真实 CDP touch)→ textarea 自动聚焦、软键盘弹出、popup 卡片保持在键盘上方(visualViewport从 872 降到 535,卡片 y=189..273)→ 输入 → Save → 卡片与 store 同步更新,刷新后持久化;验证后设备上删除测试高亮。
  • Xiaomi(PR APK):Mishima EPUB 重复上述流程。

两项重要的设备/仓库工程告诫:

  1. APK 版本核对pnpm dev-android安装成功约 5 分钟后,主仓库较旧的 APK(8 月 20 日构建)可能因 md5 不一致被重新装回设备,导致第一轮设备验证跑的是旧代码(popup DOM 里没有 Edit 按钮)。因此设备结果可信前,必须用pm path拿到 APK 与 worktree 内 APK 做md5 比对
  2. pre-push hook 陷阱tsgo会在陈旧的apps/readest-app/.next/types/validator.ts上失败(该文件由 dev-web 在 rebase 分支上生成,引用了旧 PR 头不存在的src/app/player/page)。从 checkout 移向更旧 base 的 worktree 推送前,先rm -rf .next

九、rebase 与冲突移植要点(#5805 顺序依赖)

评审记录的 Update 部分记录了一个真实的 rebase 教训:

  • #5805(笔记 popup Markdown 渲染)先于本 PR 合并(841b3639b),改写了同一处AnnotationNotes.tsx:卡片改为用parseNoteMarkdown(note.note)(已 sanitize)+prose prose-sm max-w-none+ memoizednotesHtml渲染。本 PR rebase 时必然冲突,必须把该渲染逻辑移植进AnnotationNoteItem.tsx而非继续用{note.note}
  • 2026-08-21 完成 rebase:BooknoteItem.tsx的 import 块保留removeBookNoteOverlays+parseNoteMarkdown+ 两个 hook import、丢弃 Marked;AnnotationNotes.tsx取 PR 侧、丢弃notesHtml;Markdown 渲染移植进AnnotationNoteItem.tsxnoteHtml = useMemo(parseNoteMarkdown)+prose prose-sm max-w-nonediv);AnnotationNotesMarkdown.test.tsx需要与AnnotationNotesSurface.test.tsx相同的额外 store/translation mock。目标测试 193/193 通过,lint 通过,浏览器 popup 测试 3/3 通过;
  • fork PR 的 rebase 推送必须用 SSH URL:HTTPSlibbybarremote 因 rebase 携带了主分支的 workflow 提交(.github/workflows/android-e2e.yml),被 GitHub OAuth App 以缺少workflowscope 拒绝;改用git@github.com:libbybar/readest.git推送成功。

十、对应测试资产(可继续深挖)

PR 合入后在仓库中留下了一整套测试资产,适合继续阅读源码验证:

  • 浏览器级: annotation-popup-layout.browser.test.tsx(第六节所述 ESM mock 修复点)、AnnotateNoteEditorFlow.test.tsx;
  • 组件级: AnnotationNoteItem.test.tsx、AnnotationNotes.test.tsx、AnnotationNotesMarkdown.test.tsx、AnnotationNotesSurface.test.tsx;
  • hook/工具级: useInlineTextEditor.test.ts、useSaveBooknoteNoteText.test.ts、updateBooknoteNoteText.test.ts、BooknoteItem.test.tsx。

结语

PR #5780 以"最小侵入 + 最大复用"的方式,把笔记编辑从侧边栏下沉到了阅读页气泡:useInlineTextEditor管 UI 状态、updateBooknoteNoteText管纯函数数据更新、useSaveBooknoteNoteText管 store 写入 + 气泡过渡重绘 + 落盘。它同时留下了一份宝贵的工程档案——严格 ESM 下的vi.mock具名导出陷阱、设备 APK md5 核对、rebase 推送的 SSH 要求,以及一个尚未提交 issue 的"初始加载不绘制气泡"预存缺陷。这些细节既是测试与维护者的直接参考,也是理解 Readest 注解子系统内部协作方式的窗口。

【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询