主题
EPUB 全书搜索 + ⌘F 桥 + 目录过滤(B7)—— 交接稿(as-built)
物理仓库位置:
~/reading-browser(ReadBrowser 桌面端,Tauri + React + Rust) 2026-08-08 功能收口;2026-08-12 验证矩阵跑完 + §7.2 结案(见 §5.5 与「已结案」小节)。 范围 = backlog§🔴 EPUB 长期阅读第 7 条(B7-1 / B7-2 / B7-3)。 零 migration,schema.sql未动。 前置:epub-toc-quality-handoff.md(B' + A/C/D)·epub-anchor-precision-handoff.md(B5/B6)
1. 改了什么
| 文件 | 改动 |
|---|---|
src-tauri/src/commands/epub/search.rs | 新模块。search_epub_fullbook 命令 + visible_runs / search_html / build_hit。+10 条单测 |
src-tauri/src/commands/epub/mod.rs | pub mod search;;修 EpubChapter.href 的错误文档注释(见 §3①) |
src-tauri/src/commands/epub/nav.rs | decode_entities 提为 pub(super) 供 search.rs 复用 |
src-tauri/src/commands/webview.rs | 新命令 report_find_shortcut(emit + main.set_focus()) |
src-tauri/src/lib.rs | 注册两个新命令 |
src-tauri/src/content-script/events.js | ⌘F keydown 桥(capture + preventDefault → invoke) |
src-tauri/src/content-script/features/find.js | findInPage(query, opts) 支持 {scroll, activeIndex} |
src/components/tools/FindMode.tsx | 258 → 491 行:作用域切换 / 全书结果列表 / replayAfterNav / splitSnippet |
src/components/tools/ToolsPanel.tsx · RightPanel.tsx | 透传 onEpubNavigate |
src/MainApp.tsx | find-shortcut listener;EPUB 导航后置钩子调 __rb_replayFindAfterNav |
src/components/TocPanel.tsx | 过滤框 + 过滤态接管可见性 |
src/lib/epubToc.ts | filteredTocRows() |
src/lib/commands.ts | searchEpubFullbook + 3 个类型 |
src/lib/strings/{panels,locales/{en,zh}/panels}/{find,reader}.ts | 10 + 4 条新 key(双语齐备) |
pnpm build:cs 已跑(content-script 是 include_str! 进 Rust 的打包产物)。
2. 关键设计决定
① 计数刻意是两段:「本章 i/j · 全书 M」,不做全书连续编号
j 来自页内 find.js(collectRanges 走渲染后 DOM 的 TreeWalker), M 来自 search.rs(扫磁盘 HTML)。两套实现,不可能恒等:
- 页面渲染后 content-script 会把生词/短语包进
<span>(replaceChild),把原本一个 text node 劈成几个 → 「跨越某个被高亮词边界」的查询页内找不到、磁盘侧找得到; - 反过来这也是页内自身不稳定的地方 ——
refreshFindRanges存在的理由就是高亮会打碎 Range。
所以 UI 不宣称 M 含 j,跳转落点对不齐时退化成「落在附近那一处」(页内 activeIndex 有 clamp), 不报错。全书连续编号则会逼出「next 走到章末必须触发一次真实导航才能续上」,翻页时机不可预期。
② 磁盘侧刻意复刻 find.js 的三条语义
否则 ordinal 对不上、点结果跳错位置:
- 大小写不敏感;
- 跳过
<script>/<style>(同visible_text_len); - 匹配不跨文本节点 ——
find.js是逐 text node 做indexOf的,Ro<em>me</em>它找不到。visible_runs因此按标签切成 run,只在 run 内部搜;run 之间用单个空格拼进joined(片段从joined取,跨标签的上下文也读得通),空格分隔顺带成了「匹配不跨 run」的物理保证。
单测 does_not_match_across_tag_boundaries / joined_text_does_not_create_cross_run_matches 锁住这两侧。
③ 跨章跳转走「主侧导航后置钩子」,不在页内注入 pending query
选它而非 __RB_PENDING_FIND 式注入的理由:
- query 的唯一真相源留在
FindMode的 state 里。注入式会在页内多存一份。 MainApp的navigation-state-changed已经是全仓唯一「页面加载完了」的落点,review-source的__rb_reviewRecall/__rb_reviewReveal走的就是同一拍 —— 有现成先例。- 主侧不需要知道查询词:它只喊一声
__rb_replayFindAfterNav(), 由FindMode自己决定这次要不要抢滚动位置。
两条分支的区别就是要不要抢滚动位置:
- 有
pendingJumpRef(用户点的是搜索结果)→{scroll:true, activeIndex:ordinal},跳转本身就是诉求; - 无(用户自己翻的章,只是面板恰好开着)→
{scroll:false},只重绘。 否则读者点「下一章」会被甩到该章第一处命中上。
replay 用轮询而非直接调:新页的 __rb_findInPage 可能还没注册 (同 MainApp 里「onPageReady may have run before this eval landed」那条既有竞态), 直接 eval 会静默落空。1s 窗口内每 50ms 试一次。
④ ⌘F 桥的要害是 set_focus,不是 emit
与 zoom 桥(report_zoom_shortcut)同族,但多一步:app.get_webview("main").set_focus()。 只 emit 的话面板会开,但原生 key 焦点仍在 content webview 上,FindMode 里那句 inputRef.focus() 只拿到 DOM 焦点 —— 敲下去的字符照旧被正文吃掉,面板开了却打不了字, 比完全不响应更费解。顺序:先夺焦点再 emit(FindMode 自动聚焦有 50ms 延时,接得住)。
⑤ 过滤态接管可见性,忽略折叠态
反面做法是让折叠继续生效 —— 那样一个折起来的组会把命中项藏掉,用户看到「没有匹配」 但其实匹配上了,是最难自我排查的一类假象。collapsed 本身不动,清空过滤词后原样恢复。 过滤态下组头一律渲染等宽占位而非箭头:那时点箭头不会有任何变化(后代已被过滤掉), 留着就是个骗人的控件。
保留祖先链、不放出后代:三层书(Romance 8/5/34)里搜出一条 level 2 的章, 不给祖先就看不出它属于哪个 Part;反过来命中一个组头就把下面几十章全放出来,那面墙就又回来了。
3. 两个开发期真 bug(都是实机才暴露,单测抓不到)
① EpubChapter.href 自带 cache_base 前缀,而文档注释是错的
rust
pub href: String, // path relative to cache_base, e.g. "OEBPS/Text/ch1.xhtml" ← 错实际 parse_opf 里是 format!("{}/{}", cache_base, ...),真实形状是 epub/{hash}/OEBPS/ch1.xhtml。同文件的 read_toc_src 早就写对了(lib.join(cache_href))。
照注释又 join 一次 → .../epub/{hash}/epub/{hash}/... → 路径不存在 → 被 continue 静默吞掉 → 全书总数恒为 0。单测测的是 search_html(html, ...) 算法本身,路径拼接根本没进测试。
已修:lib.join(&href) 一次;注释改成正确描述并写明它坑过谁; 顺手把闲置的 cache_base 参数转成归属闸(hrefs 来自前端 = 不可信输入, 必须 starts_with(cache_base) 且不含 ..,否则能把 library 下任意文件读出来当搜索结果)。 回归测试 real_chapter_href_shape_is_accepted / out_of_book_hrefs_are_rejected。
② 片段加粗用 String.slice() → 多字节错位
Rust 给的 match_start / match_len 是字符数,JS slice 走 UTF-16 code unit。 BMP 内一致,遇到 emoji / 增补平面汉字(1 字符 = 2 code unit)就会加粗错位置。 已修:splitSnippet() 用 [...snippet] 展开成 code point 数组再切。
4. 实测结论(全部经 /debug 探针断言,非看屏幕)
先在磁盘上独立算出真值(Python 复刻 Rust 的 run 切分语义),再拿 app 的数字去对:
| 章节文件 | Rome |
|---|---|
| h-0 | 56 |
| h-1 | 104 |
| h-2 / h-3 / h-5 / h-6 / h-7 / h-8 | 7 / 5 / 1 / 1 / 1 / 13 |
| 全书合计 | 188(8 个文件有命中) |
56 与用户界面上原本那个误导性的「共 56 处」严丝合缝,188 与 backlog 记录一致。
B7-2 ⌘F 桥
| 条目 | 证据 |
|---|---|
| 面板打开 | rightPanel = tools / toolsMode = find |
| 焦点真的抢到了 | <input ... value="Rome"> —— 用户点正文后按 ⌘F 直接打字,字进了输入框 |
B7-1 全书搜索
| 条目 | 证据 |
|---|---|
| 计数行 | 本章 1/56 · 全书 188 处 —— 与磁盘真值逐项吻合 |
| 分组数 | [aria-expanded="true"] 8 个 = 磁盘 8 个有命中文件 |
| 结果行 / 加粗 | [aria-label="跳转到该处"] 188 · mark 188(188 < 配额 200,未截断) |
| 片段质量 | …mities of the world, and especially the sack of **Rome** by the Goths… —— <mark> 精确包住 Rome,两端带省略号 |
| 首组标题 | THE WORKS OF AURELIUS AUGUSTINE,(56)—— chapterLabel() 取该文件第一条 TOC 条目,符合设计 |
| 跨章跳转 | 点 FOOTNOTES:(13)组首条 → url h-0 → h-8、epubCurrentIdx 9、计数变 本章 1/13 · 全书 188 处 |
跨章那个 13 是新章的 content-script 自己
collectRanges后经report_find_matches报回来的 —— 等于直接证明了新页高亮真的重新贴上了,__rb_replayFindAfterNav那条回路(含轮询)工作正常。
B7-3 目录过滤(The City of God,35 条)
| 动作 | 结果 |
|---|---|
过滤 BOOK | 35 → 15 行,提示 15 / 35 条 |
| 祖先链 | T. & T. CLARK, EDINBURGH.(pad=8,自身不含 BOOK)作为 SUBSCRIPTION BOOKS…(pad=20)的上下文出现 |
| 过滤态箭头 | 折叠/展开 aria 各 0(换成等宽占位) |
| 清空恢复 | 回 35 行 + 13 个折叠箭头复现(折叠态未被破坏) |
| 零命中 | 没有匹配的目录项,行数 0 |
扁平长目录(Moby Dick,146 条里 141 条 level 0 —— B7-3 的正牌动因): 过滤 whale → 146 → 15 行,提示 15 / 146 条。 独立数 toc.ncx:146 个 <navPoint>,含 whale(忽略大小写)的正好 15 条 —— 精确一致。 本书近乎全平、几乎无祖先可补,所以「命中数 == 可见行数」,正好把扁平路径单独验了一遍 (对照 City of God 那组:15 行里有 1 行是祖先上下文,命中其实只有 14 条)。 那面墙确实塌了:一屏看不完的 146 条 → 15 条。
收口检查
cargo check ✅ · cargo test --lib 147 passed(145 +2 归属闸)· pnpm build ✅ · pnpm test:run 159 passed(147 → 159,+12 filteredTocRows)· /arch-check 硬规则 H1-H6 全过;改动文件上 S2/S8/S11-S16 = 0; S19/S22/S23 三件套 = 0(831 keys,MISSING 0 / EQUAL 0);S20 两新命令均被引用; S24=7 / S25=0 / S26=3/3 / S27=3 全持平基线 · /ui-check U1=2(永久豁免持平)· U2=U3=0 · 改了 content-script → 已 pnpm build:cs。
5. 留下的东西
同轮追加已做(2026-08-08 第二次提交)
- 拆
FindMode:全书结果列表抽到tools/FullBookResults.tsx(105 行,纯呈现、不持状态;splitSnippet与MIN_BOOK_QUERY一并搬过去)。「点了怎么跳」刻意留在FindMode—— 同章就地跳 / 跨章先导航再由replayAfterNav接上,那是状态逻辑不是呈现逻辑。 顺手把 prev/next 两颗按钮收进本地StepButton(同一串 6 行 className 原本抄了两遍)。 491 → 449 行(⚠️ 上一版这里写"抽完回到 ~330 行"是估错了,实际 449 —— 剩下的都是组件自身内聚的 state / callback / effect,再切就是为拆而拆)。 - ncx 实体解码(backlog §7.1,走方案 A = 展示层):
epubToc.ts新增decodeTocEntities并接进cleanTocLabel。落点选它是因为cleanTocLabel的三个调用方 (TocPanel 渲染 / FindMode 分组名 /filteredTocRows匹配)全是展示路径、无写入路径, 于是一处改动三处同时生效,且reading_pages.title一个字节都不动。 语义对齐 Rustnav.rs::decode_entities(同一套命名实体 + 十进制/十六进制数字实体, 不认识的原样保留)。刻意不走innerHTML借浏览器解析 —— 那等于把目录标签当 HTML 执行。 连带修好:搜T & T此前零命中(标签里是字面量&),现在能搜到。+6 条单测。代价(已知并接受):
reading_pages.title里仍然存着&,即笔记/来源页标题 不会变干净。彻底修需要源头解码 + 一次性存量标题迁移 —— 该等哪天有别的理由必须迁移 标题时搭车做,为这个低频缺陷单独动存量数据不划算(City of God 35 条目录只命中 1 条)。
用户手验捞出的 6 条(2026-08-08 第三/四次提交)
前两条是 B7-1 引入的回归,其余是设计缺口。全部已修。
- 计数频繁跳回「本章 1/56」 ——
replayAfterNav挂在navigation-state-changed上, 但同一页也会反复触发:滚动联动(6.6 的 D)滚过章节锚点 →writeFragment→history.replaceState→ tracking.js 的 patch →reportNavigationState。 而findNext自身就会滚动页面 —— 等于自己踩自己,「下一个匹配」根本走不下去。 修法:MainApp按 url 的 **base(剥 fragment)**比对lastEpubDocRef, 只在 xhtml 文件真的换了时才喊重放。 - 跳过去正文没黄色高亮,要等生词高亮跑完再点一次 ——
refreshFindRanges的守卫是!state.find.active || !queryreturn;跨章重放正好撞上正文还没进 DOM 的那一刻 → 收集到 0 处 →active停在 false → 此后每次都直接 return,再也不会自愈。 这其实是个早就存在的潜伏 bug(页面加载中搜索就会中招),只是跨章重放把它稳定触发了。 修法:守卫只看query,0 处时显式置active=false;之后 highlight-queue 每跑完 一个 job 都会重试,内容到位那一刻自动补上。 - 仍要等 vocab 跑完才见黄色(延迟) ——
drain()抢占不了正在跑的 job,而 vocab / discovery 各是一个整块的全 DOM 遍历,find 优先级再高也得排在后面。 修法:给findInPage加immediate—— 跨章重放绕开队列当场画。 为什么安全:队列存在的理由是串行化 DOM 改动(vocab 靠replaceChild), 而 find 走 CSS Custom Highlight API 一个 DOM 节点都不碰,本来就没有必须排队的 物理理由;排队原本只为①连打 dedup ②读一致的state.find。 键盘输入仍走队列(那里的 dedup 是必需的),只有重放这一条走直连。 - 输入单字符仍满屏涂黄 —— 一边显示「请至少输入 2 个字符」一边画 9958 条 Range。 修法:门槛提到
src/lib/findQuery.ts::meetsMinQueryLen,两个作用域同一把尺。⚠️ 不是简单的
length >= 2:一个汉字往往就是一个完整的词(搜「城」「神」 完全合理),一刀切会直接废掉中文查找 —— 而本 app 既能读中文 EPUB 也能查中文网页。 真正的噪声源是拉丁单字母。所以规则是「长度够 2 放行;只有 1 个字符时含 CJK 才放行」。 Rust 侧meets_min_query_len必须同步(两端各一份实现,不一致的症状是 「面板说能搜、结果永远 0 处」),两边用同一组单测用例锁住。 - 点全书反应慢 —— 全书扫盘与页内 find 共用 200ms 防抖,打一个
Rome就扫 4 遍全书。 拆成两条:页内 200ms / 全书 500ms;关面板时把bookTimer一并掐掉。 check:no-inline-zh误报单测夹具 ——findQuery.test.ts用'城'验 CJK 判定, 被守门脚本当成"内联 UI 中文"。是脚本的真实盲区(此前没有测试文件带中文字面量): 该规则管的是会渲染给用户的文案,测试夹具永远不进界面。已给脚本加*.test.ts排除, 并反向验证过排除不过宽(非测试文件里的中文仍会被抓)。
✅ 已结案:旧查询词的高亮偶尔不消失(2026-08-12 根因 + 修复 + 复验)
根因不是状态,是像素 —— WKWebView 在 CSS.highlights.delete() 后不把原先画过的区域标脏, 注册表已空、计数行已对,但屏幕上的黄色要等别的东西触发一次重绘才会消失。
复现配方(2026-08-12 一次即中,此前一整轮抓不到是因为配方不对): 搜一个高频词(City of God h-3 搜 the = 2785 处)→ 立刻改成0 命中的词(zzqq)。
| 证据面 | 修复前 |
|---|---|
_diag 探针 | afterWipe hlKeys=[] · done ranges=0 —— 状态完全干净 |
| 面板 | 计数行「无匹配」、上/下一个按钮置灰 |
| 截图(0.8s 后) | the 的黄色一处不少 |
| 截图(+8s,其间无任何操作) | 自己消失了 —— 与用户报的「浮现几秒后恢复」完全吻合 |
为什么之前三条状态假设全是 ❌、而且间歇 ——它们查的都是状态,状态一直是对的。 只要有任何东西顺带触发重绘就看不见这个 bug:新查询画上了高亮、scrollActiveIntoView 真的滚了、后续 vocab job 改了 DOM、关面板让 content webview 改了宽度…… 只有「什么都没发生」时才露出来 —— 新词 0 命中,或首个命中已在视口内导致 scrollIntoView 不移动。 用户那次 the→vital(共 2 处)正是后一种。
修法(find.js::_forceHighlightRepaint,在 _wipeAllHighlights 末尾、且只在真的删掉过东西时调): 把 <html> 的 opacity 设成 0.9999,跨帧(rAF + 100ms timer 兜底)再还原。
- 必须跨帧:同一拍里设完又改回会被样式重算合并掉,等于没变、根本不会重绘。
0.9999肉眼不可辨;CSS.highlights.size === 0时直接跳过,不给每次按键白加一次样式重算。- 红线 #4(改 DOM 前停 MutationObserver)不适用:observer 挂在
document.body且只听{childList, subtree},而这里动的是<html>的 style 属性(代码里已写明这条推理)。
复验:同一本书同一章同一配方,修后 0.8s 截图干净,而同轮「搜 the」的截图证明黄色确实画上过 (2785 处)。加 timer 兜底后又重跑一遍,结果相同。
探针 _diag 已连同 5 个埋点一起删除(结案即删,见 v20/v25 phrase_interaction_log 教训)。
两条附带确凿数据(仍然有效):
- find 执行本身是 6-15ms(本轮复测 8-16ms),不是「点下一个卡顿」的原因 —— 去看 smooth scroll 动画与 vocab job 抢主线程。
- 中间态会造成视觉误判:搜到一半的
vi命中 38 处,看起来很像旧高亮没清。 判据是计数行与涂色是否一致。
✅ 已排除的假线索:日志里第一次 eval 收到
t'h(带撇号)、0 命中,400ms 后才是正确的the。一度怀疑esc()→evalInWebview途中破坏查询词 —— 用户已确认是拼音输入法的中间态 (敲拼音时输入框真的短暂含撇号)。不是 bug,别再追。 附带启示:中文输入法下handleInput会收到拼音中间态并真的去搜一次。目前只是白跑一次 页内 find(200ms 防抖内,很便宜),不值得为它引入compositionstart/end处理; 但全书搜索若将来把防抖调小,这条会变成真实浪费(每个拼音中间态扫一遍全书)。
未做(刻意)
- A1「⌘F 后看不见查找面板,另一个 tab 的网页内容盖在面板位置上」 —— 2026-08-08 首见、 2026-08-12 用户再次撞到并给了截图(露出来的是 TIME for Kids 那个非活动 tab 的页面), 但当场受控复现失败:同一本书、同样 ⌘F、面板正常,三个量全正常 (content-1 稳在
x=-20000;content-2x=744 w=2136→x=168 w=2072, 目录收起 +576 / 面板占走 640,算术分毫不差)。故仍未结案。 牵扯原生 webview 层级(content webview 是 main 的原生兄弟层,永远在上), 至少三种可能:面板打开后 ResizeObserver 没及时重摆 /set_focus影响 z-order / 非活动 tab 的 webview 没藏干净。不要猜着加规则。✅ 下次一次探测就能定性:
GET /debug/snapshot的content_tabs[]现在每个都带bounds{x,y,w,h}(物理像素)(2026-08-12 为这条加的)。藏起来的 tab 恒在-20000,-20000(=hide_content_webview的 -10000 逻辑像素 ×2)。 于是「该藏的没藏住」与「活动 webview 被摆错位置」当场可分,不必再靠截图印象。 与既有记录「原生焦点卡在 content webview → 主侧快捷键哑火」大概率同族。 - B4 章内偏移(需新列 → 迁移从 v32 起,独立立项)、B8、B9。
已知近似 / 边界
- 全书片段配额
MAX_SNIPPETS_TOTAL = 200:命中总数永远精确,只是不把上万条片段搬过 IPC。 超配额时truncated=true,UI 提示「片段仅显示前 N 条,计数仍是全书真实数」。 实测 Rome 188 < 200 未触发 —— 这条分支还没被真机跑过,下次拿高频词(如the)验一次。 - 全书搜索最短 2 字符(
MIN_QUERY_LEN,Rust 与前端MIN_BOOK_QUERY两处必须一致)。 页内 ⌘F 不受此限。 chapterLabel()取该 xhtml 的第一条 TOC 条目作为分组名。一个文件住多条目录条目时 (实测 h-0 有 7 条)分组名会偏前 —— 与resolveTocEntry的同文件兜底同一口径,可接受。
5.5 ✅ 验证矩阵 —— 2026-08-12 全部跑完
图例:🤖 =
/debug探针断言 · 👤 = 用户肉眼验过 · 📷 = 截图为证 环境:dev app 单实例 +The City of God(35 条目录)/Romance(47 条、三层)/ 一个网页 tab
A. ⌘F 桥
| # | 验什么 | 结果 |
|---|---|---|
| A1 | 点正文 → ⌘F → 面板弹出且光标能直接打字 | 🤖👤 通过。但用户遇到过面板被另一 tab 的 webview 盖住 → 见 §未做 A1 |
| A2 | 先点主界面(地址栏)再 ⌘F —— 两条路径行为应一致 | 🤖 通过:查找框 value='Rome',#url-input 原样是那个 epub 路径(字没落回地址栏) |
| A3 | 网页 tab 里 ⌘F → 弹出且不出现「本章/全书」切换 | 🤖 通过:[aria-label="搜索范围"] found=false |
| A4 | 面板内按 Esc → 关闭 + 页面黄色高亮清干净 | 🤖👤📷 通过(Romance 359 处 → Esc → rightPanel:null + 截图无残留)。⚠️ 这条不受 §7.2 重绘 bug 影响是有原因的:关面板会改 content webview 宽度,resize 本身强制重绘 |
| A5 | 关面板的另外两条路径(点「页面工具」按钮 / 切「排版」)也应清高亮 | 🤖📷 2026-08-12 补测 → 发现不清 → 已修(backlog §7.3)。FindMode 卸载即清;切「排版」那条不伴随 resize,等于连 §7.2 的强制重绘一起压测 |
B. 全书搜索
| # | 验什么 | 结果 |
|---|---|---|
| B1 | 「本章 i/j · 全书 M」计数正确 | 🤖 通过(本章 56 / 全书 188,与磁盘独立计数逐项吻合) |
| B2 | 连点「下一个匹配」计数稳定递增、不跳回 1/56 | 🤖👤 通过:连点 8 次 1/7→7/7→回 1/7,期间滚动联动一直在改 url fragment 却没触发重放(f0d1ed0 的 base 比对生效) |
| B3 | 点跨章结果 → 跳章 + 落到该处 + 计数更新 | 🤖👤 通过(h-0→h-8,本章 1/13 · 全书 188 处) |
| B4 | 跳过去后黄色高亮立刻出现(不等生词高亮) | 🤖 通过 —— 0776af8 的 immediate 实证:findInPage:enter(带 {scroll:true,activeIndex:0,immediate:true})16:48:56.965 → done .981(16ms)、ranges=13、hlKeys=[rb-find,rb-find-active];同页 phrase/vocab 管线 16:49:03.9 才跑完、drain 的 refresh:enter 在 :04.342。即黄色比生词高亮早 7.4 秒 |
| B5 | 面板开着时切章:高亮重贴、计数更新,但页面不该被甩到该章第一处命中 | 🤖 通过(走 ‹上一章,见下方 ⚠️):日志 {scroll:false,immediate:true}、ranges=1、计数 本章 1/1 · 全书 188;页面停在锚点 y=38162(该锚点在 h-7 的 85% 处)。对照组:随后点同章那条结果(scroll:true)→ y=8730,正是 Rome 在文件 21% 处的位置 —— 两分支泾渭分明 |
| B6 | 全书模式下「上一个/下一个」只在本章内循环、不跨章 | 🤖 通过:8 次 next 全程停在 h-2,7/7 后回 1/7 |
| B7 | 折叠/展开某个章节分组,不影响其它组 | 🤖 通过:折起 BOOK FIFTH(7) → 结果行 188→181、展开组 8→7、相邻 BOOK SIXTH 仍 aria-expanded=true;再点回来 8 组复原 |
| B8 | 改成不存在的词 → 「全书无匹配」 | 🤖 通过:计数行 本章无匹配 · 全书 0 处 + 列表区 全书无匹配 + 结果行 0 |
| B9 | 输单个拉丁字母 → 不涂黄,只显示「请至少输入 2 个字符」 | 🤖👤 通过 |
| B10 | 搜高频词触发片段配额 200 截断 → 出现「片段仅显示前 N 条」 | 🤖 通过(该分支首次真机跑到):搜 the → 本章 1/2874 · 全书 26380 处、结果行正好 200、11 个分组、提示「片段仅显示前 200 条,计数仍是全书真实数」。磁盘独立复算 = 26380 / 11 个文件命中 / h-2 单章 2874 —— 三项全中(连页内 TreeWalker 与磁盘扫描在 2874 这个量级上都没差) |
⚠️ B5 的原始表述(「用目录切章」)在当前 UI 下不可能发生 —— 左右辅助面板互斥 (
usePanelStore.openLeftPanel/openTools互相清空,CLAUDE.md §Zustand),开目录 = 关查找面板,FindMode随之卸载、__rb_replayFindAfterNav被注销。所以scroll:false分支的真实入口是 ‹ › 上一章/下一章(AddressBar的同一对按钮,EPUB tab 上走的正是onTocNavigate, 与目录点击同一条导航路径),本次即以它验证。
C. 目录过滤
| # | 验什么 | 结果 |
|---|---|---|
| C1 | Moby Dick 输 whale → 146 → 15 行 + 15 / 146 条 | 🤖 通过(与 ncx 独立计数一致) |
| C2 | 清空过滤词 → 回全量 + 折叠箭头回来 | 🤖 通过(35 行 + 13 箭头) |
| C3 | 先折叠某组 → 输过滤词 → 清空 → 原折叠态原样保留 | 🤖 通过:折起 BOOK SECOND(35→34 行)→ 过滤 BOOK(15 行、0 箭头)→ 清空 → 回 34 行(不是 35)、aria-expanded=false 仍是 1 个、第 7 行仍是 BOOK THIRD(说明那条 ARGUMENT 子项还藏着) |
| C4 | 过滤态下组头没有折叠箭头 | 🤖 通过(折叠/展开 aria 各 0) |
| C5 | 输乱码 → 「没有匹配的目录项」 | 🤖 通过 |
| C6 | 三层嵌套书搜子章名 → 命中项 + 其上级 Part 一起显示 | 🤖 通过:Romance 过滤 VIII → 恰 3 行 + 3 / 47 条,且三级祖先链完整:Romance(pad 8) → Part IV: Blade and Guitar(pad 20) → VIII(pad 32) |
| C7 | 过滤态下点某一行 → 正常跳章 | 🤖 通过:过滤 SIXTH → 唯一行 → 点击 → url 到 h-3#pgepubid00243、epubCurrentIdx 4 |
| C8 | 换一本书 → 过滤词自动清空 | 🤖 通过:Romance 上留着 VIII → 切到 City of God tab → 输入框 value=''、35 行全量、x / y 条 提示消失(折叠态也一并复位,同 useEffect([items])) |
D. 回归(别让新功能弄坏旧的)
| # | 验什么 | 结果 |
|---|---|---|
| D1 | 目录高亮跟随滚动(6.6 的 D) | 🤖 通过:h-3 内滚动 → 活动行 BOOK SIXTH. → ARGUMENT.;用「活动行的下一行 = BOOK EIGHTH.」确认命中的是第二个 ARGUMENT(idx 16)而非同名的 idx 14 |
| D2 | 切到另一本 EPUB 的 tab → 目录立刻高亮(6.7) | 🤖 通过:切过去 ~1s 内两本书各自有且只有 1 个活动行(City of God=BOOK SIXTH./Romance=II) |
| D3 | ⌘+ / ⌘- / ⌘0 仍正常调字号(⌘F 桥不该影响 zoom 桥) | 🤖👤 通过:⌘+ ×2(焦点在查找框,走主侧 useKeyboardShortcuts)→ tab.zoom 1.0→1.25;⌘- + ⌘0(焦点在正文,走 content-script report_zoom_shortcut)→ 1.0 + domain_zoom 落库。⚠️ 中间有一轮 ⌘-/⌘0 完全没生效且没有任何状态写入(两条路径都没走到),复现不了——最可能是在终端/app 间来回切换后原生焦点没落在任何 webview 上,与 backlog「原生焦点卡在 content webview → 主侧快捷键哑火」同族 |
| D4 | 网页 tab 用 ⌘F → 单章逻辑照旧 | 🤖 通过:计数是单章格式 第 1 / 共 69 处,无作用域切换器 |
6. 工作方式(沿用,已验证有效)
- 用户负责"到达现场"(开书 / 按键),只做窄探针检查。给编号步骤,等回「好了」再批量探测。
- 禁止
osascript合成按键——本机对 dev app 不抵达,硬试会导致误诊。 - 探针(注意是
$TMPDIR):bashP=$(cat "$TMPDIR/rb-debug-port") curl -s --get "http://127.0.0.1:$P/debug/dom" --data-urlencode 'selector=...' \ --data-urlencode 'webview=main' | jq -c '.result' - 本轮新增的可复用探针 / 踩坑:bash
# 计数行(.tabular-nums 太泛,会撞上地址栏缩放药丸 110%) --data-urlencode 'selector=.text-sm.tabular-nums.text-text-secondary.px-1' # FindMode 整块 --data-urlencode 'selector=.flex.flex-col.p-3.gap-3' # 全书结果:分组头 / 结果行 / 加粗 --data-urlencode 'selector=[aria-expanded="true"]' # 看 total_matches --data-urlencode 'selector=[aria-label="跳转到该处"]' --data-urlencode 'selector=mark' # 目录过滤框 / 空态(空态要用 p 限定,否则同样撞药丸) --data-urlencode 'selector=[aria-label="过滤目录"]' --data-urlencode 'selector=p.text-xs.text-text-secondary' # 打字 curl -X POST ".../debug/type" -d '{"webview":"main","selector":"[aria-label=\"过滤目录\"]","text":"BOOK"}' - ⚠️
/debug/dom只返回首个匹配(document.querySelector),响应是对象不是数组:{found, outerHTML, tag, total_matches, truncated}。数个数要读total_matches, 别对.result做| length(会报 "Cannot index array with string",或数错)。 - ⚠️
outerHTML在 16384 字符截断(truncated:true)。长列表逐项核对读不全 —— 改用total_matches+ 抽样。 - ⚠️
/debug/*的 JSON 可能含未转义控制字符(片段带原书换行),jq会 parse error。 用python3 -c "json.loads(raw, strict=False)"宽松解析。 - ⚠️
/debug/click走document.querySelector,支持任意 CSS 选择器 —— 可用> div:last-child/:nth-child()精确点到列表里某一项,省掉人工往返。 - ⚠️ 改 Rust / content-script 会让
tauri dev重编重启,tab 全清、debug port 变。 本轮为此白开了两次书。先把代码全部改完再请用户开书;改完 Rust 后务必重读端口文件。
⚠️ 环境卫生(本轮真踩到,代价是一小时机器卡死)
不要让用户手动跑 pnpm tauri dev —— 它本来就常驻并自己监听重编。本轮因此起了两套 (2 个 vite + 2 个 app),加上用户又从 Terminal 直接跑了一次 target/debug/app, 最终 3 个实例 + 4 个僵尸 rb-debug-mcp 把 16G 内存吃到只剩 63MB unused, 机器 swap 抖动到切不动窗口。症状极像"过热",实则不是 —— pmset -g therm 当时明确报 no thermal warning、CPU_Speed_Limit = 100、CPU 74% idle。
诊断三连(下次直接抄):
bash
pmset -g therm # 先排除过热:看 CPU_Speed_Limit 是不是 100
top -l 2 -n 5 -o cpu | grep PhysMem # 真正要看的是 "unused" 和 swapins/swapouts
ps -axo pid,ppid,command | grep "target/debug/app" # ppid 认归属:tauri dev vs 裸 zsh- 多实例判据:
ppid是tauri dev的才是正牌;ppid是-zsh的是手工起的。 - 端口文件会被最后启动的实例覆盖 → 探针可能打到空实例上(本轮就打到了 0 tab 的那个)。 多实例时显式写死正确端口,别信
$TMPDIR/rb-debug-port。 - 清理顺序:先 kill 父
tauri dev(否则它会把 app 重新拉起来),再 kill 子进程。