RAG preview: drop react-pdf renderer + pdf_regions, delete dead locators module

This commit is contained in:
Roland Tannous 2026-06-03 13:53:47 +04:00
commit bff3a04f8d
3 changed files with 0 additions and 1307 deletions

View file

@ -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()

View file

@ -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 `<Page>` 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<HTMLElement> {
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> = {}): 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("<mark>");
// 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("<mark>phrase</mark>");
});
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("<mark>phrase</mark>");
});
});
});

View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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))}<mark>${escapeHtml(
text.slice(start, end),
)}</mark>${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 <Page>
* only when scrolled near view, so large PDFs stay responsive even
* with the rail capped at 80 buttons. */
const PdfThumbnail: FC<PdfThumbnailProps> = ({
pageNumber,
active,
onSelect,
}) => {
const buttonRef = useRef<HTMLButtonElement | null>(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 (
<button
ref={buttonRef}
type="button"
onClick={() => onSelect(pageNumber)}
aria-label={`Go to page ${pageNumber}`}
aria-current={active ? "page" : undefined}
className={cn(
"mb-1.5 flex w-full flex-col items-center gap-0.5 rounded-md p-1 outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
active
? "bg-secondary/70 text-secondary-foreground"
: "hover:bg-muted/60",
)}
>
<div
className={cn(
"overflow-hidden rounded-sm border bg-white shadow-xs",
active
? "border-primary/70 ring-1 ring-primary/40"
: "border-border/60",
)}
style={{
width: PDF_THUMBNAIL_WIDTH,
minHeight: Math.round(PDF_THUMBNAIL_WIDTH * 1.3),
}}
>
{shouldRender ? (
<Page
pageNumber={pageNumber}
width={PDF_THUMBNAIL_WIDTH}
renderTextLayer={false}
renderAnnotationLayer={false}
loading={
<div
className="flex h-full w-full animate-pulse items-center justify-center bg-muted/40"
style={{
minHeight: Math.round(PDF_THUMBNAIL_WIDTH * 1.3),
}}
/>
}
error={
<div
className="flex h-full w-full items-center justify-center text-[8px] text-muted-foreground"
style={{
minHeight: Math.round(PDF_THUMBNAIL_WIDTH * 1.3),
}}
>
?
</div>
}
className="pointer-events-none [&_canvas]:!h-auto [&_canvas]:!w-full"
/>
) : null}
</div>
<span
className={cn(
"tabular-nums text-[10px]",
active ? "font-semibold" : "text-muted-foreground",
)}
>
{pageNumber}
</span>
</button>
);
};
export const PreviewPdfView: FC<PreviewPdfViewProps> = ({ target, file }) => {
const [numPages, setNumPages] = useState<number | null>(null);
const [pageNumber, setPageNumber] = useState<number>(target.targetPage ?? 1);
const [loadError, setLoadError] = useState<string | null>(null);
const [zoom, setZoom] = useState(1);
const [searchTerm, setSearchTerm] = useState("");
const [copied, setCopied] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const observerRef = useRef<ResizeObserver | null>(null);
const resizeTimeoutRef = useRef<number | null>(null);
const lastMeasuredWidthRef = useRef<number | null>(null);
const lastResetKeyRef = useRef<string | null>(null);
const [width, setWidth] = useState<number | null>(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 <Document> 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 (
<PreviewUnavailable
filename={target.filename}
reason={loadError}
variant="error"
/>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 px-3 py-2 text-xs">
<span
className="min-w-0 flex-1 truncate font-semibold font-heading"
title={target.filename}
>
{target.filename}
</span>
<div className="flex shrink-0 items-center gap-2">
{/* Navigation Pill Group */}
<div className="flex items-center rounded-full border border-border/60 bg-muted/40 p-0.5 shadow-xs">
<Button
variant="ghost"
size="icon"
onClick={goPrev}
disabled={pageNumber <= 1}
aria-label="Previous page"
className="h-7 w-7 rounded-full hover:bg-background/80"
>
<ChevronLeftIcon className="size-3.5" />
</Button>
<span className="min-w-12 text-center tabular-nums text-[10px] font-medium text-muted-foreground">
{numPages == null
? `${pageNumber}/?`
: `${pageNumber}/${numPages}`}
</span>
<Button
variant="ghost"
size="icon"
onClick={goNext}
disabled={numPages != null && pageNumber >= numPages}
aria-label="Next page"
className="h-7 w-7 rounded-full hover:bg-background/80"
>
<ChevronRightIcon className="size-3.5" />
</Button>
</div>
{/* Zoom Pill Group */}
<div className="flex items-center rounded-full border border-border/60 bg-muted/40 p-0.5 shadow-xs">
<Button
variant="ghost"
size="icon"
onClick={() => setZoom((value) => Math.max(0.6, value - 0.1))}
aria-label="Zoom out"
className="h-7 w-7 rounded-full hover:bg-background/80"
>
<ZoomOutIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setZoom(1)}
aria-label="Reset zoom"
className="h-7 w-7 rounded-full hover:bg-background/80"
>
<RotateCcwIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setZoom((value) => Math.min(2.5, value + 0.1))}
aria-label="Zoom in"
className="h-7 w-7 rounded-full hover:bg-background/80"
>
<ZoomInIcon className="size-3.5" />
</Button>
</div>
{/* Copy Excerpt Pill Group */}
<div className="flex items-center rounded-full border border-border/60 bg-muted/40 p-0.5 shadow-xs">
<Button
variant="ghost"
size="icon"
onClick={copyExcerpt}
disabled={!target.snippet}
aria-label={
copied ? "Copied source excerpt" : "Copy source excerpt"
}
className="h-7 w-7 rounded-full hover:bg-background/80"
>
<CopyIcon className="size-3.5" />
</Button>
</div>
</div>
<label
htmlFor={searchInputId}
className="flex min-w-48 max-w-full flex-1 items-center gap-1 rounded-md border border-border/60 bg-background px-2"
>
<SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
<Input
id={searchInputId}
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Search this PDF"
aria-label="Search this PDF"
className="h-7 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0"
/>
</label>
</div>
{target.snippet ? (
<div
key={excerptKey}
className="m-2 rounded-lg border border-border/60 bg-muted/30 p-3 shadow-xs text-[11px] leading-relaxed text-foreground/80 transition-all duration-300 animate-in fade-in"
>
<p className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/80">
Source Excerpt
{target.targetPage != null ? ` · Page ${target.targetPage}` : ""}
</p>
<p className="line-clamp-4 whitespace-pre-wrap font-sans text-muted-foreground">
{target.snippet}
</p>
</div>
) : null}
<Document
file={documentFile}
onLoadSuccess={handleLoadSuccess}
onLoadError={handleLoadError}
loading={
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
Loading PDF...
</div>
}
error={
<PreviewUnavailable
filename={target.filename}
reason="The PDF could not be opened."
variant="error"
/>
}
className="flex min-h-0 flex-1"
>
<div className="preview-scrollbar w-[88px] shrink-0 overflow-y-auto border-r border-border/60 bg-muted/20 p-1.5">
{visiblePageNumbers.map((page) => (
<PdfThumbnail
key={page}
pageNumber={page}
active={page === pageNumber}
onSelect={setPageNumber}
/>
))}
</div>
<div
ref={attachContainer}
className="preview-scrollbar flex-1 overflow-y-scroll overflow-x-auto bg-muted/20 p-2 [scrollbar-gutter:stable]"
>
<div
className="light [color-scheme:light] bg-white text-slate-900 rounded-md p-1 shadow-sm border border-border/30 [&_mark]:bg-primary/20 [&_mark]:text-slate-900 [&_mark]:ring-1 [&_mark]:ring-primary/60 [&_mark]:rounded-xs flex min-w-fit flex-col items-center"
style={PDF_LIGHT_THEME_STYLE}
>
{pageWidth != null ? (
<div
data-testid="pdf-main-page"
className="relative inline-block"
>
<Page
pageNumber={pageNumber}
width={pageWidth}
customTextRenderer={textRenderer}
renderTextLayer={true}
renderAnnotationLayer={false}
loading={
<div className="py-4 text-xs text-muted-foreground">
Rendering page...
</div>
}
className="shadow-sm"
/>
{currentRegions.map((region, index) => (
<div
key={`${region.pageIndex}-${region.x}-${region.y}-${index}`}
data-testid="pdf-region-highlight"
className="pointer-events-none absolute rounded-sm bg-primary/20 ring-1 ring-primary/60"
style={{
left: `${region.x * 100}%`,
top: `${region.y * 100}%`,
width: `${region.width * 100}%`,
height: `${region.height * 100}%`,
}}
/>
))}
</div>
) : null}
</div>
</div>
</Document>
</div>
);
};