RAG preview: drop react-pdf renderer + pdf_regions, delete dead locators module
This commit is contained in:
parent
39460682aa
commit
bff3a04f8d
3 changed files with 0 additions and 1307 deletions
|
|
@ -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()
|
||||
|
|
@ -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>");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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, ">")
|
||||
.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))}<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>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue