diff --git a/frontend/src/v1/components/patterns/document-viewer/DocumentViewer.test.tsx b/frontend/src/v1/components/patterns/document-viewer/DocumentViewer.test.tsx index 77871d4267..ccfc2b83a8 100644 --- a/frontend/src/v1/components/patterns/document-viewer/DocumentViewer.test.tsx +++ b/frontend/src/v1/components/patterns/document-viewer/DocumentViewer.test.tsx @@ -229,6 +229,54 @@ it("runs a scroll asked for while loading once the pages are laid out", async () } }); +it("shows text line by line, with the switcher but no paging or zoom", () => { + render( + invoice.pdf} + />, + ); + expect(screen.getByRole("region", { name: "Document text" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "invoice.pdf" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Zoom in" })).toBeNull(); + expect(screen.queryByLabelText("Page")).toBeNull(); +}); + +it("keeps the switcher while a PDF is still loading or has failed", async () => { + render( + invoice.pdf} + />, + ); + expect(screen.getByRole("button", { name: "invoice.pdf" })).toBeTruthy(); + await act(async () => opening.reject(new Error("Network error"))); + expect(screen.getByRole("button", { name: "invoice.pdf" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Zoom in" })).toBeNull(); +}); + +it("zooms an image with the toolbar, fitted like a page, without paging", () => { + render(); + const image = screen.getByRole("img", { hidden: true }); + Object.defineProperties(image, { + naturalWidth: { value: 1632 }, + naturalHeight: { value: 2112 }, + }); + fireEvent.load(image); + act(() => resize?.(864, 600)); + // 1632 natural px into an 816 px fit: 50%. + expect(zoomLabel()).toBe("50%"); + expect(screen.queryByLabelText("Page")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Zoom in" })); + expect(zoomLabel()).toBe("75%"); +}); + +it("is empty without a source", () => { + render(); + expect(screen.getByText("No document")).toBeTruthy(); +}); + it("fits every page in fit page, so a wider page later on sets the zoom", async () => { render(); await act(async () => @@ -248,6 +296,113 @@ it("fits every page in fit page, so a wider page later on sets the zoom", async expect(zoomLabel()).toBe("77%"); }); +function loadImage(width: number, height: number) { + const image = screen.getByRole("img", { hidden: true }); + Object.defineProperties(image, { + naturalWidth: { value: width }, + naturalHeight: { value: height }, + }); + fireEvent.load(image); +} + +it("leaves paging keys to the browser for text and images", () => { + const { rerender } = render( + , + ); + const text = screen.getByRole("region", { name: "Document text" }); + // fireEvent returns false when the handler called preventDefault. + expect(fireEvent.keyDown(text, { key: "PageDown" })).toBe(true); + expect(fireEvent.keyDown(text, { key: "End" })).toBe(true); + expect(fireEvent.keyDown(text, { key: "+" })).toBe(true); + + rerender(); + loadImage(1632, 2112); + const image = screen.getByRole("region", { name: "Image viewer" }); + expect(fireEvent.keyDown(image, { key: "PageDown" })).toBe(true); + expect(fireEvent.keyDown(image, { key: "+" })).toBe(false); +}); + +it("runs a highlight scroll asked for before an image loads once it has", () => { + const ref = createRef(); + render( + , + ); + const region = screen.getByRole("region", { name: "Image viewer" }); + act(() => ref.current?.scrollToHighlight("h")); + expect(region.scrollTop).toBe(0); + const frame = region.querySelector("img")?.parentElement; + if (!frame) { + throw new Error("no frame"); + } + Object.defineProperty(frame, "offsetHeight", { value: 1000 }); + Object.defineProperty(region, "clientHeight", { value: 400 }); + loadImage(1632, 2112); + // Centre of the box at 0.925 of 1000 px, minus half the 400 px viewport. + expect(region.scrollTop).toBeCloseTo(725); +}); + +it("draws only page 0's highlights over an image", () => { + const { container } = render( + , + ); + loadImage(1632, 2112); + expect(container.querySelector('[data-highlight-id="a"]')).not.toBeNull(); + expect(container.querySelector('[data-highlight-id="b"]')).toBeNull(); +}); + +it("waits for an image to load again after another source was shown", async () => { + const image = { kind: "image", url: "/scan.png" } as const; + const { rerender } = render(); + loadImage(1632, 2112); + act(() => resize?.(864, 600)); + expect(screen.queryByRole("button", { name: "Zoom in" })).not.toBeNull(); + + rerender(); + rerender(); + expect(screen.queryByRole("button", { name: "Zoom in" })).toBeNull(); + loadImage(1632, 2112); + expect(screen.queryByRole("button", { name: "Zoom in" })).not.toBeNull(); +}); + it("pages forward and back with PageDown and PageUp, one page per press", async () => { render(); await open(); @@ -400,6 +555,54 @@ it("scrolls to an active highlight that arrives after its id", async () => { } }); +it("opens another text at the top, in a fresh view", () => { + const { rerender } = render( + , + ); + const before = screen.getByRole("region", { name: "Document text" }); + before.scrollTop = 300; + rerender(); + const after = screen.getByRole("region", { name: "Document text" }); + expect(after).not.toBe(before); + expect(after.scrollTop).toBe(0); +}); + +it("drops a scroll waiting on an empty text when another text is shown", () => { + const original = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "offsetTop", + ); + Object.defineProperty(HTMLElement.prototype, "offsetTop", { + configurable: true, + get(this: HTMLElement) { + const line = this.getAttribute("data-line"); + return line === null ? 0 : Number(line) * 20; + }, + }); + try { + const ref = createRef(); + const { rerender } = render( + , + ); + // An empty text shows a status, not lines, so this has nowhere to go. + act(() => ref.current?.scrollToHighlight("total")); + rerender( + , + ); + expect( + screen.getByRole("region", { name: "Document text" }).scrollTop, + ).toBe(0); + } finally { + if (original) { + Object.defineProperty(HTMLElement.prototype, "offsetTop", original); + } + } +}); + it("does not scroll to an active highlight cleared as it arrives", async () => { const restore = layOutEveryPage(); try { @@ -508,3 +711,124 @@ it("starts a new document on its first page", async () => { // Not even one render showed page 3 of 1. expect(toolbarRenders.filter(([index, count]) => index >= count)).toEqual([]); }); + +it("keeps a live-edited text in place while its id stays the same", () => { + const { rerender } = render( + , + ); + const before = screen.getByRole("region", { name: "Document text" }); + before.scrollTop = 300; + rerender( + , + ); + const after = screen.getByRole("region", { name: "Document text" }); + expect(after).toBe(before); + expect(after.scrollTop).toBe(300); + expect(after.textContent).toContain("texts"); + + rerender( + , + ); + expect(screen.getByRole("region", { name: "Document text" })).not.toBe( + before, + ); +}); + +it("ignores zoom keys until an image has loaded, so it still opens fitted", () => { + render(); + const region = screen.getByRole("region", { name: "Image viewer" }); + // Not handled: fireEvent returns true when nothing called preventDefault. + expect(fireEvent.keyDown(region, { key: "+" })).toBe(true); + act(() => resize(864, 600)); + loadImage(1632, 2112); + expect( + screen + .getByRole("button", { name: "Fit width" }) + .getAttribute("aria-pressed"), + ).toBe("true"); +}); + +it("drops a scroll to a highlight on another page of an image instead of keeping it", () => { + const ref = createRef(); + const box = { + id: "h", + x: 0.5, + y: 0.9, + w: 0.1, + h: 0.05, + variant: "box" as const, + tone: "default" as const, + }; + const { rerender } = render( + , + ); + const region = screen.getByRole("region", { name: "Image viewer" }); + const frame = region.querySelector("img")?.parentElement; + if (!frame) { + throw new Error("no frame"); + } + Object.defineProperty(frame, "offsetHeight", { value: 1000 }); + Object.defineProperty(region, "clientHeight", { value: 400 }); + loadImage(1632, 2112); + act(() => ref.current?.scrollToHighlight("h")); + // Had the request waited, it would fire here, long after it was asked for. + rerender( + , + ); + expect(region.scrollTop).toBe(0); +}); + +it("runs a scroll waiting on an empty text once that same text fills in", () => { + const original = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "offsetTop", + ); + Object.defineProperty(HTMLElement.prototype, "offsetTop", { + configurable: true, + get(this: HTMLElement) { + const line = this.getAttribute("data-line"); + return line === null ? 0 : Number(line) * 20; + }, + }); + try { + const ref = createRef(); + const textHighlights = [{ id: "total", line: 5 }]; + const { rerender } = render( + , + ); + act(() => ref.current?.scrollToHighlight("total")); + // The same text, by id, fills in; the highlights are unchanged. + rerender( + , + ); + expect( + screen.getByRole("region", { name: "Document text" }).scrollTop, + ).toBe(100); + } finally { + if (original) { + Object.defineProperty(HTMLElement.prototype, "offsetTop", original); + } + } +}); diff --git a/frontend/src/v1/components/patterns/document-viewer/DocumentViewer.tsx b/frontend/src/v1/components/patterns/document-viewer/DocumentViewer.tsx index 076b5ef480..a1cfcd83b1 100644 --- a/frontend/src/v1/components/patterns/document-viewer/DocumentViewer.tsx +++ b/frontend/src/v1/components/patterns/document-viewer/DocumentViewer.tsx @@ -4,7 +4,11 @@ import { cn } from "@v1/lib/cn"; import type { Highlight } from "@v1/lib/pdf/highlights"; import { viewerKeyAction } from "@v1/lib/pdf/keyboard"; import { objectKey, pdfSourceKey } from "@v1/lib/pdf/pdfjs"; -import type { PageSize, PdfSource } from "@v1/lib/pdf/types"; +import { + type DocumentSource, + isPdfSource, + type PageSize, +} from "@v1/lib/pdf/types"; import { clampZoom, fitZoom, @@ -17,6 +21,7 @@ import { import { scrollbarWidth } from "@v1/lib/scrollbar"; import { type KeyboardEvent, + type ReactNode, type Ref, useCallback, useEffect, @@ -26,7 +31,9 @@ import { useRef, useState, } from "react"; +import { ImageView } from "./ImageView"; import { PdfPageList, type PdfPageListHandle } from "./PdfPageList"; +import { type TextLineHighlight, TextView } from "./TextView"; import { ViewerStatus } from "./ViewerStatus"; import { ViewerToolbar } from "./ViewerToolbar"; @@ -41,8 +48,15 @@ export interface DocumentViewerHandle { } export interface DocumentViewerProps { - source: PdfSource | null; + /** A PDF (URL or bytes), an image, or text. */ + source: DocumentSource | null; + /** Page highlights, for a PDF or an image. */ highlights?: readonly Highlight[]; + /** Line highlights, for text. */ + textHighlights?: readonly TextLineHighlight[]; + onTextHighlightClick?: (highlight: TextLineHighlight) => void; + /** Shown at the start of the toolbar, whatever state the document is in. */ + switcher?: ReactNode; /** Shown in the active tone and scrolled into view when it changes. */ activeHighlightId?: string | null; onHighlightClick?: (highlight: Highlight) => void; @@ -103,53 +117,96 @@ function initialZoomState( } /** - * A PDF with a toolbar: loads it, shows its states, and renders its pages with - * highlights. Keys while the pages have focus: PageUp/PageDown, Home/End, - * + and −. + * A document with a toolbar. A PDF is loaded and rendered page by page, an + * image as a single page, and text line by line; PDFs and images share zoom + * and highlights. Keys while the content has focus: PageUp/PageDown, + * Home/End, + and −. */ export function DocumentViewer({ source, highlights, + textHighlights, + onTextHighlightClick, + switcher, activeHighlightId, onHighlightClick, initialZoom, className, ref, }: DocumentViewerProps) { - const { state, retry, submitPassword } = usePdfDocument(source); + const { state, retry, submitPassword } = usePdfDocument( + isPdfSource(source) ? source : null, + ); + // An image's size is known once it loads; kept with its URL so a new image + // never borrows the last one's. + const [image, setImage] = useState<{ url: string; size: PageSize } | null>( + null, + ); const [zoomState, setZoomState] = useState(() => initialZoomState(initialZoom), ); const [pageIndex, setPageIndex] = useState(0); - // A new document starts on its first page: reset while rendering, so the - // toolbar never shows the last document's page against this one's count. - const shownDocument = state.status === "ready" ? state.document : null; - const [pageIndexFor, setPageIndexFor] = useState(shownDocument); - if (pageIndexFor !== shownDocument) { - setPageIndexFor(shownDocument); - setPageIndex(0); - } const [bodyRef, bodySize] = useElementSize(); const listRef = useRef(null); const pendingRef = useRef(null); - const ready = state.status === "ready"; - // In a fit mode the zoom comes from the viewer's size, so the pages wait for - // that measurement instead of laying out at 100% first and again after. + const kind = + source === null ? null : isPdfSource(source) ? "pdf" : source.kind; + // The one identity of what is shown: the PDF's source key, the image's URL, + // or the text's id (the text itself when it has none). Everything kept per + // source hangs off it: the views are keyed on it, so a new source starts at + // the top, and the state below is reset when it changes, while rendering, + // so nothing from the last source is ever shown against this one. + const sourceKey = + source === null || isPdfSource(source) + ? pdfSourceKey(source) + : source.kind === "image" + ? `image:${source.url}` + : `text:${source.id ?? source.text}`; + const [shownSourceKey, setShownSourceKey] = useState(sourceKey); + if (shownSourceKey !== sourceKey) { + setShownSourceKey(sourceKey); + // Coming back to an image waits for its reload, not its old size. + setImage(null); + // A new document starts on its first page. + setPageIndex(0); + } + const imageSize = + source?.kind === "image" && image?.url === source.url ? image.size : null; + const text = source?.kind === "text" ? source.text : null; + const pdfReady = kind === "pdf" && state.status === "ready"; + const pageSizes = pdfReady + ? state.pageSizes + : imageSize + ? [imageSize] + : undefined; + const pageCount = pageSizes?.length ?? 0; + // In a fit mode the zoom comes from the viewer's size, so a PDF's pages wait + // for that measurement instead of laying out at 100% first and again after. const measured = zoomState.mode === "custom" || bodySize !== null; - const showPages = ready && measured; - const sourceKey = pdfSourceKey(source); + const pdfShown = pdfReady && measured; + // When the content can take scroll requests. + const ready = pdfShown || kind === "text" || imageSize !== null; + // Zoom needs a size to zoom from: a key pressed before then would leave the + // fit mode and the content would never be fitted. + const zoomable = pdfReady || imageSize !== null; // Both kept in step at commit, before any parent's layout effect can make // a request, so a request is always judged against the source it was made - // for. - const readyRef = useRef(showPages); + // for, and a render React throws away cannot leave them stale. + const readyRef = useRef(ready); const sourceKeyRef = useRef(sourceKey); + // An image is one page, so a highlight on any other page can never be + // scrolled to: the request is dropped instead of waiting for ever. + const offPageRef = useRef<(id: string) => boolean>(() => false); useLayoutEffect(() => { - readyRef.current = showPages; + readyRef.current = ready; sourceKeyRef.current = sourceKey; - }, [showPages, sourceKey]); - const pageSizes = ready ? state.pageSizes : undefined; - const pageCount = pageSizes?.length ?? 0; + offPageRef.current = (id) => + kind === "image" && + (highlights ?? []).some( + (highlight) => highlight.id === id && highlight.page !== 0, + ); + }, [ready, sourceKey, kind, highlights]); const zoom = useMemo(() => { if (zoomState.mode === "custom" || !pageSizes || !bodySize) { @@ -179,6 +236,8 @@ export function DocumentViewer({ // request replaces a waiting one. const run = useCallback((request: ScrollRequest, fromActive = false) => { const list = listRef.current; + // An image's view exists before the image has loaded, so wait for the + // content to be ready, not just mounted. const wait = () => { pendingRef.current = { request, @@ -186,7 +245,9 @@ export function DocumentViewer({ fromActive, }; }; - if (!list || !readyRef.current) { + if (request.kind === "highlight" && offPageRef.current(request.id)) { + pendingRef.current = null; + } else if (!list || !readyRef.current) { wait(); } else if (request.kind === "page") { pendingRef.current = null; @@ -217,14 +278,15 @@ export function DocumentViewer({ }, [sourceKey]); // A waiting request runs once the pages are laid out, and is tried again - // when the highlights change, in case its highlight has arrived. One made + // when the highlights or the text change, in case its target has arrived + // (a text kept by its id changes in place, without a new source). One made // for another source is dropped rather than scrolling a document nobody // asked about, and so is one made for an active highlight that is no longer // active: this effect runs before the one below that would cancel it. // biome-ignore lint/correctness/useExhaustiveDependencies: retried on each new highlights list useEffect(() => { const pending = pendingRef.current; - if (!showPages || !pending) { + if (!ready || !pending) { return; } const stillActive = @@ -236,7 +298,15 @@ export function DocumentViewer({ } else { pendingRef.current = null; } - }, [showPages, sourceKey, highlights, activeHighlightId, run]); + }, [ + ready, + sourceKey, + highlights, + textHighlights, + text, + activeHighlightId, + run, + ]); // The active highlight is scrolled to when it changes, and again for each // new source, so one chosen before its document arrived is still found. @@ -261,7 +331,14 @@ export function DocumentViewer({ const onKeyDown = (event: KeyboardEvent) => { const action = viewerKeyAction(event); - if (action === null) { + // Text and images are one scrolling page: their paging keys keep the + // browser's own scrolling, and text has no zoom. + const paging = action !== null && !action.startsWith("zoom"); + if ( + action === null || + (paging && kind !== "pdf") || + (!paging && !zoomable) + ) { return; } event.preventDefault(); @@ -287,6 +364,73 @@ export function DocumentViewer({ } }; + let content: ReactNode; + if (source === null || kind === null) { + content = ( + + ); + } else if (source.kind === "text") { + content = ( + + ); + } else if (source.kind === "image") { + const { url } = source; + content = ( + setImage({ url, size })} + onKeyDown={onKeyDown} + /> + ); + } else if (state.status === "ready") { + content = pdfShown && ( + + ); + } else { + content = ( + + ); + } + + const showPaging = pdfReady; + const showZoom = zoomable; + return (
- {ready && ( + {(showPaging || showZoom || switcher) && ( )}
- {ready ? ( - showPages && ( - - ) - ) : ( - - )} + {content}
); diff --git a/frontend/src/v1/components/patterns/document-viewer/ImageView.test.tsx b/frontend/src/v1/components/patterns/document-viewer/ImageView.test.tsx new file mode 100644 index 0000000000..3c082f9ec5 --- /dev/null +++ b/frontend/src/v1/components/patterns/document-viewer/ImageView.test.tsx @@ -0,0 +1,216 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createRef } from "react"; +import { expect, it, vi } from "vitest"; +import { ImageView } from "./ImageView"; +import type { PdfPageListHandle } from "./PdfPageList"; + +function loadImage(width: number, height: number) { + const image = screen.getByRole("img", { hidden: true }); + Object.defineProperties(image, { + naturalWidth: { value: width }, + naturalHeight: { value: height }, + }); + fireEvent.load(image); + return image; +} + +it("shows loading until the image arrives, then sizes it by the zoom", () => { + const onLoadSize = vi.fn(); + const { container, rerender } = render( + , + ); + expect(screen.getByRole("status")).toBeTruthy(); + loadImage(1200, 1600); + expect(screen.queryByRole("status")).toBeNull(); + // Reported in points, so the viewer fits it like a page. + expect(onLoadSize).toHaveBeenCalledWith({ width: 900, height: 1200 }); + + const frame = container.querySelector("img")?.parentElement; + expect(frame?.style.getPropertyValue("--page-width")).toBe("1200px"); + rerender(); + expect(frame?.style.getPropertyValue("--page-width")).toBe("600px"); + expect(frame?.style.getPropertyValue("--page-height")).toBe("800px"); +}); + +it("draws highlights over the image", () => { + const { container } = render( + , + ); + loadImage(1200, 1600); + expect(container.querySelector('[data-highlight-id="h"]')).not.toBeNull(); +}); + +it("offers Retry when the image fails, and loads it again", async () => { + render(); + fireEvent.error(screen.getByRole("img", { hidden: true })); + expect(screen.getByRole("alert").textContent).toContain("didn't load"); + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(screen.getByRole("status")).toBeTruthy(); + expect(screen.getByRole("img", { hidden: true })).toBeTruthy(); +}); + +it("centres a box across and down, and a line only down", () => { + const ref = createRef(); + const base = { page: 0, y: 0.25, h: 0.5, tone: "default" as const }; + const { container } = render( + , + ); + loadImage(1200, 1600); + const frame = container.querySelector("img")?.parentElement; + const root = container.querySelector("section"); + if (!frame || !root) { + throw new Error("no image frame"); + } + Object.defineProperties(frame, { + offsetTop: { value: 16 }, + offsetLeft: { value: 16 }, + offsetHeight: { value: 1600 }, + offsetWidth: { value: 1200 }, + }); + Object.defineProperties(root, { + clientHeight: { value: 400 }, + clientWidth: { value: 500 }, + }); + act(() => { + ref.current?.scrollToHighlight("box"); + }); + // 0.5 × 1600 = 800 down and 0.75 × 1200 = 900 across, from 16. + expect(root.scrollTop).toBe(16 + 800 - 200); + expect(root.scrollLeft).toBe(16 + 900 - 250); + + root.scrollLeft = 30; + act(() => { + ref.current?.scrollToHighlight("line"); + }); + expect(root.scrollTop).toBe(16 + 800 - 200); + expect(root.scrollLeft).toBe(30); +}); + +it("will not scroll to a highlight before the image has its size", () => { + const ref = createRef(); + render( + , + ); + expect(ref.current?.scrollToHighlight("h")).toBe(false); + loadImage(1200, 1600); + expect(ref.current?.scrollToHighlight("h")).toBe(true); +}); + +it("starts over when the URL changes without a remount", () => { + const { container, rerender } = render( + , + ); + loadImage(1200, 1600); + rerender(); + expect(screen.getByRole("status")).toBeTruthy(); + const frame = container.querySelector("img")?.parentElement; + expect(frame?.style.getPropertyValue("--page-width")).toBe(""); +}); + +it("keeps the spot at the centre of the view when zooming", () => { + const { container, rerender } = render( + , + ); + loadImage(1200, 1600); + const frame = container.querySelector("img")?.parentElement; + const root = container.querySelector("section"); + if (!frame || !root) { + throw new Error("no image frame"); + } + // The frame's size follows its style, as the browser's layout would. + const px = (name: string) => + Number.parseFloat(frame.style.getPropertyValue(name)); + Object.defineProperties(frame, { + offsetTop: { value: 16 }, + offsetLeft: { value: 16 }, + offsetWidth: { get: () => px("--page-width") }, + offsetHeight: { get: () => px("--page-height") }, + }); + Object.defineProperties(root, { + clientWidth: { value: 600 }, + clientHeight: { value: 400 }, + }); + // The image's centre at the centre of the view. + root.scrollLeft = 16 + 600 - 300; + root.scrollTop = 16 + 800 - 200; + fireEvent.scroll(root); + + rerender(); + expect(root.scrollLeft).toBeCloseTo(16 + 1200 - 300); + expect(root.scrollTop).toBeCloseTo(16 + 1600 - 200); +}); + +it("names the viewer and the image differently, so neither is read twice", () => { + render(); + loadImage(1200, 1600); + expect(screen.getByRole("region", { name: "Image viewer" })).toBeTruthy(); + expect(screen.getByRole("img", { name: "Invoice scan" })).toBeTruthy(); +}); + +it("anchors the first zoom on where the view is, not on the image's centre", () => { + const { container, rerender } = render( + , + ); + const frame = container.querySelector("img")?.parentElement; + const root = container.querySelector("section"); + if (!frame || !root) { + throw new Error("no image frame"); + } + const px = (name: string) => + Number.parseFloat(frame.style.getPropertyValue(name)) || 0; + Object.defineProperties(frame, { + offsetTop: { value: 16 }, + offsetLeft: { value: 16 }, + offsetWidth: { get: () => px("--page-width") }, + offsetHeight: { get: () => px("--page-height") }, + }); + Object.defineProperties(root, { + clientWidth: { value: 600 }, + clientHeight: { value: 400 }, + }); + // Opened at the top left, wider than the view; never scrolled. + loadImage(1200, 1600); + rerender(); + // The spot at the view's centre (284 px into a 1200 px image) stays there. + expect(root.scrollLeft).toBeCloseTo(16 + (284 / 1200) * 2400 - 300); +}); diff --git a/frontend/src/v1/components/patterns/document-viewer/ImageView.tsx b/frontend/src/v1/components/patterns/document-viewer/ImageView.tsx new file mode 100644 index 0000000000..8faaf125c6 --- /dev/null +++ b/frontend/src/v1/components/patterns/document-viewer/ImageView.tsx @@ -0,0 +1,236 @@ +import { type Highlight, NO_HIGHLIGHTS } from "@v1/lib/pdf/highlights"; +import type { PageSize } from "@v1/lib/pdf/types"; +import { CSS_PIXELS_PER_POINT, pageCssSize } from "@v1/lib/pdf/windowing"; +import { + type KeyboardEvent, + type Ref, + type SyntheticEvent, + useCallback, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { HighlightLayer } from "./HighlightLayer"; +import type { PdfPageListHandle } from "./PdfPageList"; +import { PAGE_FRAME_CLASS } from "./pageFrame"; +import { ViewerStatus } from "./ViewerStatus"; + +export interface ImageViewProps { + url: string; + alt?: string; + /** 1 shows the image at its natural size. */ + zoom: number; + /** Highlights on page 0, in fractions of the image. */ + highlights?: readonly Highlight[]; + activeHighlightId?: string | null; + onHighlightClick?: (highlight: Highlight) => void; + /** + * The image's size once loaded, in PDF points, so the viewer can fit it the + * same way as a page. + */ + onLoadSize?: (size: PageSize) => void; + onKeyDown?: (event: KeyboardEvent) => void; + ref?: Ref; +} + +type LoadState = + | { status: "loading" } + | { status: "failed" } + | { status: "ready"; size: PageSize }; + +/** An image as a one-page document: the same zoom, highlights and scrolling. */ +export function ImageView({ + url, + alt = "Document image", + zoom, + highlights = NO_HIGHLIGHTS, + activeHighlightId, + onHighlightClick, + onLoadSize, + onKeyDown, + ref, +}: ImageViewProps) { + const [load, setLoad] = useState({ status: "loading" }); + const [attempt, setAttempt] = useState(0); + // A new URL is a new image: forget the last one's size and state while + // rendering, so it is never laid out at the old size. + const [shownUrl, setShownUrl] = useState(url); + if (shownUrl !== url) { + setShownUrl(url); + setLoad({ status: "loading" }); + } + const rootRef = useRef(null); + const frameRef = useRef(null); + // The point of the image at the viewport's centre, as fractions of the + // image, so a zoom keeps the reader on the same spot. + const anchorRef = useRef({ x: 0.5, y: 0 }); + const ready = load.status === "ready"; + // An image is one page: highlights for any other page are not drawn. + const pageHighlights = useMemo( + () => highlights.filter((highlight) => highlight.page === 0), + [highlights], + ); + const css = load.status === "ready" ? pageCssSize(load.size, zoom) : null; + + const readAnchor = useCallback(() => { + const root = rootRef.current; + const frame = frameRef.current; + if ( + !root || + !frame || + frame.offsetWidth === 0 || + frame.offsetHeight === 0 + ) { + return; + } + anchorRef.current = { + x: + (root.scrollLeft + root.clientWidth / 2 - frame.offsetLeft) / + frame.offsetWidth, + y: + (root.scrollTop + root.clientHeight / 2 - frame.offsetTop) / + frame.offsetHeight, + }; + }, []); + + useImperativeHandle( + ref, + () => ({ + scrollToPage() { + if (rootRef.current) { + rootRef.current.scrollTop = 0; + readAnchor(); + } + }, + scrollToHighlight(id) { + const highlight = pageHighlights.find( + (candidate) => candidate.id === id, + ); + const root = rootRef.current; + const frame = frameRef.current; + // Until the image has its size the frame is a placeholder: say not + // yet, so the viewer tries again once it has loaded. + if (!highlight || !root || !frame || !ready) { + return false; + } + root.scrollTop = + frame.offsetTop + + (highlight.y + highlight.h / 2) * frame.offsetHeight - + root.clientHeight / 2; + // A line spans the image, so only a box moves the view across; a + // line keeps the reader's horizontal place, as on a PDF page. + if (highlight.variant === "box") { + root.scrollLeft = + frame.offsetLeft + + (highlight.x + highlight.w / 2) * frame.offsetWidth - + root.clientWidth / 2; + } + readAnchor(); + return true; + }, + }), + [pageHighlights, ready, readAnchor], + ); + + // A zoom resizes the frame; put the same spot back at the centre. The frame + // took its new size from its style at commit. + // Once the image is first laid out, the anchor is wherever the view is, + // not a guess. + const previousZoom = useRef(zoom); + const wasReady = useRef(ready); + useLayoutEffect(() => { + const justLoaded = ready && !wasReady.current; + wasReady.current = ready; + const zoomed = previousZoom.current !== zoom; + previousZoom.current = zoom; + if (justLoaded) { + readAnchor(); + return; + } + const root = rootRef.current; + const frame = frameRef.current; + if (!zoomed || !root || !frame || !ready) { + return; + } + const { x, y } = anchorRef.current; + root.scrollLeft = + frame.offsetLeft + x * frame.offsetWidth - root.clientWidth / 2; + root.scrollTop = + frame.offsetTop + y * frame.offsetHeight - root.clientHeight / 2; + }, [zoom, ready, readAnchor]); + + const onLoad = (event: SyntheticEvent) => { + const { naturalWidth, naturalHeight } = event.currentTarget; + // Natural pixels as points, so zoom 1 shows the image pixel for pixel. + const size = { + width: naturalWidth / CSS_PIXELS_PER_POINT, + height: naturalHeight / CSS_PIXELS_PER_POINT, + }; + setLoad({ status: "ready", size }); + onLoadSize?.(size); + }; + + if (load.status === "failed") { + return ( + { + setLoad({ status: "loading" }); + setAttempt((count) => count + 1); + }} + onSubmitPassword={() => undefined} + /> + ); + } + + return ( + // biome-ignore lint/a11y/noNoninteractiveElementInteractions: the keys belong to the focusable scroll region +
+ {load.status === "loading" && ( + undefined} + onSubmitPassword={() => undefined} + /> + )} +
+ +
+
+ ); +} diff --git a/frontend/src/v1/components/patterns/document-viewer/PdfPage.tsx b/frontend/src/v1/components/patterns/document-viewer/PdfPage.tsx index 33f7b2da43..43e59f0b5b 100644 --- a/frontend/src/v1/components/patterns/document-viewer/PdfPage.tsx +++ b/frontend/src/v1/components/patterns/document-viewer/PdfPage.tsx @@ -9,6 +9,7 @@ import { import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from "pdfjs-dist"; import { memo, type ReactNode, useEffect, useRef, useState } from "react"; import { HighlightLayer } from "./HighlightLayer"; +import { PAGE_FRAME_CLASS } from "./pageFrame"; export interface PdfPageProps { document: PDFDocumentProxy; @@ -171,9 +172,7 @@ export const PdfPage = memo(function PdfPage({ // A figure, not role="img": an img's children are presentational, which // would hide the highlight buttons from assistive technology. aria-label={`Page ${index + 1} of ${pageCount}`} - className="ui:relative ui:h-(--page-height) ui:w-(--page-width) ui:shrink-0 ui:bg-card ui:shadow-sm" - // A page's size is runtime geometry, not a token: it goes through custom - // properties, which the classes read. + className={PAGE_FRAME_CLASS} style={{ "--page-width": `${css.width}px`, "--page-height": `${css.height}px`, diff --git a/frontend/src/v1/components/patterns/document-viewer/PdfPageList.tsx b/frontend/src/v1/components/patterns/document-viewer/PdfPageList.tsx index 2be77b2789..d2dea3df5a 100644 --- a/frontend/src/v1/components/patterns/document-viewer/PdfPageList.tsx +++ b/frontend/src/v1/components/patterns/document-viewer/PdfPageList.tsx @@ -1,7 +1,11 @@ import { useDevicePixelRatio } from "@v1/hooks/useDevicePixelRatio"; import { usePageWindow } from "@v1/hooks/usePageWindow"; import { cn } from "@v1/lib/cn"; -import { type Highlight, highlightsByPage } from "@v1/lib/pdf/highlights"; +import { + type Highlight, + highlightsByPage, + NO_HIGHLIGHTS, +} from "@v1/lib/pdf/highlights"; import type { PageSize } from "@v1/lib/pdf/types"; import type { PDFDocumentProxy } from "pdfjs-dist"; import { @@ -18,8 +22,6 @@ import { } from "react"; import { PdfPage } from "./PdfPage"; -const NO_HIGHLIGHTS: readonly Highlight[] = []; - export interface PdfPageListHandle { /** Scrolls a 0-based page to the top of the viewer. */ scrollToPage: (index: number) => void; diff --git a/frontend/src/v1/components/patterns/document-viewer/TextView.test.tsx b/frontend/src/v1/components/patterns/document-viewer/TextView.test.tsx new file mode 100644 index 0000000000..f5711d1360 --- /dev/null +++ b/frontend/src/v1/components/patterns/document-viewer/TextView.test.tsx @@ -0,0 +1,105 @@ +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createRef } from "react"; +import { expect, it, vi } from "vitest"; +import type { PdfPageListHandle } from "./PdfPageList"; +import { TextView } from "./TextView"; + +// Shaped like an LLMWhisperer layout result: columns kept with spaces. +const text = [ + "INVOICE No. 4471", + "", + "Bill to: Acme Corp Date: 2026-10-01", + "Total due: $1,240.00", +].join("\n"); + +function line(container: HTMLElement, index: number): HTMLElement { + const element = container.querySelector( + `[data-line="${index}"]`, + ); + if (!element) { + throw new Error(`no line ${index}`); + } + return element; +} + +it("shows every line, blank ones included, with its number", () => { + const { container } = render(); + expect(container.querySelectorAll("[data-line]")).toHaveLength(4); + expect(line(container, 3).textContent).toBe( + "4Total due: $1,240.00", + ); +}); + +it("highlights whole lines in their tone, and the active one as active", () => { + const { container } = render( + , + ); + expect(line(container, 3).getAttribute("data-tone")).toBe("active"); + expect(line(container, 2).getAttribute("data-tone")).toBe("low-confidence"); + expect(line(container, 0).getAttribute("data-tone")).toBeNull(); +}); + +it("reports clicks on highlighted lines only", async () => { + const onHighlightClick = vi.fn(); + render( + , + ); + const buttons = screen.getAllByRole("button"); + expect(buttons).toHaveLength(1); + await userEvent.click(buttons[0] as HTMLElement); + expect(onHighlightClick).toHaveBeenCalledWith({ id: "total", line: 3 }); +}); + +it("centres a highlighted line on request", () => { + const ref = createRef(); + const { container } = render( + , + ); + const root = screen.getByRole("region", { name: "Document text" }); + Object.defineProperty(root, "clientHeight", { value: 100 }); + Object.defineProperties(line(container, 3), { + offsetTop: { value: 400 }, + offsetHeight: { value: 20 }, + }); + act(() => { + expect(ref.current?.scrollToHighlight("total")).toBe(true); + }); + expect(root.scrollTop).toBe(400 + 10 - 50); + expect(ref.current?.scrollToHighlight("nope")).toBe(false); +}); + +it("says no text was found for blank text, not that there is no document", () => { + render(); + expect(screen.getByText("No text found")).toBeTruthy(); + expect(screen.queryByText("No document")).toBeNull(); +}); + +it("gives every row one gutter, as wide as the longest line number", () => { + const text = Array.from({ length: 120 }, (_, n) => `line ${n}`).join("\n"); + const { container } = render(); + const list = container.querySelector("ol"); + expect(list?.style.getPropertyValue("--gutter")).toBe("3ch"); + const rows = container.querySelectorAll("li"); + expect(rows[8]?.className).toBe(rows[119]?.className); + expect(rows[8]?.className).toContain("grid-cols-[var(--gutter)_1fr]"); +}); + +it("lets the browser skip lines that are off screen, keeping them in the DOM", () => { + const text = Array.from({ length: 50 }, (_, n) => `line ${n}`).join("\n"); + const { container } = render(); + const lines = container.querySelectorAll("li"); + expect(lines).toHaveLength(50); + expect(lines[49]?.className).toContain("[content-visibility:auto]"); +}); diff --git a/frontend/src/v1/components/patterns/document-viewer/TextView.tsx b/frontend/src/v1/components/patterns/document-viewer/TextView.tsx new file mode 100644 index 0000000000..33eee89de4 --- /dev/null +++ b/frontend/src/v1/components/patterns/document-viewer/TextView.tsx @@ -0,0 +1,189 @@ +import { cn } from "@v1/lib/cn"; +import { type HighlightTone, NO_HIGHLIGHTS } from "@v1/lib/pdf/highlights"; +import { + type KeyboardEvent, + memo, + type Ref, + useImperativeHandle, + useMemo, + useRef, +} from "react"; +import type { PdfPageListHandle } from "./PdfPageList"; +import { ViewerStatus } from "./ViewerStatus"; + +/** A highlighted line of text. Lines are 0-based, as LLMWhisperer numbers them. */ +export interface TextLineHighlight { + id: string; + line: number; + tone?: HighlightTone; +} + +export interface TextViewProps { + text: string; + highlights?: readonly TextLineHighlight[]; + activeHighlightId?: string | null; + onHighlightClick?: (highlight: TextLineHighlight) => void; + onKeyDown?: (event: KeyboardEvent) => void; + ref?: Ref; +} + +const TONES: Record = { + default: "ui:bg-primary/15", + active: "ui:bg-primary/30 ui:ring-1 ui:ring-primary", + "low-confidence": "ui:bg-destructive/15", +}; + +/** + * One line. Memoised so that moving the active highlight re-renders only the + * lines it leaves and reaches, not every line of a long result. + */ +const TextLine = memo(function TextLine({ + index, + content, + highlight, + active, + onHighlightClick, +}: { + index: number; + content: string; + highlight: TextLineHighlight | undefined; + active: boolean; + onHighlightClick?: (highlight: TextLineHighlight) => void; +}) { + const tone: HighlightTone | null = highlight + ? active + ? "active" + : (highlight.tone ?? "default") + : null; + const body = + highlight && onHighlightClick ? ( + + ) : ( + {content || " "} + ); + return ( +
  • + + {body} +
  • + ); +}); + +/** + * Text shown line by line with line numbers, as extracted (Raw Text, HITL + * text, an LLMWhisperer result), with whole lines highlighted. + */ +export function TextView({ + text, + highlights = NO_HIGHLIGHTS, + activeHighlightId, + onHighlightClick, + onKeyDown, + ref, +}: TextViewProps) { + const rootRef = useRef(null); + const lines = useMemo(() => text.split(/\r\n|\r|\n/), [text]); + // The last highlight on a line decides how it looks. + const byLine = useMemo(() => { + const map = new Map(); + for (const highlight of highlights) { + map.set(highlight.line, highlight); + } + return map; + }, [highlights]); + + useImperativeHandle( + ref, + () => ({ + scrollToPage() { + if (rootRef.current) { + rootRef.current.scrollTop = 0; + } + }, + scrollToHighlight(id) { + const highlight = highlights.find((candidate) => candidate.id === id); + const root = rootRef.current; + const line = + highlight && + root?.querySelector(`[data-line="${highlight.line}"]`); + if (!root || !line) { + return false; + } + root.scrollTop = + line.offsetTop + line.offsetHeight / 2 - root.clientHeight / 2; + return true; + }, + }), + [highlights], + ); + + if (text.trim() === "") { + return ( + undefined} + onSubmitPassword={() => undefined} + /> + ); + } + + return ( + // biome-ignore lint/a11y/noNoninteractiveElementInteractions: the keys belong to the focusable scroll region +
    +
      + {lines.map((content, index) => { + const highlight = byLine.get(index); + return ( + + ); + })} +
    +
    + ); +} diff --git a/frontend/src/v1/components/patterns/document-viewer/ViewerStatus.tsx b/frontend/src/v1/components/patterns/document-viewer/ViewerStatus.tsx index b99223d878..448c0b439d 100644 --- a/frontend/src/v1/components/patterns/document-viewer/ViewerStatus.tsx +++ b/frontend/src/v1/components/patterns/document-viewer/ViewerStatus.tsx @@ -1,8 +1,13 @@ import type { PdfDocumentState } from "@v1/hooks/usePdfDocument"; import { type FormEvent, useEffect, useId, useRef, useState } from "react"; -/** Every state except `ready`, which the page list renders instead. */ -export type ViewerStatusState = Exclude; +/** + * Every document state except `ready`, which the content renders instead, and + * `no-text`: a text source that was read and holds nothing. + */ +export type ViewerStatusState = + | Exclude + | { status: "no-text" }; export interface ViewerStatusProps { state: ViewerStatusState; @@ -36,6 +41,13 @@ export function ViewerStatus({

    There is nothing to show yet.

    ); + case "no-text": + return ( +
    +

    No text found

    +

    The document was read, but no text came out of it.

    +
    + ); case "unsupported": return (
    diff --git a/frontend/src/v1/components/patterns/document-viewer/ViewerToolbar.tsx b/frontend/src/v1/components/patterns/document-viewer/ViewerToolbar.tsx index 9100d765c9..20ae8b3d85 100644 --- a/frontend/src/v1/components/patterns/document-viewer/ViewerToolbar.tsx +++ b/frontend/src/v1/components/patterns/document-viewer/ViewerToolbar.tsx @@ -9,7 +9,7 @@ import { ZoomIn, ZoomOut, } from "lucide-react"; -import { type FormEvent, useId, useState } from "react"; +import { type FormEvent, type ReactNode, useId, useState } from "react"; export interface ViewerToolbarProps { /** 0-based. */ @@ -24,6 +24,12 @@ export interface ViewerToolbarProps { onZoomOut: () => void; onFitWidth: () => void; onFitPage: () => void; + /** Leading content, such as a document switcher (Figma C10). */ + leading?: ReactNode; + /** Paging controls; off for a single image. */ + showPaging?: boolean; + /** Zoom and fit controls; off for text. */ + showZoom?: boolean; className?: string; } @@ -111,6 +117,9 @@ export function ViewerToolbar({ onZoomOut, onFitWidth, onFitPage, + leading, + showPaging = true, + showZoom = true, className, }: ViewerToolbarProps) { return ( @@ -122,58 +131,65 @@ export function ViewerToolbar({ className, )} > -
    - onPageChange(pageIndex - 1)} - /> - - = pageCount - 1} - onClick={() => onPageChange(pageIndex + 1)} - /> -
    -
    - - - {formatZoom(zoom)} - - - - +
    + {leading} + {showPaging && ( +
    + onPageChange(pageIndex - 1)} + /> + + = pageCount - 1} + onClick={() => onPageChange(pageIndex + 1)} + /> +
    + )}
    + {showZoom && ( +
    + + + {formatZoom(zoom)} + + + + +
    + )}
    ); } diff --git a/frontend/src/v1/components/patterns/document-viewer/pageFrame.ts b/frontend/src/v1/components/patterns/document-viewer/pageFrame.ts new file mode 100644 index 0000000000..bbedf37590 --- /dev/null +++ b/frontend/src/v1/components/patterns/document-viewer/pageFrame.ts @@ -0,0 +1,7 @@ +/** + * The box a page or an image sits in, shared by PdfPage and ImageView. It is + * sized by `--page-width` and `--page-height`, which each sets in its style + * attribute: the size is runtime geometry, not a token. + */ +export const PAGE_FRAME_CLASS = + "ui:relative ui:h-(--page-height) ui:w-(--page-width) ui:shrink-0 ui:bg-card ui:shadow-sm"; diff --git a/frontend/src/v1/lib/pdf/highlights.ts b/frontend/src/v1/lib/pdf/highlights.ts index 82ec494f53..49f748076b 100644 --- a/frontend/src/v1/lib/pdf/highlights.ts +++ b/frontend/src/v1/lib/pdf/highlights.ts @@ -62,6 +62,9 @@ export interface NormaliseOptions { export const LOW_CONFIDENCE_BELOW = 0.9; +/** One shared empty list, so a default never breaks a memo. Fits any list type. */ +export const NO_HIGHLIGHTS: readonly never[] = []; + function clamp01(value: number): number { return Math.min(1, Math.max(0, value)); } diff --git a/frontend/src/v1/lib/pdf/types.ts b/frontend/src/v1/lib/pdf/types.ts index 84cdd3af9c..a408ae8a04 100644 --- a/frontend/src/v1/lib/pdf/types.ts +++ b/frontend/src/v1/lib/pdf/types.ts @@ -27,3 +27,31 @@ export type PdfLoadFailure = | { kind: "password"; incorrect: boolean } | { kind: "unsupported" } | { kind: "failed"; message: string }; + +/** An image shown as a single page. */ +export interface ImageSource { + kind: "image"; + url: string; + alt?: string; +} + +/** Extracted or raw text, shown line by line. */ +export interface TextSource { + kind: "text"; + text: string; + /** + * Which text this is, across edits. Keep it the same while the text is + * edited live (a HITL correction) and the view keeps its place; a new id is + * a new text, back at the top. Without one, any change of text is a new text. + */ + id?: string; +} + +/** Anything the document viewer shows. */ +export type DocumentSource = PdfSource | ImageSource | TextSource; + +export function isPdfSource( + source: DocumentSource | null, +): source is PdfSource { + return source?.kind === "url" || source?.kind === "data"; +}