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) +})