From 7b22e3f5aa23424535d44b919d99130d9efb7fd0 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Wed, 7 Oct 2026 12:40:30 +0800 Subject: [PATCH] feat: Add bookmarks locally --- documents/bookmarks.md | 6 + documents/en/bookmarks.md | 9 + .../exercises/00-thread-lifecycle.md | 14 +- .../exercises/01-bounded-queue.md | 6 +- .../exercises/00-thread-lifecycle.md | 12 +- .../exercises/01-bounded-queue.md | 4 +- scripts/build.ts | 10 +- scripts/check_quality.py | 2 +- scripts/validate_frontmatter.py | 4 +- site/.vitepress/config/nav.ts | 2 + .../theme/components/ArticleNotes.vue | 249 ++++++++++++ .../theme/components/BookmarkButton.vue | 83 ++++ .../theme/components/BookmarkList.vue | 354 ++++++++++++++++++ .../theme/components/NoteCapture.vue | 149 ++++++++ site/.vitepress/theme/composables/noteDom.ts | 90 +++++ .../theme/composables/readingKeys.ts | 57 +++ .../theme/composables/useBookmarks.ts | 232 ++++++++++++ site/.vitepress/theme/composables/useNotes.ts | 133 +++++++ site/.vitepress/theme/custom.css | 10 + site/.vitepress/theme/index.ts | 15 +- site/.vitepress/theme/utils/noteText.test.ts | 49 +++ site/.vitepress/theme/utils/noteText.ts | 59 +++ 22 files changed, 1522 insertions(+), 27 deletions(-) create mode 100644 documents/bookmarks.md create mode 100644 documents/en/bookmarks.md create mode 100644 site/.vitepress/theme/components/ArticleNotes.vue create mode 100644 site/.vitepress/theme/components/BookmarkButton.vue create mode 100644 site/.vitepress/theme/components/BookmarkList.vue create mode 100644 site/.vitepress/theme/components/NoteCapture.vue create mode 100644 site/.vitepress/theme/composables/noteDom.ts create mode 100644 site/.vitepress/theme/composables/readingKeys.ts create mode 100644 site/.vitepress/theme/composables/useBookmarks.ts create mode 100644 site/.vitepress/theme/composables/useNotes.ts create mode 100644 site/.vitepress/theme/utils/noteText.test.ts create mode 100644 site/.vitepress/theme/utils/noteText.ts diff --git a/documents/bookmarks.md b/documents/bookmarks.md new file mode 100644 index 000000000..b2398f6ae --- /dev/null +++ b/documents/bookmarks.md @@ -0,0 +1,6 @@ +--- +title: "书签" +description: "收藏的文章与摘录的便签都在这里——续读、跳回原文原处、导出导入备份" +--- + + diff --git a/documents/en/bookmarks.md b/documents/en/bookmarks.md new file mode 100644 index 000000000..a7b1dee35 --- /dev/null +++ b/documents/en/bookmarks.md @@ -0,0 +1,9 @@ +--- +title: "Bookmarks" +description: "Your saved articles and highlighted notes live here — resume reading, jump back to the exact passage, export/import a backup" +translation: + source: documents/bookmarks.md + source_hash: 964ebc328726e597852aaee288a2c8c4a350ea148065b1639ce3e2cc89b46457 +--- + + diff --git a/documents/en/vol5-concurrency/exercises/00-thread-lifecycle.md b/documents/en/vol5-concurrency/exercises/00-thread-lifecycle.md index 9f3251dc2..4305845a7 100644 --- a/documents/en/vol5-concurrency/exercises/00-thread-lifecycle.md +++ b/documents/en/vol5-concurrency/exercises/00-thread-lifecycle.md @@ -21,14 +21,14 @@ related: - "Thread Ownership and RAII" translation: source: documents/vol5-concurrency/exercises/00-thread-lifecycle.md - source_hash: ff4f57476dec5b5d89b2ce4d45333b7aa37f6a7714a8be66b5ffee096c7fea97 + source_hash: a0dd59669eea75ce22481a7d0419d3ff2c5e84f4e216a933a830c20cbe1cdebb translated_at: '2026-09-26T09:17:12+00:00' engine: anthropic token_count: 11800 --- # Lab 0: Thread Lifecycle -> The runnable project that goes with this Lab lives at [`code/volumn_codes/vol5-labs/templates/lab0_thread_lifecycle/`](../../../../code/volumn_codes/vol5-labs/templates/lab0_thread_lifecycle/). Expect roughly **4–6 hours** of hands-on work (`reading_time_minutes` counts pure reading minutes, not hands-on time). +> The runnable project that goes with this Lab lives at `code/volumn_codes/vol5-labs/templates/lab0_thread_lifecycle/`. Expect roughly **4–6 hours** of hands-on work (`reading_time_minutes` counts pure reading minutes, not hands-on time). ## Objectives @@ -71,7 +71,7 @@ templates/lab0_thread_lifecycle/ └── test_milestone1.cpp … test_milestone4.cpp ``` -Build instructions for the entire `vol5-labs/` directory, plus the dogfooding feedback process, live in [`vol5-labs/README.md`](../../../../code/volumn_codes/vol5-labs/README.md). Read it first. +Build instructions for the entire `vol5-labs/` directory, plus the dogfooding feedback process, live in `vol5-labs/README.md`. Read it first. First build (requires internet access; FetchContent pulls Catch2 v3): @@ -181,7 +181,7 @@ For statistics, start with the simplest thing possible: global `std::atomic **Don't let the tests fool you**: `test_milestone2` only tests the `JoiningThread` class itself (decoupled from `FileScanner`) and **does not check whether `scan()` actually uses it**. So even with `JoiningThread` implemented and every test green, if `scan()` still uses raw `std::thread`s plus a manual `join()` loop, this milestone has not truly been completed. **The real acceptance criteria: no manual `join()` loop visible in `scan()`, and the thread container is `std::vector`.** -[`test/test_milestone2.cpp`](../../../../code/volumn_codes/vol5-labs/templates/lab0_thread_lifecycle/test/test_milestone2.cpp) only tests `JoiningThread` itself (decoupled from `FileScanner`), covering four scenarios: automatic join at scope exit, all workers still joined on an exception path, ownership transferred by move, and `vector` destruction joining everything. Pay attention to the exception-path one: +`test/test_milestone2.cpp` only tests `JoiningThread` itself (decoupled from `FileScanner`), covering four scenarios: automatic join at scope exit, all workers still joined on an exception path, ownership transferred by move, and `vector` destruction joining everything. Pay attention to the exception-path one: ```cpp TEST_CASE("MS2: exception path still joins all workers", "[lab0][milestone2]") { @@ -272,7 +272,7 @@ In MS1 we passed the file path list to workers by value — which is in fact alr ### Verification -[`test/test_milestone3.cpp`](../../../../code/volumn_codes/vol5-labs/templates/lab0_thread_lifecycle/test/test_milestone3.cpp) verifies: non-divisible splits still cover all files (30 files / 8 workers), prime file counts (17 files) lose nothing under any split, and move-only types (`unique_ptr`) can be passed into threads safely. Take the prime case: +`test/test_milestone3.cpp` verifies: non-divisible splits still cover all files (30 files / 8 workers), prime file counts (17 files) lose nothing under any split, and move-only types (`unique_ptr`) can be passed into threads safely. Take the prime case: ```cpp TEST_CASE("MS3: prime file count covered by any worker count", "[lab0][milestone3]") { @@ -330,7 +330,7 @@ One more small point: the `worker_id` in `results[worker_id]` must be unique per > **Don't let the tests fool you**: `test_milestone4` only checks that the result numbers are right (matching the single-threaded run); it **does not check whether the statistics are genuinely "local per worker"**. So even with every test green, if `scan()` still does its statistics through a shared `mutex`/`atomic`, you are really still at MS1 and this milestone has not truly been completed. **The real acceptance criteria: no locks and no shared atomics in `scan()`; statistics go through independent `results[worker_id]` slots plus main-thread aggregation.** -[`test/test_milestone4.cpp`](../../../../code/volumn_codes/vol5-labs/templates/lab0_thread_lifecycle/test/test_milestone4.cpp) verifies: a multi-threaded scan's results are **exactly identical** to a single-threaded file-by-file scan (all three of file count, byte count, and extension distribution must match), plus a stress test of 200 files / 8 workers. Key assertion: +`test/test_milestone4.cpp` verifies: a multi-threaded scan's results are **exactly identical** to a single-threaded file-by-file scan (all three of file count, byte count, and extension distribution must match), plus a stress test of 200 files / 8 workers. Key assertion: ```cpp TEST_CASE("MS4: multi-threaded stats match single-threaded baseline", "[lab0][milestone4]") { diff --git a/documents/en/vol5-concurrency/exercises/01-bounded-queue.md b/documents/en/vol5-concurrency/exercises/01-bounded-queue.md index e1511fad0..89d4ae511 100644 --- a/documents/en/vol5-concurrency/exercises/01-bounded-queue.md +++ b/documents/en/vol5-concurrency/exercises/01-bounded-queue.md @@ -21,7 +21,7 @@ related: - 'latch, barrier, and semaphore' translation: source: documents/vol5-concurrency/exercises/01-bounded-queue.md - source_hash: 956f42289afc87049bb47f380040c1b6681e68a1a1c502c030dcf97530a0cb5c + source_hash: 2d5b3222401ff2c5b5d79f833d783a8940074148bc9588a893a31dc250cf4617 translated_at: '2026-09-26T09:22:45+00:00' engine: anthropic token_count: 8200 @@ -29,7 +29,7 @@ translation: # Lab 1: Bounded Queue, Concurrent Cache and Sync Primitives -> The runnable companion project for this lab lives at [`code/volumn_codes/vol5-labs/templates/lab1_bounded_queue/`](../../../../code/volumn_codes/vol5-labs/templates/lab1_bounded_queue/). Expect roughly **8–12 hours** of hands-on work (`reading_time_minutes` counts pure reading minutes, not hands-on time). +> The runnable companion project for this lab lives at `code/volumn_codes/vol5-labs/templates/lab1_bounded_queue/`. Expect roughly **8–12 hours** of hands-on work (`reading_time_minutes` counts pure reading minutes, not hands-on time). ## Goal @@ -145,7 +145,7 @@ That lambda predicate is your lifeline. If you write `not_full_.wait(lock)` (no > **Don't be fooled by the tests**: `test_milestone1` checks "you can push and pop, FIFO order, blocking behavior, multiple producers with no loss or duplication" — **all behavior, never whether you used a predicate wait**. You could perfectly well pass the tests with a bare `wait()` (no predicate), if no spurious wakeup happens to fire. But that's a time bomb: under high concurrency or particular schedulings, it will go off. **The real acceptance criterion: every wait in `push`/`pop` is a predicate wait (`cv.wait(lock, predicate)`); there is no bare `wait()`.** Run MS4's stress test under TSan, and a bare wait will sooner or later surface as a data race or an out-of-bounds access. -[`test/test_milestone1.cpp`](../../../../code/volumn_codes/vol5-labs/templates/lab1_bounded_queue/test/test_milestone1.cpp) covers four scenarios: single push/pop, FIFO order, pop blocking until a push arrives, and multiple producers with no loss or duplication under concurrency. +`test/test_milestone1.cpp` covers four scenarios: single push/pop, FIFO order, pop blocking until a push arrives, and multiple producers with no loss or duplication under concurrency. ## Milestone 2: close Semantics diff --git a/documents/vol5-concurrency/exercises/00-thread-lifecycle.md b/documents/vol5-concurrency/exercises/00-thread-lifecycle.md index ae39c5b95..033023716 100644 --- a/documents/vol5-concurrency/exercises/00-thread-lifecycle.md +++ b/documents/vol5-concurrency/exercises/00-thread-lifecycle.md @@ -23,7 +23,7 @@ related: # Lab 0: Thread Lifecycle Lab -> 本 Lab 配套可运行工程在 [`code/volumn_codes/vol5-labs/templates/lab0_thread_lifecycle/`](../../../code/volumn_codes/vol5-labs/templates/lab0_thread_lifecycle/)。动手工作量约 **4–6 小时**(`reading_time_minutes` 是纯阅读分钟数,不是动手时间)。 +> 本 Lab 配套可运行工程在 `code/volumn_codes/vol5-labs/templates/lab0_thread_lifecycle/`。动手工作量约 **4–6 小时**(`reading_time_minutes` 是纯阅读分钟数,不是动手时间)。 ## 目标 @@ -66,7 +66,7 @@ templates/lab0_thread_lifecycle/ └── test_milestone1.cpp … test_milestone4.cpp ``` -整个 `vol5-labs/` 目录的构建说明和 dogfooding 反馈流程见 [`vol5-labs/README.md`](../../../code/volumn_codes/vol5-labs/README.md)。先把它读一遍。 +整个 `vol5-labs/` 目录的构建说明和 dogfooding 反馈流程见 `vol5-labs/README.md`。先把它读一遍。 第一次构建(需要联网,FetchContent 会拉取 Catch2 v3): @@ -176,7 +176,7 @@ return 汇总 ### 验证 -对应测试在 [`test/test_milestone1.cpp`](../../../code/volumn_codes/vol5-labs/templates/lab0_thread_lifecycle/test/test_milestone1.cpp),覆盖三个场景:扫描收集到全部文件、空目录不崩溃、总字节数正确。关键断言: +对应测试在 `test/test_milestone1.cpp`,覆盖三个场景:扫描收集到全部文件、空目录不崩溃、总字节数正确。关键断言: ```cpp TEST_CASE("MS1: scan collects all files", "[lab0][milestone1]") { @@ -223,7 +223,7 @@ Milestone 1 的手工 `join()` 有个明显问题:如果在 join 循环之前 > **别被测试骗了**:`test_milestone2` 只测 `JoiningThread` 类本身(和 `FileScanner` 解耦),**不检查 `scan()` 有没有真的用它**。所以哪怕你实现了 `JoiningThread`、测试全绿,但 `scan()` 里还是裸 `std::thread` + 手工 `join()` 循环——这个 milestone 就没真正完成。**真正的验收标准:`scan()` 里看不到手工 `join()` 循环,线程容器是 `std::vector`。** -[`test/test_milestone2.cpp`](../../../code/volumn_codes/vol5-labs/templates/lab0_thread_lifecycle/test/test_milestone2.cpp) 只测 `JoiningThread` 本身(和 `FileScanner` 解耦),覆盖四个场景:作用域结束自动 join、异常路径仍 join 全部 worker、move 转移所有权、`vector` 析构 join 全部。重点看异常路径这个: +`test/test_milestone2.cpp` 只测 `JoiningThread` 本身(和 `FileScanner` 解耦),覆盖四个场景:作用域结束自动 join、异常路径仍 join 全部 worker、move 转移所有权、`vector` 析构 join 全部。重点看异常路径这个: ```cpp TEST_CASE("MS2: exception path still joins all workers", "[lab0][milestone2]") { @@ -267,7 +267,7 @@ MS1 我们把文件路径列表按值传给 worker——这其实已经是安全 ### 验证 -[`test/test_milestone3.cpp`](../../../code/volumn_codes/vol5-labs/templates/lab0_thread_lifecycle/test/test_milestone3.cpp) 验证:非整除分片也覆盖所有文件(30 文件 / 8 worker)、素数文件数(17 文件)任何分片都不丢、move-only 类型(`unique_ptr`)能安全传入线程。比如素数那个: +`test/test_milestone3.cpp` 验证:非整除分片也覆盖所有文件(30 文件 / 8 worker)、素数文件数(17 文件)任何分片都不丢、move-only 类型(`unique_ptr`)能安全传入线程。比如素数那个: ```cpp TEST_CASE("MS3: prime file count covered by any worker count", "[lab0][milestone3]") { @@ -325,7 +325,7 @@ return total; > **别被测试骗了**:`test_milestone4` 只验结果数值对不对(和单线程一致),**不检查统计是不是真的"每 worker 局部"**。所以哪怕测试全绿,但 `scan()` 里还在用共享 `mutex`/`atomic` 统计——你其实停在 MS1,这个 milestone 没真正完成。**真正的验收标准:`scan()` 里没有锁、没有共享 atomic,统计走 `results[worker_id]` 独立槽位 + 主线程汇总。** -[`test/test_milestone4.cpp`](../../../code/volumn_codes/vol5-labs/templates/lab0_thread_lifecycle/test/test_milestone4.cpp) 验证:多线程扫描结果与单线程逐一扫描**完全一致**(文件数、字节数、扩展名分布三项都对),外加一个 200 文件 / 8 worker 的压力测试。关键断言: +`test/test_milestone4.cpp` 验证:多线程扫描结果与单线程逐一扫描**完全一致**(文件数、字节数、扩展名分布三项都对),外加一个 200 文件 / 8 worker 的压力测试。关键断言: ```cpp TEST_CASE("MS4: multi-threaded stats match single-threaded baseline", "[lab0][milestone4]") { diff --git a/documents/vol5-concurrency/exercises/01-bounded-queue.md b/documents/vol5-concurrency/exercises/01-bounded-queue.md index 9ae0e39fd..2b64a9ca5 100644 --- a/documents/vol5-concurrency/exercises/01-bounded-queue.md +++ b/documents/vol5-concurrency/exercises/01-bounded-queue.md @@ -23,7 +23,7 @@ related: # Lab 1: Bounded Queue, Concurrent Cache and Sync Primitives -> 本 Lab 配套可运行工程在 [`code/volumn_codes/vol5-labs/templates/lab1_bounded_queue/`](../../../code/volumn_codes/vol5-labs/templates/lab1_bounded_queue/)。动手工作量约 **8–12 小时**(`reading_time_minutes` 是纯阅读分钟数,不是动手时间)。 +> 本 Lab 配套可运行工程在 `code/volumn_codes/vol5-labs/templates/lab1_bounded_queue/`。动手工作量约 **8–12 小时**(`reading_time_minutes` 是纯阅读分钟数,不是动手时间)。 ## 目标 @@ -139,7 +139,7 @@ not_empty_.notify_one(); > **别被测试骗了**:`test_milestone1` 测的是"能放能取、FIFO、阻塞行为、多生产者不丢不重"——**全是行为,不查你用没用 predicate wait**。你完全可以用裸 `wait()`(无谓词)蒙混过测试(碰巧没触发虚假唤醒)。但那是定时炸弹:高并发或特定调度下必炸。**真正的验收标准:`push`/`pop` 的等待都是谓词 wait(`cv.wait(lock, predicate)`),没有裸 `wait()`。** 用 TSan 跑 MS4 的压力测试,裸 wait 迟早会以 data race 或越界暴露。 -[`test/test_milestone1.cpp`](../../../code/volumn_codes/vol5-labs/templates/lab1_bounded_queue/test/test_milestone1.cpp) 覆盖四个场景:单 push/pop、FIFO、pop 阻塞直到 push、多生产者并发不丢不重。 +`test/test_milestone1.cpp` 覆盖四个场景:单 push/pop、FIFO、pop 阻塞直到 push、多生产者并发不丢不重。 ## Milestone 2: close 语义 diff --git a/scripts/build.ts b/scripts/build.ts index a51bc4f64..a65915d0a 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -280,7 +280,11 @@ export default withDrawio(defineConfig({ ...sharedBase, srcDir: '${relSrc.replace(/\\/g, '/')}', outDir: '${relOut.replace(/\\/g, '/')}', - ignoreDeadLinks: true, + // 根站 srcDir 只有 index/tags/bookmarks(中英),首页正文链向各卷的入口物理不存在, + // 只忽略各卷挂载前缀(含 /en 侧);根站页面之间的链接(/tags /bookmarks /en/tags...)仍被死链检查覆盖。 + ignoreDeadLinks: ${JSON.stringify([ + ...VOLUMES.flatMap(v => [v.urlPrefix, `${v.urlPrefix}/**`, `/en${v.urlPrefix}`, `/en${v.urlPrefix}/**`]), + ])}, transformPageData(pageData) { applyTagsPageData(pageData); applyArticleContributors(pageData) }, title: '现代 C++ 教程', description: '系统化的现代 C++ 教程 — 从基础入门到领域实战', @@ -760,7 +764,7 @@ async function main() { const rootSrcDir = join(BUILD_TMP, 'root-src') mkdirSync(rootSrcDir, { recursive: true }) - for (const f of ['index.md', 'tags.md']) { + for (const f of ['index.md', 'tags.md', 'bookmarks.md']) { const s = join(DOCUMENTS, f) if (existsSync(s)) cpSync(s, join(rootSrcDir, f)) } @@ -772,7 +776,7 @@ async function main() { } if (existsSync(join(DOCUMENTS, 'en'))) { mkdirSync(join(rootSrcDir, 'en'), { recursive: true }) - for (const f of ['index.md', 'tags.md']) { + for (const f of ['index.md', 'tags.md', 'bookmarks.md']) { const s = join(DOCUMENTS, 'en', f) if (existsSync(s)) cpSync(s, join(rootSrcDir, 'en', f)) } diff --git a/scripts/check_quality.py b/scripts/check_quality.py index f29c4d94e..0b6473585 100644 --- a/scripts/check_quality.py +++ b/scripts/check_quality.py @@ -37,7 +37,7 @@ REQUIRED_FM_FIELDS = {'title', 'chapter', 'order'} RECOMMENDED_FM_FIELDS = {'description', 'tags'} -SKIP_FILENAMES = {'index.md', 'tags.md', 'README.md'} +SKIP_FILENAMES = {'index.md', 'tags.md', 'bookmarks.md', 'README.md'} SKIP_DIR_PARTS = {'images', 'generated'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.drawio'} diff --git a/scripts/validate_frontmatter.py b/scripts/validate_frontmatter.py index 787a82a01..70efa1a94 100755 --- a/scripts/validate_frontmatter.py +++ b/scripts/validate_frontmatter.py @@ -221,9 +221,9 @@ def run(self) -> bool: """Run validation on all markdown files in tutorial directory.""" md_files = list(self.tutorial_dir.rglob('*.md')) - # Skip index.md files and tags.md (they don't need frontmatter) + # Skip index.md files and tags.md/bookmarks.md (site tool pages, not chapter articles) # Also skip non-article files (e.g. images/ directory) - skip_names = {'index.md', 'tags.md', 'README.md'} + skip_names = {'index.md', 'tags.md', 'bookmarks.md', 'README.md'} skip_dir_parts = {'images'} md_files = [ f for f in md_files diff --git a/site/.vitepress/config/nav.ts b/site/.vitepress/config/nav.ts index a3c7f55a9..4efd4872e 100644 --- a/site/.vitepress/config/nav.ts +++ b/site/.vitepress/config/nav.ts @@ -41,6 +41,7 @@ export const navZh: DefaultTheme.NavItem[] = [ { text: '更多', items: [ + { text: '书签', link: '/bookmarks' }, { text: '标签索引', link: '/tags' }, { text: '附录', link: '/appendix/' }, { text: '路线图', link: '/roadmap/' }, @@ -89,6 +90,7 @@ export const navEn: DefaultTheme.NavItem[] = [ { text: 'More', items: [ + { text: 'Bookmarks', link: '/en/bookmarks' }, { text: 'Tag Index', link: '/en/tags' }, { text: 'Appendix', link: '/en/appendix/' }, { text: 'Roadmap', link: '/en/roadmap/' }, diff --git a/site/.vitepress/theme/components/ArticleNotes.vue b/site/.vitepress/theme/components/ArticleNotes.vue new file mode 100644 index 000000000..e2f153753 --- /dev/null +++ b/site/.vitepress/theme/components/ArticleNotes.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/site/.vitepress/theme/components/BookmarkButton.vue b/site/.vitepress/theme/components/BookmarkButton.vue new file mode 100644 index 000000000..0ff4b0de0 --- /dev/null +++ b/site/.vitepress/theme/components/BookmarkButton.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/site/.vitepress/theme/components/BookmarkList.vue b/site/.vitepress/theme/components/BookmarkList.vue new file mode 100644 index 000000000..81e95829a --- /dev/null +++ b/site/.vitepress/theme/components/BookmarkList.vue @@ -0,0 +1,354 @@ + + + + + diff --git a/site/.vitepress/theme/components/NoteCapture.vue b/site/.vitepress/theme/components/NoteCapture.vue new file mode 100644 index 000000000..3ba574a6b --- /dev/null +++ b/site/.vitepress/theme/components/NoteCapture.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/site/.vitepress/theme/composables/noteDom.ts b/site/.vitepress/theme/composables/noteDom.ts new file mode 100644 index 000000000..e7460499c --- /dev/null +++ b/site/.vitepress/theme/composables/noteDom.ts @@ -0,0 +1,90 @@ +import { appendNodeText, findLeadIndex, normalizeQuote, type CharSite } from '../utils/noteText' + +// 便签的 DOM 定位与高亮(无存储依赖,书签恢复管道与便签存储层共用)。 +// 思路:摘录开头 40 字做指纹,跳回文章后在正文里重建「归一化文本↔text node 偏移」 +// 的逐字符映射,indexOf 命中即还原成 Range,滚动过去并用 CSS Custom Highlight +// 闪一下(不支持该 API 的浏览器退化为只滚动)。找不到就交回百分比兜底。 + +/** lead 指纹长度:摘录前 40 字,长到几乎唯一,短到不受改版影响 */ +const LEAD_LEN = 40 +/** 高亮停留时长 */ +const FLASH_MS = 2600 +const HIGHLIGHT_NAME = 'note-flash' + +/** 从摘录文本算定位指纹(空白归一) */ +export function leadOf(quote: string): string { + return normalizeQuote(quote).slice(0, LEAD_LEN) +} + +interface TextIndex { + big: string + map: CharSite[] +} + +function buildIndex(root: Element): TextIndex { + const big: string[] = [] + const map: CharSite[] = [] + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) + let prev = '' + while (walker.nextNode()) { + const node = walker.currentNode as Text + prev = appendNodeText(node.data, prev, big, map, node) + } + return { big: big.join(''), map } +} + +/** 在正文容器里定位 leadText,命中返回 Range(截到 lead 长度,足够定位与高亮) */ +export function locateLead(root: Element, leadText: string): Range | null { + if (!leadText || typeof document === 'undefined' || !document.createRange) return null + const { big, map } = buildIndex(root) + const idx = findLeadIndex(big, leadText) + if (idx < 0) return null + const end = Math.min(idx + leadText.length - 1, map.length - 1) + const start = map[idx] + const stop = map[end] + if (!start || !stop) return null + const range = document.createRange() + range.setStart(start.node, start.offset) + range.setEnd(stop.node, stop.offset + 1) + return range +} + +let flashTimer: ReturnType | null = null + +/** 把 Range 滚到视口中央并短暂高亮;返回是否成功 */ +export function flashRange(range: Range): boolean { + const anchor = range.startContainer.parentElement + if (!anchor) return false + // 滚三次(立即/300ms/1200ms):与路由切换自身的滚动重置赛跑,单次会被拉回顶部 + // (百分比分支的 scrollToPercent 同款三次校准节奏) + const scroll = () => anchor.scrollIntoView({ block: 'center' }) + try { + scroll() + setTimeout(scroll, 300) + setTimeout(scroll, 1200) + } catch { + return false + } + // CSS Custom Highlight API(Chrome 105+/Safari 17.2+),不支持则只滚动 + const CSSLike = (globalThis as { CSS?: { highlights?: Map } }).CSS + if (CSSLike?.highlights && typeof Highlight !== 'undefined') { + try { + CSSLike.highlights.set(HIGHLIGHT_NAME, new Highlight(range)) + if (flashTimer) clearTimeout(flashTimer) + flashTimer = setTimeout(() => CSSLike.highlights?.delete(HIGHLIGHT_NAME), FLASH_MS) + } catch { + // 高亮失败不影响定位本身 + } + } + return true +} + +/** 恢复入口:定位 + 滚动 + 高亮。全文找不到指纹返回 false(调用方退百分比兜底)。 */ +export function restoreByLeadText(quote: string): boolean { + if (typeof document === 'undefined') return false + const root = document.querySelector('.vp-doc') ?? document.querySelector('main') + if (!root) return false + const range = locateLead(root, leadOf(quote)) + if (!range) return false + return flashRange(range) +} diff --git a/site/.vitepress/theme/composables/readingKeys.ts b/site/.vitepress/theme/composables/readingKeys.ts new file mode 100644 index 000000000..b32a8d1c4 --- /dev/null +++ b/site/.vitepress/theme/composables/readingKeys.ts @@ -0,0 +1,57 @@ +// 书签/便签共用的「路径换算 + 跳转恢复标记」。 +// 独立成模块是为了让 useBookmarks 与 useNotes 单向依赖它,互不引用(避免循环 import)。 + +/** 剥掉路径尾部的 .html 后缀:外链常带 .html,站内 SPA 导航是 clean URL, + * 同一篇文章必须归到同一个键,否则会存出两条书签。 */ +export function stripHtmlExt(path: string): string { + return path.endsWith('.html') ? path.slice(0, -5) : path +} + +/** 把 location.pathname 换算成存储键:剥掉部署 base(如 /Tutorial_AwesomeModernCPP/) + * 与查询/锚点,统一 clean URL。跳转时再 withBase 拼回,换 base 部署旧书签依然有效。 */ +export function pathnameToKey(pathname: string): string { + const base = import.meta.env.BASE_URL ?? '/' + let p = pathname.split('#')[0].split('?')[0] + if (base !== '/' && p === base.slice(0, -1)) p = '' // 恰好停在 base 根 + else if (base !== '/' && p.startsWith(base)) p = p.slice(base.length) + p = stripHtmlExt(p) + return p.startsWith('/') ? p : `/${p}` +} + +// ── 跳转恢复标记:书签页跳转前写入一次性标记,目标页消费后删除 ── + +interface RestoreMark { + path: string + scrollPercent: number + /** 便签跳转带的定位指纹(摘录开头 40 字),书签跳转不填 */ + noteLead?: string +} + +const RESTORE_KEY = 'bookmarks:restore' +/** 标记有效期:过了这个毫秒数按残留丢弃(书签页写入后用户半路去了别处) */ +const RESTORE_TTL_MS = 60 * 60 * 1000 + +export function markRestore(path: string, scrollPercent: number, noteLead?: string): void { + if (typeof sessionStorage === 'undefined') return + try { + sessionStorage.setItem(RESTORE_KEY, JSON.stringify({ path, scrollPercent, noteLead, ts: Date.now() } satisfies RestoreMark & { ts: number })) + } catch { + // 隐私模式:恢复不了就算了,跳转本身不受影响 + } +} + +/** 消费恢复标记:当前路径与新鲜度都匹配才返回内容,否则丢弃返回 null */ +export function takeRestoreMark(path: string): RestoreMark | null { + if (typeof sessionStorage === 'undefined') return null + let mark: (RestoreMark & { ts?: number }) | null = null + try { + mark = JSON.parse(sessionStorage.getItem(RESTORE_KEY) ?? 'null') + sessionStorage.removeItem(RESTORE_KEY) + } catch { + try { sessionStorage.removeItem(RESTORE_KEY) } catch { /* 双重失败就不管了 */ } + return null + } + if (!mark || mark.path !== path) return null + if (typeof mark.ts !== 'number' || Date.now() - mark.ts > RESTORE_TTL_MS) return null + return mark +} diff --git a/site/.vitepress/theme/composables/useBookmarks.ts b/site/.vitepress/theme/composables/useBookmarks.ts new file mode 100644 index 000000000..a70e6ba42 --- /dev/null +++ b/site/.vitepress/theme/composables/useBookmarks.ts @@ -0,0 +1,232 @@ +import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue' +import { subscribeAfterRouteChange, subscribeBeforeRouteChange } from '../router-hooks' +import { pathnameToKey, stripHtmlExt, takeRestoreMark } from './readingKeys' +import { restoreByLeadText } from './noteDom' +import { loadNotes, importNotes, type Note } from './useNotes' + +// 「书签」localStorage 存储:文章级收藏,想回头再看的文章一枚星标。 +// 无账号无云端:书签跟着浏览器走,换设备即丢(导出/导入 JSON 搬运)。 +// 所有读写都做 SSR 守卫(构建期无 localStorage),组件里在 onMounted 后加载。 +// +// path 统一存剥掉部署 base 的 clean 路径(见 readingKeys.pathnameToKey)。 + +const BOOKMARKS_KEY = 'bookmarks:v1' +export const BOOKMARKS_EVENT = 'bookmarks:change' + +export interface Bookmark { + /** 文章路径(clean URL,如 /vol1-fundamentals/ch01/02-xxx) */ + path: string + /** 文章标题(收藏时从页面 frontmatter 取,不带站名后缀) */ + title: string + /** 上次读到的滚动百分比 0-100(自动跟随会更新;恢复的兜底定位) */ + scrollPercent: number + createdAt: number + updatedAt: number +} + +function readStore(): Record { + if (typeof localStorage === 'undefined') return {} + try { + const parsed = JSON.parse(localStorage.getItem(BOOKMARKS_KEY) ?? '{}') + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return {} + } +} + +function writeStore(value: Record): boolean { + if (typeof localStorage === 'undefined') return false + try { + localStorage.setItem(BOOKMARKS_KEY, JSON.stringify(value)) + window.dispatchEvent(new Event(BOOKMARKS_EVENT)) + return true + } catch { + // 隐私模式/配额满:书签存不进去就算了,不阻断阅读 + return false + } +} + +function coerceBookmark(raw: unknown): Bookmark | null { + if (!raw || typeof raw !== 'object') return null + const b = raw as Partial + if (typeof b.path !== 'string' || !b.path || typeof b.title !== 'string') return null + const percent = typeof b.scrollPercent === 'number' ? b.scrollPercent : 0 + return { + path: stripHtmlExt(b.path.startsWith('/') ? b.path : `/${b.path}`), + title: b.title, + scrollPercent: Math.min(100, Math.max(0, percent)), + createdAt: typeof b.createdAt === 'number' ? b.createdAt : 0, + updatedAt: typeof b.updatedAt === 'number' ? b.updatedAt : 0, + } +} + +/** 全部书签,按更新时间降序(最近在读的排前面) */ +export function loadBookmarks(): Bookmark[] { + return Object.values(readStore()) + .map(coerceBookmark) + .filter((b): b is Bookmark => b !== null) + .sort((a, b) => b.updatedAt - a.updatedAt) +} + +/** 收藏/更新一条:同路径已存在则刷新标题与位置,createdAt 保留 */ +export function saveBookmark(input: { path: string; title: string; scrollPercent: number }): Bookmark | null { + if (!input.path) return null + const store = readStore() + const prev = coerceBookmark(store[input.path]) + const now = Date.now() + const next: Bookmark = { + path: input.path, + title: input.title, + scrollPercent: Math.min(100, Math.max(0, input.scrollPercent)), + createdAt: prev?.createdAt ?? now, + updatedAt: now, + } + store[input.path] = next + writeStore(store) + return next +} + +export function removeBookmark(path: string): void { + const store = readStore() + delete store[path] + writeStore(store) +} + +// ── 备份:书签跟浏览器走,换设备/清缓存前导出一份,到新设备再导回来 ── +// 备份文件 v2 含便签(notes)段;导入侧兼容只有 bookmarks 的 v1 旧文件。 + +export interface BookmarksBackup { + app: 'site-bookmarks' + version: 1 | 2 + exportedAt: string + bookmarks: Bookmark[] + /** version 2 起携带的便签;v1 旧文件没有此段 */ + notes?: Note[] +} + +export function exportBookmarks(): string { + const backup: BookmarksBackup = { + app: 'site-bookmarks', + version: 2, + exportedAt: new Date().toISOString(), + bookmarks: loadBookmarks(), + notes: loadNotes(), + } + return JSON.stringify(backup) +} + +export interface ImportResult { + /** 合并进本浏览器的书签条数 */ + bookmarks: number + /** 合并进本浏览器的便签条数(v1 旧文件为 0) */ + notes: number +} + +/** 导入备份:书签同路径 updatedAt 新者胜,便签按 id 去重(见 useNotes)。格式不对返回 null。 */ +export function importBookmarks(raw: string): ImportResult | null { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + const data = parsed && typeof parsed === 'object' ? (parsed as Partial) : null + if (!data || data.app !== 'site-bookmarks' || !Array.isArray(data.bookmarks)) return null + + const store = readStore() + let bookmarkCount = 0 + for (const rawBookmark of data.bookmarks) { + const incoming = coerceBookmark(rawBookmark) + if (!incoming) continue + const local = coerceBookmark(store[incoming.path]) + if (!local || incoming.updatedAt >= local.updatedAt) { + store[incoming.path] = incoming + bookmarkCount++ + } + } + writeStore(store) + const noteCount = importNotes(data.notes) + return { bookmarks: bookmarkCount, notes: noteCount } +} + +// ── 跳转恢复:书签页跳过来后把滚动位置还原;便签跳转优先文本定位 ── + +/** 滚动到百分比:rAF/300ms/1200ms 三次校准(图片与 mermaid 异步撑高,单次会落错位置) */ +function scrollToPercent(percent: number): void { + const attempt = () => { + const el = document.documentElement + const max = el.scrollHeight - el.clientHeight + if (max > 0) el.scrollTop = (max * percent) / 100 + } + requestAnimationFrame(attempt) + setTimeout(attempt, 300) + setTimeout(attempt, 1200) +} + +/** + * 全局安装书签恢复(在 theme setup() 里调用一次,与 setupMermaid 同模式)。 + * 必须在 setup 上下文执行:内部的路由订阅依赖 useRouter()。 + */ +export function setupBookmarkRestore(): void { + subscribeAfterRouteChange(() => { + const mark = takeRestoreMark(pathnameToKey(location.pathname)) + if (!mark) return + // 便签:先试文本指纹定位(能精确到段落并高亮),失败退回百分比 + if (mark.noteLead && restoreByLeadText(mark.noteLead)) return + scrollToPercent(mark.scrollPercent) + }) +} + +/** + * 收藏位置自动跟随:离开页面时,若当前文章已被收藏,静默把读到的地方记下来。 + * 下次从书签进来就落在最新进度。未收藏的文章不记(不制造隐私残留)。 + */ +export function setupBookmarkAutoTrack(): void { + const snapshot = (): void => { + const key = pathnameToKey(location.pathname) + const prev = coerceBookmark(readStore()[key]) + if (!prev) return + const el = document.documentElement + const max = el.scrollHeight - el.clientHeight + const percent = max > 0 ? Math.min(100, Math.round((el.scrollTop / max) * 100)) : 0 + // 页首(刚进来就跳走)不写,避免把有效进度覆盖成 0 + if (percent <= 0) return + saveBookmark({ path: key, title: prev.title, scrollPercent: percent }) + } + subscribeBeforeRouteChange(snapshot) + if (typeof window !== 'undefined') { + window.addEventListener('pagehide', snapshot) + } +} + +// 组件侧的响应式封装:当前页的书签状态在 onMounted 后加载(SSR/水合安全) +export function useBookmarkState(): { + bookmark: Ref + currentPath: () => string + toggle: (title: string, scrollPercent: number) => void +} { + const bookmark = ref(null) + const currentPath = () => (typeof location !== 'undefined' ? pathnameToKey(location.pathname) : '') + const toggle = (title: string, scrollPercent: number) => { + const path = currentPath() + if (bookmark.value && bookmark.value.path === path) { + removeBookmark(path) + bookmark.value = null + } else { + bookmark.value = saveBookmark({ path, title, scrollPercent }) + } + } + const sync = () => { + bookmark.value = coerceBookmark(readStore()[currentPath()]) ?? null + } + // 按钮挂导航栏,SPA 切页时实例保留不重建,路由变了要重新加载 + subscribeAfterRouteChange(sync) + onMounted(() => { + sync() + window.addEventListener(BOOKMARKS_EVENT, sync) + }) + onBeforeUnmount(() => { + window.removeEventListener(BOOKMARKS_EVENT, sync) + }) + return { bookmark, currentPath, toggle } +} diff --git a/site/.vitepress/theme/composables/useNotes.ts b/site/.vitepress/theme/composables/useNotes.ts new file mode 100644 index 000000000..9598e6a20 --- /dev/null +++ b/site/.vitepress/theme/composables/useNotes.ts @@ -0,0 +1,133 @@ +import { pathnameToKey } from './readingKeys' +import { normalizeQuote } from '../utils/noteText' + +// 「便签」localStorage 存储:选中正文里的一段文字存下来,回头点它跳回原文原处。 +// 一篇文章可以存多条;定位靠摘录开头 40 字指纹(noteDom.restoreByLeadText)。 +// 无账号无云端,导出/导入随书签备份文件走(version 2 段)。 + +const NOTES_KEY = 'notes:v1' +export const NOTES_EVENT = 'notes:change' + +/** 摘录原文的存储截断长度 */ +const QUOTE_MAX = 240 + +export interface Note { + /** 唯一 id:path + 创建时刻 + 随机尾,列表 key 与删除都靠它 */ + id: string + path: string + title: string + /** 摘录的原文(空白归一,截 240 字) */ + quote: string + /** 存的那一刻的滚动百分比:指纹定位失败时的兜底 */ + scrollPercent: number + createdAt: number + updatedAt: number +} + +function readStore(): Record { + if (typeof localStorage === 'undefined') return {} + try { + const parsed = JSON.parse(localStorage.getItem(NOTES_KEY) ?? '{}') + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return {} + } +} + +function writeStore(value: Record): boolean { + if (typeof localStorage === 'undefined') return false + try { + localStorage.setItem(NOTES_KEY, JSON.stringify(value)) + window.dispatchEvent(new Event(NOTES_EVENT)) + return true + } catch { + return false + } +} + +function coerceNote(raw: unknown): Note | null { + if (!raw || typeof raw !== 'object') return null + const n = raw as Partial + if (typeof n.id !== 'string' || !n.id || typeof n.path !== 'string' || !n.path) return null + if (typeof n.quote !== 'string' || !n.quote) return null + return { + id: n.id, + path: n.path.startsWith('/') ? n.path : `/${n.path}`, + title: typeof n.title === 'string' ? n.title : '', + quote: n.quote, + scrollPercent: typeof n.scrollPercent === 'number' ? Math.min(100, Math.max(0, n.scrollPercent)) : 0, + createdAt: typeof n.createdAt === 'number' ? n.createdAt : 0, + updatedAt: typeof n.updatedAt === 'number' ? n.updatedAt : 0, + } +} + +/** 全部便签按创建时间降序(最新的在前) */ +export function loadNotes(): Note[] { + return Object.values(readStore()) + .map(coerceNote) + .filter((n): n is Note => n !== null) + .sort((a, b) => b.createdAt - a.createdAt) +} + +export function removeNote(id: string): void { + const store = readStore() + delete store[id] + writeStore(store) +} + +// ── 捕获:选区 → Note ── + +/** 选区是否落在正文容器(.vp-doc)内;代码块、表格都在容器里,自然可摘录 */ +function selectionInDoc(sel: Selection): boolean { + if (sel.rangeCount === 0) return false + const container = sel.getRangeAt(0).commonAncestorContainer + const el = container.nodeType === Node.TEXT_NODE ? container.parentElement : (container as Element) + return !!el?.closest?.('.vp-doc') +} + +/** 从当前选区捕获一条便签;选区无效(太短/不在正文里)返回 null */ +export function captureNoteFromSelection(title: string): Note | null { + if (typeof window === 'undefined') return null + const sel = window.getSelection() + if (!sel || sel.isCollapsed || !selectionInDoc(sel)) return null + const quote = normalizeQuote(sel.toString()).slice(0, QUOTE_MAX) + if (quote.length < 4) return null + const el = document.documentElement + const max = el.scrollHeight - el.clientHeight + const percent = max > 0 ? Math.min(100, Math.round((el.scrollTop / max) * 100)) : 0 + const path = pathnameToKey(location.pathname) + const now = Date.now() + const note: Note = { + id: `${path}#${now}${Math.random().toString(36).slice(2, 6)}`, + path, + title, + quote, + scrollPercent: percent, + createdAt: now, + updatedAt: now, + } + const store = readStore() + store[note.id] = note + writeStore(store) + return note +} + +// ── 备份段:挂在书签备份文件(version 2)的 notes 字段上 ── + +/** 导入便签段:按 id 去重(同 id updatedAt 新者胜),返回合并条数 */ +export function importNotes(rawNotes: unknown[] | undefined): number { + if (!Array.isArray(rawNotes)) return 0 + const store = readStore() + let count = 0 + for (const raw of rawNotes) { + const incoming = coerceNote(raw) + if (!incoming) continue + const local = coerceNote(store[incoming.id]) + if (!local || incoming.updatedAt >= local.updatedAt) { + store[incoming.id] = incoming + count++ + } + } + writeStore(store) + return count +} diff --git a/site/.vitepress/theme/custom.css b/site/.vitepress/theme/custom.css index 0b85c2fb6..602b7a528 100644 --- a/site/.vitepress/theme/custom.css +++ b/site/.vitepress/theme/custom.css @@ -2065,3 +2065,13 @@ body.rs-resizing { .mxgraph { position: relative; } + +/* ── 便签定位高亮(noteDom 的 CSS Custom Highlight,渐进增强) ────── */ + +::highlight(note-flash) { + background-color: rgba(37, 99, 235, 0.22); + text-decoration: underline; + text-decoration-color: var(--vp-c-brand-1); + text-decoration-thickness: 2px; + text-underline-offset: 3px; +} diff --git a/site/.vitepress/theme/index.ts b/site/.vitepress/theme/index.ts index f79b4f34a..67fba77c6 100644 --- a/site/.vitepress/theme/index.ts +++ b/site/.vitepress/theme/index.ts @@ -32,6 +32,11 @@ import Anim from './components/Anim.vue' import TagExplorer from './components/TagExplorer.vue' import DocTags from './components/DocTags.vue' import StatusToast from './components/StatusToast.vue' +import BookmarkButton from './components/BookmarkButton.vue' +import BookmarkList from './components/BookmarkList.vue' +import NoteCapture from './components/NoteCapture.vue' +import ArticleNotes from './components/ArticleNotes.vue' +import { setupBookmarkRestore, setupBookmarkAutoTrack } from './composables/useBookmarks' import ArticleContributors from './components/ArticleContributors.vue' import { setupDevFakeLag } from './dev-fake-lag' import './custom.css' @@ -47,23 +52,26 @@ export default { extends: DefaultTheme, Layout() { return h(WeeklyPracticeProvider, null, { default: () => h(DefaultTheme.Layout, null, { - 'layout-top': () => [h(NavSpinner), h(ReadingProgress), h(ResizableSidebar), h(MermaidLightbox), h(ImageLightbox), h(StatusToast)], + 'layout-top': () => [h(NavSpinner), h(ReadingProgress), h(ResizableSidebar), h(MermaidLightbox), h(ImageLightbox), h(StatusToast), h(NoteCapture)], 'doc-before': () => [h(WeeklyPageHeader), h(ArticleContributors, { variant: 'meta' })], 'doc-footer-before': () => [h(ArticleContributors, { automatic: true }), h(DocTags)], + 'aside-outline-after': () => h(ArticleNotes), 'home-hero-image': () => h(HomeHeroVisual), 'home-hero-actions-after': () => h('div', { class: 'proof-on-mobile' }, [h(WeeklyHomeWidget), h(ProofStrip)]), 'home-hero-after': () => [h(WeeklyHomeWidget), h('div', { class: 'proof-on-desktop' }, [h(ProofStrip)])], 'home-features-before': () => h('div', { class: 'home-pre-features' }, [h(ScreenshotCarousel), h(HomeTipBanner)]), 'home-features-after': () => h(HomePathExplorer), - 'nav-bar-content-after': () => h(FontSizeSwitcher), - 'nav-screen-content-after': () => h(FontSizeSwitcher), + 'nav-bar-content-after': () => [h(FontSizeSwitcher), h(BookmarkButton)], + 'nav-screen-content-after': () => [h(FontSizeSwitcher), h(BookmarkButton)], }) }) }, setup() { setupMermaid() setupDocImageZoom() setupDevFakeLag() + setupBookmarkRestore() + setupBookmarkAutoTrack() }, enhanceApp({ app }) { app.component('ChapterNav', ChapterNav) @@ -80,5 +88,6 @@ export default { app.component('TagExplorer', TagExplorer) app.component('ArticleContributors', ArticleContributors) app.component('Anim', Anim) + app.component('BookmarkList', BookmarkList) } } satisfies Theme diff --git a/site/.vitepress/theme/utils/noteText.test.ts b/site/.vitepress/theme/utils/noteText.test.ts new file mode 100644 index 000000000..37624ea06 --- /dev/null +++ b/site/.vitepress/theme/utils/noteText.test.ts @@ -0,0 +1,49 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { appendNodeText, normalizeQuote, findLeadIndex } from './noteText' + +// appendNodeText 依赖 DOM Text 只用 node.data 与引用,测试里用假对象即可 +const fakeText = (data: string) => ({ data } as unknown as Text) + +test('appendNodeText 折叠连续空白并记录原始下标', () => { + const big: string[] = [] + const map: { node: Text; offset: number }[] = [] + const node = fakeText('foo\n bar\tbaz') + const prev = appendNodeText(node.data, '', big, map, node) + assert.equal(big.join(''), 'foo bar baz') + assert.equal(prev, 'z') + // 'bar' 的 b 在原始串里下标是 6('\n'1 + 两个空格 = foo=0..2,\n=3,' '=4,' '=5,b=6) + const bIndex = big.join('').indexOf('bar') + assert.equal(map[bIndex].offset, 6) +}) + +test('appendNodeText 跨节点折叠空白', () => { + const big: string[] = [] + const map: { node: Text; offset: number }[] = [] + const a = fakeText('end ') + const b = fakeText(' start') + let prev = appendNodeText(a.data, '', big, map, a) + prev = appendNodeText(b.data, prev, big, map, b) + // 两端的空白折叠成一个:期望 'end start' + assert.equal(big.join(''), 'end start') +}) + +test('normalizeQuote 与 appendNodeText 规则一致', () => { + assert.equal(normalizeQuote(' a\n\nb c\t'), 'a b c') + const big: string[] = [] + const map: { node: Text; offset: number }[] = [] + appendNodeText(' a\n\nb c\t', '', big, map, fakeText('x')) + // 采集侧 trim 了首尾,检索侧 big 尾部可能带空白——findLeadIndex 用 indexOf,首尾差异不影响 + assert.ok(big.join('').includes(normalizeQuote('a b c'))) +}) + +test('findLeadIndex 精确命中', () => { + assert.equal(findLeadIndex('abcdef', 'cd'), 2) + assert.equal(findLeadIndex('abcdef', 'zz'), -1) +}) + +test('findLeadIndex 宽容匹配跳过 lead 开头差异', () => { + // 正文渲染后行内元素边界可能吃掉 lead 的头一两个字符,尾段仍可定位 + const idx = findLeadIndex('世界你好,这是正文的很长一段', '你好,这是正文的很长一段') + assert.ok(idx >= 0) +}) diff --git a/site/.vitepress/theme/utils/noteText.ts b/site/.vitepress/theme/utils/noteText.ts new file mode 100644 index 000000000..b0fb1d7fd --- /dev/null +++ b/site/.vitepress/theme/utils/noteText.ts @@ -0,0 +1,59 @@ +// 便签锚点的文本归一化与索引映射(纯逻辑,可单测)。 +// +// 问题:选区拿到的 text 是排版后的(displayed),正文 DOM 里被行内元素 +// (code/strong/a)切成多个 text node,空白也随源码换行散落。要在跳转回来后 +// 把「摘录开头 40 字」重新定位成 DOM Range,需要一份「归一化大字符串 ↔ 原始 +// text node 偏移」的逐字符映射。 +// +// 归一化规则(采集与检索两边必须一致): +// 连续空白(空格/换行/制表)折叠为单个空格;text node 边界处同样折叠。 + +export interface CharSite { + node: Text + /** 该归一化字符在 node.data 里的原始下标(归一坐标→原始坐标的换算在这里完成) */ + offset: number +} + +/** 逐字符折叠一个 text node 的内容,追加进 big/map。prevChar 传上一个字符,处理跨节点折叠。 */ +export function appendNodeText( + data: string, + prevChar: string, + big: string[], + map: CharSite[], + node: Text, +): string { + let prev = prevChar + for (let i = 0; i < data.length; i++) { + const ch = data[i] + if (/\s/.test(ch)) { + if (prev === ' ') continue // 连续空白:折叠 + big.push(' ') + map.push({ node, offset: i }) + prev = ' ' + } else { + big.push(ch) + map.push({ node, offset: i }) + prev = ch + } + } + return prev +} + +/** 选区/摘录文本的归一化(与 appendNodeText 同规则) */ +export function normalizeQuote(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +/** 在 big 里找 leadText 的首选位置;找不到时做「跳过 leadText 首字符」的宽容匹配 + * (正文行首的列表标记、标点前的空白差异兜底)。返回下标或 -1。 */ +export function findLeadIndex(big: string, leadText: string): number { + const idx = big.indexOf(leadText) + if (idx >= 0) return idx + // 宽容:leadText 首字符后可能紧跟标点差异,试去掉 lead 首字符再搜其尾段 + if (leadText.length > 8) { + const tail = leadText.slice(2) + const t = big.indexOf(tail) + if (t >= 0) return t - 2 >= 0 ? t - 2 : t + } + return -1 +}