From bff3a04f8dbb59a0bb4169715e1772dfef7960c6 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 3 Jun 2026 13:53:47 +0400 Subject: [PATCH] RAG preview: drop react-pdf renderer + pdf_regions, delete dead locators module --- studio/backend/core/rag/locators.py | 323 ---------- .../src/__tests__/preview-pdf-smoke.test.tsx | 385 ----------- .../rag/components/preview-pdf-view.tsx | 599 ------------------ 3 files changed, 1307 deletions(-) delete mode 100644 studio/backend/core/rag/locators.py delete mode 100644 studio/frontend/src/__tests__/preview-pdf-smoke.test.tsx delete mode 100644 studio/frontend/src/features/rag/components/preview-pdf-view.tsx diff --git a/studio/backend/core/rag/locators.py b/studio/backend/core/rag/locators.py deleted file mode 100644 index 2e2786b8e7..0000000000 --- a/studio/backend/core/rag/locators.py +++ /dev/null @@ -1,323 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - -"""Backfill and PDF-region helpers for durable RAG chunk locators.""" - -from __future__ import annotations - -import json -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from loggers import get_logger -from storage.studio_db import closing_connection - -from . import vector_store -from .parsers import ParsedPage, parse -from .vector_store import kb_scope, thread_scope - -logger = get_logger(__name__) - - -@dataclass(frozen = True) -class LocatorMatch: - page_index: int - page_number: int | None - start: int - end: int - line_start: int - line_end: int - - -def _line_bounds(text: str, start: int, end: int) -> tuple[int, int]: - line_start = text.count("\n", 0, start) + 1 - line_end = text.count("\n", 0, max(start, end - 1)) + 1 - return line_start, line_end - - -def _find_exact(page_text: str, needle: str) -> list[tuple[int, int]]: - if not needle: - return [] - out: list[tuple[int, int]] = [] - cursor = 0 - while True: - idx = page_text.find(needle, cursor) - if idx < 0: - break - out.append((idx, idx + len(needle))) - cursor = idx + 1 - return out - - -def _normalize_with_map(text: str) -> tuple[str, list[int], list[int]]: - chars: list[str] = [] - starts: list[int] = [] - ends: list[int] = [] - last_space = False - for idx, ch in enumerate(text): - if ch.isspace(): - if chars and not last_space: - chars.append(" ") - starts.append(idx) - ends.append(idx + 1) - elif chars and last_space: - ends[-1] = idx + 1 - last_space = True - continue - chars.append(ch.casefold()) - starts.append(idx) - ends.append(idx + 1) - last_space = False - - first = 0 - while first < len(chars) and chars[first] == " ": - first += 1 - last = len(chars) - while last > first and chars[last - 1] == " ": - last -= 1 - return "".join(chars[first:last]), starts[first:last], ends[first:last] - - -def _find_normalized(page_text: str, needle: str) -> list[tuple[int, int]]: - norm_page, starts, ends = _normalize_with_map(page_text) - norm_needle, _needle_starts, _needle_ends = _normalize_with_map(needle) - if not norm_page or not norm_needle: - return [] - out: list[tuple[int, int]] = [] - cursor = 0 - while True: - idx = norm_page.find(norm_needle, cursor) - if idx < 0: - break - end_idx = idx + len(norm_needle) - 1 - if 0 <= idx < len(starts) and 0 <= end_idx < len(ends): - out.append((starts[idx], ends[end_idx])) - cursor = idx + 1 - return out - - -def _locate_unique( - text: str, pages: list[ParsedPage] -) -> tuple[LocatorMatch | None, str]: - text = (text or "").strip() - if not text: - return None, "missing" - - matches: list[LocatorMatch] = [] - for page_index, page in enumerate(pages): - for start, end in _find_exact(page.text, text): - line_start, line_end = _line_bounds(page.text, start, end) - matches.append( - LocatorMatch( - page_index = page_index, - page_number = page.page_number, - start = start, - end = end, - line_start = line_start, - line_end = line_end, - ) - ) - if len(matches) == 1: - return matches[0], "matched" - if len(matches) > 1: - return None, "ambiguous" - - for page_index, page in enumerate(pages): - for start, end in _find_normalized(page.text, text): - line_start, line_end = _line_bounds(page.text, start, end) - matches.append( - LocatorMatch( - page_index = page_index, - page_number = page.page_number, - start = start, - end = end, - line_start = line_start, - line_end = line_end, - ) - ) - if len(matches) == 1: - return matches[0], "matched" - if len(matches) > 1: - return None, "ambiguous" - return None, "missing" - - -def _replace_document_pages(document_id: str, pages: list[ParsedPage]) -> None: - now = int(time.time()) - rows = [ - ( - document_id, - index, - page.page_number, - page.text, - len(page.text), - len(page.text.splitlines()), - now, - ) - for index, page in enumerate(pages) - ] - with closing_connection() as conn: - conn.execute( - "DELETE FROM rag_document_pages WHERE document_id = ?", (document_id,) - ) - if rows: - conn.executemany( - """ - INSERT INTO rag_document_pages - (document_id, page_index, page_number, text, char_count, - line_count, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - rows, - ) - conn.commit() - - -def _region_anchor(page_text: str, match: LocatorMatch) -> str | None: - segment = page_text[match.start : match.end] - words = [w.strip(" \t\r\n*#`[]()") for w in segment.split()] - words = [w for w in words if len(w) >= 2] - if len(words) < 3: - return None - anchor = " ".join(words[: min(16, len(words))]) - return anchor if len(anchor) >= 12 else None - - -def _normalized_occurrences(haystack: str, needle: str) -> int: - norm_haystack, _starts, _ends = _normalize_with_map(haystack) - norm_needle, _needle_starts, _needle_ends = _normalize_with_map(needle) - if not norm_haystack or not norm_needle: - return 0 - count = 0 - cursor = 0 - while True: - idx = norm_haystack.find(norm_needle, cursor) - if idx < 0: - return count - count += 1 - cursor = idx + 1 - - -def pdf_regions_for_match( - pdf_path: Path, - pages: list[ParsedPage], - match: LocatorMatch, -) -> list[dict[str, Any]]: - """Return normalized PDF rectangles for a unique chunk match. - - Regions are intentionally conservative: no PyMuPDF, no page, no - unique anchor, or no positive-area rectangles all produce an empty - list rather than guessed highlights. - """ - if pdf_path.suffix.lower() != ".pdf": - return [] - if match.page_index < 0 or match.page_index >= len(pages): - return [] - anchor = _region_anchor(pages[match.page_index].text, match) - if not anchor: - return [] - - try: - import pymupdf - except Exception: - return [] - - try: - doc = pymupdf.open(str(pdf_path)) - except Exception: - return [] - - try: - return _pdf_regions_for_match_doc(doc, pages, match, anchor) - finally: - doc.close() - - -def _pdf_regions_for_match_doc( - doc: Any, - pages: list[ParsedPage], - match: LocatorMatch, - anchor: str, -) -> list[dict[str, Any]]: - try: - if match.page_index >= len(doc): - return [] - page = doc[match.page_index] - raw_text = page.get_text("text") or "" - if _normalized_occurrences(raw_text, anchor) != 1: - return [] - rects = page.search_for(anchor) or [] - page_rect = page.rect - page_width = float(page_rect.width) - page_height = float(page_rect.height) - if page_width <= 0 or page_height <= 0: - return [] - - out: list[dict[str, Any]] = [] - for rect in rects: - width = max(0.0, float(rect.x1 - rect.x0)) - height = max(0.0, float(rect.y1 - rect.y0)) - if width <= 0 or height <= 0: - continue - out.append( - { - "pageIndex": match.page_index, - "pageNumber": match.page_number, - "x": max(0.0, min(1.0, float(rect.x0) / page_width)), - "y": max(0.0, min(1.0, float(rect.y0) / page_height)), - "width": max(0.0, min(1.0, width / page_width)), - "height": max(0.0, min(1.0, height / page_height)), - "confidence": "exact", - "source": "pymupdf-search", - } - ) - return out - except Exception: - return [] - - -def pdf_regions_for_chunks( - pdf_path: Path, - pages: list[ParsedPage], - chunks: list[Any], -) -> list[list[dict[str, Any]]]: - if pdf_path.suffix.lower() != ".pdf": - return [[] for _ in chunks] - try: - import pymupdf - - doc = pymupdf.open(str(pdf_path)) - except Exception: - return [[] for _ in chunks] - - regions: list[list[dict[str, Any]]] = [] - try: - for chunk in chunks: - page_index = getattr(chunk, "source_page_index", None) - start = getattr(chunk, "page_char_start", None) - end = getattr(chunk, "page_char_end", None) - if page_index is None or start is None or end is None: - regions.append([]) - continue - if page_index < 0 or page_index >= len(pages): - regions.append([]) - continue - line_start, line_end = _line_bounds(pages[page_index].text, start, end) - match = LocatorMatch( - page_index = int(page_index), - page_number = getattr(chunk, "page_number", None), - start = int(start), - end = int(end), - line_start = line_start, - line_end = line_end, - ) - anchor = _region_anchor(pages[match.page_index].text, match) - if not anchor: - regions.append([]) - continue - regions.append(_pdf_regions_for_match_doc(doc, pages, match, anchor)) - return regions - finally: - doc.close() diff --git a/studio/frontend/src/__tests__/preview-pdf-smoke.test.tsx b/studio/frontend/src/__tests__/preview-pdf-smoke.test.tsx deleted file mode 100644 index 2762ad8ceb..0000000000 --- a/studio/frontend/src/__tests__/preview-pdf-smoke.test.tsx +++ /dev/null @@ -1,385 +0,0 @@ -import type { PreviewTarget } from "@/features/rag/api/rag-api"; -import type { PreviewPdfRegion } from "@/features/rag/api/rag-api"; -import { PreviewPdfView } from "@/features/rag/components/preview-pdf-view"; -import { - act, - fireEvent, - render, - screen, - waitFor, - within, -} from "@testing-library/react"; -import React from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const SOURCE_EXCERPT_TEXT = /source excerpt/i; - -/** The thumbnail rail also renders mocked `` elements, so tests - * targeting the main render scope through the `pdf-main-page` wrapper - * instead of taking the first `pdf-page`. */ -async function findMainPdfPage(): Promise { - const wrapper = await screen.findByTestId("pdf-main-page"); - return within(wrapper).getByTestId("pdf-page"); -} -function getMainPdfPage(): HTMLElement { - const wrapper = screen.getByTestId("pdf-main-page"); - return within(wrapper).getByTestId("pdf-page"); -} - -vi.mock("react-pdf", () => ({ - Document: ({ - children, - file, - onLoadSuccess, - }: { - children: React.ReactNode; - file?: unknown; - onLoadSuccess?: (result: { numPages: number }) => void; - }) => { - onLoadSuccess?.({ numPages: 1 }); - return React.createElement( - "div", - { - "data-testid": "pdf-document", - "data-file-kind": file instanceof Blob ? "blob" : typeof file, - "data-file-url": - file && typeof file === "object" && "url" in file - ? String((file as { url: string }).url) - : "", - }, - children, - ); - }, - Page: ({ - customTextRenderer, - width, - renderTextLayer, - }: { - customTextRenderer?: (item: { str: string }) => string; - width?: number; - renderTextLayer?: boolean; - }) => { - const html = - customTextRenderer?.({ str: "target phrase" }) ?? "target phrase"; - return React.createElement("div", { - "data-testid": "pdf-page", - "data-width": String(width ?? ""), - "data-render-text-layer": String(renderTextLayer), - "data-rendered-html": html, - }); - }, - pdfjs: { GlobalWorkerOptions: { workerSrc: "" } }, -})); - -function target(overrides: Partial = {}): PreviewTarget { - return { - documentId: "doc-abc", - filename: "report.pdf", - contentType: "application/pdf", - mediaKind: "pdf", - byteSize: 100, - status: "completed", - kbId: "kb-1", - threadId: null, - chunkId: "chunk-1", - chunkIndex: 0, - targetPage: 1, - snippet: "target phrase appears here", - kind: "text", - sourcePageIndex: 0, - pageCharStart: 0, - pageCharEnd: 13, - lineStart: 1, - lineEnd: 1, - pdfRegions: [], - ...overrides, - }; -} - -beforeEach(() => { - class ResizeObserverMock implements ResizeObserver { - observe(_target: Element, _options?: ResizeObserverOptions) { - // jsdom has no layout observer; only the API shape is needed. - } - unobserve(_target: Element) { - // jsdom has no layout observer; only the API shape is needed. - } - disconnect() { - // jsdom has no layout observer; only the API shape is needed. - } - } - vi.stubGlobal("ResizeObserver", ResizeObserverMock); -}); - -describe("PreviewPdfView smoke", () => { - it("renders a range URL source with text search and exact region overlay", async () => { - render( - React.createElement(PreviewPdfView, { - target: target({ - pdfRegions: [ - { - pageIndex: 0, - pageNumber: 1, - x: 0.1, - y: 0.2, - width: 0.3, - height: 0.04, - confidence: "exact", - source: "pymupdf-search", - }, - ], - }), - file: "http://127.0.0.1:8888/api/rag/documents/doc-abc/file-signed?token=signed", - }), - ); - - expect(screen.getByTestId("pdf-document")).toHaveAttribute( - "data-file-kind", - "object", - ); - expect(screen.getByTestId("pdf-document")).toHaveAttribute( - "data-file-url", - expect.stringContaining("/file-signed?token=signed"), - ); - const page = await findMainPdfPage(); - await waitFor(() => { - expect(page.getAttribute("data-render-text-layer")).toBe("true"); - }); - expect(page.getAttribute("data-rendered-html")).toBe("target phrase"); - expect(page.getAttribute("data-rendered-html")).not.toContain(""); - - // Verify brand green highlight overlays - const regionHighlight = screen.getByTestId("pdf-region-highlight"); - expect(regionHighlight).toBeInTheDocument(); - expect(regionHighlight).toHaveClass("bg-primary/20"); - expect(regionHighlight).toHaveClass("ring-primary/60"); - - // Verify Tailwind v4 light-mode isolation reset wrapper. Post - // thumbnail-rail refactor it lives INSIDE the Document, directly - // wrapping the main-page block. - const wrapper = screen.getByTestId("pdf-main-page").parentElement; - expect(wrapper).toHaveClass("light"); - expect(wrapper).toHaveClass("bg-white"); - expect(wrapper).toHaveClass("text-slate-900"); - - // Verify Shadcn toolbar elements and rounded-full pill groups - const zoomInBtn = screen.getByRole("button", { name: "Zoom in" }); - expect(zoomInBtn).toHaveClass("rounded-full"); - expect(zoomInBtn.parentElement).toHaveClass( - "bg-muted/40", - "p-0.5", - "shadow-xs", - ); - - // Source-excerpt card uses a neutral muted surface (no brand-coloured - // left rail) so it doesn't visually compete in the panel. - const excerptCard = screen.getByText(SOURCE_EXCERPT_TEXT).parentElement; - expect(excerptCard).toHaveClass("border-border/60"); - expect(excerptCard).toHaveClass("bg-muted/30"); - expect(excerptCard).not.toHaveClass("border-l-primary"); - - fireEvent.change(screen.getByLabelText("Search this PDF"), { - target: { value: "phrase" }, - }); - await waitFor(() => { - expect( - getMainPdfPage().getAttribute("data-rendered-html"), - ).toContain("phrase"); - }); - - const beforeZoom = Number(page.getAttribute("data-width")); - fireEvent.click(screen.getByRole("button", { name: "Zoom in" })); - await waitFor(() => { - expect( - Number(getMainPdfPage().getAttribute("data-width")), - ).toBeGreaterThan(beforeZoom); - }); - }); - - it("debounces ResizeObserver transitions to prevent infinite rendering loops", async () => { - const resizeCallbacks: ResizeObserverCallback[] = []; - class FakeResizeObserver implements ResizeObserver { - constructor(callback: ResizeObserverCallback) { - resizeCallbacks.push(callback); - } - observe(_target: Element, _options?: ResizeObserverOptions) { - // jsdom has no layout observer; only the API shape is needed. - } - unobserve(_target: Element) { - // jsdom has no layout observer; only the API shape is needed. - } - disconnect() { - // jsdom has no layout observer; only the API shape is needed. - } - } - vi.stubGlobal("ResizeObserver", FakeResizeObserver); - - render( - React.createElement(PreviewPdfView, { - target: target(), - file: "http://127.0.0.1:8888/api/rag/documents/doc-abc/file-signed?token=signed", - }), - ); - - // Initial render sets width synchronously on mount; capture it. - const page = await findMainPdfPage(); - const initialWidth = Number(page.getAttribute("data-width")); - - // Fake timers AFTER finding elements, to avoid findByTestId timeout - vi.useFakeTimers(); - - // Set up HTMLDivElement.prototype.clientWidth mock - const originalClientWidth = Object.getOwnPropertyDescriptor( - HTMLDivElement.prototype, - "clientWidth", - ); - let clientWidthValue = 300; - Object.defineProperty(HTMLDivElement.prototype, "clientWidth", { - get() { - return clientWidthValue; - }, - configurable: true, - }); - - // Trigger resize callback after changing clientWidth - clientWidthValue = 600; - const resizeCallback = resizeCallbacks[0]; - if (!resizeCallback) { - throw new Error("Expected ResizeObserver callback to be registered"); - } - const resizeObserver: ResizeObserver = { - observe() { - // The callback under test ignores the observer arg. - }, - unobserve() { - // The callback under test ignores the observer arg. - }, - disconnect() { - // The callback under test ignores the observer arg. - }, - }; - resizeCallback([], resizeObserver); - - // Width must NOT update immediately (100ms debounce) - expect(Number(getMainPdfPage().getAttribute("data-width"))).toBe( - initialWidth, - ); - - // Advance 100ms to fire the debounced callback and flush updates - act(() => { - vi.advanceTimersByTime(100); - vi.runAllTimers(); - }); - - // Now the width should have updated - expect( - Number(getMainPdfPage().getAttribute("data-width")), - ).not.toBe(initialWidth); - expect(Number(getMainPdfPage().getAttribute("data-width"))).toBe(572); // 600 - 28 (PDF_BODY_GUTTER_PX) - - // Clean up prototype descriptor - if (originalClientWidth) { - Object.defineProperty( - HTMLDivElement.prototype, - "clientWidth", - originalClientWidth, - ); - } else { - Reflect.deleteProperty(HTMLDivElement.prototype, "clientWidth"); - } - - vi.useRealTimers(); - }); - - it("renders only 'exact' confidence highlights and positions them with correct percentages", async () => { - const nonExactRegion = { - pageIndex: 0, - pageNumber: 1, - x: 0.5, - y: 0.5, - width: 0.2, - height: 0.2, - confidence: "fuzzy", - source: "pymupdf-search", - } as unknown as PreviewPdfRegion; - - render( - React.createElement(PreviewPdfView, { - target: target({ - pdfRegions: [ - { - pageIndex: 0, - pageNumber: 1, - x: 0.15, - y: 0.25, - width: 0.35, - height: 0.45, - confidence: "exact", - source: "pymupdf-search", - }, - nonExactRegion, - ], - }), - file: "http://127.0.0.1:8888/api/rag/documents/doc-abc/file-signed?token=signed", - }), - ); - - await findMainPdfPage(); - - const highlights = screen.getAllByTestId("pdf-region-highlight"); - expect(highlights.length).toBe(1); - - const exactHighlight = highlights[0]; - expect(exactHighlight.style.left).toBe("15%"); - expect(exactHighlight.style.top).toBe("25%"); - expect(exactHighlight.style.width).toBe("35%"); - expect(exactHighlight.style.height).toBe("45%"); - }); - - it("uses stable scrollbar style classes in the PDF sidebar and page container to prevent shifting", async () => { - render( - React.createElement(PreviewPdfView, { - target: target(), - file: "http://127.0.0.1:8888/api/rag/documents/doc-abc/file-signed?token=signed", - }), - ); - - const mainPageWrapper = await screen.findByTestId("pdf-main-page"); - - // pdf-main-page → light-wrapper → scrollContainer - const scrollContainer = - mainPageWrapper.parentElement?.parentElement ?? null; - expect(scrollContainer).toHaveClass("preview-scrollbar"); - expect(scrollContainer).toHaveClass("overflow-y-scroll"); - expect(scrollContainer).toHaveClass("overflow-x-auto"); - - const sidebar = screen.getByRole("button", { - name: "Go to page 1", - }).parentElement; - expect(sidebar).toHaveClass("preview-scrollbar"); - expect(sidebar).toHaveClass("overflow-y-auto"); - }); - - it("highlights search terms using the custom text renderer with the mark wrapper", async () => { - render( - React.createElement(PreviewPdfView, { - target: target({ - snippet: "this snippet contains some special keyword", - }), - file: "http://127.0.0.1:8888/api/rag/documents/doc-abc/file-signed?token=signed", - }), - ); - - await findMainPdfPage(); - - fireEvent.change(screen.getByLabelText("Search this PDF"), { - target: { value: "phrase" }, - }); - - await waitFor(() => { - expect( - getMainPdfPage().getAttribute("data-rendered-html"), - ).toContain("phrase"); - }); - }); -}); diff --git a/studio/frontend/src/features/rag/components/preview-pdf-view.tsx b/studio/frontend/src/features/rag/components/preview-pdf-view.tsx deleted file mode 100644 index 097bf67fad..0000000000 --- a/studio/frontend/src/features/rag/components/preview-pdf-view.tsx +++ /dev/null @@ -1,599 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { copyToClipboard } from "@/lib/copy-to-clipboard"; -import { cn } from "@/lib/utils"; -import { - ChevronLeftIcon, - ChevronRightIcon, - CopyIcon, - LoaderIcon, - RotateCcwIcon, - SearchIcon, - ZoomInIcon, - ZoomOutIcon, -} from "lucide-react"; -import { - type CSSProperties, - type FC, - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, -} from "react"; -import { Document, Page, pdfjs } from "react-pdf"; -import "react-pdf/dist/Page/AnnotationLayer.css"; -import "react-pdf/dist/Page/TextLayer.css"; -import type { PreviewPdfRegion, PreviewTarget } from "../api/rag-api"; -import { PreviewUnavailable } from "./preview-unavailable"; - -// Configure the pdfjs worker in the same module as react-pdf, per its -// README. `import.meta.url` resolves to this module's JS bundle, and -// Vite (+ Tauri) rewrites the URL at build so the worker sits with the -// chunk. -pdfjs.GlobalWorkerOptions.workerSrc = new URL( - "pdfjs-dist/build/pdf.worker.min.mjs", - import.meta.url, -).toString(); - -type PreviewPdfFile = Blob | string; - -interface PreviewPdfViewProps { - target: PreviewTarget; - file: PreviewPdfFile; -} - -type LoadSuccess = { numPages: number }; -type PdfLightThemeStyle = CSSProperties & Record<`--${string}`, string>; - -const RESIZE_DEBOUNCE_MS = 100; -const MIN_PDF_WIDTH = 280; -// Body p-2 (8px each side) + stable scrollbar gutter (~10px) + a small -// margin so the page render doesn't kiss the scrollbar. -const PDF_BODY_GUTTER_PX = 28; -const PDF_THUMBNAIL_WIDTH = 64; - -const PDF_LIGHT_THEME_STYLE: PdfLightThemeStyle = { - "--background": "oklch(1 0 0)", - "--foreground": "oklch(0.2686 0 0)", - "--card": "oklch(1 0 0)", - "--card-foreground": "oklch(0.1281 0.0179 169.2764)", - "--popover": "oklch(1 0 0)", - "--popover-foreground": "oklch(0.1281 0.0179 169.2764)", - "--primary": "#17b88b", - "--primary-foreground": "oklch(1 0 0)", - "--secondary": "oklch(0.9596 0.0275 167.8295)", - "--secondary-foreground": "oklch(0.2868 0.0649 159.9823)", - "--muted": "oklch(0.9702 0 0)", - "--muted-foreground": "oklch(0.5486 0 0)", - "--accent": "oklch(0.9596 0.0275 167.8295)", - "--accent-foreground": "oklch(0.2868 0.0649 159.9823)", - "--border": "oklch(0.9208 0.0101 164.8536)", - "--input": "oklch(0.9208 0.0101 164.8536)", - "--ring": "#17b88b", - colorScheme: "light", -}; - -function escapeHtml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -function markFirstMatch(text: string, needle: string): string | null { - const trimmed = needle.trim(); - if (trimmed.length < 2) { - return null; - } - const lower = text.toLowerCase(); - const start = lower.indexOf(trimmed.toLowerCase()); - if (start < 0) { - return null; - } - const end = start + trimmed.length; - return `${escapeHtml(text.slice(0, start))}${escapeHtml( - text.slice(start, end), - )}${escapeHtml(text.slice(end))}`; -} - -// Keep text-layer highlighting opt-in. Citation snippets render in the -// card below; using them here would mark common words across the PDF. -function highlightPdfText(text: string, searchTerm: string): string { - const trimmed = searchTerm.trim(); - if (trimmed.length < 2) { - return escapeHtml(text); - } - const searchHit = markFirstMatch(text, trimmed); - if (searchHit) { - return searchHit; - } - return escapeHtml(text); -} - -function regionIsOnPage(region: PreviewPdfRegion, pageNumber: number): boolean { - if (region.confidence !== "exact") { - return false; - } - if (region.pageNumber != null) { - return region.pageNumber === pageNumber; - } - return region.pageIndex === pageNumber - 1; -} - -interface PdfThumbnailProps { - pageNumber: number; - active: boolean; - onSelect: (pageNumber: number) => void; -} - -/** Lazy thumbnail via IntersectionObserver — mounts the inner - * only when scrolled near view, so large PDFs stay responsive even - * with the rail capped at 80 buttons. */ -const PdfThumbnail: FC = ({ - pageNumber, - active, - onSelect, -}) => { - const buttonRef = useRef(null); - const [shouldRender, setShouldRender] = useState(false); - - useEffect(() => { - if (shouldRender) { - return; - } - const el = buttonRef.current; - if (!el || typeof IntersectionObserver === "undefined") { - // Fallback for jsdom / older browsers: render eagerly. - setShouldRender(true); - return; - } - const observer = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if (entry.isIntersecting) { - setShouldRender(true); - observer.disconnect(); - return; - } - } - }, - { rootMargin: "320px" }, - ); - observer.observe(el); - return () => observer.disconnect(); - }, [shouldRender]); - - useEffect(() => { - const el = buttonRef.current; - if (!active || !el || typeof el.scrollIntoView !== "function") { - return; - } - el.scrollIntoView({ block: "nearest", behavior: "smooth" }); - }, [active]); - - return ( - - ); -}; - -export const PreviewPdfView: FC = ({ target, file }) => { - const [numPages, setNumPages] = useState(null); - const [pageNumber, setPageNumber] = useState(target.targetPage ?? 1); - const [loadError, setLoadError] = useState(null); - const [zoom, setZoom] = useState(1); - const [searchTerm, setSearchTerm] = useState(""); - const [copied, setCopied] = useState(false); - const containerRef = useRef(null); - const observerRef = useRef(null); - const resizeTimeoutRef = useRef(null); - const lastMeasuredWidthRef = useRef(null); - const lastResetKeyRef = useRef(null); - const [width, setWidth] = useState(null); - const searchInputId = useId(); - - const sourceKey = - typeof file === "string" - ? file - : `${target.documentId}:${target.chunkId ?? ""}:${file.size}:${file.type}`; - - const documentFile = useMemo(() => { - return typeof file === "string" ? { url: file } : file; - }, [file]); - const resetKey = `${sourceKey}:${target.targetPage ?? ""}`; - - useEffect(() => { - if (lastResetKeyRef.current === resetKey) { - return; - } - lastResetKeyRef.current = resetKey; - setNumPages(null); - setLoadError(null); - setPageNumber(target.targetPage ?? 1); - setZoom(1); - setSearchTerm(""); - setCopied(false); - }, [resetKey, target.targetPage]); - - const measureWidth = useCallback(() => { - const el = containerRef.current; - if (!el) { - return; - } - const next = Math.max(MIN_PDF_WIDTH, el.clientWidth - PDF_BODY_GUTTER_PX); - if (lastMeasuredWidthRef.current === next) { - return; - } - lastMeasuredWidthRef.current = next; - setWidth(next); - }, []); - - // Callback ref, not useRef + mount effect: the scroll container lives - // INSIDE and only enters the DOM after the PDF loads. - // Attaching the ResizeObserver the instant the node mounts (vs the - // component mount effect, when the node is still absent) keeps the main - // page from rendering at width 0 — the thin white strip regression. - const attachContainer = useCallback( - (node: HTMLDivElement | null) => { - if (observerRef.current) { - observerRef.current.disconnect(); - observerRef.current = null; - } - if (resizeTimeoutRef.current !== null) { - window.clearTimeout(resizeTimeoutRef.current); - resizeTimeoutRef.current = null; - } - containerRef.current = node; - if (!node) { - return; - } - measureWidth(); - const observer = new ResizeObserver(() => { - if (resizeTimeoutRef.current !== null) { - window.clearTimeout(resizeTimeoutRef.current); - } - resizeTimeoutRef.current = window.setTimeout( - measureWidth, - RESIZE_DEBOUNCE_MS, - ); - }); - observer.observe(node); - observerRef.current = observer; - }, - [measureWidth], - ); - - useEffect(() => { - if (!copied) { - return; - } - const id = window.setTimeout(() => setCopied(false), 1200); - return () => window.clearTimeout(id); - }, [copied]); - - const handleLoadSuccess = useCallback(({ numPages }: LoadSuccess) => { - setNumPages(numPages); - setLoadError(null); - }, []); - - const handleLoadError = useCallback((err: Error) => { - setLoadError(err.message || "Failed to load PDF"); - }, []); - - const goPrev = useCallback(() => { - setPageNumber((p) => Math.max(1, p - 1)); - }, []); - - const goNext = useCallback(() => { - setPageNumber((p) => - numPages == null ? p + 1 : Math.min(numPages, p + 1), - ); - }, [numPages]); - - const textRenderer = useCallback( - ({ str }: { str: string }) => highlightPdfText(str, searchTerm), - [searchTerm], - ); - - const currentRegions = useMemo( - () => - (target.pdfRegions ?? []).filter((region) => - regionIsOnPage(region, pageNumber), - ), - [target.pdfRegions, pageNumber], - ); - - const visiblePageNumbers = useMemo(() => { - if (!numPages) { - return []; - } - const maxButtons = 80; - if (numPages <= maxButtons) { - return Array.from({ length: numPages }, (_, index) => index + 1); - } - const half = Math.floor(maxButtons / 2); - let start = Math.max(1, pageNumber - half); - const end = Math.min(numPages, start + maxButtons - 1); - start = Math.max(1, end - maxButtons + 1); - return Array.from({ length: end - start + 1 }, (_, index) => start + index); - }, [numPages, pageNumber]); - - const pageWidth = width == null ? null : Math.round(width * zoom); - const excerptKey = `${sourceKey}:${target.chunkId ?? ""}:${ - target.targetPage ?? "" - }:${pageNumber}`; - - const copyExcerpt = useCallback(() => { - copyToClipboard(target.snippet ?? "").then(setCopied); - }, [target.snippet]); - - if (loadError) { - return ( - - ); - } - - return ( -
-
- - {target.filename} - -
- {/* Navigation Pill Group */} -
- - - {numPages == null - ? `${pageNumber}/?` - : `${pageNumber}/${numPages}`} - - -
- - {/* Zoom Pill Group */} -
- - - -
- - {/* Copy Excerpt Pill Group */} -
- -
-
- -
- - {target.snippet ? ( -
-

- Source Excerpt - {target.targetPage != null ? ` · Page ${target.targetPage}` : ""} -

-

- {target.snippet} -

-
- ) : null} - - - - Loading PDF... -
- } - error={ - - } - className="flex min-h-0 flex-1" - > -
- {visiblePageNumbers.map((page) => ( - - ))} -
-
-
- {pageWidth != null ? ( -
- - Rendering page... -
- } - className="shadow-sm" - /> - {currentRegions.map((region, index) => ( -
- ))} -
- ) : null} -
-
-
- - ); -};