ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/9/21 16:32:24 拓冰建站 浏览量
Readest 笔记气泡内联编辑(PR 5780)源码评审与实现解析 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 #5780issue #4668从气泡弹窗bubble popup编辑笔记 的完整评审记录深入剖析该功能如何把笔记编辑能力从侧边栏下沉到阅读页的笔记气泡卡片上包括AnnotationNoteItem中的 Edit / Save / Cancel 交互、从BooknoteItem中抽取的useInlineTextEditor/useSaveBooknoteNoteText/updateBooknoteNoteText三个复用单元以及随之引入的浏览器端vi.mock严格 ESM 测试陷阱。阅读本文后你将理解该功能的完整调用链、持久化与气泡重绘机制、移动端软键盘与竖排文本的处理以及一次真实的跨平台Chrome 与 Xiaomi 真机验收流程。一、功能背景为什么要从气泡弹窗编辑笔记在 Readest 中选中文本后弹出的是选区工具栏 笔记气泡note bubble。PR #5780 之前气泡卡片只能查看笔记内容要编辑必须打开侧边栏的笔记列表BooknoteItem进行修改。用户从阅读上下文跳到侧边栏编辑体验割裂。PR #5780作者 libbybarfork 分支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 过滤空白/纯空格文本被规范化为空字符串非空白文本原样存储、不做 trimnow由调用方传入用于写updatedAt保证函数确定、与运行时机无关绝不修改原数组返回全新数组booknotes.map(...)若找不到匹配的存活记录例如并发同步已将其 tombstone 删除则返回null让调用方可放弃保存避免复活一条已删除的笔记。3.useSaveBooknoteNoteText—— 保存 气泡重绘 落盘的一体化接线文件useSaveBooknoteNoteText.tsexport function useSaveBooknoteNoteText(bookKey: string) { // 返回 (booknoteId: string, noteText: string) void }它的执行顺序设计得非常严谨源码注释点明先写 store成功后再重绘气泡与落盘getConfig(bookKey)读取当前书本配置拿不到则直接返回调用updateBooknoteNoteText(config.booknotes ?? [], booknoteId, noteText, Date.now())失败返回null则放弃updateBooknotes(bookKey, result.booknotes)写入 storestore 拒绝则放弃——绝不让 store 拒绝的配置落盘用decideNoteBubbleTransition(previousNoteText, updatedBooknote.note)决定气泡过渡类型applyNoteBubbleTransition(getViewsById(...), updatedBooknote, transition)在所有已渲染视图上重绘气泡最后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-rl与fontFeatureSettings: vrt2 1, vert 1卡片尺寸按popupHeight撑满。时间戳dayjs(note.createdAt).fromNow()相对时间。卡片布局为 flex 上下结构上部分是 Markdown 正文下部分是相对时间 编辑按钮一行。五、编辑器调度桌面 popup 与移动端 bottom sheet文件AnnotationPopup.tsx、Annotator.tsxAnnotator.tsx负责调度三种 popup 主体优先级是noteEditor notes 列表 高亮选项工具栏AnnotationPopup.tsx第 136-196 行noteEditor非空 → 渲染AnnotationNoteEditor高度为useResponsiveSize(180)够放下几行文本加上 Cancel/Save 行而不是工具栏 44px 的矮高度它锚定在 popup 三角形边缘并向远离三角形的方向生长与AnnotationNotes一致。否则若notes.length 0→ 渲染AnnotationNotes按updatedAt降序排序。否则若highlightOptionsVisible→ 渲染HighlightOptions。Annotator.tsx中相关的状态与回调noteEditorTargetuseState第 191 行记录当前正在编辑的笔记与 placeholder当 target 消失时由 effect 清理removeNotePlaceholders第 1593-1603 行保证编辑器的清理逻辑不依赖单一 dismiss 路径handleSaveNotesaveBooknoteNoteText(annotationId, note)→ 清空 placeholder → 关闭编辑器与选区 popuphandleCancelNote关闭编辑器与 popup移动端判定window.innerWidth 640 || window.innerHeight 640时使用 bottom sheetnoteEditorInSheet否则使用 popupnoteEditorInPopuppopup 的onDismiss在编辑态下被替换为handleCancelNote即Escape 取消编辑并关闭 popup评审记录确认这依赖Popup组件在 window 上挂载的useKeyDownActions不是本次回归维持原样。AnnotationPopup外层包装容器的 z-index 设计z-[43]也值得注意工具栏开在选区上与文本选择器的拖拽手柄层z-[44]重叠手柄是抓取目标、优先占位因此工具栏必须低于手柄层、但又高于段落/TTS 层z-40和脚注 popupz-[42]从工具栏弹出的所有次级 popup 保持z-50以上。同时外层使用absolute而非fixed因为position位于书本单元格坐标系内Annotator减去了#gridcell-bookKey的 rectfixed会在侧边栏打开或分屏第二本书离开视口原点时把 popup 错位cell.left像素。六、浏览器测试的严格 ESM 陷阱真实踩坑记录评审文档中最具工程价值的部分是 CItest_web_app (1)的失败根因与修复方法现象annotation-popup-layout.browser.test.tsx报does not provide an export named ...根因该测试用 factory 方式 mockannotatorUtil而 factory 只导出了getHighlightColorLabelPR 新增的调用链AnnotationPopup - AnnotationNotes - AnnotationNoteItem - useSaveBooknoteNoteText会导入applyNoteBubbleTransition/decideNoteBubbleTransition。浏览器模式的vi.mock是严格 ESMmock 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 重渲染时不被绘制。onCreateOverlayAnnotator.tsx只重新添加style注解气泡只来自依赖progress的 effect约第 1130 行而该 effect 触发时 overlayer 尚不存在。Chrome 与 Xiaomi 上带已有笔记验证均可复现气泡只有在 Notebook/popup 保存后才出现。截至评审记录时间该问题尚未提交独立 issue。八、验收流程与工程实践真实验证记录评审记录包含一份跨平台真机验收清单展示了该功能的完整验证路径ChromeWeb用户库中的 Alice 书笔记在验证后恢复为原始 Chapter II气泡点击 → popup → Edit真实 CDP touch→ textarea 自动聚焦、软键盘弹出、popup 卡片保持在键盘上方visualViewport从 872 降到 535卡片 y189..273→ 输入 → Save → 卡片与 store 同步更新刷新后持久化验证后设备上删除测试高亮。XiaomiPR APKMishima EPUB 重复上述流程。两项重要的设备/仓库工程告诫APK 版本核对pnpm dev-android安装成功约 5 分钟后主仓库较旧的 APK8 月 20 日构建可能因 md5 不一致被重新装回设备导致第一轮设备验证跑的是旧代码popup DOM 里没有 Edit 按钮。因此设备结果可信前必须用pm path拿到 APK 与 worktree 内 APK 做md5 比对。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)已 sanitizeprose prose-sm max-w-none memoizednotesHtml渲染。本 PR rebase 时必然冲突必须把该渲染逻辑移植进AnnotationNoteItem.tsx而非继续用{note.note}2026-08-21 完成 rebaseBooknoteItem.tsx的 import 块保留removeBookNoteOverlaysparseNoteMarkdown 两个 hook import、丢弃 MarkedAnnotationNotes.tsx取 PR 侧、丢弃notesHtmlMarkdown 渲染移植进AnnotationNoteItem.tsxnoteHtml useMemo(parseNoteMarkdown)prose prose-sm max-w-nonedivAnnotationNotesMarkdown.test.tsx需要与AnnotationNotesSurface.test.tsx相同的额外 store/translation mock。目标测试 193/193 通过lint 通过浏览器 popup 测试 3/3 通过fork PR 的 rebase 推送必须用 SSH URLHTTPSlibbybarremote 因 rebase 携带了主分支的 workflow 提交.github/workflows/android-e2e.yml被 GitHub OAuth App 以缺少workflowscope 拒绝改用gitgithub.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.tsxhook/工具级 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),仅供参考