From 1d86efab34c95e4e28fda32346bc8282d1cd69ee Mon Sep 17 00:00:00 2001 From: hectorqin Date: Thu, 24 Sep 2026 16:03:35 +0800 Subject: [PATCH] feat(reader): add selection annotations and server-side book search --- README.md | 1 + docs/api.md | 23 ++++++++ server/package-lock.json | 1 + server/package.json | 1 + server/src/http/routes/library.ts | 22 +++++++ server/src/services/book-search.ts | 89 +++++++++++++++++++++++++++++ server/test/book-search.test.ts | 27 +++++++++ server/test/sources-api.test.ts | 10 ++++ web/src/api/client.ts | 5 ++ web/src/api/types.ts | 6 ++ web/src/styles/reader.css | 9 +++ web/src/ui/annotation-popover.tsx | 53 +++++++++++++++++ web/src/ui/reader-screen.tsx | 44 +++++++++++--- web/src/ui/reader-view.ts | 41 ++++++++++--- web/src/ui/reading-tools.tsx | 24 +++++++- web/test/book-search-parity.test.js | 11 ++++ web/test/chapter-reader.test.ts | 1 + web/tools/ui-review/p1-reading.mjs | 24 +++++--- 18 files changed, 369 insertions(+), 23 deletions(-) create mode 100644 server/src/services/book-search.ts create mode 100644 server/test/book-search.test.ts create mode 100644 web/src/ui/annotation-popover.tsx create mode 100644 web/test/book-search-parity.test.js diff --git a/README.md b/README.md index 1c015fa..edf2ed1 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - **专注阅读**:EPUB 保留出版方样式,TXT 支持编码识别与章节切分;支持分页、滚动、主题、字体设置和朗读。 - **多种来源**:内置本地书库和 OPDS,通过独立插件接入远程书源。 - **跨设备继续**:每个账号拥有独立书架、阅读进度、笔记和高亮;浏览器与 Android 连接同一服务。 +- **随读随记**:选中文字即可复制、高亮、批注或添加书签,点击高亮查看批注;在线书内搜索由服务端执行,离线仅搜索已缓存内容。 - **适合自部署**:一个 Docker 容器包含服务端和 Web 界面,书库可按需要选择只读或可写挂载。 ## 书籍与来源 diff --git a/docs/api.md b/docs/api.md index 278b4f2..767c448 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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` — 公开 diff --git a/server/package-lock.json b/server/package-lock.json index 869fd0c..a664701 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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" diff --git a/server/package.json b/server/package.json index 4d564b5..1a86977 100644 --- a/server/package.json +++ b/server/package.json @@ -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" diff --git a/server/src/http/routes/library.ts b/server/src/http/routes/library.ts index 93c568b..4f78006 100644 --- a/server/src/http/routes/library.ts +++ b/server/src/http/routes/library.ts @@ -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; + 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 }; diff --git a/server/src/services/book-search.ts b/server/src/services/book-search.ts new file mode 100644 index 0000000..1b572f3 --- /dev/null +++ b/server/src/services/book-search.ts @@ -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, 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 } : {}) }; +} diff --git a/server/test/book-search.test.ts b/server/test/book-search.test.ts new file mode 100644 index 0000000..595c8fe --- /dev/null +++ b/server/test/book-search.test.ts @@ -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('不应命中

甲&乙目标丙

'),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('

文本

'),'文本'); +}); +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('

旧内容

'),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'); +}); diff --git a/server/test/sources-api.test.ts b/server/test/sources-api.test.ts index 33da132..670a00d 100644 --- a/server/test/sources-api.test.ts +++ b/server/test/sources-api.test.ts @@ -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']) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 9a2f291..2133a8d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -391,6 +391,11 @@ export class ReaderApi { return this.get(`/api/v1/books/${encodeURIComponent(id)}/items${query}`, options); } + async searchBook(id: string, query: string, cursor: string | undefined, options: RequestOptions = {}): Promise { + 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. * diff --git a/web/src/api/types.ts b/web/src/api/types.ts index e781f89..d1b7f75 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 } diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index 19be2d6..578d1bc 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -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; } diff --git a/web/src/ui/annotation-popover.tsx b/web/src/ui/annotation-popover.tsx new file mode 100644 index 0000000..7112ca7 --- /dev/null +++ b/web/src/ui/annotation-popover.tsx @@ -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; + remove(): Promise; 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(null), input = useRef(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) { + 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 +
{ event.preventDefault(); void work(() => save(comment.trim() ? 'note' : 'highlight', comment, color)); }}> +
{target.anchor.quote}
+