From 0790b3e347313761fc238f354c5bd736811bafc8 Mon Sep 17 00:00:00 2001 From: mrsibe Date: Fri, 25 Sep 2026 17:07:28 +0800 Subject: [PATCH] feat(reader): render sources through a PDF-first reader contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document viewer was a `
` of concatenated chunk text: overlap duplicated
at every boundary, the parser's cleaned form instead of the source, no page, no
selection mapping, and no way to "jump to page 5". This replaces it with a reader
surface that renders whatever structure a source has.

- **One contract, two implementations (#71).** `ReaderHandle` exposes
  `openAt({documentId, page?, blockId?, startOffset?, endOffset?})` and
  `getSelection()`. PDF is the full implementation (continuous pages, zoom, a
  pdfjs text layer for real text selection, a block overlay); every other format
  uses a text reader. #72 and #73 talk to the contract, never to a format.
- **Bytes by id, never by path.** `knownote-doc://docs/` is resolved
  against `documents.localFilePath` in the main process. The renderer never builds
  a filesystem path and an unknown id is a 404. The scheme is registered before
  app-ready and CORS-reflected only to the app's own origins.
- **Blocks travel with the source.** A new `knowledge:get-document-blocks` IPC
  returns page / char span / normalized bbox, so `openAt` can scroll to a page and
  highlight the paragraph instead of re-deriving geometry from chunk text.
- **Selection maps back to a source location.** The DOM-selection → anchor mapping
  is a pure function (`anchor.ts`) and unit-tested, because it is what #73 will
  build excerpts on.

Verification: typecheck, 143 tests (10 new), `npm run build`, `build:unpack`,
`smoke:packaged` (19 checks, including a new `knownote-doc://` byte-serving and
404 check that runs in the real main process).

Not verified here (no GUI): actual page rendering, selectable text, page
navigation/zoom, `openAt` landing, and highlighting are Manual QA in the PR.

Refs #71
---
 docs/architecture.md                          |  30 ++
 src/main/index.ts                             |   9 +
 src/main/ipc/knowledgeHandlers.ts             |  15 +
 src/main/ipc/validation.ts                    |   4 +
 src/main/protocol/documentProtocol.ts         |  74 ++++
 src/main/smokeTest.ts                         |  43 ++
 src/preload/index.d.ts                        |   4 +-
 src/preload/index.ts                          |   2 +
 src/renderer/index.html                       |   2 +-
 .../src/components/notebook/SourcePanel.tsx   |  40 +-
 .../source/reader/PdfSourceReader.tsx         | 372 ++++++++++++++++++
 .../notebook/source/reader/SourceReader.tsx   |  84 ++++
 .../source/reader/TextSourceReader.tsx        | 151 +++++++
 .../notebook/source/reader/anchor.ts          | 110 ++++++
 .../notebook/source/reader/reader.css         |  88 +++++
 src/renderer/src/locales/en-US/ui.json        |   4 +
 src/renderer/src/locales/zh-CN/ui.json        |   4 +
 src/shared/types/index.ts                     |   3 +
 src/shared/types/source.ts                    |  47 +++
 src/shared/utils/documentUrl.ts               |  39 ++
 test/sourceReader.test.ts                     | 117 ++++++
 21 files changed, 1205 insertions(+), 37 deletions(-)
 create mode 100644 src/main/protocol/documentProtocol.ts
 create mode 100644 src/renderer/src/components/notebook/source/reader/PdfSourceReader.tsx
 create mode 100644 src/renderer/src/components/notebook/source/reader/SourceReader.tsx
 create mode 100644 src/renderer/src/components/notebook/source/reader/TextSourceReader.tsx
 create mode 100644 src/renderer/src/components/notebook/source/reader/anchor.ts
 create mode 100644 src/renderer/src/components/notebook/source/reader/reader.css
 create mode 100644 src/shared/types/source.ts
 create mode 100644 src/shared/utils/documentUrl.ts
 create mode 100644 test/sourceReader.test.ts

diff --git a/docs/architecture.md b/docs/architecture.md
index 490ef34..f8a4b01 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -103,3 +103,33 @@ Rules:
 The harness is a main-process entry (`--eval-harness`), like the packaged smoke
 test, because the DB layer, vector store and loaders do not exist outside
 Electron. See `eval/README.md` for the dataset format and commands.
+
+## Source reader
+
+The reader is not "the PDF component". It is the surface that renders whatever
+structure a source has, behind one format-agnostic contract:
+
+```ts
+interface ReaderHandle {
+  openAt(anchor: ReaderAnchor): void
+  getSelection(): ReaderSelection | null
+}
+```
+
+`ReaderAnchor` is `{ documentId, page?, blockId?, startOffset?, endOffset? }`,
+and `ReaderSelection` is the same shape plus the selected `text`. PDF is the full
+implementation in v1.4 (pages, text layer, block overlay); every other format uses
+a text reader. Nothing downstream may branch on which one is mounted — #72
+(citation click) and #73 (excerpt to note) only use `openAt` / `getSelection`.
+
+Rules:
+
+- **Bytes are served by id, never by path.** `knownote-doc://docs/` is
+  resolved against `documents.localFilePath` in the main process. The renderer
+  never constructs a filesystem path, and an unknown id is a 404.
+- **Blocks travel with the source.** `document_blocks` holds page, char span and
+  normalized bbox; `openAt({ page, blockId })` uses them to scroll and highlight,
+  so the reader does not re-derive geometry from chunk text.
+- **The text layer is real text.** pdfjs's `TextLayer` is rendered over the canvas
+  so `getSelection()` can map a DOM selection back to a source location; selecting
+  the canvas would give nothing.
diff --git a/src/main/index.ts b/src/main/index.ts
index e3ffe3f..3851a05 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -21,8 +21,16 @@ import { getStore } from './config/store'
 import { migrateProvidersToConnections } from './config/connectionMigration'
 import { isSmokeTestRequested, runSmokeTest } from './smokeTest'
 import { isEvalRequested, runEvalCli } from './eval/run'
+import {
+  registerDocumentProtocolHandler,
+  registerDocumentScheme
+} from './protocol/documentProtocol'
 import Logger from '../shared/utils/logger'
 
+// Scheme privileges must be registered before the app is ready, so this is a
+// module-scope call rather than part of the whenReady sequence.
+registerDocumentScheme()
+
 let connectionManager: ConnectionManager | null = null
 let embeddingService: EmbeddingService | null = null
 let sessionAutoSwitchService: SessionAutoSwitchService | null = null
@@ -69,6 +77,7 @@ app.whenReady().then(async () => {
   initDatabase()
   runMigrations()
   initVectorStore()
+  registerDocumentProtocolHandler()
   Logger.info('Main', 'Database initialized')
 
   // Initialize electron-store
diff --git a/src/main/ipc/knowledgeHandlers.ts b/src/main/ipc/knowledgeHandlers.ts
index beb9ada..38ce8a8 100644
--- a/src/main/ipc/knowledgeHandlers.ts
+++ b/src/main/ipc/knowledgeHandlers.ts
@@ -198,6 +198,21 @@ export function registerKnowledgeHandlers(knowledgeService: KnowledgeService) {
     })
   )
 
+  // 获取文档的结构块(#71)。Reader 需要页码与 bbox 才能把引用落点高亮。
+  ipcMain.handle(
+    'knowledge:get-document-blocks',
+    validate(KnowledgeSchemas.getDocumentBlocks, async (params) => {
+      Logger.debug('KnowledgeHandlers', 'get-document-blocks:', params.documentId)
+
+      try {
+        return knowledgeService.getDocumentBlocks(params.documentId)
+      } catch (error) {
+        Logger.error('KnowledgeHandlers', 'Error getting document blocks:', error)
+        return []
+      }
+    })
+  )
+
   // 删除文档
   ipcMain.handle(
     'knowledge:delete-document',
diff --git a/src/main/ipc/validation.ts b/src/main/ipc/validation.ts
index c83f203..8c1ea84 100644
--- a/src/main/ipc/validation.ts
+++ b/src/main/ipc/validation.ts
@@ -166,6 +166,10 @@ export const KnowledgeSchemas = {
     documentId: z.string().min(1, '文档 ID 不能为空')
   }),
 
+  getDocumentBlocks: z.object({
+    documentId: z.string().min(1, '文档 ID 不能为空')
+  }),
+
   deleteDocument: z.object({
     documentId: z.string().min(1, '文档 ID 不能为空')
   }),
diff --git a/src/main/protocol/documentProtocol.ts b/src/main/protocol/documentProtocol.ts
new file mode 100644
index 0000000..30ba360
--- /dev/null
+++ b/src/main/protocol/documentProtocol.ts
@@ -0,0 +1,74 @@
+/**
+ * `knownote-doc://` 协议(#71)
+ *
+ * 渲染进程不应拿到任意文件路径。它只知道 documentId,主进程按 id 查库、只服务
+ * 这份来源自己的文件。路径永远来自数据库,不来自请求,因此不存在路径穿越。
+ *
+ * scheme 必须在 app ready 之前注册为 privileged;handler 在 ready 之后安装。
+ */
+
+import { net, protocol } from 'electron'
+import { pathToFileURL } from 'url'
+import { eq } from 'drizzle-orm'
+import { getDatabase } from '../db'
+import { documents } from '../db/schema'
+import { DOCUMENT_SCHEME, parseDocumentUrl } from '../../shared/utils/documentUrl'
+import Logger from '../../shared/utils/logger'
+
+/**
+ * Origins allowed to read a document. The renderer is `file://` in production
+ * (serialised as `null`) or the electron-vite dev server in development. A
+ * wildcard would let any origin that guesses a document id read the file, so the
+ * request's own origin is reflected only when it is one of these.
+ */
+const ALLOWED_ORIGINS = new Set(['null', 'http://localhost:5173', 'http://127.0.0.1:5173'])
+
+export { DOCUMENT_SCHEME, documentUrl, parseDocumentUrl } from '../../shared/utils/documentUrl'
+
+/** 必须在 app ready 之前调用一次。 */
+export function registerDocumentScheme(): void {
+  protocol.registerSchemesAsPrivileged([
+    {
+      scheme: DOCUMENT_SCHEME,
+      privileges: {
+        standard: true,
+        secure: true,
+        supportFetchAPI: true,
+        stream: true,
+        // The renderer fetches this scheme from a different origin (`file://` in
+        // production, the dev server in development), so it is a cross-origin
+        // request and must be CORS-enabled to succeed.
+        corsEnabled: true
+      }
+    }
+  ])
+}
+
+/** 在 app ready 之后调用,安装只读的字节服务 handler。 */
+export function registerDocumentProtocolHandler(): void {
+  protocol.handle(DOCUMENT_SCHEME, async (request) => {
+    const documentId = parseDocumentUrl(request.url)
+    if (!documentId) return new Response('Not found', { status: 404 })
+
+    const document = getDatabase()
+      .select({ localFilePath: documents.localFilePath })
+      .from(documents)
+      .where(eq(documents.id, documentId))
+      .get()
+
+    if (!document?.localFilePath) return new Response('Not found', { status: 404 })
+
+    try {
+      // net.fetch handles file:// in the main process and streams the response, so
+      // a large PDF is not read into memory to be served.
+      const response = await net.fetch(pathToFileURL(document.localFilePath).toString())
+      const headers = new Headers(response.headers)
+      const origin = request.headers.get('Origin') ?? 'null'
+      if (ALLOWED_ORIGINS.has(origin)) headers.set('Access-Control-Allow-Origin', origin)
+      return new Response(response.body, { status: response.status, headers })
+    } catch (error) {
+      Logger.warn('DocumentProtocol', `Failed to serve ${documentId}:`, error)
+      return new Response('Not found', { status: 404 })
+    }
+  })
+}
diff --git a/src/main/smokeTest.ts b/src/main/smokeTest.ts
index 8434f40..aaf67d9 100644
--- a/src/main/smokeTest.ts
+++ b/src/main/smokeTest.ts
@@ -24,7 +24,10 @@
 
 import Logger from '../shared/utils/logger'
 import { readFile } from 'fs/promises'
+import { mkdtempSync, rmSync, writeFileSync } from 'fs'
+import { tmpdir } from 'os'
 import { join } from 'path'
+import { net } from 'electron'
 import { eq } from 'drizzle-orm'
 import {
   closeDatabase,
@@ -48,6 +51,8 @@ import { ChunkingService } from './services/ChunkingService'
 import { insertChunkBlocks, resolveChunkProvenance } from './services/chunkProvenance'
 import { KnowledgeService } from './services/KnowledgeService'
 import { DenseRetriever } from './services/retrieval'
+import { documentUrl } from '../shared/utils/documentUrl'
+import { registerDocumentProtocolHandler } from './protocol/documentProtocol'
 import type { EmbeddingService } from './services/EmbeddingService'
 
 export const SMOKE_TEST_FLAG = '--smoke-test'
@@ -215,6 +220,44 @@ async function runChecks(): Promise {
   )
   pass('legacy global vec_embeddings migrates into a searchable per-notebook table')
 
+  // --- document protocol (#71): bytes by id, never by path -------------------
+  // The renderer asks for `knownote-doc://docs/`; only a document that the
+  // database actually owns is served. This runs in the real packaged main
+  // process, which is the only place `protocol.handle` exists.
+  const protocolDir = mkdtempSync(join(tmpdir(), 'knownote-smoke-doc-'))
+  const protocolFilePath = join(protocolDir, 'payload.pdf')
+  const protocolPayload = 'knownote-protocol-payload'
+  const protocolDocumentId = 'smoke-protocol-doc'
+  writeFileSync(protocolFilePath, protocolPayload)
+
+  database
+    .prepare(
+      'INSERT INTO documents (id, notebook_id, title, type, content, local_file_path, status, chunk_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
+    )
+    .run(
+      protocolDocumentId,
+      LEGACY_NOTEBOOK,
+      'Protocol smoke document',
+      'file',
+      protocolPayload,
+      protocolFilePath,
+      'indexed',
+      0,
+      Date.now(),
+      Date.now()
+    )
+
+  registerDocumentProtocolHandler()
+  const served = await net.fetch(documentUrl(protocolDocumentId))
+  assert(served.ok, `an owned document did not serve (status ${served.status})`)
+  assert((await served.text()) === protocolPayload, 'the document protocol served the wrong bytes')
+  const unknown = await net.fetch(documentUrl('smoke-missing-doc'))
+  assert(unknown.status === 404, `an unknown document id returned ${unknown.status}, not 404`)
+
+  database.prepare('DELETE FROM documents WHERE id = ?').run(protocolDocumentId)
+  rmSync(protocolDir, { recursive: true, force: true })
+  pass('knownote-doc:// serves an owned document and 404s an unknown id')
+
   const { version } = database.prepare('SELECT vec_version() AS version').get() as {
     version: string
   }
diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts
index 8d021d0..1ec3d26 100644
--- a/src/preload/index.d.ts
+++ b/src/preload/index.d.ts
@@ -10,7 +10,8 @@ import type {
   ModelConnection,
   LocalEmbeddingModelInfo,
   EmbeddingDownloadProgress,
-  EmbeddingSourceInfo
+  EmbeddingSourceInfo,
+  SourceBlock
 } from '../shared/types'
 import type {
   KnowledgeDocument,
@@ -282,6 +283,7 @@ declare global {
         getDocuments: (notebookId: string) => Promise
         getDocument: (documentId: string) => Promise
         getDocumentChunks: (documentId: string) => Promise
+        getDocumentBlocks: (documentId: string) => Promise
         deleteDocument: (documentId: string) => Promise<{ success: boolean; error?: string }>
         reindexDocument: (documentId: string) => Promise<{ success: boolean; error?: string }>
 
diff --git a/src/preload/index.ts b/src/preload/index.ts
index c33e93a..1c9d37d 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -179,6 +179,8 @@ const api = {
       ipcRenderer.invoke('knowledge:get-document', { documentId }),
     getDocumentChunks: (documentId: string) =>
       ipcRenderer.invoke('knowledge:get-document-chunks', { documentId }),
+    getDocumentBlocks: (documentId: string) =>
+      ipcRenderer.invoke('knowledge:get-document-blocks', { documentId }),
     deleteDocument: (documentId: string) =>
       ipcRenderer.invoke('knowledge:delete-document', { documentId }),
     reindexDocument: (documentId: string) =>
diff --git a/src/renderer/index.html b/src/renderer/index.html
index 4be99b6..eb454c8 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -6,7 +6,7 @@
     
     
     
     
diff --git a/src/renderer/src/components/notebook/SourcePanel.tsx b/src/renderer/src/components/notebook/SourcePanel.tsx
index 4ab2c91..19be6f3 100644
--- a/src/renderer/src/components/notebook/SourcePanel.tsx
+++ b/src/renderer/src/components/notebook/SourcePanel.tsx
@@ -14,6 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
 import { Card } from '../ui/card'
 import { PanelHeader } from '../ui/panel-header'
 import DocumentList from './source/DocumentList'
+import SourceReader from './source/reader/SourceReader'
 import type { KnowledgeDocument } from '../../../../shared/types/knowledge'
 
 // 添加来源类型
@@ -168,27 +169,6 @@ interface DocumentViewerPanelProps {
 
 function DocumentViewerPanel({ document, onBack }: DocumentViewerPanelProps) {
   const { t } = useTranslation('ui')
-  const [content, setContent] = useState('')
-  const [isLoading, setIsLoading] = useState(false)
-
-  // 加载文档内容
-  useEffect(() => {
-    const loadContent = async () => {
-      setIsLoading(true)
-      try {
-        const chunks = await window.api.knowledge.getDocumentChunks(document.id)
-        // 合并所有 chunk 的内容
-        const fullContent = chunks.map((chunk) => chunk.content).join('\n\n')
-        setContent(fullContent)
-      } catch (error) {
-        console.error('Error loading document content:', error)
-      } finally {
-        setIsLoading(false)
-      }
-    }
-
-    loadContent()
-  }, [document.id])
 
   return (
     <>
@@ -209,20 +189,10 @@ function DocumentViewerPanel({ document, onBack }: DocumentViewerPanelProps) {
         center={{document.title}}
       />
 
-      {/* 文档内容 */}
-      
-        {isLoading ? (
-          
- -
- ) : ( -
-
-
{content}
-
-
- )} -
+ {/* 来源阅读器(#71):PDF 渲染原页,其余格式走文本回退,两者同一契约。 */} +
+ +
) } diff --git a/src/renderer/src/components/notebook/source/reader/PdfSourceReader.tsx b/src/renderer/src/components/notebook/source/reader/PdfSourceReader.tsx new file mode 100644 index 0000000..77204ac --- /dev/null +++ b/src/renderer/src/components/notebook/source/reader/PdfSourceReader.tsx @@ -0,0 +1,372 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type CSSProperties, + type ReactElement +} from 'react' +import * as pdfjs from 'pdfjs-dist' +import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist' +import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url' +import { useTranslation } from 'react-i18next' +import { Minus, Plus, Loader2 } from 'lucide-react' +import { documentUrl } from '../../../../../../shared/utils/documentUrl' +import type { + ReaderAnchor, + ReaderHandle, + ReaderSelection, + SourceBlock +} from '../../../../../../shared/types/source' +import { Button } from '../../../ui/button' +import { blockById, blockRect, resolveSelection, type NormalizedPoint } from './anchor' +import './reader.css' + +// The worker is emitted as its own asset by Vite; pointing pdfjs at it keeps the +// whole thing offline and same-origin, which the renderer CSP allows. +pdfjs.GlobalWorkerOptions.workerSrc = workerUrl + +const MIN_SCALE = 0.5 +const MAX_SCALE = 3 + +interface PdfSourceReaderProps { + documentId: string + blocks: SourceBlock[] + anchor?: ReaderAnchor | null + onSelectionChange?: (selection: ReaderSelection | null) => void +} + +interface PdfPageProps { + pdf: PDFDocumentProxy + pageNumber: number + scale: number + blocks: SourceBlock[] + highlightBlockId: string | null + registerPage: (pageNumber: number, element: HTMLDivElement | null) => void +} + +function PdfPage({ + pdf, + pageNumber, + scale, + blocks, + highlightBlockId, + registerPage +}: PdfPageProps): ReactElement { + const canvasRef = useRef(null) + const textLayerRef = useRef(null) + const [size, setSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 }) + + useEffect(() => { + let cancelled = false + let task: RenderTask | null = null + + const render = async (): Promise => { + const page = await pdf.getPage(pageNumber) + if (cancelled) return + + const viewport = page.getViewport({ scale }) + const canvas = canvasRef.current + if (!canvas) return + + const outputScale = window.devicePixelRatio || 1 + canvas.width = Math.floor(viewport.width * outputScale) + canvas.height = Math.floor(viewport.height * outputScale) + canvas.style.width = `${Math.floor(viewport.width)}px` + canvas.style.height = `${Math.floor(viewport.height)}px` + setSize({ width: viewport.width, height: viewport.height }) + + task = page.render({ + canvas, + viewport, + transform: outputScale === 1 ? undefined : [outputScale, 0, 0, outputScale, 0, 0] + }) + await task.promise + if (cancelled) return + + const textLayerDiv = textLayerRef.current + if (textLayerDiv) { + textLayerDiv.replaceChildren() + const textLayer = new pdfjs.TextLayer({ + textContentSource: await page.getTextContent(), + container: textLayerDiv, + viewport + }) + await textLayer.render() + } + } + + render().catch((error) => { + if (!cancelled) console.error('Failed to render PDF page', pageNumber, error) + }) + + return () => { + cancelled = true + task?.cancel() + } + }, [pdf, pageNumber, scale]) + + const highlight = blockById(blocks, highlightBlockId) + const rect = + highlight && (highlight.page === null || highlight.page === pageNumber) && size.width > 0 + ? blockRect(highlight, size.width, size.height) + : null + + return ( +
{ + registerPage(pageNumber, element) + return undefined + }} + data-page-number={pageNumber} + className="kn-reader__page" + style={{ width: size.width || undefined, height: size.height || undefined } as CSSProperties} + > + +
+
+ {rect && ( +
+ )} +
+
+ ) +} + +/** + * PDF reader(#71):连续分页 + 文本层(可选中)+ 高亮层。 + * + * 字节通过 `knownote-doc://` 协议取,渲染进程不接触文件路径。文本层由 pdfjs 的 + * `TextLayer` 生成,高亮层按 `document_blocks.bbox` 归一化坐标绘制。 + */ +const PdfSourceReader = forwardRef(function PdfSourceReader( + { documentId, blocks, anchor, onSelectionChange }, + ref +): ReactElement { + const { t } = useTranslation('ui') + const scrollRef = useRef(null) + const pageElements = useRef(new Map()) + const lastSelection = useRef(null) + + const [pdf, setPdf] = useState(null) + const [numPages, setNumPages] = useState(0) + const [scale, setScale] = useState(1) + const [currentPage, setCurrentPage] = useState(1) + const [highlightBlockId, setHighlightBlockId] = useState(null) + const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading') + + const registerPage = useCallback((pageNumber: number, element: HTMLDivElement | null) => { + if (element) pageElements.current.set(pageNumber, element) + else pageElements.current.delete(pageNumber) + }, []) + + const applyAnchor = useCallback( + (target: ReaderAnchor): void => { + const block = blockById(blocks, target.blockId) + const page = target.page ?? block?.page ?? null + + if (page !== null) { + setCurrentPage(page) + pageElements.current.get(page)?.scrollIntoView({ block: 'start', behavior: 'auto' }) + } + if (target.blockId) setHighlightBlockId(target.blockId) + }, + [blocks] + ) + + useEffect(() => { + let cancelled = false + let loaded: PDFDocumentProxy | null = null + + const load = async (): Promise => { + const response = await fetch(documentUrl(documentId)) + if (!response.ok) throw new Error(`Document request failed: ${response.status}`) + const data = new Uint8Array(await response.arrayBuffer()) + const doc = await pdfjs.getDocument({ data }).promise + if (cancelled) { + await doc.destroy() + return + } + loaded = doc + setPdf(doc) + setNumPages(doc.numPages) + setStatus('ready') + } + + load().catch((error) => { + if (!cancelled) { + console.error('Failed to open PDF', error) + setStatus('error') + } + }) + + return () => { + cancelled = true + void loaded?.destroy() + } + }, [documentId]) + + useImperativeHandle( + ref, + () => ({ + openAt(target: ReaderAnchor): void { + applyAnchor(target) + }, + getSelection(): ReaderSelection | null { + return lastSelection.current + } + }), + [applyAnchor] + ) + + // An anchor can arrive before the pages have rendered (a citation click that + // mounts the reader). rAF defers the scroll until layout exists, instead of + // setting state synchronously inside the effect body. + useEffect(() => { + if (!anchor || status !== 'ready') return + const frame = requestAnimationFrame(() => applyAnchor(anchor)) + return () => cancelAnimationFrame(frame) + }, [anchor, status, numPages, applyAnchor]) + + const handleSelection = useCallback((): void => { + const selection = window.getSelection() + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + lastSelection.current = null + onSelectionChange?.(null) + return + } + const range = selection.getRangeAt(0) + if (!scrollRef.current?.contains(range.commonAncestorContainer)) { + lastSelection.current = null + onSelectionChange?.(null) + return + } + + const startNode = + range.startContainer instanceof Element + ? range.startContainer + : range.startContainer.parentElement + const pageElement = startNode?.closest('[data-page-number]') as HTMLElement | null + const pageNumber = pageElement ? Number(pageElement.dataset.pageNumber) : null + + let point: { page: number | null; normalized: NormalizedPoint } | null = null + const pageRect = pageElement?.getBoundingClientRect() + const selectionRect = range.getBoundingClientRect() + if (pageRect && pageRect.width > 0 && pageRect.height > 0) { + point = { + page: pageNumber, + normalized: { + x: (selectionRect.left - pageRect.left) / pageRect.width, + y: (selectionRect.top - pageRect.top) / pageRect.height + } + } + } + + const resolved = resolveSelection(documentId, selection.toString(), blocks, point, null) + lastSelection.current = resolved + onSelectionChange?.(resolved) + }, [blocks, documentId, onSelectionChange]) + + const handleScroll = useCallback((): void => { + const container = scrollRef.current + if (!container) return + const top = container.getBoundingClientRect().top + let nearest = 1 + let nearestDistance = Number.POSITIVE_INFINITY + for (const [pageNumber, element] of pageElements.current) { + const distance = Math.abs(element.getBoundingClientRect().top - top) + if (distance < nearestDistance) { + nearest = pageNumber + nearestDistance = distance + } + } + setCurrentPage(nearest) + }, []) + + const zoom = useCallback((factor: number) => { + setScale((value) => + Math.min(MAX_SCALE, Math.max(MIN_SCALE, Number((value * factor).toFixed(2)))) + ) + }, []) + + const pages = useMemo(() => Array.from({ length: numPages }, (_, index) => index + 1), [numPages]) + + return ( +
+
+ {t('readerPage', { current: currentPage, total: numPages })} +
+ + {Math.round(scale * 100)}% + +
+
+ + {status === 'error' ? ( +
+ {t('readerPdfUnavailable')} +
+ ) : ( +
+ {status === 'loading' && ( +
+ +
+ )} +
+ {pdf && + pages.map((pageNumber) => ( + + ))} +
+
+ )} +
+ ) +}) + +export default PdfSourceReader diff --git a/src/renderer/src/components/notebook/source/reader/SourceReader.tsx b/src/renderer/src/components/notebook/source/reader/SourceReader.tsx new file mode 100644 index 0000000..79b693b --- /dev/null +++ b/src/renderer/src/components/notebook/source/reader/SourceReader.tsx @@ -0,0 +1,84 @@ +import { forwardRef, useEffect, useState, type ReactElement } from 'react' +import { Loader2 } from 'lucide-react' +import type { KnowledgeDocument } from '../../../../../../shared/types/knowledge' +import type { + ReaderAnchor, + ReaderHandle, + ReaderSelection, + SourceBlock +} from '../../../../../../shared/types/source' +import PdfSourceReader from './PdfSourceReader' +import TextSourceReader from './TextSourceReader' + +interface SourceReaderProps { + document: KnowledgeDocument + anchor?: ReaderAnchor | null + onSelectionChange?: (selection: ReaderSelection | null) => void +} + +/** A stored PDF is the format with pages; everything else uses the text reader. */ +function isPdf(document: KnowledgeDocument): boolean { + if (document.mimeType === 'application/pdf') return true + const path = document.localFilePath ?? document.sourceUri ?? '' + return path.toLowerCase().endsWith('.pdf') +} + +/** + * 阅读器入口(#71)。选 PDF 还是文本实现是这里唯一的分支;两者对下游暴露同一个 + * `ReaderHandle`。结构块(页码 / bbox)在一次 IPC 里取回后传给实现。 + */ +const SourceReader = forwardRef(function SourceReader( + { document, anchor, onSelectionChange }, + ref +): ReactElement { + const [blocks, setBlocks] = useState(null) + + useEffect(() => { + let cancelled = false + window.api.knowledge + .getDocumentBlocks(document.id) + .then((loaded) => { + if (!cancelled) setBlocks(loaded) + }) + .catch((error) => { + console.error('Failed to load document blocks', error) + if (!cancelled) setBlocks([]) + }) + return () => { + cancelled = true + } + }, [document.id]) + + if (blocks === null) { + return ( +
+ +
+ ) + } + + // `key` remounts the reader when the document changes, so per-document state + // (loaded bytes, zoom, highlight) never leaks across sources. + return isPdf(document) ? ( + + ) : ( + + ) +}) + +export default SourceReader diff --git a/src/renderer/src/components/notebook/source/reader/TextSourceReader.tsx b/src/renderer/src/components/notebook/source/reader/TextSourceReader.tsx new file mode 100644 index 0000000..b0e9a50 --- /dev/null +++ b/src/renderer/src/components/notebook/source/reader/TextSourceReader.tsx @@ -0,0 +1,151 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, + type CSSProperties, + type ReactElement +} from 'react' +import type { + ReaderAnchor, + ReaderHandle, + ReaderSelection, + SourceBlock +} from '../../../../../../shared/types/source' +import { blockById, resolveSelection } from './anchor' +import './reader.css' + +interface TextSourceReaderProps { + documentId: string + content: string + blocks: SourceBlock[] + anchor?: ReaderAnchor | null + onSelectionChange?: (selection: ReaderSelection | null) => void +} + +interface HighlightRect { + left: number + top: number + width: number + height: number +} + +/** + * 非 PDF 来源的文本阅读器(#71)。与 PDF reader 实现同一 `openAt` / `getSelection` + * 契约:下游不需要知道挂载的是哪一种实现。按字符偏移滚动并用 `Range` 画高亮, + * 不修改正文 DOM。 + */ +const TextSourceReader = forwardRef(function TextSourceReader( + { documentId, content, blocks, anchor, onSelectionChange }, + ref +): ReactElement { + const scrollRef = useRef(null) + const contentRef = useRef(null) + const lastSelection = useRef(null) + const [highlightRects, setHighlightRects] = useState([]) + + const applyAnchor = useCallback( + (target: ReaderAnchor): void => { + const block = blockById(blocks, target.blockId) + const start = target.startOffset ?? block?.startOffset ?? null + const end = target.endOffset ?? block?.endOffset ?? null + + const node = contentRef.current?.firstChild + const container = scrollRef.current + if (!node || start === null || !container) return + + const limit = node.textContent?.length ?? 0 + const range = window.document.createRange() + range.setStart(node, Math.max(0, Math.min(start, limit))) + range.setEnd(node, Math.max(0, Math.min(end ?? start, limit))) + + const containerRect = container.getBoundingClientRect() + const rects = Array.from(range.getClientRects()).map((rect) => ({ + left: rect.left - containerRect.left + container.scrollLeft, + top: rect.top - containerRect.top + container.scrollTop, + width: rect.width, + height: rect.height + })) + setHighlightRects(rects) + + const first = rects[0] + if (first) container.scrollTo({ top: Math.max(0, first.top - 80), behavior: 'auto' }) + }, + [blocks] + ) + + useImperativeHandle( + ref, + () => ({ + openAt(target: ReaderAnchor): void { + applyAnchor(target) + }, + getSelection(): ReaderSelection | null { + return lastSelection.current + } + }), + [applyAnchor] + ) + + useEffect(() => { + if (!anchor) return + const frame = requestAnimationFrame(() => applyAnchor(anchor)) + return () => cancelAnimationFrame(frame) + }, [anchor, blocks, applyAnchor]) + + const handleSelection = useCallback((): void => { + const selection = window.getSelection() + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + lastSelection.current = null + onSelectionChange?.(null) + return + } + const range = selection.getRangeAt(0) + const node = contentRef.current?.firstChild + if (!node || !contentRef.current?.contains(range.commonAncestorContainer)) { + lastSelection.current = null + onSelectionChange?.(null) + return + } + + // The body is a single text node, so a range offset is already an absolute + // document offset — no approximation needed here. + const offset = range.startContainer === node ? range.startOffset : null + const resolved = resolveSelection(documentId, selection.toString(), blocks, null, offset) + lastSelection.current = resolved + onSelectionChange?.(resolved) + }, [blocks, documentId, onSelectionChange]) + + return ( +
+
+ {highlightRects.map((rect, index) => ( +
+ ))} +
+
+ {content} +
+
+ ) +}) + +export default TextSourceReader diff --git a/src/renderer/src/components/notebook/source/reader/anchor.ts b/src/renderer/src/components/notebook/source/reader/anchor.ts new file mode 100644 index 0000000..661b2c4 --- /dev/null +++ b/src/renderer/src/components/notebook/source/reader/anchor.ts @@ -0,0 +1,110 @@ +import type { ReaderSelection, SourceBlock } from '../../../../../../shared/types/source' + +/** + * Reader 定位的纯计算(#71) + * + * 把「阅读器里的一次选择 / 一个点」映射成来源里的位置。拆出来是为了能在没有浏览器 + * 的环境里测:DOM 里只有 rect 和 text,映射规则本身不该藏在组件里。 + */ + +/** 归一化页面坐标(0-1,左上原点)里的一个点。 */ +export interface NormalizedPoint { + x: number + y: number +} + +/** 按字符偏移找包含它的块。 */ +export function blockContainingOffset( + blocks: readonly SourceBlock[], + offset: number +): SourceBlock | null { + return blocks.find((block) => offset >= block.startOffset && offset <= block.endOffset) ?? null +} + +/** + * 按归一化坐标找块。`page` 用于排除其它页;命中多个时取面积最小的(最具体)。 + */ +export function blockAtPoint( + blocks: readonly SourceBlock[], + page: number | null, + point: NormalizedPoint +): SourceBlock | null { + let best: SourceBlock | null = null + let bestArea = Number.POSITIVE_INFINITY + + for (const block of blocks) { + if (page !== null && block.page !== null && block.page !== page) continue + const bbox = block.bbox + if (!bbox) continue + if (point.x < bbox.x || point.x > bbox.x + bbox.w) continue + if (point.y < bbox.y || point.y > bbox.y + bbox.h) continue + + const area = bbox.w * bbox.h + if (area < bestArea) { + best = block + bestArea = area + } + } + + return best +} + +export function blockById( + blocks: readonly SourceBlock[], + blockId: string | null | undefined +): SourceBlock | null { + if (!blockId) return null + return blocks.find((block) => block.id === blockId) ?? null +} + +/** + * 把一次选区映射成 `ReaderSelection`。 + * + * 字符区间在能定位到块时给出;定位不到就省略,而不是编一个看似精确的偏移。 + * 返回 null 表示这次选择没有可用的定位信息(空白、跨出阅读器)。 + */ +export function resolveSelection( + documentId: string, + text: string, + blocks: readonly SourceBlock[], + point: { page: number | null; normalized: NormalizedPoint } | null, + fallbackOffset: number | null +): ReaderSelection | null { + if (text.trim().length === 0) return null + + const block = + (point ? blockAtPoint(blocks, point.page, point.normalized) : null) ?? + (fallbackOffset !== null ? blockContainingOffset(blocks, fallbackOffset) : null) + + if (!block) { + return { documentId, page: point?.page ?? null, text } + } + + const localStart = block.text.indexOf(text) + const startOffset = localStart >= 0 ? block.startOffset + localStart : null + const endOffset = startOffset !== null ? startOffset + text.length : null + + return { + documentId, + page: block.page ?? point?.page ?? null, + blockId: block.id, + startOffset, + endOffset, + text + } +} + +/** 一个块在页面上的像素矩形(由归一化 bbox 换算)。 */ +export function blockRect( + block: SourceBlock, + pageWidth: number, + pageHeight: number +): { left: number; top: number; width: number; height: number } | null { + if (!block.bbox) return null + return { + left: block.bbox.x * pageWidth, + top: block.bbox.y * pageHeight, + width: block.bbox.w * pageWidth, + height: block.bbox.h * pageHeight + } +} diff --git a/src/renderer/src/components/notebook/source/reader/reader.css b/src/renderer/src/components/notebook/source/reader/reader.css new file mode 100644 index 0000000..cf701a9 --- /dev/null +++ b/src/renderer/src/components/notebook/source/reader/reader.css @@ -0,0 +1,88 @@ +/* Source reader surface (#71). The `.textLayer` rules mirror pdfjs-dist's viewer + stylesheet (web/pdf_viewer.css) for the subset the reader uses; the page wrapper + sets `--total-scale-factor`, which is what pdfjs sizes text runs against. */ + +.kn-reader { + position: relative; + height: 100%; + overflow: auto; + background: var(--muted); +} + +.kn-reader__pages { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 8px 32px; + gap: 16px; +} + +.kn-reader__page { + position: relative; + background: #fff; + box-shadow: 0 1px 3px rgb(0 0 0 / 0.18); + flex: none; +} + +.dark .kn-reader__page { + background: #fff; +} + +.kn-reader__canvas { + display: block; +} + +.kn-reader .textLayer { + position: absolute; + inset: 0; + overflow: clip; + line-height: 1; + text-align: initial; + text-size-adjust: none; + transform-origin: 0 0; + caret-color: CanvasText; + z-index: 2; +} + +.kn-reader .textLayer :is(span, br) { + position: absolute; + color: transparent; + white-space: pre; + cursor: text; + transform-origin: 0% 0%; +} + +.kn-reader .textLayer ::selection { + background: rgb(0 0 255 / 0.25); +} + +.kn-reader__overlay { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 3; +} + +.kn-reader__highlight { + position: absolute; + background: rgb(250 204 21 / 0.3); + outline: 1px solid rgb(202 138 4 / 0.7); + border-radius: 0.375rem; +} + +.kn-reader__text { + padding: 24px 28px; + white-space: pre-wrap; + font-size: 14px; + line-height: 1.7; + user-select: text; +} + +.kn-reader__fallback { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + padding: 24px; + text-align: center; +} diff --git a/src/renderer/src/locales/en-US/ui.json b/src/renderer/src/locales/en-US/ui.json index 3990700..e0f9cd9 100644 --- a/src/renderer/src/locales/en-US/ui.json +++ b/src/renderer/src/locales/en-US/ui.json @@ -58,6 +58,10 @@ "goToSettings": "Go to Settings", "emptyNoteCannotImport": "Note content is empty and cannot be imported to knowledge base", "backToList": "Back to List", + "readerPage": "Page {{current}} / {{total}}", + "readerZoomIn": "Zoom in", + "readerZoomOut": "Zoom out", + "readerPdfUnavailable": "This PDF could not be opened. The original file may be missing.", "openUrl": "Open URL", "cannotPreview": "Cannot preview this file type", "onboardingTagline": "Research your documents. Trust every answer.", diff --git a/src/renderer/src/locales/zh-CN/ui.json b/src/renderer/src/locales/zh-CN/ui.json index 7461ed6..fbe32c1 100644 --- a/src/renderer/src/locales/zh-CN/ui.json +++ b/src/renderer/src/locales/zh-CN/ui.json @@ -57,6 +57,10 @@ "goToSettings": "前往设置", "emptyNoteCannotImport": "笔记内容为空,无法导入到知识库", "backToList": "返回列表", + "readerPage": "第 {{current}} / {{total}} 页", + "readerZoomIn": "放大", + "readerZoomOut": "缩小", + "readerPdfUnavailable": "无法打开此 PDF,原始文件可能已丢失。", "openUrl": "打开网页", "openFile": "打开文件", "cannotPreview": "无法预览此文件类型", diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 0f7a1c2..e818dcf 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -9,6 +9,9 @@ export * from './knowledge' // 导出聊天类型 export * from './chat' +// 导出来源阅读器契约类型 +export * from './source' + // 导出 Result 错误处理类型 export * from './result' diff --git a/src/shared/types/source.ts b/src/shared/types/source.ts new file mode 100644 index 0000000..abed543 --- /dev/null +++ b/src/shared/types/source.ts @@ -0,0 +1,47 @@ +/** + * 来源阅读器契约(#71) + * + * Reader 不是「PDF 组件」,而是「渲染来源自身结构」的界面。PDF 在 v1.4 是完整实现 + * (有页码、有版面),其余格式走文本回退。下游(#72 引用跳转、#73 摘录到笔记)只认 + * `openAt` / `getSelection`,不关心当前挂载的是哪一种实现。 + */ + +/** 一个来源里的可定位块(`document_blocks` 的读取投影)。 */ +export interface SourceBlock { + id: string + kind: string + /** 文档内阅读顺序。 */ + order: number + /** 1 起始页码;非分页格式为 null。 */ + page: number | null + /** 标题层级;非标题块为 null。 */ + level: number | null + text: string + /** 相对 `documents.content` 的字符偏移。 */ + startOffset: number + endOffset: number + /** 归一化页面坐标(分页格式),供高亮使用。 */ + bbox: { x: number; y: number; w: number; h: number } | null +} + +/** 阅读器的定位目标:页 / 块 / 字符区间,三者都可选。 */ +export interface ReaderAnchor { + documentId: string + page?: number | null + blockId?: string | null + startOffset?: number | null + endOffset?: number | null +} + +/** 用户在阅读器里选中的一段文字。用于 #73 的摘录。 */ +export interface ReaderSelection extends ReaderAnchor { + text: string +} + +/** 任意 Reader 实现都要满足的命令式接口。 */ +export interface ReaderHandle { + /** 滚动/高亮到 anchor。页码、块或字符区间任一可用即可。 */ + openAt(anchor: ReaderAnchor): void + /** 当前选区,或 null。选区不在这个 reader 内时返回 null。 */ + getSelection(): ReaderSelection | null +} diff --git a/src/shared/utils/documentUrl.ts b/src/shared/utils/documentUrl.ts new file mode 100644 index 0000000..1489c84 --- /dev/null +++ b/src/shared/utils/documentUrl.ts @@ -0,0 +1,39 @@ +/** + * `knownote-doc://` URL 形状(#71) + * + * 主进程按这个形状解析 documentId、按 id 查库;渲染进程按它构造请求。两侧共用同一 + * 份实现,避免「解析端接受、构造端生成」两处漂移。 + */ + +export const DOCUMENT_SCHEME = 'knownote-doc' + +/** 渲染进程请求某个来源字节时使用的 URL。 */ +export function documentUrl(documentId: string): string { + return `${DOCUMENT_SCHEME}://docs/${encodeURIComponent(documentId)}` +} + +/** + * 只接受 `knownote-doc://docs/`。其余形状一律返回 null,调用方回 404。 + * + * WHATWG URL 把 `//docs` 解析为 host(不是 path 的一段),所以这里检查 hostname, + * 而不是把整条路径当字符串切。 + */ +export function parseDocumentUrl(url: string): string | null { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return null + } + if (parsed.protocol !== `${DOCUMENT_SCHEME}:`) return null + if (parsed.hostname !== 'docs') return null + + const match = /^\/([^/]+)$/.exec(parsed.pathname) + if (!match) return null + + try { + return decodeURIComponent(match[1]) + } catch { + return null + } +} diff --git a/test/sourceReader.test.ts b/test/sourceReader.test.ts new file mode 100644 index 0000000..3855d0e --- /dev/null +++ b/test/sourceReader.test.ts @@ -0,0 +1,117 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + blockAtPoint, + blockContainingOffset, + blockRect, + blockById, + resolveSelection +} from '../src/renderer/src/components/notebook/source/reader/anchor.ts' +import { DOCUMENT_SCHEME, documentUrl, parseDocumentUrl } from '../src/shared/utils/documentUrl.ts' +import type { SourceBlock } from '../src/shared/types/source.ts' + +/** + * The reader contract (#71) is what #72 (citation jump) and #73 (excerpt to note) + * are built on, so the mapping from a DOM selection to a source location is pinned + * here rather than left to a component that can only be checked by eye. + */ + +const block = (over: Partial = {}): SourceBlock => ({ + id: 'b1', + kind: 'paragraph', + order: 0, + page: 1, + level: null, + text: 'The quick brown fox', + startOffset: 100, + endOffset: 119, + bbox: { x: 0.1, y: 0.2, w: 0.5, h: 0.05 }, + ...over +}) + +test('documentUrl round-trips through parseDocumentUrl', () => { + const url = documentUrl('doc_123_abc') + assert.equal(url, `${DOCUMENT_SCHEME}://docs/doc_123_abc`) + assert.equal(parseDocumentUrl(url), 'doc_123_abc') +}) + +test('parseDocumentUrl rejects anything that is not a document URL', () => { + for (const url of [ + 'https://example.com/docs/x', + 'file:///etc/passwd', + `${DOCUMENT_SCHEME}://docs/`, + `${DOCUMENT_SCHEME}://docs/a/b`, + `${DOCUMENT_SCHEME}://`, + 'not a url' + ]) { + assert.equal(parseDocumentUrl(url), null, url) + } +}) + +test('blockContainingOffset finds the block whose span holds the offset', () => { + const blocks = [block(), block({ id: 'b2', startOffset: 200, endOffset: 210 })] + assert.equal(blockContainingOffset(blocks, 105)?.id, 'b1') + assert.equal(blockContainingOffset(blocks, 205)?.id, 'b2') + assert.equal(blockContainingOffset(blocks, 500), null) +}) + +test('blockAtPoint filters by page and picks the smallest containing block', () => { + const blocks = [ + block({ id: 'outer', bbox: { x: 0, y: 0, w: 1, h: 1 } }), + block({ id: 'inner', bbox: { x: 0.1, y: 0.2, w: 0.3, h: 0.03 } }), + block({ id: 'other-page', page: 2, bbox: { x: 0, y: 0, w: 1, h: 1 } }) + ] + + assert.equal(blockAtPoint(blocks, 1, { x: 0.2, y: 0.21 })?.id, 'inner') + assert.equal(blockAtPoint(blocks, 2, { x: 0.2, y: 0.21 })?.id, 'other-page') + assert.equal(blockAtPoint(blocks, 1, { x: 0.9, y: 0.9 })?.id, 'outer') + assert.equal(blockAtPoint(blocks, 3, { x: 0.2, y: 0.21 }), null) +}) + +test('resolveSelection returns a grounded anchor when the block is known', () => { + const selection = resolveSelection('doc_1', 'quick brown', [block()], null, 105) + + assert.equal(selection?.documentId, 'doc_1') + assert.equal(selection?.blockId, 'b1') + assert.equal(selection?.page, 1) + assert.equal(selection?.startOffset, 104) + assert.equal(selection?.endOffset, 115) +}) + +test('resolveSelection locates a block from a normalized point', () => { + const selection = resolveSelection( + 'doc_1', + 'quick brown', + [block()], + { page: 1, normalized: { x: 0.2, y: 0.21 } }, + null + ) + assert.equal(selection?.blockId, 'b1') +}) + +test('resolveSelection keeps text without inventing offsets when nothing matches', () => { + const selection = resolveSelection('doc_1', 'a phrase from nowhere', [], null, null) + assert.equal(selection?.text, 'a phrase from nowhere') + assert.equal(selection?.blockId, undefined) + assert.equal(selection?.startOffset, undefined) +}) + +test('resolveSelection ignores a blank selection', () => { + assert.equal(resolveSelection('doc_1', ' ', [block()], null, 100), null) +}) + +test('blockRect converts a normalized bbox into page pixels', () => { + assert.deepEqual(blockRect(block(), 500, 800), { + left: 50, + top: 160, + width: 250, + height: 40 + }) + assert.equal(blockRect(block({ bbox: null }), 500, 800), null) +}) + +test('blockById is null-safe', () => { + assert.equal(blockById([block()], 'b1')?.id, 'b1') + assert.equal(blockById([block()], null), null) + assert.equal(blockById([block()], 'missing'), null) +})