diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..35bf485 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,47 @@ +# 把介绍页与纯内存演示页(make dist 生成的 dist/index.html、dist/demo.html)发布到 GitHub Pages。 +# 演示页不连后端,评论数据只存在访问者浏览器内存里。 +# 首次使用需在仓库 Settings → Pages 把 Source 设为「GitHub Actions」。 +name: Pages + +on: + push: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - run: npm ci + + - run: make dist + + - uses: actions/upload-pages-artifact@v3 + with: + path: dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/AGENTS.md b/AGENTS.md index fc4d113..6823d0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,84 @@ 面向 AI agent 的项目约定。与 `CLAUDE.md`(通用编码行为准则)配合使用。 +## 笔记功能设计约定 + +以下是用户确认的目标设计,后续实现、接口对接和 UI 文案应遵循。阅读器一侧已按此实现;数据由宿主 talebook 通过 `annotation_callbacks` 提供,阅读器不直接调用 BRS 或任何评论、账号接口(契约见 `README.md`)。 + +### 统一模型 + +- 划线、文字笔记和整书评论统一属于「笔记」,不能再作为相互独立的数据和产品体系设计。 +- 每条笔记至少包含:类型、笔记内容、公开范围,以及必要的作者、书籍和位置关联信息。 +- 类型包括「划线」「文字」「整书评论」。「段落评论」「章节评论」「本书评论」是查看范围,不是额外的笔记类型;「整书评论」类型表示针对整本书撰写的笔记。 +- 公开范围区分私密与公开。私密笔记仅作者可查看;公开笔记可以被其他读者查看。 +- 新建文字笔记和整书评论在没有已保存偏好时默认公开。用户可以修改公开范围,并持久记忆最近一次选择,后续新建时沿用,刷新页面或重新打开阅读器后仍应保留。 +- 公开范围偏好只影响后续新建笔记,不修改已有记录;创建私密划线不能覆盖文字笔记和整书评论的公开范围偏好。 +- 划线固定为私密笔记,不提供公开划线选项。划线记录应保存所选原文和准确选区,不能要求用户填写文字内容。 +- 文字笔记有用户填写的内容和公开范围;公开的章段文字笔记必须出现在对应的段落、章节和本书查看范围中。保存时标记为公开、却无法从评论面板读到,不符合设计。 +- 宿主回调、评论 API 或本地存储是实现细节,界面中的创建、查询和展示必须遵循同一个笔记模型。仅保存在当前浏览器的数据不能被视为已经向其他读者公开。 + +### 选区与评论归属 + +- 单段文字笔记关联所在段落。 +- 允许多段选择后创建文字笔记,不能因跨段选择直接禁用文字笔记功能。 +- 多段文字笔记在章段评论查询、展示时,归属到选区最后一个段落的 CFI;本段评论入口也应使用同一归属规则。 +- 评论归属 CFI 与原始选区范围应分开保存或表达:归属最后一段不意味着丢弃前面选中的文字,也不能改变划线的真实范围。 +- 同一条公开笔记可以出现在多个查看范围中,但仍是同一条记录,不得为段落、章节、全书分别保存副本。 + +### 入口与功能面板 + +- 界面中原「笔记」文案统一改为「评论」,总入口为「评论」,创建按钮为「写评论」;划线、文字和整书评论仍使用同一统一模型。 +- 从总入口打开的笔记弹窗默认展示当前章节的所有评论。弹窗顶部提供「查看更多评论」入口,右侧带箭头,点击进入完整评论页面。 +- 评论弹窗采用底部抽屉模式,顶部不显示独立标题栏或 × 关闭按钮,内容顶部直接展示「本章评论」小标题与「查看更多评论 ›」入口;「写评论」按钮放在抽屉底部。 +- 笔记抽屉不额外展示「当前段落」引用区;从段尾气泡进入本段评论时也不展示该区域。编辑笔记时所需的原文引用仍由编辑框承载。 +- 抽屉与完整评论页面都不提供搜索功能。 +- 评论列表采用分页加载,滚动到底部时自动加载下一页;展示加载中、加载失败可重试和没有更多的状态。切换范围或筛选条件时重置分页,加载期间不能重复请求;过期请求不能混入新范围。自己评论优先的排序必须覆盖整个结果集,不能仅对每一页分别排序。 +- 移动端笔记抽屉占底部菜单上方可用阅读区域高度的 90%,贴底部菜单上沿展开,顶部保留 10% 的外部区域用于展示阅读上下文和点击收起。 +- 桌面端采用侧边栏:目录从左侧打开,笔记和设置从右侧打开;侧边栏覆盖在阅读区域上,不将正文改为双栏或压缩正文宽度。不同功能面板互斥,点击侧边栏外收起。原型暂用 850px 作为桌面断点,宽度值不是已确认的产品约定。 +- 阅读中的侧边栏、抽屉以及外部遮罩都必须避开底部菜单;这些面板展开时底部菜单可见、可点击,仍可直接切换目录、评论和设置。独立的完整评论页不保留阅读菜单。 +- 抽屉支持向下拖拽收起,也支持点击抽屉外区域收起。完整评论页面的返回导航与抽屉的收起操作是不同动作。 +- 完整评论页面支持「本章评论」与「全书评论」范围切换,并提供返回笔记弹窗的入口;弹窗承担阅读中的快速查看,完整页面承担更多评论的浏览。 +- 「查看更多评论」进入独立的完整评论页面,不是在抽屉或侧边栏内扩大列表;完整页占满整个内容区域,不保留阅读正文、底部阅读菜单或其他阅读界面元素,支持页面返回导航及浏览器返回。 +- 完整评论页从右侧进入,是独立页面容器和导航状态,不能通过改变原抽屉尺寸模拟新页面;返回时恢复原抽屉查看范围、筛选和滚动位置。 +- 完整评论页不显示独立 titlebar,顶部直接是「本章评论/全书评论」Tab 导航;返回入口融入同一导航行,不另占标题栏。「写评论」与抽屉保持一致,固定放在页面底部。 +- 选区工具栏的「划线」创建私密划线笔记;文字笔记入口打开文字笔记编辑框,编辑框应明确笔记内容和公开范围。 +- 面板支持三个查看范围:「本段评论」「当前章节的所有评论」「本书评论」。范围必须明确显示在面板标题或筛选状态中。 +- 「本段评论」查询归属当前段落的公开笔记;选区工具栏与正文段落评论气泡应进入相同的段落范围。 +- 正文段尾保留评论数量气泡,显示归属本段的公开笔记数量(不计私密划线或私密文字笔记),点击进入该段评论;调整抽屉或桌面侧边栏时不能丢失这个入口。 +- 「当前章节的所有评论」汇总本章的公开笔记,包含本章各段的公开文字笔记,不能仅查询旧接口中 `segment_id=0` 的章节级记录。 +- 本章评论列表直接展示每个人的作者信息和评论内容,不逐条展示所关联的正文引用;笔记编辑框中的原文引用不受影响。 +- 评论列表先列当前用户自己的评论,再列其他人的评论。其他人的条目仅展示名字、时间和评论内容;自己的条目额外提供「修改」按钮,打开该记录的编辑框,不得为其他人的评论提供修改入口。 +- 自己评论的「修改」入口使用文字按钮,紧跟评论正文末尾,与正文自然行内排版,不单独占据底部操作行。 +- 「本书评论」查看这本书的公开笔记,包含各章段的公开笔记和整书评论类型;不能被理解为仅查看「整书评论」类型。 +- 公开笔记的创建入口和查看入口必须互通,不能出现「看本段评论」实际只打开另一套本段笔记数据的情况。 +- 完整评论页的 Tab 为「本章评论/全书评论/我的」。「我的」列出当前用户在本书的全部记录,包含私密划线、私密文字和自己的公开评论;私密记录只在「我的」中出现,不混入「本章评论」「全书评论」等公开范围。游客进入「我的」时引导登录。 +- 整书评论关联到全书最开头的位置(首章起始处),不留空位置;它与其他记录使用同一套位置字段。 + +- 设置里只有两个相互独立的评论开关:「显示全部划线和评论」(正文划线标记与段尾评论气泡)和「选中文字后显示工具栏」。不再设「启用划线和笔记功能」总开关;关闭任一开关都不删除数据,评论入口始终可用。 + +### 互动:赞、踩、回复 + +以下为用户确认的规则。 + +- 公开评论及其回复都支持赞、踩、回复;阅读器中的单条记录直接展示「赞 N/踩 N/回复 N」操作行,其中赞和踩用图标加数字表示,不显示「赞」「踩」文字(保留无障碍标签);已投的一项用实心图标和主色区分。游客可浏览,点击操作时引导登录。私密记录不开放他人互动。 +- 赞与踩互斥:重复点击取消,点另一项则替换;每个用户对每个目标只有一个投票。允许给自己的评论投票,也允许回复自己的评论。请求失败时界面恢复到操作前状态。 +- 自己评论的「修改」入口仍紧跟正文末尾,不并入操作行。 +- 详情页中,直接回复主评论的第一层回复不缩进,与主评论左对齐;第二层及之后的回复(回复的回复)统一只缩进一级,归在所属的第一层回复下面,彼此同级排列并显示回复对象。不得出现更深的层级缩进。 +- 回复挂在主评论下,不单独出现在段落、章节、全书的顶层列表中。段尾气泡只统计公开顶层评论,不计投票和回复。 +- 查看回复时进入独立的评论详情页面,评论列表内不展开回复。点击列表中的单条评论(按钮以外的区域)或其「回复 N」都进入详情页;赞、踩、修改、删除等按钮在列表中原地生效,不跳转。详情页结构:顶部是返回入口,正文是主评论及其操作行,下面是回复列表(缩进规则见上),回复输入固定在页面底部。详情页与完整评论页一样占满整个内容区域、从右侧进入,支持页面返回和浏览器返回;返回后恢复原列表(抽屉或完整评论页)的范围、已加载数量和滚动位置,计数与列表保持同步。 +- 删除是真删除,不做软删除,不保留「已删除」占位。删除主评论时,其下全部回复以及相关投票一并删除。 +- 作者可以修改自己的评论和回复;修改后不显示「已编辑」标记。 +- 回复没有独立的公开范围,跟随主评论。公开评论改为私密后,其下已有回复只有该评论作者自己能看到,其他人(包括回复的作者)不可见,也不能再对其投票或回复。 +- 整书评论虽然关联到全书开头,但不出现在首章的「本章评论」中,只出现在「全书评论」和作者的「我的」里。 +- 不迁移旧 BRS 评论数据,升级时直接清空。 + +尚未确认,实现前需先问用户,不要把临时选择当成已确认的设计: + +- 评论详情页中回复的排序与分页方式。当前实现:时间正序,每页 20 条,滚动到底继续加载。 +- 抽屉和完整评论页底部的「写评论」在没有选区时写什么。当前实现:本段范围写该段的文字评论,本章/全书/我的范围写整书评论。 +- 删除第一层回复时,其下第二层及之后的回复是否一并删除。 +- 私密评论重新改回公开后,原有回复和投票是否恢复可见。 + ## 提交 PR 前必须跑 e2e 测试 **任何改动在提交 / 推送 PR 之前,必须本地跑通 Playwright e2e 测试。** CI 会跑同一套用例,本地先验证可避免红 CI 来回。 @@ -18,5 +96,5 @@ npm run test:e2e E2E_PORT=5009 npm run test:e2e # 让 Playwright 复用它 ``` -- 全量套件应全部通过(当前 33 passed)。失败用例的现场快照在 `test-results/<用例名>/error-context.md`。 -- 改了底部导航 / 登录 / 评论面板等 UI 时,重点核对 `tests/e2e/{auth,user-center,reader-ui,comments-login}.spec.js`:这些用例按文案(如「评论」「点击登录,发表评论」)定位元素,改文案会连带改测试。 +- 全量套件应全部通过(当前 61 passed)。失败用例的现场快照在 `test-results/<用例名>/error-context.md`。 +- 改了底部导航 / 评论面板等 UI 时,重点核对 `tests/e2e/{comments,annotations,panel-focus,reader-ui}.spec.js`:这些用例按文案(如「评论」「查看更多评论」「写评论」)定位元素,改文案会连带改测试。 diff --git a/CLAUDE.md b/CLAUDE.md index e92d28f..5621069 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,18 +3,20 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. > 提交 / 推送 PR 前必须本地跑通 e2e(见 `AGENTS.md`)。通用编码准则见仓库外层 `~/CLAUDE.md`。 +> 架构、职责边界和宿主回调规范见 `DESIGN.md`,改动前先读;改了其中描述的行为或接口要同步更新它。 ## 这是什么 -candle-reader(秉烛夜读)是一个**可嵌入的 EPUB 阅读器**,以 Vue 3 + Vuetify 写成、用 Vite **library 模式**打包,供 talebook 书库服务嵌入使用。它不是独立 SPA:`src/main.js` 导出一个 `Reader` 类,宿主页面 `new Reader('#app', { server, book_url, display_url, themes_css })` 即挂载。 +candle-reader(秉烛夜读)是一个**可嵌入的 EPUB 阅读器**,以 Vue 3 + Vuetify 写成、用 Vite **library 模式**打包,供 talebook 书库服务嵌入使用。它不是独立 SPA:`src/main.js` 导出一个 `Reader` 类,宿主页面 `new Reader('#app', { book_url, display_url, themes_css, book_id, annotation_callbacks })` 即挂载。 ## 常用命令 ```bash make dev # 解压 public/demo/*.epub 后启动 vite(首次开发用这个,不是 npm run dev) + # 演示页 http://localhost:5001/demo.html :西游记 + 纯内存版宿主,可体验全部评论功能 npm run dev # vite dev server,端口 5001 npm run build # vite build --lib → dist/candle-reader.es.js(并清理 demo 产物) -make all # build + 给 dist/demo.html 资源打时间戳版本号(缓存刷新) +make dist # build + 生成 dist/demo.html(同一份演示页,改为引用打包产物并带时间戳版本号)和介绍页 dist/index.html(源文件 intro.html);make all 等同 make install # 把 dist/ 拷进本地 talebook 的 static 目录 npm run lint # eslint --fix(standard 风格) @@ -30,20 +32,22 @@ npm run test:e2e:ui # 带调试面板 **EPUB 渲染依赖全局 UMD 脚本,不是 npm 依赖。** `index.html` / 测试 harness 里先后引入 `js/jszip-*.js` 和 `js/epub-*.js`,epub.js 靠全局 `window.JSZip` 解压 `.epub`,**JSZip 必须在 epub.js 之前加载**。因此 headless 环境下正文渲染不完整——e2e 刻意不断言 epub 正文/目录渲染(属第三方渲染器行为)。 -**所有后端数据走 `$backend`。** `src/plugins/server.js` 注入全局 `this.$backend(url, options)`,对 `server + url` 发 `fetch`(`credentials: 'include'`)。`server` 在构造 `Reader` 时传入。约定返回 `{ err, msg, data }`:`err === 'ok'` 为成功;`err === 'exception'` 会触发全局 alert;`need_login` 类表示游客态。网络失败兜底返回 `{ err: 'network_error', data: {} }`,所以**调用方需对 `rsp.data.*` 做空值兜底**(如列表赋值 `rsp.data.list || []`)。 +**评论数据只走宿主回调,阅读器不直接请求后端。** 划线、文字评论、整书评论、回复、赞踩和登录态都通过构造 `Reader` 时注入的 `annotation_callbacks` 交给宿主(talebook);契约见 `README.md`。`src/annotations.js` 把回调包成 `annotation_repository`,并在未注入回调时提供 `localStorage` 实现。不要在阅读器里新增对评论服务(BRS)或账号接口的直接请求。 -**组件树几乎全在一个文件里。** `CandleReader.vue`(根,仅透传 props)→ `EpubReader.vue`(~1300 行,承载底部导航、epub.js 集成、主题、阅读进度、评论逻辑的「全家桶」组件)。各面板是它的子组件: -- `Settings.vue` 设置面板、`BookToc.vue` 目录、`Guest.vue` 登录对话框、`UserCenter.vue` 已登录用户中心 -- `BookReview.vue` —— 底部「评论」按钮打开的**本书评论**面板(panel 名为 `more`)。游客态显示「点击登录」入口(emit `login`),已登录显示用户行(emit `open-settings`) -- `BookComments.vue` —— **章评/段评**面板(panel 名 `comments`),从正文 iframe 选段触发 +**组件树。** `CandleReader.vue`(根,仅透传 props)→ `EpubReader.vue`(承载底部导航、epub.js 集成、主题、阅读进度、选区工具栏、评论编辑框、正文标记与段尾气泡)。子组件: +- `Settings.vue` 设置面板、`BookToc.vue` 目录、`AudiobookPlayer.vue` 听书 +- `comments/ReaderComments.vue` —— 评论抽屉(panel 名 `annotations`)以及从它进入的完整评论页、评论详情页、删除确认;列表与单条评论分别是 `CommentList.vue`、`CommentItem.vue`。写评论和修改通过事件交回 `EpubReader` 的编辑框。 -**面板切换靠 `set_menu(name)`。** 一次只开一个底部面板(`toc`/`settings`/`more`/`comments`/`ai`/`hide`),它会把其余 `menu.panels[*]` 置 false。登录框(`show_login`)和用户中心(`show_user_center`)是独立 overlay,由 `BookReview` 的事件触发,不走 `set_menu`。改动导航文案或入口时,相关 e2e 按可见文案(如「评论」「点击登录,发表评论」)定位元素,务必同步。 +**面板切换靠 `set_menu(name)`。** 一次只开一个底部面板(`toc`/`settings`/`annotations`/`ai`/`hide`),它会把其余 `menu.panels[*]` 置 false。移动端是底部抽屉,≥850px 时目录是左侧侧边栏、评论和设置是右侧侧边栏。完整评论页和详情页是独立的全屏页面,用浏览器历史记录逐层返回,离开评论抽屉时一并退出。打开评论用 `open_comments(scope, paragraph)`。改动导航文案或入口时,相关 e2e 按可见文案(如「评论」「查看更多评论」)定位元素,务必同步。 + +**演示页。** 根目录 `demo.html` + `demo/memory-host.js`(`annotation_callbacks` 的纯内存参照实现,e2e 的 `mock-host.js` 也基于它)。改了回调契约要同步这两处和 `README.md`。 **主题。** `src/themes.js` + `themes_css`(运行时注入的皮肤 CSS)。图片皮肤需把 color-scheme / 透明背景注入正文 iframe 防白屏,相关回归在 `tests/e2e/reader-theme.spec.js`。顶部状态栏/灵动岛颜色由 `apply_theme_color` 改 `index.html` 里静态声明的 ``。 ## 测试架构 -后端**全量 mock**,不依赖真实 talebook。 -- 入口用 `tests/e2e/fixtures/reader-harness.html`(复刻 `index.html`,但 `server` 指同源、`book_url` 用体积小的 `/demo/book1.epub`),**不要用 `index.html`**。 -- `helpers/mock-api.js` 的 `setupApiMock(page, overrides)`:默认游客态;未显式 mock 的接口返回安全空响应 `{ err:'ok', data:{} }`(注意:这意味着 `data.list` 等字段会是 `undefined`,需在应用侧兜底)。 -- `helpers/reader.js`:`gotoReader`、以及通过 Vue 内部实例直接调 `EpubReader.set_menu()` 的 `openPanel` / `openCommentsPanel`(避免依赖 iframe 内选段,减少 flaky);`readState` 读组件响应式数据做断言。 +评论数据由内存版宿主提供,不依赖真实 talebook。 +- 入口用 `tests/e2e/fixtures/reader-harness.html`(复刻 `index.html`,`book_url` 用体积小的 `/demo/book1.epub`),**不要用 `index.html`**。加 `?host=1` 注入内存版宿主 `fixtures/mock-host.js`(完整回调契约,含多位读者、分页、投票、回复;`&guest=1` 为游客,`&extra=N` 追加 N 条评论);不加时阅读器走 `localStorage`。测试里用 `window.__host` 检查调用记录(`calls`)、制造失败(`fail`)或挂起某次调用(`hold` / `release`)。 +- `helpers/mock-api.js` 的 `setupApiMock(page, overrides)` 只用于仍走 HTTP 的接口(有声书);它记录的 `calls` 也用来断言阅读器没有请求评论或账号接口。 +- `helpers/reader.js`:`gotoReader`、`gotoHostReader`、`readerProxy`、通过 Vue 内部实例直接调 `EpubReader.set_menu()` 的 `openPanel`(避免依赖 iframe 内选段,减少 flaky)、`readState` 读组件响应式数据做断言。 +- 浮层有展开动画:读几何位置或用键盘操作前,先用 `expect.poll` 等位置稳定、焦点进入浮层。 diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..1342baf --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,330 @@ +# DESIGN.md + +candle-reader(秉烛夜读)的架构设计与对外接口规范。改动阅读器之前先读这份文档;产品交互的细节约定见 `AGENTS.md`,开发命令与测试方式见 `CLAUDE.md`,接入示例见 `README.md`。 + +本文描述的是**当前实现**。改了这里描述的行为或接口,必须在同一次改动里更新本文。 + +## 1. 定位 + +candle-reader 是一个**可嵌入的 EPUB 阅读器组件**,不是独立应用: + +- 用 Vue 3 + Vuetify 写成,Vite library 模式打包成 `dist/candle-reader.es.js` / `.umd.js` + `style.css`。 +- 宿主页面(目前是 talebook 的在线阅读页)`new Reader('#app', options)` 挂载,整页都交给阅读器。 +- 阅读器负责「怎么读、怎么交互」;数据「存在哪里、谁能看、怎么同步」全部属于宿主。 + +一句话边界:**阅读器只认识回调,不认识任何后端。** 评论数据、读者身份、权限、同步通过宿主注入的 `annotation_callbacks` 完成,有声书清单和收听进度通过 `audiobook_callbacks` 完成。阅读器代码里不得出现对任何后端接口(评论服务 BRS、账号、有声书等)的直接请求;唯一由浏览器直接加载的是宿主给出的资源地址(电子书文件、有声书音频)。 + +## 2. 职责划分 + +| 能力 | 阅读器负责 | 宿主负责 | +|---|---|---| +| 电子书渲染 | 用 epub.js 渲染、翻页、主题、字号行距、阅读进度条 | 提供 `book_url`(解压目录或 `.epub`),保证可访问 | +| 选中文字 | 选区工具栏、复制、计算 CFI 和段落归属、选区预览 | — | +| 划线 / 评论的界面 | 抽屉、完整评论页、详情页、编辑框、赞踩按钮、删除确认、分页滚动、空态和错误态 | — | +| 正文标记 | 把当前读者本章的划线画到正文上,点击划线进入它所在段落的本段评论;有评论的段落只用段尾气泡表示,不铺底色 | 通过 `load` 返回这些记录 | +| 段尾评论气泡 | 把数量画到对应段落末尾,点击进入本段评论 | 通过 `summary` 返回每段的公开评论数 | +| 评论存储 | 不存(未注入回调时才退化为本机 `localStorage`) | 唯一权威存储 | +| 读者身份与登录 | 游客互动时调用 `login`,之后重新取 `user` | 判断登录态、执行登录流程 | +| 可见性与权限 | 只做界面上的显示(如私密记录不显示赞踩,右下角标「私密」,自己的记录在右下角给出修改、删除) | **服务端强制**:私密不外泄、只能改删自己的、私密记录不接受投票和回复 | +| 排序与分页 | 透传 `cursor` / `limit`,按返回顺序展示,滚动到底取下一页 | 排序(自己的在前,再按时间倒序,覆盖整个结果集后再分页)、生成游标 | +| 计数 | 乐观更新赞踩,以宿主返回值为准 | `like_count` / `dislike_count` / `reply_count` / `user_vote` | +| 回复结构 | 按 `thread_id` 分组、控制缩进 | 根据 `reply_to_id` 推出 `thread_id`、`reply_to_name` | +| 删除 | 确认框、从界面移除 | 真删除及级联(见 5.4) | +| 外部同步 | 不感知 | 公开记录同步到 BRS 等外部服务 | +| 阅读设置、阅读位置、公开范围偏好 | 存在本机 `localStorage`(见 8) | — | +| 听书 | 播放器界面、正文高亮跟随、本机记住收听位置 | 通过 `audiobook_callbacks` 提供章节清单、时间轴、收听进度(见 6.3) | + +判断一个新功能放在哪边时,问两个问题: + +1. **离开这个宿主还成立吗?** 换一个宿主(另一个书库、纯本地 demo)也需要的交互逻辑,放阅读器。 +2. **涉及数据归属、身份或权限吗?** 涉及就放宿主,阅读器只通过回调提出请求。 + +## 3. 嵌入接口 + +```js +import { Reader } from './candle-reader.es.js' // 需先全局加载 jszip 与 epub.js,见 3.2 + +new Reader('#app', { + book_url: '/books/1/', // 必填。解压后的 EPUB 目录或 .epub 地址 + display_url: '', // 首次打开的位置(章节 href 或 CFI);有本机阅读位置时以本机为准 + book_id: 1, // 书的稳定标识;作为回调上下文和本机存储的隔离键 + themes_css: '/static/candle-reader/css/themes.css', // 注入正文 iframe 的主题样式表 + annotation_callbacks: { ... }, // 评论回调,见 5;不传则使用本机 localStorage + audiobook_callbacks: null, // 有声书回调,见 6.3;不传则不显示「听书」 + debug: false, +}) +``` + +### 3.1 选项 + +| 选项 | 类型 | 说明 | +|---|---|---| +| `book_url` | string | 必填 | +| `display_url` | string | 无本机阅读位置时的起始位置 | +| `book_id` | number / string | 回调上下文 `book_id`;缺省时本机存储改用 `book_url` 隔离 | +| `themes_css` | string | 默认 `theme.css`;须包含 `src/themes.js` 中纯色主题的同名 class 和评论气泡样式 | +| `annotation_callbacks` | object / null | 见 5 | +| `audiobook_callbacks` | object / null | 传了才显示「听书」入口 | +| `debug` | boolean | 在顶栏显示 epub.js 事件名,并在点击处画点,仅调试用 | + +`server`、`audiobook_edition_id`、`audiobook_manifest_url` 选项已废弃,传入会被忽略。 + +### 3.2 宿主页面的要求 + +- 先加载 `js/jszip-*.js`,再加载 `js/epub-*.js`。epub.js 依赖全局 `window.JSZip` 解压 `.epub`,顺序不能反。 +- 阅读器占满整个页面:它会把 `html, body` 设为 `position: fixed; overflow: hidden`,防止 iOS Safari 整页回弹、地址栏收起导致正文重排。宿主不要在同一页面放需要滚动的其他内容。 +- 页面需要 ``;如需顶部状态栏随主题变色,静态声明 ``。 +- 引入打包产物时给 `candle-reader.es.js` 和 `style.css` 带版本号,避免手机浏览器使用旧缓存(`make dist` 的做法)。 + +## 4. 内部架构 + +``` +CandleReader.vue 根组件,只透传 props +└─ EpubReader.vue epub.js 集成、底部菜单、面板切换、主题、选区工具栏、 + │ 评论编辑框、正文标记、段尾气泡、设置的应用与保存 + ├─ Settings.vue 设置面板 + ├─ BookToc.vue 目录 + ├─ AudiobookPlayer.vue 听书 + └─ comments/ + ├─ ReaderComments.vue 评论抽屉 + 完整评论页 + 评论详情页 + 删除确认 + ├─ CommentList.vue 分页列表(滚动到底取下一页、加载/错误/空态) + └─ CommentItem.vue 单条记录(作者、时间、正文、修改/删除、赞/踩/回复) + +src/annotations.js 把 annotation_callbacks 包装成 annotation_repository;本机 localStorage 实现 +src/audiobook.js 把 audiobook_callbacks 包装成 audiobook_repository +src/note-settings.js 旧设置迁移 +src/themes.js 主题数据 +demo/memory-host.js 回调契约的纯内存参照实现(演示页与 e2e 共用) +``` + +### 4.1 数据流 + +``` +宿主回调 ←→ annotation_repository(src/annotations.js:补上下文、校验返回形状) + ├─ EpubReader:load / summary / save(划线、写评论、编辑)/ user / login + └─ ReaderComments:list / replies / save(回复)/ remove / vote +``` + +- 组件**只**通过 `annotation_repository` 访问数据,不直接调用 `annotation_callbacks`。 +- `ReaderComments` 不自己打开编辑框:写评论和修改通过 `write` / `edit` 事件交回 `EpubReader`,保存成功后由 `EpubReader` 调用 `ReaderComments.apply_saved()` 刷新列表,并通过 `on_comments_changed()` 同步正文标记和段尾气泡。 +- 同一条记录可能同时出现在抽屉、完整页、详情页里(是不同的对象副本)。修改计数或内容时用 `ReaderComments.patch(id, fields)` 同步所有副本。 + +### 4.2 面板与页面 + +- **底部面板**:`toc` / `settings` / `annotations`(评论抽屉)/ `ai`,一次只开一个,统一由 `EpubReader.set_menu(name)` 切换,`set_menu('hide')` 全部收起。打开评论请用 `open_comments(scope, paragraph)`,不要直接 `set_menu('annotations')`,否则列表不会按范围加载。 +- **布局**:移动端为底部抽屉(评论抽屉占底部菜单上方区域的 90%);宽度 ≥ 850px 时目录是左侧侧边栏,评论和设置是右侧侧边栏。都覆盖在正文上,不压缩正文,底部菜单保持可点。侧边栏内侧边缘有胶囊形分隔条(`PanelResizer`,role=separator):9px 拖拽热区覆盖在面板真实边界上、不占布局,1px 分隔线与边界重合,中间是 8×24 的胶囊;悬停、聚焦、拖动时分隔线和胶囊变为深蓝 `#2563eb`、握持刻度为白色。可拖动或用方向键(每次 16px)调整宽度,双击或 Home 恢复默认;宽度按面板分别记在 localStorage(`candle-reader:panel-widths`),范围 260px 到 min(720px, 窗口宽度 − 360px)。分隔条放在面板浮层外面(浮层内容区会裁掉越过边框的部分),宽屏时侧边栏不锁定焦点,键盘可以移到分隔条上。 +- **独立页面**:完整评论页、评论详情页是全屏 `v-dialog`(class `rc-standalone`,z-index 2600,盖住顶栏和底部菜单)。每进入一层就 `history.pushState({ candle_comments: 层数 })`,返回按钮、Esc、浏览器后退都走 `history.back()`,由 `popstate` 统一退出到对应层数。离开评论抽屉时 `close_pages()` 会一并退回历史记录。 +- **层级**:编辑框、删除确认在独立页面之上(class `rc-above-standalone`,z-index 2700);Vuetify 给嵌套浮层的层级只比父级高一档,所以这两个值用 class 显式抬高,新增浮层时沿用。 +- **焦点**:面板最终关闭时焦点回到打开它的入口(`panel_trigger`);从详情页返回时焦点回到列表里那条评论的「回复」按钮。 + +### 4.3 选中文字与工具栏 + +- 选中事件**只用 epub.js 的 `rendition.on('selected')`**,不要自己监听 `selectionchange` 另起一套(曾经这样做,在 iOS 上问题更多,已回退)。 +- `on_select_content` 计算:真实选区 `cfi`、所在段落 `paragraph_cfi` 与整段原文、是否跨段(跨段时归属**最后一个段落**)、章节(`find_toc`)。 +- 工具栏位置:默认放在选区上方;触屏设备(`pointer: coarse`)优先放下方,避开 iOS 系统文字菜单。 +- 选区预览(蓝色底色)只在开启「选中文字后显示工具栏」时绘制;`hide_toolbar()` 会一并清掉。 +- 点击正文的判定(`on_click_content`): + - 快速拖选(按下到松开移动超过 8px)、双击选词:按选字处理,不翻页。 + - 已经选中文字后再点一下:只取消选中、收起工具栏,这一下不翻页。iOS 在 click 之前就会清掉系统选区,所以以 `selection_active`(epub.js 报告过选中)为准,不以点击时读到的选区为准。 +- 开启工具栏时,正文注入 `-webkit-touch-callout: none` 尝试关闭 iOS 系统文字菜单;关闭工具栏时保留系统菜单用于复制。 +- 正文 iframe 里**不拦截** `touchmove`:iOS 拖动选区两端的手柄依赖原生拖动。左右翻页模式下只拦截阅读区域外框(`#main`、顶栏、底部菜单)的拖动。 + +### 4.4 章节识别 + +- 章节标识是目录项的 `label`(去首尾空白),作为回调里的 `chapter` 字段。宿主必须原样保存并用它查询,不要自行改写。 +- 目录项都带锚点、多章共用一个正文文件时(如《西游记》),按位置取光标之前最近的一章(`find_toc_in_same_file`)。 +- 正文文件不在目录里时,取文件内第一个标题作为章节名,没有标题则为「正文 N」。 + +## 5. 评论回调规范(annotation_callbacks) + +### 5.1 统一模型 + +划线、文字评论、整书评论是**同一种记录**(统称「评论」),回复也是记录(带 `root_id`)。「本段 / 本章 / 全书 / 我的」是**查看范围**,不是记录类型;同一条公开记录可以出现在多个范围里,宿主不得为不同范围保存副本。 + +### 5.2 回调一览 + +```js +annotation_callbacks: { + user(ctx), // → { id, nickname, avatar } | null + login(ctx), // → void;游客互动时调用 + load({ ...ctx, chapter }), // → Record[] 或 { annotations } + list({ ...ctx, scope, chapter, paragraph_cfi, cursor, limit }), // → Page + summary({ ...ctx, chapter }), // → [{ paragraph_cfi, count }] 或 { items } + get({ ...ctx, id }), // → Record + replies({ ...ctx, root_id, cursor, limit }), // → Page + save(record, ctx), // → Record 或 { annotation } + remove({ ...ctx, id }), // → 任意 + vote({ ...ctx, id, value }), // → { like_count, dislike_count, user_vote } +} +// ctx = { book_id, book_url } +// Page = { items: Record[], next_cursor: string | null, has_more: boolean }(也接受纯数组,视为没有下一页) +``` + +- 所有回调都是异步函数(返回 Promise)。除 `save` 外,第一个参数都已并入 `ctx`;`save` 的上下文在第二个参数。 +- `load` 和 `save` 必须提供,否则初始化失败。其余回调缺失时,用到的功能会显示「宿主未提供 xx 回调」错误,**不会静默退化到本机存储**。 +- `user` 缺失视为游客;`login` 缺失时游客互动只会提示「请先登录」。 + +| 回调 | 何时调用 | 宿主要做的 | +|---|---|---| +| `user` | 初始化、`login` 完成后 | 返回当前读者;游客返回 `null` | +| `login` | 游客点赞踩、回复、写评论、划线、看「我的」时 | 引导登录(跳转、弹窗均可);resolve 后阅读器重新调用 `user` | +| `load` | 进入新章节、保存后、打开「显示全部划线和评论」时 | 返回**当前读者自己**在该章节的顶层记录(含私密),用于正文标记 | +| `list` | 打开抽屉 / 切换范围 / 滚动到底 | 按 `scope` 过滤、排序、分页(见 5.3) | +| `summary` | 进入新章节(同一章 1 分钟内不重复)、评论增删后、打开「显示全部划线和评论」时 | 返回该章节各段**公开顶层文字评论**的数量 | +| `get` | 预留:按 id 取单条(如深链进入详情) | 不可见时按不存在处理 | +| `replies` | 进入详情页 / 滚动到底 | 返回某主评论的回复,**按时间正序**分页 | +| `save` | 划线、写评论、写整书评论、修改、回复、修改回复 | 按 `id` 或 `client_id` 判断新建还是修改(幂等);返回保存后的完整记录 | +| `remove` | 删除确认后 | 真删除并级联(见 5.4) | +| `vote` | 点赞 / 踩 / 再点取消 | 每人每条一票;返回最新计数和当前读者的票 | + +### 5.3 list 的范围 + +| scope | 返回 | 不含 | +|---|---|---| +| `paragraph` | 归属 `paragraph_cfi` 这一段的公开顶层评论 | 私密记录、回复 | +| `chapter` | `chapter` 这一章的全部公开顶层评论 | 整书评论、私密记录、回复 | +| `book` | 全书公开顶层评论,含整书评论 | 私密记录、回复 | +| `mine` | 当前读者在本书的全部顶层记录,含私密划线、私密文字 | 别人的记录、回复 | + +排序:当前读者自己的在前,其余按时间倒序。**排序必须覆盖整个结果集后再分页**,不能每页各自排序。游标由宿主生成,阅读器原样回传;切换范围时游标重置,迟到的旧范围响应会被丢弃。 + +### 5.4 记录字段 + +| 字段 | 谁填 | 说明 | +|---|---|---| +| `id` | 宿主 | 稳定 id | +| `client_id` | 阅读器 | 新建时生成的 UUID;宿主用它做幂等 | +| `annotation_type` | 阅读器 | `highlight` 划线 / `note` 文字评论 / `book_comment` 整书评论;回复也是 `note` | +| `is_private` | 阅读器 | 划线固定 `true`;回复不使用(跟随主评论) | +| `chapter` | 阅读器 | 章节名(见 4.4);整书评论为空串 | +| `cfi` | 阅读器 | **归属位置**:划线是精确选区;文字评论是所在段落(跨段归属最后一段);整书评论是全书开头 `epubcfi(<第一个正文文件>!/4)` | +| `range_cfi` | 阅读器 | 真实选区;与 `cfi` 分开保存,不得用归属位置覆盖 | +| `quote_text` | 阅读器 | 单段文字评论为整段原文;跨段评论和划线为所选文字 | +| `content` | 阅读器 | 评论内容;划线为空串 | +| `color` | 阅读器 | 标记颜色:划线 `yellow`,评论 `blue` | +| `root_id` | 阅读器 | 回复所属的主评论;顶层记录为空 | +| `reply_to_id` | 阅读器 | 回复对象(主评论或另一条回复);空表示直接回复主评论 | +| `thread_id` | 宿主 | 所属第一层回复;直接回复主评论的为空。由宿主根据 `reply_to_id` 推出,不信任客户端 | +| `reply_to_name` | 宿主 | 回复对象的昵称,用于显示「回复 某人:」 | +| `author_name` / `is_mine` | 宿主 | 作者昵称;是否当前读者所写(决定修改、删除入口) | +| `created_at` | 宿主 | ISO 时间 | +| `like_count` / `dislike_count` / `user_vote` / `reply_count` | 宿主 | 计数与当前读者的票(`1` / `-1` / `0`);`reply_count` 只对顶层记录有意义 | + +修改时阅读器只发 `{ id, client_id, annotation_type, content, is_private }`(回复为 `{ id, client_id, root_id, content }`),宿主只更新这些字段。 + +### 5.5 宿主必须在服务端保证的规则 + +1. 私密记录及其全部回复,只有主评论作者可见;对其他人按「不存在」处理,包括 `get` / `replies` / `vote` / 回复的 `save`。 +2. 回复没有自己的公开范围,跟随主评论。公开评论改为私密后,已有回复只有主评论作者能看到,不能再投票或回复。 +3. 只能修改、删除自己的记录。 +4. 删除是真删除:删主评论连同全部回复和投票;删第一层回复连同其下回复。 +5. 划线固定私密;私密记录不接受投票。 +6. 回复的 `root_id` / `reply_to_id` 必须属于同一本书、同一主评论。 +7. `summary` 只统计公开的顶层文字评论,不计私密记录、回复、投票。 + +### 5.6 错误约定 + +- 回调失败时 **reject 一个 `Error`,`message` 写给读者看的原因**(如「请先登录」「这条评论已不存在」),阅读器会原样展示。 +- 需要登录才能查看:`list` / `replies` 以 `code` 为 `need_login` 的错误拒绝(`Object.assign(new Error('登录后查看评论'), { code: 'need_login' })`),阅读器显示「登录后查看评论」和「去登录」按钮(调用 `login`),读者登录后自动重新加载。宿主不开放游客浏览时这样做。 +- 列表失败:显示可重试的错误态,已加载的内容保留。 +- 保存失败:编辑框不关、选区不丢,可以再次保存;划线失败时正文不出现标记。 +- 投票失败:界面恢复到投票前的计数和状态。 +- 阅读器不重试,重试由读者触发。 + +### 5.7 本机实现与参照实现 + +- **不传 `annotation_callbacks`**:使用 `src/annotations.js` 的 `localStorage` 实现,按 `book_id`(缺省为 `book_url`)隔离,键为 `candle-reader:annotations:v1:`。只有当前浏览器这一位读者,「公开」不会真的对他人生效,编辑框会提示这一点。 +- **参照实现**:`demo/memory-host.js` 完整实现了本节所有规则,是写新宿主时的对照样本;`demo.html` 和 e2e 的 `tests/e2e/fixtures/mock-host.js` 都基于它。改契约时三处(本文、`README.md`、`memory-host.js`)同步修改。 + +## 6. 其他对外依赖 + +### 6.1 主题样式表(themes_css) + +- 纯色主题以 class 形式写在样式表里(`.white` / `.eyecare` / `.grey` / `.dark`),epub.js 把它注入正文 iframe 并给 body 加上对应 class。 +- 段尾气泡(`.comment-anchor` / `.comment-icon` / `.comment-count`)的样式也在这里;锚点与气泡的定位规则(零宽锚点 + 绝对定位)是「气泡不改变排版」的前提,宿主自带样式表时需同步。深色主题的 `.grey *` / `.dark *` 会给所有元素刷底色,新增插入正文的元素时注意显式设透明。 +- 图片皮肤不靠样式表:阅读器把背景图铺在外层 `#main`,并向 iframe 注入透明背景、`color-scheme` 和文字色(`apply_custom_style`)。 + +### 6.2 阅读器向正文注入的内容 + +- 段尾评论气泡:段落末尾插入零宽锚点 `span.comment-anchor`,气泡 `div.comment-icon` 绝对定位挂在锚点上,**不参与正文排版**——插入、移除气泡都不会让文字折行或后文移动。epub.js 的划线标记按绘制那一刻的文字位置画、之后不随排版重算,气泡若占位就会让标记错位。气泡以段落**最后一个可见字**定位(段尾常有 `
` 或换行空白,锚点本身会落到下一行),与末行垂直居中、紧跟其后;末行写满时伸进右侧页边距,超出页边距的部分向左收回(`fit_comment_icon`)。字号、行距等设置变化或窗口尺寸变化后重新定位(`refit_comment_icons`)。计算段落原文和 CFI 时会跳过气泡,不影响选区与引用。一屏可能从上一章末尾跨进下一章,短章、宽屏或连续滚动时还会跨好几章(一个正文文件里有多章时常见),正文标记和段尾气泡按这一屏从页首到页尾出现的每一章分别读取(`tocs_between` / `visible_chapters`);本段评论按段落自己所在的章节查询,不用页首章节。 +- epub.js 标记(class `candle-reader-annotation`):只画划线,文字评论不铺底色;同一 CFI 只画一个,记录可以有多条。 +- 行距、字距、`-webkit-touch-callout` 等通过 `rendition.themes.default()` 注入。 + +### 6.3 有声书回调(audiobook_callbacks) + +```js +audiobook_callbacks: { + manifest(ctx), // → { manifest: { id, chapters: [Chapter] }, progress: { chapter_id, position_ms, version } | null } + timeline({ ...ctx, manifest_id, chapter }), // → Segment[],或 { segments },或 { timeline: { segments } } + start_session({ ...ctx, manifest_id, source, device_id }), // 可选 → { session_id } + report_progress({ ...ctx, session_id, chapter_id, position_ms, segment_id, + listened_delta_ms, completed, version }), // 可选 → { version } + end_session({ ...ctx, session_id }), // 可选 +} +// Chapter = { id, number, title, source_key, duration_ms, audio_url, timeline_url? } +// Segment = { id, index, start_ms, end_ms, text, locator: { type: 'epub-dom-text', href, dom_path, start_char, end_char } } +``` + +- `manifest`、`timeline` 必须提供;三个会话回调可选,不提供时只在本机记住收听位置(`candle:audiobook:`)。 +- `audio_url` 由 `