feat(reader): add selection annotations and server-side book search

This commit is contained in:
hectorqin committed 2026-09-24 16:03:35 +08:00
1 parent 9c83e3e013
commit 1d86efab34
18 files changed
+369 -23

No files matched your search

+1
View File
@@ -14,6 +14,7 @@
- **专注阅读**:EPUB 保留出版方样式,TXT 支持编码识别与章节切分;支持分页、滚动、主题、字体设置和朗读。
- **多种来源**:内置本地书库和 OPDS,通过独立插件接入远程书源。
- **跨设备继续**:每个账号拥有独立书架、阅读进度、笔记和高亮;浏览器与 Android 连接同一服务。
- **随读随记**:选中文字即可复制、高亮、批注或添加书签,点击高亮查看批注;在线书内搜索由服务端执行,离线仅搜索已缓存内容。
- **适合自部署**:一个 Docker 容器包含服务端和 Web 界面,书库可按需要选择只读或可写挂载。
## 书籍与来源
+23
View File
@@ -40,6 +40,29 @@
---
## 书内搜索
### `GET /books/:id/search?q=关键词&cursor=上一页游标`
仅允许搜索当前用户书架中的书籍,支持 TXT、EPUB 和远程章节。关键词去除首尾空白后搜索,原始长度限 1–200 个字符。响应不缓存。
```json
{
"hits": [{ "title": "第一章", "excerpt": "匹配附近的文字", "anchor": {
"sectionId": "chapter:0", "start": 10, "end": 12,
"quote": "关键词", "prefix": "前文", "suffix": "后文"
}}],
"failures": [], "scanned": 8, "total": 30, "limited": false,
"nextCursor": "8.签名"
}
```
服务端按需扫描正文,每页本地最多 8 章、远程最多 1 章,每页最多返回 200 条匹配;`limited` 表示达到匹配上限。客户端连续请求 `nextCursor`,最多展示 200 条。游标绑定书籍目录、版本、关键词和个人整理版本,条件变化需重新搜索。此接口不是预建全文索引。
单章限制 8 MiB,远程章节有 12 秒超时;失败章节在 `failures` 中返回 `{title, code}`,保留其他章节结果。客户端停止请求会取消后续扫描。返回定位包含当前用户的个人纠错。
使用自定义 TXT 标题规则时返回 `400 SEARCH_LOCAL_LAYOUT`,客户端使用本地目录和缓存搜索并明确提示。断网时同样仅搜索已缓存内容,缓存不完整时提示结果可能不完整,不下载整书到浏览器搜索。
## 认证
### `POST /auth/register` — 公开
+1
View File
@@ -13,6 +13,7 @@
"epub2": "^3.0.2",
"fast-xml-parser": "^4.5.1",
"fastify": "^5.2.1",
"htmlparser2": "^12.0.0",
"jszip": "^3.10.1",
"pino": "^9.6.0",
"sanitize-html": "^2.17.7"
+1
View File
@@ -20,6 +20,7 @@
"epub2": "^3.0.2",
"fast-xml-parser": "^4.5.1",
"fastify": "^5.2.1",
"htmlparser2": "^12.0.0",
"jszip": "^3.10.1",
"pino": "^9.6.0",
"sanitize-html": "^2.17.7"
+22
View File
@@ -17,6 +17,8 @@ import {
import { sendAssetPayload } from '../assets.ts';
import { FilePublications } from '../../publications/files.ts';
import { withSignal } from '../request-signal.ts';
import { searchBookPage } from '../../services/book-search.ts';
import { ReadingOverrideService } from '../../services/reading-overrides.ts';
/**
* The handler that owns a book, resolved from its recorded format.
@@ -314,6 +316,26 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
* route stable as formats evolve and lets each handler decide how to address
* its own contents.
*/
app.get('/api/v1/books/:id/search', { preHandler: auth }, async (request, reply) => {
const user = currentUser(request), { id } = request.params as { id: string };
const book = ctx.shelf.get(user.id, id), query = request.query as Record<string, string | undefined>;
if (typeof query.q !== 'string' || !query.q.trim() || query.q.length > 200) throw badRequest('关键词须为 1–200 个字符');
if (query.cursor !== undefined && typeof query.cursor !== 'string') throw badRequest('无效的搜索游标');
const manifest = await contentManifest(ctx, user.id, id, book.format);
if (!manifest) throw badRequest('此格式暂不支持正文搜索', 'UNSUPPORTED_FORMAT');
const overrides = new ReadingOverrideService(ctx.db).get(user.id,id);
if (overrides.headingPrefix) throw badRequest('自定义 TXT 目录使用本地搜索','SEARCH_LOCAL_LAYOUT');
const chapters = ctx.sources?.chapters.has(id) ? ctx.sources.chapters : undefined;
const handler = chapters ? null : resolveHandler(ctx,id,book.format);
reply.header('cache-control','no-store');
return withSignal(request,reply,signal => searchBookPage(manifest,query.q!.trim(),query.cursor,overrides,async (item, chapterSignal) => {
const ref = item.resourceRef ?? (item.format === 'html' && item.href.startsWith('chapter:') ? item.href.replace('chapter:','chapter-full:') : item.href);
if (chapters) return chapters.asset(user.id,id,ref,chapterSignal);
if (!handler) throw badRequest('此格式暂不支持正文搜索','UNSUPPORTED_FORMAT');
return handler.asset({...sourceContext(ctx,id),bookId:id},{ref});
},signal,chapters ? 1 : 8));
});
app.get('/api/v1/books/:id/assets', { preHandler: auth }, async (request, reply) => {
const user = currentUser(request);
const { id } = request.params as { id: string };
+89
View File
@@ -0,0 +1,89 @@
import { Parser } from 'htmlparser2';
import { createHash } from 'node:crypto';
import type { Manifest, AssetPayload, ContentItem } from '../indexer/formats/registry.ts';
import type { ReadingOverrides } from './reading-overrides.ts';
import { badRequest, conflict } from '../lib/errors.ts';
// Match the plain-text rendition's paragraph and heading boundaries so returned
// offsets describe what the reader sees, including the separating newlines.
export function plainSearchText(input: string): string {
let text = input.replace(/\r\n?/g, '\n'), heading = '';
const lines = text.split('\n'), first = lines.findIndex(line => line.trim());
const line = (lines[first] ?? '').trim();
if (line.length <= 60 && [/^第\s*[0-90-9零一二三四五六七八九十百千万两]+\s*[章回节卷部篇集]/, /^(?:序章|序言|楔子|引子|前言|后记|尾声|终章|番外|附录)/, /^[卷部]\s*[0-90-9零一二三四五六七八九十百千万两]+/, /^Chapter\s*\d+/i, /^#{1,3}\s+.+/].some(pattern => pattern.test(line))) {
heading = line + '\n'; text = lines.slice(first + 1).join('\n');
}
const paragraphs = text.split(/\n{2,}/).flatMap(block => {
const parts = block.split('\n'); let current = parts.shift() ?? ''; const out: string[] = [];
for (const part of parts) {
const content = part.replace(/^[\s\u3000\u00a0]+/, '');
if (/[。!?…”』】]$/.test(current.trimEnd()) && content && !/^[,。!?、;:”』】)\u3001-\u303f\uff01-\uff5e]/.test(content)) { out.push(current); current = part; }
else current += content;
}
out.push(current); return out.map(value => value.trim()).filter(Boolean);
});
return '\n' + heading + paragraphs.join('\n') + '\n\n';
}
export function htmlSearchText(html: string): string {
let result = ''; const skipped: string[] = [];
const parser = new Parser({
onopentag(name) { if (skipped.length || ['head','script','style','noscript'].includes(name)) skipped.push(name); },
onclosetag(name) { if (skipped.at(-1) === name) skipped.pop(); },
ontext(text) { if (!skipped.length) result += text; },
}, { decodeEntities: true });
parser.end(html); return result;
}
export function correctedSearchText(text: string, sectionId: string, overrides: ReadingOverrides): string {
for (const { anchor, replacement } of overrides.corrections) {
if (anchor.sectionId.replace(/^xhtml:/,'') !== sectionId.replace(/^xhtml:/,'')) continue;
let start = anchor.start;
if (text.slice(start, start + anchor.quote.length) !== anchor.quote) {
const context = anchor.prefix + anchor.quote + anchor.suffix, at = text.indexOf(context);
if (at >= 0 && text.indexOf(context, at + 1) < 0) start = at + anchor.prefix.length;
else { start = text.indexOf(anchor.quote); if (start < 0 || text.indexOf(anchor.quote, start + 1) >= 0) continue; }
}
text = text.slice(0,start) + replacement + text.slice(start + anchor.quote.length);
}
return text;
}
export async function searchBookPage(manifest: Manifest, query: string, cursor: string | undefined, overrides: ReadingOverrides,
load: (item: ContentItem, signal: AbortSignal) => Promise<AssetPayload>, signal: AbortSignal, batchSize = 8) {
if (!query.trim() || query.length > 200) throw badRequest('关键词须为 1–200 个字符');
if (!['text','reflowable'].includes(manifest.kind)) throw badRequest('此格式暂不支持正文搜索','UNSUPPORTED_FORMAT');
const signature = createHash('sha256').update(JSON.stringify([manifest.revision, manifest.items.map(i=>[i.href,i.resourceRef]), query, overrides.version])).digest('hex').slice(0,24);
let index = 0;
if (cursor) {
const match = /^(\d+)\.([a-f0-9]{24})$/.exec(cursor);
if (!match) throw badRequest('无效的搜索游标');
if (match[2] !== signature) throw conflict('书籍或搜索条件已变化,请重新搜索');
index = Number(match[1]); if (!Number.isSafeInteger(index) || index > manifest.items.length) throw badRequest('无效的搜索游标');
}
const hits = [], failures: Array<{title: string; code: string}> = [];
const end = Math.min(index + batchSize, manifest.items.length);
let limited = false;
for (; index < end; index++) {
signal.throwIfAborted(); const item = manifest.items[index]!;
let payload: AssetPayload | undefined;
try {
const chapterSignal = AbortSignal.any([signal, AbortSignal.timeout(12000)]);
payload = await load(item, chapterSignal); chapterSignal.throwIfAborted();
if (!payload.data || payload.data.length > 8 * 1024 * 1024) throw badRequest('章节超过搜索大小限制','SEARCH_CHAPTER_TOO_LARGE');
const raw = payload.data.toString('utf8');
const text = correctedSearchText(/html|xml/i.test(payload.contentType) ? htmlSearchText(raw) : plainSearchText(raw), item.href, overrides);
let start = text.indexOf(query);
while (start >= 0 && hits.length < 200) {
const stop = start + query.length;
hits.push({title:item.title,excerpt:text.slice(Math.max(0,start-35),stop+65),anchor:{sectionId:item.href,start,end:stop,quote:query,prefix:text.slice(Math.max(0,start-32),start),suffix:text.slice(stop,stop+32)}});
start = text.indexOf(query,stop);
}
if (hits.length >= 200) { limited = true; index++; break; }
} catch (error) {
signal.throwIfAborted();
const code = typeof error === 'object' && error && 'code' in error ? String(error.code) : 'CHAPTER_UNAVAILABLE';
failures.push({title:item.title,code});
} finally { payload?.stream?.destroy(); }
await new Promise(resolve => setImmediate(resolve));
}
return { hits, failures, scanned:index, total:manifest.items.length, limited,
...(index < manifest.items.length && !limited ? { nextCursor:index+'.'+signature } : {}) };
}
+27
View File
@@ -0,0 +1,27 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { searchBookPage, htmlSearchText, plainSearchText } from '../src/services/book-search.ts';
import type { Manifest } from '../src/indexer/formats/registry.ts';
const overrides = {version:0,corrections:[],headingPrefix:''};
const manifest: Manifest = {kind:'text',revision:'one',total:3,groups:[],items:[0,1,2].map(seq=>({id:String(seq),seq,title:'章节'+seq,href:'chapter:'+seq,kind:'chapter',mediaType:'text/html'}))};
test('server search returns bounded pages, decoded text anchors, and excludes scripts', async () => {
const load = async () => ({data:Buffer.from('<head><title>不应命中</title></head><body><p>甲&amp;乙目标丙</p><script>目标</script></body>'),contentType:'text/html'});
const one = await searchBookPage(manifest,'目标',undefined,overrides,load,AbortSignal.timeout(3000),1);
assert.equal(one.hits.length,1); assert.equal(one.scanned,1); assert.equal(one.hits[0]?.anchor.start,3);
assert.equal(one.hits[0]?.anchor.prefix,'甲&乙'); assert.ok(one.nextCursor);
const two = await searchBookPage(manifest,'目标',one.nextCursor,overrides,load,AbortSignal.timeout(3000),2);
assert.equal(two.hits.length,2); assert.equal(two.scanned,3); assert.equal(two.nextCursor,undefined);
await assert.rejects(searchBookPage({...manifest,revision:'changed'},'目标',one.nextCursor,overrides,load,AbortSignal.timeout(3000)),/已变化/);
assert.equal(htmlSearchText('<p>文本</p><style>secret</style><noscript>secret</noscript>'),'文本');
});
test('chapter failures, cancellation, personal corrections and match limits remain explicit', async () => {
const loaded: string[]=[];
const result=await searchBookPage(manifest,'新内容',undefined,{...overrides,corrections:[{id:'c',anchor:{sectionId:'chapter:0',start:0,end:3,quote:'旧内容',prefix:'',suffix:''},replacement:'新内容'}]},async item=>{
loaded.push(item.href); if(item.seq===1) throw Error('private upstream details'); return {data:Buffer.from('<p>旧内容</p>'),contentType:'text/html'};
},AbortSignal.timeout(3000));
assert.equal(result.hits.length,1);assert.deepEqual(result.failures,[{title:'章节1',code:'CHAPTER_UNAVAILABLE'}]);assert.equal(loaded.length,3);
const abort=new AbortController();abort.abort();await assert.rejects(searchBookPage(manifest,'a',undefined,overrides,async()=>{throw Error('must not run')},abort.signal),{name:'AbortError'});
const capped=await searchBookPage(manifest,'字',undefined,overrides,async()=>({data:Buffer.from('字'.repeat(1000)),contentType:'text/plain'}),AbortSignal.timeout(3000));
assert.equal(capped.hits.length,200);assert.equal(capped.limited,true);assert.equal(capped.nextCursor,undefined);
assert.equal(plainSearchText('第一章 测试\n\n 正文。\n 下一段。'),'\n第一章 测试\n正文。\n下一段。\n\n');
});
+10
View File
@@ -354,6 +354,12 @@ test('OPDS acquisition feeds existing reading, shelf and progress APIs and survi
const content = await h.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}/content`, headers: auth(member) });
assert.equal(content.statusCode, 200, content.body);
assert.equal(content.body, catalog.body);
const search = await h.app.inject({method:'GET',url:`/api/v1/books/${bookId}/search?q=OPDS`,headers:auth(member)});
assert.equal(search.statusCode,200,search.body);assert.ok(search.json().hits.length>0);assert.equal(search.json().failures.length,0);
const deniedSearch=await h.app.inject({method:'GET',url:`/api/v1/books/${bookId}/search?q=OPDS`,headers:auth(h.admin)});
assert.equal(deniedSearch.statusCode,404);
const invalidSearch=await h.app.inject({method:'GET',url:`/api/v1/books/${bookId}/search?q=`,headers:auth(member)});
assert.equal(invalidSearch.statusCode,400);
const shelf = await h.app.inject({ method: 'GET', url: '/api/v1/books', headers: auth(member) });
assert.ok(shelf.json().items.some((book: { id: string }) => book.id === bookId), shelf.body);
const progress = await h.app.inject({ method: 'PUT', url: `/api/v1/sync/progress/${bookId}`, headers: auth(member), payload: {
@@ -686,6 +692,10 @@ test('alternative streams use owned book identity and forced chapter refresh upd
assert.equal(h.ctx.db.get<{body:string|null}>('SELECT body FROM chapter_resources WHERE book_id=?',id)!.body,null);
await h.ctx.sources!.chapters.asset(h.admin.id, id, ref); text = 'new';
const refreshed = await h.app.inject({ method: 'POST', url: '/api/v1/books/' + id + '/refresh-chapter', headers: auth(h.admin), payload: { ref } });
const chapterSearch=await h.app.inject({method:'GET',url:'/api/v1/books/'+id+'/search?q=new',headers:auth(h.admin)});
assert.equal(chapterSearch.statusCode,200,chapterSearch.body);assert.equal(chapterSearch.json().hits.length,1);
assert.equal(chapterSearch.json().hits[0].anchor.sectionId,content.items[0]!.href);
assert.equal((await h.app.inject({method:'GET',url:'/api/v1/books/'+id+'/search?q=new',headers:auth(member)})).statusCode,404);
assert.equal(refreshed.statusCode, 200, refreshed.body); assert.equal(refreshed.body, 'new'); assert.equal(refreshed.headers['cache-control'], 'no-store');
assert.equal((await h.ctx.sources!.chapters.asset(h.admin.id, id, ref)).data!.toString(), 'new');
for (const endpoint of ['alternatives', 'refresh-chapter', 'switch-quality']) {
+5
View File
@@ -391,6 +391,11 @@ export class ReaderApi {
return this.get<BookContent>(`/api/v1/books/${encodeURIComponent(id)}/items${query}`, options);
}
async searchBook(id: string, query: string, cursor: string | undefined, options: RequestOptions = {}): Promise<import('./types.ts').BookSearchPage> {
const params = new URLSearchParams({q:query}); if (cursor) params.set('cursor',cursor);
return this.get(`/api/v1/books/${encodeURIComponent(id)}/search?${params}`,options);
}
/**
* The book's complete table of contents.
*
+6
View File
@@ -337,4 +337,10 @@ export interface ApiErrorBody {
error: { code: string; message: string };
}
export interface BookSearchPage {
hits: Array<{title: string; excerpt: string; anchor: {sectionId: string; start: number; end: number; quote: string; prefix: string; suffix: string}}>;
scanned: number; total: number; limited: boolean; nextCursor?: string;
failures: Array<{title: string; code: string}>;
}
export interface ReadingOverrides { version: number; corrections: Array<{ id: string; anchor: { sectionId: string; start: number; end: number; quote: string; prefix: string; suffix: string }; replacement: string }>; headingPrefix: string }
+9
View File
@@ -3975,6 +3975,15 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.reading-tool-empty strong { font-weight: 500; }
.reading-tool-empty .button { margin-top: .75rem; }
.reading-tool-feedback { padding: .6rem .75rem; border-radius: .5rem; background: var(--reader-surface); color: var(--reader-accent); }
.annotation-popover { position: fixed; z-index: 60; box-sizing: border-box; padding: .6rem; border: 1px solid var(--reader-line); border-radius: .75rem; box-shadow: 0 6px 28px #0003; background: var(--reader-paper); color: var(--reader-ink); font: 14px/1.5 system-ui,sans-serif; }
.annotation-actions { display: flex; gap: .25rem; }
.annotation-actions button { flex: 1; min-height: 44px; border: 0; border-radius: .4rem; background: transparent; color: var(--reader-accent); font: inherit; cursor: pointer; }
.annotation-actions button:hover { background: var(--reader-surface); }
.annotation-popover blockquote, .annotation-editor blockquote { margin: .3rem 0 .75rem; padding: .5rem .75rem; border-inline-start: 3px solid var(--reader-accent); max-height: 6rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.annotation-comment { margin: .5rem; max-height: 8rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.annotation-editor { display: grid; gap: 1rem; padding-top: 1rem; }
.annotation-editor label { display: grid; gap: .5rem; }
.annotation-editor textarea { box-sizing: border-box; width: 100%; min-height: 6rem; padding: .75rem; font: inherit; border: 1px solid var(--reader-line); border-radius: .5rem; background: var(--reader-paper); color: var(--reader-ink); resize: vertical; }
.reading-tool-advanced { border-top: 1px solid var(--reader-line); padding-top: .5rem; }
.reading-tool-advanced summary { padding: .6rem 0; cursor: pointer; font-size: .9rem; }
.reading-tool-advanced > div { display: grid; gap: .75rem; padding-top: .5rem; }
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef, useState } from './vendor/preact.ts';
import type { Note } from '../api/types.ts';
import type { TextAnchor } from './text-anchor.ts';
import { Modal } from './modal.tsx';
export interface AnnotationTarget { anchor: TextAnchor; rect: DOMRect; note?: Note }
export function AnnotationPopover({ target, save, remove, close }: {
target: AnnotationTarget;
save(type: 'highlight' | 'note' | 'bookmark', comment: string, color: string): Promise<void>;
remove(): Promise<void>; close(): void;
}) {
const [editing, setEditing] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState('');
const [comment, setComment] = useState(target.note?.comment ?? ''), [color, setColor] = useState(target.note?.color || '#ffd54f');
const [confirmDelete, setConfirmDelete] = useState(false);
const panel = useRef<HTMLDivElement>(null), input = useRef<HTMLTextAreaElement>(null);
const viewport = window.visualViewport;
const width = Math.min(328, (viewport?.width ?? innerWidth) - 24);
const left = Math.max(12, Math.min((viewport?.offsetLeft ?? 0) + (viewport?.width ?? innerWidth) - width - 12, target.rect.left));
const top = Math.max((viewport?.offsetTop ?? 0) + 12, Math.min(target.rect.bottom + 10, (viewport?.offsetTop ?? 0) + (viewport?.height ?? innerHeight) - 260));
async function work(action: () => Promise<void>) {
if (busy) return; setBusy(true); setError('');
try { await action(); close(); } catch (e) { setError(e instanceof Error ? e.message : '操作失败'); setBusy(false); }
}
useEffect(() => {
const dismiss = (event: PointerEvent) => { if (!editing && !busy && !panel.current?.contains(event.target as Node)) close(); };
const key = (event: KeyboardEvent) => { if (event.key === 'Escape' && !busy) { event.stopPropagation(); close(); } };
const moved = (event: Event) => { if (!editing && !busy && !(event.target instanceof Node && panel.current?.contains(event.target))) close(); };
document.addEventListener('pointerdown', dismiss); document.addEventListener('keydown', key, true);
window.addEventListener('resize',moved); document.addEventListener('scroll',moved,true);
return () => { document.removeEventListener('pointerdown', dismiss); document.removeEventListener('keydown', key, true); window.removeEventListener('resize',moved); document.removeEventListener('scroll',moved,true); };
}, [editing, busy]);
useEffect(() => { if (editing) input.current?.focus(); }, [editing]);
if (editing) return <Modal title={target.note ? '编辑批注' : '添加批注'} busy={busy} onClose={close}>
<form className="annotation-editor source-modal-content" onSubmit={event => { event.preventDefault(); void work(() => save(comment.trim() ? 'note' : 'highlight', comment, color)); }}>
<blockquote>{target.anchor.quote}</blockquote>
<label>批注<textarea ref={input} aria-label="批注" maxLength={4000} value={comment} onInput={event => setComment(event.currentTarget.value)} placeholder="写下此刻的想法" disabled={busy} /></label>
<div className="reading-note-colors" role="group" aria-label="高亮颜色">{[['#ffd54f','黄色'],['#80cbc4','绿色'],['#ce93d8','紫色']].map(([value, label]) => <button type="button" aria-label={label} aria-pressed={color === value} style={{'--note-color':value}} disabled={busy} onClick={() => setColor(value)}>{color === value ? '✓' : ''}</button>)}</div>
{error && <p role="alert">{error}</p>}<div className="reading-tool-actions"><button type="button" className="button" disabled={busy} onClick={close}>取消</button><button className="button primary" disabled={busy}>保存批注</button></div>
</form>
</Modal>;
return <div ref={panel} className="annotation-popover" style={{left,top,width}} role={target.note ? 'dialog' : 'toolbar'} aria-label={target.note ? '批注详情' : '选中文字操作'} onPointerDown={event => event.preventDefault()}>
{target.note && <><blockquote>{target.anchor.quote}</blockquote><p className="annotation-comment">{target.note.comment || '这段文字已高亮,尚未添加批注。'}</p></>}
{error && <p role="alert">{error}</p>}
<div className="annotation-actions">
{!target.note && <button disabled={busy} onClick={() => void work(async () => { if (!navigator.clipboard) throw new Error('当前环境不支持剪贴板,请使用系统复制'); await navigator.clipboard.writeText(target.anchor.quote); })}>复制</button>}
{!target.note && <button disabled={busy} onClick={() => void work(() => save('highlight', '', color))}>高亮</button>}
<button disabled={busy} onClick={() => setEditing(true)}>{target.note ? '编辑批注' : '批注'}</button>
{!target.note && <button disabled={busy} onClick={() => void work(() => save('bookmark', '', ''))}>书签</button>}
{target.note && <button disabled={busy} onClick={() => confirmDelete ? void work(remove) : setConfirmDelete(true)}>{confirmDelete ? '确认删除' : '删除'}</button>}
<button disabled={busy} aria-label="关闭选区工具" onClick={close}>关闭</button>
</div>
</div>;
}
+37 -7
View File
@@ -31,7 +31,8 @@ import { mountUI } from './mount.ts';
import { ReaderChrome, type ChromeState, type ChromeTocEntry } from './reader-chrome.tsx';
import { OFFLINE_FILE, PublicationCache, publicationScope } from '../store/publications.ts';
import { ReadingTools, type SearchSection } from './reading-tools.tsx';
import { decodeAnchor, searchableText, type TextAnchor } from './text-anchor.ts';
import { decodeAnchor, encodeAnchor, searchableText, type TextAnchor } from './text-anchor.ts';
import { AnnotationPopover, type AnnotationTarget } from './annotation-popover.tsx';
import { textToChapterHtml } from '../formats/segments.ts';
import { parseLocator } from './locator.ts';
@@ -195,6 +196,7 @@ export class ReaderScreen {
private loadingToken = 0;
private overrides: ReadingOverrides = emptyOverrides();
private toolsOpen = false;
private annotation: AnnotationTarget | null = null;
private toolsSelection: TextAnchor | null = null;
private toolsReturn = '';
private preview: SpeechEngine | null = null;
@@ -232,7 +234,9 @@ export class ReaderScreen {
this.element,
() => (
<ReaderChrome
tools={this.toolsOpen && this.manifest ? <ReadingTools
tools={<>{this.annotation && <AnnotationPopover key={this.annotation.note?.id ?? encodeAnchor(this.annotation.anchor)} target={this.annotation}
save={(type, comment, color) => this.saveAnnotation(type, comment, color)} remove={() => this.removeAnnotation()}
close={() => { this.annotation = null; this.view?.clearSelection(); this.patch({}); }} />}{this.toolsOpen && this.manifest ? <ReadingTools
api={this.options.api} cache={this.publicationCache} offline={this.options.offline}
sync={this.options.sync} manifest={this.manifest} selection={this.toolsSelection}
locator={this.view?.position()?.locator ?? ''} returnLocator={this.toolsReturn}
@@ -240,7 +244,7 @@ export class ReaderScreen {
overrides={this.overrides} saveOverrides={value => this.saveOverrides(value)} undoOverrides={() => this.undoOverrides()}
previewHeadings={prefix => this.previewHeadings(prefix)}
onNotes={() => this.paintNotes()} onClose={() => { this.toolsOpen = false; this.patch({}); }}
previewSpeech={() => this.previewSpeech()} stopPreview={() => this.stopPreview()} /> : null}
previewSpeech={() => this.previewSpeech()} stopPreview={() => this.stopPreview()} /> : null}</>}
state={this.chrome}
stage={this.stage}
handlers={{
@@ -285,7 +289,7 @@ export class ReaderScreen {
async open(book: Book): Promise<void> {
this.closeAlternatives(); this.chapterRefresh?.abort(); this.chapterRefresh = null;
const token = ++this.loadingToken;
this.toolsOpen = false; this.toolsReturn = ''; this.toolsSelection = null; this.stopPreview();
this.toolsOpen = false; this.annotation = null; this.toolsReturn = ''; this.toolsSelection = null; this.stopPreview();
this.content = null; this.book = book;
this.patch({ title: book.title, author: book.author, description: book.description, bookInfoOpen: false, canRefresh: book.format === 'chapters', canSwitch: false, alternatives: null, refreshingChapter: false, sourceName: '', chapterUrl: '', toc: [] });
this.setStatus('loading', '正在载入…');
@@ -528,6 +532,7 @@ export class ReaderScreen {
private openTools(): void {
if (!this.manifest || !this.view) return;
this.toolsSelection = this.view.selectionAnchor();
this.annotation = null;
if (!this.toolsReturn) this.toolsReturn = this.view.position()?.locator ?? '';
this.toolsOpen = true;
this.patch({ tocOpen: false, settingsOpen: false });
@@ -537,10 +542,22 @@ export class ReaderScreen {
if (!this.book) return;
this.view?.paintAnnotations(this.options.offline.notesFor(this.book.id).flatMap(note => {
const anchor = decodeAnchor(note.locator);
return anchor ? [{ anchor: { ...anchor, sectionId: this.localSectionId(anchor.sectionId) }, color: note.color || '#ffd54f' }] : [];
return anchor && note.type !== 'bookmark' ? [{ id: note.id, anchor: { ...anchor, sectionId: this.localSectionId(anchor.sectionId) }, color: note.color || '#ffd54f' }] : [];
}));
}
private async saveAnnotation(type: 'highlight' | 'note' | 'bookmark', comment: string, color: string): Promise<void> {
if (!this.book || !this.annotation) return;
const { anchor, note } = this.annotation;
await this.options.offline.upsertNotes([{ id: note?.id ?? crypto.randomUUID(), bookId: this.book.id,
type, locator: encodeAnchor(anchor), text: anchor.quote, comment, color, updatedAt: Date.now() }]);
this.options.sync.schedule(); this.paintNotes();
}
private async removeAnnotation(): Promise<void> {
if (!this.annotation?.note) return;
await this.options.offline.deleteNote(this.annotation.note.id); this.options.sync.schedule(); this.paintNotes();
}
private searchText(html: string, sectionId: string): string {
const root = new DOMParser().parseFromString(html,'text/html').body;
applyCorrections(root,sectionId,this.overrides);
@@ -552,12 +569,14 @@ export class ReaderScreen {
if (this.content && content) {
for (let group = 0; group < Math.max(1, content.groups.length); group++) {
signal.throwIfAborted();
const window = group === 0 ? content : await this.readWindow(book.id, group, signal);
const window = group === 0 ? content : await this.publicationCache.window(book.id, group);
if (!window || window.revision !== content.revision) throw new Error('部分目录未缓存,离线搜索不完整;已保留找到的结果');
for (const item of window.items) {
signal.throwIfAborted();
const ref = renditionRef(item);
const bytes = await this.publicationCache.resource(book.id, ref);
const html = bytes ? new TextDecoder().decode(bytes) : await (await this.options.api.asset(book.id, ref, { signal })).text();
if (!bytes) throw new Error('部分章节未缓存,离线搜索不完整;已保留找到的结果');
const html = new TextDecoder().decode(bytes);
yield { id: item.href, title: item.title, text: this.searchText(/(?:xhtml|html|xml)/i.test(item.mediaType) ? html : textToChapterHtml(html),item.href) };
}
}
@@ -954,6 +973,16 @@ export class ReaderScreen {
...(token ? { accessToken: token } : { accessToken: '' }),
});
this.view = new ReaderView({
onSelection: (anchor, rect) => {
if (epoch !== this.viewEpoch || this.toolsOpen || this.annotation?.note || this.element.querySelector('dialog[open]')) return;
if (anchor && rect && anchor.quote.trim()) { this.annotation = { anchor, rect }; this.patch({}); }
},
onAnnotation: (id, rect) => {
if (epoch !== this.viewEpoch || !this.book || this.toolsOpen) return;
const note = this.options.offline.notesFor(this.book.id).find(note => note.id === id);
const anchor = note && decodeAnchor(note.locator);
if (note && anchor) { this.view?.clearSelection(); this.annotation = { anchor, rect, note }; this.patch({}); }
},
transformText: (root, sectionId) => { applyCorrections(root,sectionId,this.overrides); },
container: this.stage,
doc,
@@ -974,6 +1003,7 @@ export class ReaderScreen {
},
onChapterChange: (index, section) => {
if (epoch !== this.viewEpoch) return;
this.annotation = null;
// One patch, and `syncTocPosition` folds its own correction into the same
// one rather than issuing a second. A chapter change is the one moment
// several pieces of chrome move together (label, section, progress, the
+34 -7
View File
@@ -17,6 +17,8 @@ import { BOOK_RESOURCE_MARKER, chapterRefFromLink } from '../formats/book-resour
import { anchorRange, selectedAnchor, type TextAnchor } from './text-anchor.ts';
export interface ReaderViewOptions {
onSelection?(anchor: TextAnchor | null, rect: DOMRect | null): void;
onAnnotation?(id: string, rect: DOMRect): void;
transformText?(root: HTMLElement, sectionId: string): void;
container: HTMLElement;
doc: BookDoc;
@@ -194,6 +196,7 @@ export class ReaderView {
* read end to end would end up with 1200 of them.
*/
private detachChapterLinks: (() => void) | null = null;
private detachSelection: (() => void) | null = null;
/**
* The trim currently written to the host, so a re-measure cannot write it twice.
*
@@ -218,6 +221,21 @@ export class ReaderView {
this.resolver = new ResourceResolver(options.doc.resources);
this.container.append(this.host);
this.bindChapterLinks();
let timer: ReturnType<typeof setTimeout> | undefined;
const changed = () => {
clearTimeout(timer);
timer = setTimeout(() => {
const anchor = this.selectionAnchor();
const range = anchor ? anchorRange(this.host.flow, anchor) : null;
this.options.onSelection?.(anchor, range && typeof range.getBoundingClientRect === 'function' ? range.getBoundingClientRect() : null);
}, 160);
};
this.host.ownerDocument.addEventListener('selectionchange', changed);
this.host.shadow.addEventListener('pointerup', changed);
this.host.shadow.addEventListener('keyup', changed);
const contextMenu = (event: Event) => { if (this.selectionAnchor()) { event.preventDefault(); changed(); } };
this.host.shadow.addEventListener('contextmenu',contextMenu);
this.detachSelection = () => { clearTimeout(timer); this.host.ownerDocument.removeEventListener('selectionchange', changed); this.host.shadow.removeEventListener('pointerup', changed); this.host.shadow.removeEventListener('keyup', changed); this.host.shadow.removeEventListener('contextmenu',contextMenu); };
this.applySettings();
}
@@ -558,24 +576,32 @@ export class ReaderView {
return true;
}
paintAnnotations(anchors: Array<{ anchor: TextAnchor; color: string }>): void {
clearSelection(): void {
(this.host.shadow as ShadowRoot & {getSelection?: () => Selection | null}).getSelection?.()?.removeAllRanges();
this.host.ownerDocument.getSelection()?.removeAllRanges();
}
paintAnnotations(anchors: Array<{ id?: string; anchor: TextAnchor; color: string }>): void {
for (const mark of this.host.flow.querySelectorAll('[data-reader-mark="note"]')) mark.remove();
const sectionId = this.doc.sections[this.sectionIndex]?.id;
for (const { anchor, color } of anchors) {
for (const { id, anchor, color } of anchors) {
if (anchor.sectionId !== sectionId) continue;
const range = anchorRange(this.host.flow, anchor);
if (range) this.paintRange(range, /^#[0-9a-f]{6}$/i.test(color) ? color : '#ffd54f', 'note');
if (range) this.paintRange(range, /^#[0-9a-f]{6}$/i.test(color) ? color : '#ffd54f', 'note', id);
}
}
private paintRange(range: Range, color: string, kind: string): void {
private paintRange(range: Range, color: string, kind: string, id?: string): void {
if (kind === 'search') for (const node of this.host.flow.querySelectorAll('[data-reader-mark="search"]')) node.remove();
const box = this.host.flow.getBoundingClientRect();
const rects = typeof range.getClientRects === 'function' ? [...range.getClientRects()] : [];
for (const rect of rects) {
const mark = document.createElement('span'); mark.dataset['readerMark'] = kind;
mark.setAttribute('aria-hidden', 'true');
mark.style.cssText = `position:absolute;pointer-events:none;background:${color};opacity:.32;left:${rect.left - box.left + this.host.flow.scrollLeft}px;top:${rect.top - box.top + this.host.flow.scrollTop}px;width:${rect.width}px;height:${rect.height}px;`;
const mark = document.createElement(id ? 'button' : 'span'); mark.dataset['readerMark'] = kind;
if (id) {
mark.dataset['noteId'] = id; mark.setAttribute('aria-label', '查看高亮或批注'); mark.setAttribute('type', 'button');
mark.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); this.options.onAnnotation?.(id, mark.getBoundingClientRect()); });
} else mark.setAttribute('aria-hidden', 'true');
mark.style.cssText = `position:absolute;pointer-events:${id ? 'auto' : 'none'};cursor:${id ? 'pointer' : 'default'};border:0;padding:0;margin:0;min-width:0;min-height:0;background:${color};opacity:.32;left:${rect.left - box.left + this.host.flow.scrollLeft}px;top:${rect.top - box.top + this.host.flow.scrollTop}px;width:${rect.width}px;height:${rect.height}px;`;
this.host.flow.append(mark);
}
}
@@ -711,6 +737,7 @@ export class ReaderView {
}
dispose(): void {
this.detachSelection?.(); this.detachSelection = null;
this.resizeObserver?.disconnect();
this.resizeObserver = null;
if (this.animationTimer !== null) clearTimeout(this.animationTimer);
+23 -1
View File
@@ -8,6 +8,7 @@ import type { SyncEngine } from '../core/sync.ts';
import { PublicationCache } from '../store/publications.ts';
import { OfflineDownload, type DownloadState } from '../core/offline-download.ts';
import { decodeAnchor, encodeAnchor, findText, type SearchHit, type TextAnchor } from './text-anchor.ts';
import { ApiError } from '../api/errors.ts';
export interface SearchSection { id: string; title: string; text: string }
export interface ReadingToolsProps {
@@ -27,6 +28,8 @@ export function ReadingTools(props: ReadingToolsProps) {
const [tab, setTab] = useState<'search' | 'notes' | 'offline' | 'speech' | 'edit'>(props.selection ? 'notes' : 'search');
const [query, setQuery] = useState(''), [hits, setHits] = useState<SearchHit[]>([]);
const [scanned, setScanned] = useState(0), [searching, setSearching] = useState(false);
const [searchMode, setSearchMode] = useState('服务端搜索'), [searchTotal, setSearchTotal] = useState(0);
const [searchFailures, setSearchFailures] = useState<Array<{title: string; code: string}>>([]);
const [message, setMessage] = useState('');
const [replacement, setReplacement] = useState(''), [prefix, setPrefix] = useState(props.overrides.headingPrefix);
const [headingPreview, setHeadingPreview] = useState<string[] | null>(null), [saving, setSaving] = useState(false);
@@ -68,8 +71,26 @@ export function ReadingTools(props: ReadingToolsProps) {
async function search() {
searchRun.current?.abort(); const run = searchRun.current = new AbortController();
setHits([]); setScanned(0); setSearching(true); setMessage('');
setSearchFailures([]); setSearchTotal(0); setSearchMode('服务端搜索');
const found: SearchHit[] = []; let count = 0;
try {
try {
let cursor: string | undefined; const failed: Array<{title: string; code: string}> = [];
do {
const page = await props.api.searchBook(bookId,query.trim(),cursor,{signal:run.signal});
run.signal.throwIfAborted();
found.push(...page.hits.slice(0,200-found.length)); failed.push(...page.failures);
if (alive.current) { setHits([...found]); setScanned(page.scanned); setSearchTotal(page.total); setSearchFailures([...failed]); }
if (page.limited || found.length >= 200) { setMessage('已显示前 200 个命中,请缩小关键词范围'); break; }
cursor = page.nextCursor;
} while (cursor);
return;
} catch (error) {
run.signal.throwIfAborted();
if (!(error instanceof ApiError) || (!error.isConnectivity && error.code !== 'SEARCH_LOCAL_LAYOUT')) throw error;
found.length = 0; setHits([]); setSearchFailures([]); setSearchTotal(0);
setSearchMode(error.code === 'SEARCH_LOCAL_LAYOUT' ? '本地搜索(自定义目录)' : '离线搜索(仅已缓存内容)');
}
for await (const section of props.sections(run.signal)) {
run.signal.throwIfAborted();
found.push(...findText(section.id, section.title, section.text, query.trim(), 200 - found.length));
@@ -104,7 +125,8 @@ export function ReadingTools(props: ReadingToolsProps) {
<h3>书内搜索</h3>
<form className="reading-tool-search" onSubmit={event => { event.preventDefault(); if (query.trim()) void search(); }}><label>关键词<input placeholder="输入要查找的文字" value={query} maxLength={200} onInput={event => setQuery(event.currentTarget.value)} /></label><button className="button primary" disabled={searching || !query.trim()}>搜索全文</button></form>
{searching && <button className="button" onClick={() => { searchRun.current?.abort(); setSearching(false); }}>停止搜索</button>}
<p role="status">已扫描 {scanned} 章,找到 {hits.length} 处</p>
<p role="status">{searchMode} · 已扫描 {scanned}{searchTotal ? ` / ${searchTotal}` : ''} 章,找到 {hits.length} 处</p>
{!!searchFailures.length && <details className="reading-tool-advanced"><summary>{searchFailures.length} 章读取失败,搜索结果可能不完整</summary><ul>{searchFailures.map((failure,index)=><li key={index}>{failure.title} · {failure.code}</li>)}</ul></details>}
<button className="button" disabled={!returnTo} onClick={() => void action(async () => { await props.navigate(returnTo); props.onClose(); })}>返回原阅读位置</button>
<ol className="reading-tool-results">{hits.map((hit, index) => <li key={index}><button className="button" onClick={() => void action(async () => { await props.navigate(hit.anchor); props.onClose(); })}><strong>{hit.title}</strong><span>{hit.excerpt}</span></button></li>)}</ol>
</section>}
+11
View File
@@ -0,0 +1,11 @@
// @vitest-environment jsdom
import {test,expect} from 'vitest';
import {plainSearchText,htmlSearchText} from '../../server/src/services/book-search.ts';
import {textToChapterHtml} from '../src/formats/segments.ts';
import {searchableText} from '../src/ui/text-anchor.ts';
test('server text offsets match the reader rendition including headings and paragraphs',()=>{
for(const raw of ['第一章 测试\n\n 正文。\n 下一段。','前言\r\n\r\n中文 & <原文>\n续行','\n\n普通标题\n软换行\n\n新段','Chapter 1\nHello\n world.','\n',' 两个 空格。\n 中文!']) {
expect(plainSearchText(raw)).toBe(searchableText(textToChapterHtml(raw)));
}
for(const html of ['<html><head><title>忽略</title></head><body><p>甲&amp;乙</p>\n<p>目标</p></body></html>','<p>甲<strong>乙</strong>丙</p><script>忽略</script>'])expect(htmlSearchText(html)).toBe(searchableText(html));
});
+1
View File
@@ -85,6 +85,7 @@ async function setup(kv = new MemoryKv(), blobs = new MemoryBlobs(), userId = 'u
function serve(transport: FakeTransport, current: BookContent, next = current) {
transport.respondWith((request) => {
if (request.url.endsWith('/manifest')) return { status: 200, headers: {}, json: manifest(current) };
if (request.url.includes('/search?')) return { status:200,headers:{},json:{hits:current.items.map(item=>({title:item.title,excerpt:'正文',anchor:{sectionId:item.href,start:0,end:2,quote:'正文',prefix:'',suffix:''}})),scanned:current.total,total:current.total,failures:[],limited:false} };
if (request.url.endsWith('/refresh')) return { status: 200, headers: {}, json: next };
if (request.url.includes('/assets?')) {
const ref = new URL(request.url, 'http://test').searchParams.get('ref');
+16 -8
View File
@@ -29,8 +29,8 @@ try {
const registered=await app.inject({method:'POST',url:'/api/v1/auth/register',payload:{username:'p1-reader',password:'password123'}});assert.equal(registered.statusCode,201);
const books=db.all('SELECT id,format FROM books'), epub=books.find(b=>b.format==='epub'),txt=books.find(b=>b.format==='txt');
browser=await chromium.launch({headless:true,...(process.env.CHROME_PATH?{executablePath:process.env.CHROME_PATH}:{})});
const context=await browser.newContext({viewport:{width:390,height:844}}), page=await context.newPage();page.setDefaultTimeout(12000);
const errors=[];page.on('pageerror',e=>errors.push(e.message));const button=name=>page.getByRole('button',{name,exact:true});
const context=await browser.newContext({viewport:{width:390,height:844},hasTouch:true,isMobile:true}), page=await context.newPage();page.setDefaultTimeout(12000);
const errors=[],requests=[];page.on('pageerror',e=>errors.push(e.message));page.on('request',r=>requests.push(r.url()));const button=name=>page.getByRole('button',{name,exact:true});
await page.goto(base);await page.locator('input[autocomplete=username]').fill('p1-reader');await page.locator('input[type=password]').fill('password123');await page.locator('button[type=submit]').click();await page.locator('.shelf-screen').waitFor();
await page.goto(base+'/#/book/'+epub.id);await page.locator('book-content h1').waitFor();assert.ok(await button('设置').evaluate(el=>el.getBoundingClientRect().bottom<=innerHeight));await button('工具').click();
for(const width of [320,390,1280]){await page.setViewportSize({width,height:844});await page.screenshot({path:join(shots,'tools-'+width+'.png')});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);}
@@ -42,18 +42,26 @@ try {
await button('高亮与批注').click();assert.equal(await page.locator('.reading-note-list li').count(),0);await button('全部').click();
await button('删除笔记').click();assert.equal(await page.locator('.reading-note-list li').count(),1);await button('取消').click();await button('删除笔记').click();await button('确认删除').click();await page.getByText('还没有阅读记录',{exact:true}).waitFor();
await button('书内搜索').click();
await page.setViewportSize({width:390,height:844});await page.getByLabel('关键词',{exact:true}).fill('检索目标');await button('搜索全文').click();await page.locator('.reading-tool-results li').nth(1).waitFor();await page.locator('.reading-tool-results li button').nth(1).click();await page.getByRole('dialog').waitFor({state:'hidden'});
const assetsBefore=requests.filter(url=>url.includes('/assets?')).length;
await page.setViewportSize({width:390,height:844});await page.getByLabel('关键词',{exact:true}).fill('检索目标');await button('搜索全文').click();await page.locator('.reading-tool-results li').nth(1).waitFor();
assert.ok(requests.some(url=>url.includes('/search?q=')));assert.equal(requests.filter(url=>url.includes('/assets?')).length,assetsBefore);
await page.locator('.reading-tool-results li button').nth(1).click();await page.getByRole('dialog').waitFor({state:'hidden'});
assert.ok(await page.locator('book-content').evaluate(host=>host.shadowRoot.querySelectorAll('[data-reader-mark="search"]').length)>0);
await button('工具').click();await button('返回原阅读位置').click();
await button('目录').click();await page.getByRole('button',{name:'第三层',exact:false}).last().waitFor();await page.getByRole('button',{name:'第一层 折叠',exact:true}).click();assert.equal(await page.getByRole('button',{name:'第三层',exact:false}).count(),0);await page.getByRole('button',{name:'第一层 展开',exact:true}).click();await page.getByRole('button',{name:/3.*第三层/}).click();
await button('工具').click();await button('离线缓存').click();await button('下载 / 继续').click();await page.getByText('整书已可离线阅读',{exact:false}).waitFor();await page.screenshot({path:join(shots,'offline.png')});await button('关闭弹窗').click();
await context.route('**/api/**',route=>route.abort('internetdisconnected'));await page.reload();await page.locator('book-content h1').waitFor();await context.unroute('**/api/**');
await context.route('**/api/**',route=>route.abort('internetdisconnected'));await page.reload();await page.locator('book-content h1').waitFor();
await button('工具').click();await page.getByLabel('关键词',{exact:true}).fill('检索目标');await button('搜索全文').click();await page.locator('.reading-tool-results li').nth(1).waitFor();await page.getByText('离线搜索(仅已缓存内容)',{exact:false}).waitFor();await button('关闭弹窗').click();await context.unroute('**/api/**');
// Select actual shadow DOM text, then create a persisted personal correction.
await page.locator('book-content').evaluate(host=>{const node=host.shadowRoot.querySelector('p').firstChild;const range=document.createRange();range.setStart(node,3);range.setEnd(node,7);const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);});
await button('工具').click();assert.equal(await button('笔记').getAttribute('aria-pressed'),'true');await page.getByLabel('批注',{exact:true}).fill('跨设备批注测试');await button('绿色').click();
await page.screenshot({path:join(shots,'notes-editor-390.png')});
await button('保存批注').click();await page.getByText('笔记已保存',{exact:true}).waitFor();assert.equal(await page.locator('.reading-note-editor').count(),0);
await button('关闭弹窗').click();assert.ok(await page.locator('book-content').evaluate(host=>host.shadowRoot.querySelectorAll('[data-reader-mark="note"]').length)>0);
await page.getByRole('toolbar',{name:'选中文字操作'}).waitFor();await page.screenshot({path:join(shots,'selection-toolbar-390.png')});
await button('批注').click();await page.getByLabel('批注',{exact:true}).fill('跨设备批注测试');await button('绿色').click();
await page.screenshot({path:join(shots,'annotation-editor-390.png')});await button('保存批注').click();await page.getByRole('dialog').waitFor({state:'hidden'});
const mark=page.locator('book-content [data-reader-mark="note"]').first();await mark.waitFor();await mark.scrollIntoViewIfNeeded();
const beforeClick=await page.locator('book-content').evaluate(el=>el.scrollTop);
await mark.tap();await page.getByRole('dialog',{name:'批注详情'}).waitFor();assert.equal(await page.locator('book-content').evaluate(el=>el.scrollTop),beforeClick);await page.getByText('跨设备批注测试',{exact:true}).waitFor();await page.screenshot({path:join(shots,'annotation-detail-390.png')});
await button('编辑批注').click();await page.getByLabel('批注',{exact:true}).fill('编辑后的批注');await button('保存批注').click();await page.getByRole('dialog').waitFor({state:'hidden'});
await mark.click();await page.getByText('编辑后的批注',{exact:true}).waitFor();await button('删除').click();await button('确认删除').click();await mark.waitFor({state:'detached'});
await page.locator('book-content').evaluate(host=>{const node=host.shadowRoot.querySelector('p').firstChild;const range=document.createRange();range.setStart(node,3);range.setEnd(node,7);const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);});
await button('工具').click();await button('内容整理').click();await page.getByLabel('替换为',{exact:true}).fill('已经修正');await button('保存纠错 / 过滤').click();await page.getByText('纠错已保存,正文已更新').waitFor();await button('关闭弹窗').click();await page.reload();await page.locator('book-content p').filter({hasText:'已经修正'}).waitFor();
await button('工具').click();await button('内容整理').click();await button('撤销上一次整理修改').click();await page.getByText('已撤销上一次整理修改').waitFor();await button('关闭弹窗').click();assert.ok(!(await page.locator('book-content p').first().innerText()).includes('已经修正'));