diff --git a/frontend/src/v1/components/patterns/document-viewer/DocumentViewer.test.tsx b/frontend/src/v1/components/patterns/document-viewer/DocumentViewer.test.tsx new file mode 100644 index 0000000000..77871d4267 --- /dev/null +++ b/frontend/src/v1/components/patterns/document-viewer/DocumentViewer.test.tsx @@ -0,0 +1,510 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { OpenedPdf, PdfSource } from "@v1/lib/pdf/types"; +import type { PDFDocumentProxy } from "pdfjs-dist"; +import { createRef, useLayoutEffect, useRef } from "react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { DocumentViewer, type DocumentViewerHandle } from "./DocumentViewer"; + +const opening = vi.hoisted( + (): { + resolve: (value: OpenedPdf) => void; + reject: (error: unknown) => void; + } => ({ resolve: () => undefined, reject: () => undefined }), +); + +vi.mock("@v1/lib/pdf/pdfjs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + openPdfDocument: () => { + const promise = new Promise((resolve, reject) => { + opening.resolve = resolve; + opening.reject = reject; + }); + return { promise, destroy: vi.fn() }; + }, + }; +}); + +// Every page/count pair the toolbar is rendered with, to catch a stale one. +const toolbarRenders = vi.hoisted(() => [] as [number, number][]); +vi.mock("./ViewerToolbar", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ViewerToolbar: (props: Parameters[0]) => { + toolbarRenders.push([props.pageIndex, props.pageCount]); + return actual.ViewerToolbar(props); + }, + }; +}); + +// The platform's scrollbar, as the viewer would measure it. +const scrollbar = vi.hoisted(() => ({ width: 16 })); +vi.mock("@v1/lib/scrollbar", () => ({ + scrollbarWidth: () => scrollbar.width, +})); + +const resizeCallbacks = new Set<(entries: ResizeObserverEntry[]) => void>(); +// Every observer hears the size: the viewer's body and the page list's own. +const resize = (width: number, height: number) => { + for (const callback of resizeCallbacks) { + callback([{ contentRect: { width, height } } as ResizeObserverEntry]); + } +}; + +class FakeResizeObserver { + constructor( + private readonly callback: (entries: ResizeObserverEntry[]) => void, + ) {} + observe() { + // Sizes arrive only when a test calls resize(). + resizeCallbacks.add(this.callback); + } + disconnect() { + resizeCallbacks.delete(this.callback); + } +} + +/** Reports nothing, so no page renders: these tests are about the chrome. */ +class FakeIntersectionObserver { + observe() { + // Never reports an intersection. + } + unobserve() { + // Nothing to stop. + } + disconnect() { + // Nothing to release. + } +} + +const source: PdfSource = { kind: "url", url: "/invoice.pdf" }; +const document = { + numPages: 3, + getPage: vi.fn(), +} as unknown as PDFDocumentProxy; +// US Letter: 816 CSS px wide at 100%. +const pageSizes = Array.from({ length: 3 }, () => ({ + width: 612, + height: 792, +})); + +async function open() { + await act(async () => opening.resolve({ document, pageSizes })); + // The body is 864 px wide: 816 + 32 px of padding + a 16 px scrollbar. + act(() => resize(864, 600)); +} + +function zoomLabel() { + return screen.getByLabelText("Zoom level").textContent; +} + +beforeEach(() => { + scrollbar.width = 16; + vi.stubGlobal("ResizeObserver", FakeResizeObserver); + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); +}); +afterEach(() => vi.unstubAllGlobals()); + +it("shows the loading state, then the toolbar once the document opens", async () => { + render(); + expect(screen.getByRole("status")).toBeTruthy(); + expect(screen.queryByRole("toolbar")).toBeNull(); + await open(); + expect(screen.getByRole("toolbar")).toBeTruthy(); + expect(screen.getByText("/ 3")).toBeTruthy(); +}); + +it("shows a failure with Retry, in plain words rather than pdf.js's", async () => { + const log = vi.spyOn(console, "error").mockImplementation(() => undefined); + render(); + await act(async () => opening.reject(new Error("Network error"))); + log.mockRestore(); + const alert = screen.getByRole("alert").textContent; + expect(alert).toContain("The document couldn't be opened."); + expect(alert).not.toContain("Network error"); + expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy(); +}); + +it("fits the width by default, and zooms in steps from there", async () => { + render(); + await open(); + expect(zoomLabel()).toBe("100%"); + expect( + screen + .getByRole("button", { name: "Fit width" }) + .getAttribute("aria-pressed"), + ).toBe("true"); + + await userEvent.click(screen.getByRole("button", { name: "Zoom in" })); + expect(zoomLabel()).toBe("125%"); + expect( + screen + .getByRole("button", { name: "Fit width" }) + .getAttribute("aria-pressed"), + ).toBe("false"); +}); + +it("refits when the viewer is resized", async () => { + render(); + await open(); + act(() => resize(456, 600)); + expect(zoomLabel()).toBe("50%"); +}); + +it.each([ + [0, "25%"], + [Number.NaN, "100%"], + [10, "400%"], +])("keeps a starting zoom of %s within range", async (initialZoom, label) => { + render(); + await open(); + expect(zoomLabel()).toBe(label); +}); + +it("zooms with + and − while the pages have focus, and leaves other keys alone", async () => { + render(); + await open(); + const pages = screen.getByRole("region", { name: "Document pages" }); + fireEvent.keyDown(pages, { key: "+" }); + expect(zoomLabel()).toBe("125%"); + fireEvent.keyDown(pages, { key: "-" }); + fireEvent.keyDown(pages, { key: "-" }); + expect(zoomLabel()).toBe("75%"); + fireEvent.keyDown(pages, { key: "+", ctrlKey: true }); + expect(zoomLabel()).toBe("75%"); +}); + +/** Pages 1000 px tall, 16 px apart, as a browser would lay them out. */ +function layOutPages() { + for (const page of window.document.querySelectorAll( + "[data-page-index]", + )) { + const index = Number(page.getAttribute("data-page-index")); + Object.defineProperties(page, { + offsetTop: { value: 16 + index * 1016 }, + offsetHeight: { value: 1000 }, + }); + } + return screen.getByRole("region", { name: "Document pages" }); +} + +it("pages with End and Home while the pages have focus", async () => { + render(); + await open(); + const pages = layOutPages(); + fireEvent.keyDown(pages, { key: "End" }); + expect(pages.scrollTop).toBe(2 * 1016); + fireEvent.keyDown(pages, { key: "Home" }); + expect(pages.scrollTop).toBe(0); +}); + +it("runs a scroll asked for while loading once the pages are laid out", async () => { + // Every page element reports its browser layout from the moment it mounts. + const original = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "offsetTop", + ); + Object.defineProperty(HTMLElement.prototype, "offsetTop", { + configurable: true, + get(this: HTMLElement) { + const index = this.getAttribute("data-page-index"); + return index === null ? 0 : 16 + Number(index) * 1016; + }, + }); + try { + const ref = createRef(); + render(); + act(() => ref.current?.scrollToPage(2)); + await open(); + expect( + screen.getByRole("region", { name: "Document pages" }).scrollTop, + ).toBe(2 * 1016); + } finally { + if (original) { + Object.defineProperty(HTMLElement.prototype, "offsetTop", original); + } + } +}); + +it("fits every page in fit page, so a wider page later on sets the zoom", async () => { + render(); + await act(async () => + opening.resolve({ + document, + // A portrait page first, then a landscape one. + pageSizes: [ + { width: 612, height: 792 }, + { width: 792, height: 612 }, + ], + }), + ); + // Tall enough that only width limits: 816 px across, after the gutter. + act(() => resize(864, 2000)); + await userEvent.click(screen.getByRole("button", { name: "Fit page" })); + // The landscape page is 1056 px wide at 100%: 816 / 1056 ≈ 77%. + expect(zoomLabel()).toBe("77%"); +}); + +it("pages forward and back with PageDown and PageUp, one page per press", async () => { + render(); + await open(); + const pages = layOutPages(); + fireEvent.keyDown(pages, { key: "PageDown" }); + fireEvent.keyDown(pages, { key: "PageDown" }); + expect(pages.scrollTop).toBe(2 * 1016); + expect(screen.getByLabelText("Page").value).toBe("3"); + fireEvent.keyDown(pages, { key: "PageUp" }); + expect(pages.scrollTop).toBe(1016); + expect(screen.getByLabelText("Page").value).toBe("2"); +}); + +/** + * Lays out every page element from the moment it mounts: 1000 px tall, 16 px + * apart. Returns a function that puts the prototype back. + */ +function layOutEveryPage() { + const originals = (["offsetTop", "offsetHeight"] as const).map( + (name) => + [ + name, + Object.getOwnPropertyDescriptor(HTMLElement.prototype, name), + ] as const, + ); + Object.defineProperties(HTMLElement.prototype, { + offsetTop: { + configurable: true, + get(this: HTMLElement) { + const index = this.getAttribute("data-page-index"); + return index === null ? 0 : 16 + Number(index) * 1016; + }, + }, + offsetHeight: { + configurable: true, + get(this: HTMLElement) { + return this.hasAttribute("data-page-index") ? 1000 : 0; + }, + }, + }); + return () => { + for (const [name, descriptor] of originals) { + if (descriptor) { + Object.defineProperty(HTMLElement.prototype, name, descriptor); + } + } + }; +} + +const onPage1 = { + id: "total", + page: 1, + x: 0.1, + y: 0.5, + w: 0.2, + h: 0, + variant: "box" as const, + tone: "default" as const, +}; +// Page 1's top is at 1032, padding included, and the highlight's centre is +// 500 px into it; the viewer reports no height, so centring adds nothing. +const onPage1Top = 1032 + 500; + +function pagesRegion() { + return screen.getByRole("region", { name: "Document pages" }); +} + +it("drops a scroll asked for one document when another is shown", async () => { + const restore = layOutEveryPage(); + try { + const ref = createRef(); + const { rerender } = render(); + act(() => ref.current?.scrollToPage(2)); + rerender( + , + ); + await open(); + expect(pagesRegion().scrollTop).toBe(0); + } finally { + restore(); + } +}); + +it("drops a waiting scroll when the source changes, even back to its own document", async () => { + const restore = layOutEveryPage(); + try { + const ref = createRef(); + const other: PdfSource = { kind: "url", url: "/other.pdf" }; + const { rerender } = render(); + act(() => ref.current?.scrollToPage(2)); + // Away and back before either document has opened. + rerender(); + rerender(); + await open(); + expect(pagesRegion().scrollTop).toBe(0); + } finally { + restore(); + } +}); + +it("scrolls to the active highlight when it changes, and to one set while loading", async () => { + const restore = layOutEveryPage(); + try { + const { rerender } = render( + , + ); + await open(); + expect(pagesRegion().scrollTop).toBe(onPage1Top); + + pagesRegion().scrollTop = 0; + rerender( + , + ); + expect(pagesRegion().scrollTop).toBe(16 + 2 * 1016 + 500); + } finally { + restore(); + } +}); + +it("scrolls to an active highlight that arrives after its id", async () => { + const restore = layOutEveryPage(); + try { + const { rerender } = render( + , + ); + await open(); + expect(pagesRegion().scrollTop).toBe(0); + rerender( + , + ); + expect(pagesRegion().scrollTop).toBe(onPage1Top); + } finally { + restore(); + } +}); + +it("does not scroll to an active highlight cleared as it arrives", async () => { + const restore = layOutEveryPage(); + try { + const { rerender } = render( + , + ); + await open(); + // One render brings the highlight in and clears the selection. + rerender( + , + ); + expect(pagesRegion().scrollTop).toBe(0); + } finally { + restore(); + } +}); + +it("keeps a scroll a parent asks for in its layout effect on mount", async () => { + const restore = layOutEveryPage(); + function Parent() { + const ref = useRef(null); + useLayoutEffect(() => { + ref.current?.scrollToPage(2); + }, []); + return ; + } + try { + render(); + await open(); + expect(pagesRegion().scrollTop).toBe(2 * 1016); + } finally { + restore(); + } +}); + +it("scrolls to an active highlight chosen before its document is shown", async () => { + const restore = layOutEveryPage(); + try { + const { rerender } = render( + , + ); + await open(); + rerender( + , + ); + await open(); + expect(pagesRegion().scrollTop).toBe(onPage1Top); + } finally { + restore(); + } +}); + +it("fits the width to the measured scrollbar, so a 17 px one adds no horizontal scroll", async () => { + scrollbar.width = 17; + render(); + await open(); + const page = screen.getAllByRole("figure")[0]; + const width = Number.parseFloat( + page?.style.getPropertyValue("--page-width") ?? "", + ); + // 864 - 32 - 17 = 815 px for the page, not 816. + expect(width).toBeCloseTo(815); +}); + +it("waits for the viewer's size before laying out pages in a fit mode", async () => { + render(); + await act(async () => opening.resolve({ document, pageSizes })); + expect(screen.getByRole("toolbar")).toBeTruthy(); + expect(screen.queryAllByRole("figure")).toHaveLength(0); + act(() => resize(864, 600)); + expect(screen.getAllByRole("figure")).toHaveLength(3); +}); + +it("starts a new document on its first page", async () => { + const { rerender } = render(); + await open(); + fireEvent.keyDown(screen.getByRole("region", { name: "Document pages" }), { + key: "End", + }); + expect(screen.getByLabelText("Page")).toHaveProperty("value", "3"); + + toolbarRenders.length = 0; + rerender(); + const other = { ...document, numPages: 1 } as PDFDocumentProxy; + await act(async () => + opening.resolve({ document: other, pageSizes: pageSizes.slice(0, 1) }), + ); + expect(screen.getByLabelText("Page")).toHaveProperty("value", "1"); + expect(screen.getByText("/ 1")).toBeTruthy(); + // Not even one render showed page 3 of 1. + expect(toolbarRenders.filter(([index, count]) => index >= count)).toEqual([]); +}); diff --git a/frontend/src/v1/components/patterns/document-viewer/DocumentViewer.tsx b/frontend/src/v1/components/patterns/document-viewer/DocumentViewer.tsx new file mode 100644 index 0000000000..076b5ef480 --- /dev/null +++ b/frontend/src/v1/components/patterns/document-viewer/DocumentViewer.tsx @@ -0,0 +1,340 @@ +import { useElementSize } from "@v1/hooks/useElementSize"; +import { usePdfDocument } from "@v1/hooks/usePdfDocument"; +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 { + clampZoom, + fitZoom, + MAX_ZOOM, + MIN_ZOOM, + type ZoomMode, + zoomIn, + zoomOut, +} from "@v1/lib/pdf/zoom"; +import { scrollbarWidth } from "@v1/lib/scrollbar"; +import { + type KeyboardEvent, + type Ref, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { PdfPageList, type PdfPageListHandle } from "./PdfPageList"; +import { ViewerStatus } from "./ViewerStatus"; +import { ViewerToolbar } from "./ViewerToolbar"; + +export interface DocumentViewerHandle { + /** Scrolls a 0-based page to the top. Waits for the document if it is loading. */ + scrollToPage: (index: number) => void; + /** + * Centres a highlight. Waits for the document if it is loading, and for the + * highlight if it is not in `highlights` yet. + */ + scrollToHighlight: (id: string) => void; +} + +export interface DocumentViewerProps { + source: PdfSource | null; + highlights?: readonly Highlight[]; + /** Shown in the active tone and scrolled into view when it changes. */ + activeHighlightId?: string | null; + onHighlightClick?: (highlight: Highlight) => void; + /** A zoom level (1 is 100%) or a fit; fit width by default. */ + initialZoom?: number | Exclude; + className?: string; + ref?: Ref; +} + +type ScrollRequest = + | { kind: "page"; index: number } + | { kind: "highlight"; id: string }; + +/** A request that could not run yet, and what it was made for. */ +interface PendingRequest { + request: ScrollRequest; + /** The source shown when it was made; another source drops it. */ + sourceKey: string; + /** Made for `activeHighlightId`, so clearing that id drops it. */ + fromActive: boolean; +} + +interface ZoomState { + mode: ZoomMode; + /** The level in custom mode; ignored while fitting. */ + level: number; +} + +/** The page list's padding, both sides together, in CSS px. */ +const LIST_PADDING = 32; + +/** + * Room around a fitted page, in CSS px: the list's padding plus this + * platform's scrollbar, measured rather than guessed, so fitting never causes + * a horizontal scrollbar. + */ +function fitGutter(): number { + return LIST_PADDING + scrollbarWidth(); +} + +function widest(pageSizes: readonly PageSize[]): PageSize | undefined { + let best: PageSize | undefined; + for (const size of pageSizes) { + if (!best || size.width > best.width) { + best = size; + } + } + return best; +} + +function initialZoomState( + initial: DocumentViewerProps["initialZoom"], +): ZoomState { + if (typeof initial === "number") { + return { mode: "custom", level: clampZoom(initial) }; + } + return { mode: initial ?? "fit-width", level: 1 }; +} + +/** + * 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 −. + */ +export function DocumentViewer({ + source, + highlights, + activeHighlightId, + onHighlightClick, + initialZoom, + className, + ref, +}: DocumentViewerProps) { + const { state, retry, submitPassword } = usePdfDocument(source); + 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 measured = zoomState.mode === "custom" || bodySize !== null; + const showPages = ready && measured; + const sourceKey = pdfSourceKey(source); + // 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); + const sourceKeyRef = useRef(sourceKey); + useLayoutEffect(() => { + readyRef.current = showPages; + sourceKeyRef.current = sourceKey; + }, [showPages, sourceKey]); + const pageSizes = ready ? state.pageSizes : undefined; + const pageCount = pageSizes?.length ?? 0; + + const zoom = useMemo(() => { + if (zoomState.mode === "custom" || !pageSizes || !bodySize) { + return zoomState.level; + } + if (zoomState.mode === "fit-width") { + const page = widest(pageSizes); + return page + ? fitZoom(page, bodySize.width, null, fitGutter()) + : zoomState.level; + } + // Fit page fits every page, not the current one: in a document that mixes + // sizes, a zoom that followed the current page would rescale the whole + // document each time scrolling crossed into a page of another size. + let level = Number.POSITIVE_INFINITY; + for (const page of pageSizes) { + level = Math.min( + level, + fitZoom(page, bodySize.width, bodySize.height, fitGutter()), + ); + } + return Number.isFinite(level) ? level : zoomState.level; + }, [zoomState, pageSizes, bodySize]); + + // A request runs now if it can, or waits in pendingRef: for the pages while + // loading, and for its highlight while that is not in the list yet. A newer + // request replaces a waiting one. + const run = useCallback((request: ScrollRequest, fromActive = false) => { + const list = listRef.current; + const wait = () => { + pendingRef.current = { + request, + sourceKey: sourceKeyRef.current, + fromActive, + }; + }; + if (!list || !readyRef.current) { + wait(); + } else if (request.kind === "page") { + pendingRef.current = null; + list.scrollToPage(request.index); + } else if (list.scrollToHighlight(request.id)) { + pendingRef.current = null; + } else { + wait(); + } + }, []); + + useImperativeHandle( + ref, + () => ({ + scrollToPage: (index) => run({ kind: "page", index }), + scrollToHighlight: (id) => run({ kind: "highlight", id }), + }), + [run], + ); + + // A request waiting for one source is dropped as soon as the source changes, + // even while nothing is shown: switching away and back before either opens + // must not bring it back. + useEffect(() => { + if (pendingRef.current && pendingRef.current.sourceKey !== sourceKey) { + pendingRef.current = null; + } + }, [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 + // 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) { + return; + } + const stillActive = + !pending.fromActive || + (pending.request.kind === "highlight" && + pending.request.id === activeHighlightId); + if (pending.sourceKey === sourceKey && stillActive) { + run(pending.request, pending.fromActive); + } else { + pendingRef.current = null; + } + }, [showPages, sourceKey, highlights, 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. + // biome-ignore lint/correctness/useExhaustiveDependencies: re-armed on each new source + useEffect(() => { + if (activeHighlightId) { + run({ kind: "highlight", id: activeHighlightId }, true); + } else if (pendingRef.current?.fromActive) { + pendingRef.current = null; + } + }, [activeHighlightId, sourceKey, run]); + + const goToPage = useCallback( + (index: number) => run({ kind: "page", index }), + [run], + ); + const zoomBy = useCallback( + (step: (level: number) => number) => + setZoomState({ mode: "custom", level: step(zoom) }), + [zoom], + ); + + const onKeyDown = (event: KeyboardEvent) => { + const action = viewerKeyAction(event); + if (action === null) { + return; + } + event.preventDefault(); + switch (action) { + case "previous-page": + goToPage(pageIndex - 1); + break; + case "next-page": + goToPage(pageIndex + 1); + break; + case "first-page": + goToPage(0); + break; + case "last-page": + goToPage(pageCount - 1); + break; + case "zoom-in": + zoomBy(zoomIn); + break; + case "zoom-out": + zoomBy(zoomOut); + break; + } + }; + + return ( +
+ {ready && ( + MIN_ZOOM} + onPageChange={goToPage} + onZoomIn={() => zoomBy(zoomIn)} + onZoomOut={() => zoomBy(zoomOut)} + onFitWidth={() => setZoomState({ mode: "fit-width", level: zoom })} + onFitPage={() => setZoomState({ mode: "fit-page", level: zoom })} + /> + )} +
+ {ready ? ( + showPages && ( + + ) + ) : ( + + )} +
+
+ ); +} diff --git a/frontend/src/v1/components/patterns/document-viewer/PdfPageList.test.tsx b/frontend/src/v1/components/patterns/document-viewer/PdfPageList.test.tsx index 0338f19477..a67269e113 100644 --- a/frontend/src/v1/components/patterns/document-viewer/PdfPageList.test.tsx +++ b/frontend/src/v1/components/patterns/document-viewer/PdfPageList.test.tsx @@ -1,7 +1,8 @@ import { act, render } from "@testing-library/react"; import type { PDFDocumentProxy } from "pdfjs-dist"; +import { createRef } from "react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { PdfPageList } from "./PdfPageList"; +import { PdfPageList, type PdfPageListHandle } from "./PdfPageList"; /** An IntersectionObserver whose "in view" set the test controls. */ class FakeIntersectionObserver { @@ -21,12 +22,20 @@ class FakeIntersectionObserver { disconnect() { this.targets.clear(); } + emit(entries: object[]) { + this.callback(entries as unknown as IntersectionObserverEntry[]); + } show(indexes: number[]) { const entries = [...this.targets].map((target) => ({ target, isIntersecting: indexes.includes( Number(target.getAttribute("data-page-index")), ), + intersectionRatio: indexes.includes( + Number(target.getAttribute("data-page-index")), + ) + ? 1 + : 0, })); this.callback(entries as unknown as IntersectionObserverEntry[]); } @@ -95,8 +104,23 @@ function show(indexes: number[]) { act(() => FakeIntersectionObserver.current?.show(indexes)); } +let viewerResized: (() => void) | null = null; + +class FakeResizeObserver { + constructor(callback: () => void) { + viewerResized = callback; + } + observe() { + // Reports only when a test calls viewerResized(). + } + disconnect() { + viewerResized = null; + } +} + beforeEach(() => { vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + vi.stubGlobal("ResizeObserver", FakeResizeObserver); vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => window.setTimeout(() => cb(0), 0), ); @@ -378,6 +402,363 @@ it("draws each highlight on its own page", () => { ).toBeNull(); }); +/** + * Gives the list a layout, which happy-dom does not compute: pages 800 × 600 + * at zoom 1, 16 px apart after 16 px of padding, in a viewport 500 wide and + * 400 tall unless `viewHeight` says otherwise. + */ +function layOut( + container: HTMLElement, + zoom: () => number, + viewHeight: () => number = () => 400, +) { + const root = container.querySelector("section"); + if (!root) { + throw new Error("no scroll region"); + } + const count = container.querySelectorAll("[data-page-index]").length; + Object.defineProperties(root, { + clientHeight: { get: viewHeight }, + clientWidth: { value: 500 }, + scrollHeight: { + get: () => 16 + count * (800 * zoom() + 16), + configurable: true, + }, + }); + for (const page of container.querySelectorAll( + "[data-page-index]", + )) { + const index = Number(page.getAttribute("data-page-index")); + Object.defineProperties(page, { + offsetTop: { + get: () => 16 + index * (800 * zoom() + 16), + configurable: true, + }, + offsetHeight: { get: () => 800 * zoom(), configurable: true }, + offsetWidth: { get: () => 600 * zoom() }, + offsetLeft: { get: () => 16 }, + }); + } + return root; +} + +it("scrolls a page to the top exactly, with no wait", () => { + const { document } = fakeDocument(10); + const ref = createRef(); + const { container } = render( + , + ); + const root = layOut(container, () => 1); + act(() => ref.current?.scrollToPage(3)); + expect(root.scrollTop).toBe(3 * 816); + act(() => ref.current?.scrollToPage(99)); + expect(root.scrollTop).toBe(9 * 816); +}); + +it("centres a highlight, across as well as down for a box", () => { + const { document } = fakeDocument(10); + const ref = createRef(); + const highlights = [ + { + id: "w", + page: 2, + x: 0.5, + y: 0.25, + w: 0.1, + h: 0.05, + variant: "box" as const, + tone: "default" as const, + }, + ]; + const { container } = render( + , + ); + const root = layOut(container, () => 1); + let found = false; + act(() => { + found = ref.current?.scrollToHighlight("w") ?? false; + }); + expect(found).toBe(true); + // Page 2's top is at 1648, padding included; the box's centre is + // 0.275 × 800 = 220 into it, and the view is 400 tall. + expect(root.scrollTop).toBe(1648 + 220 - 200); + // 0.55 × 600 = 330 across, from the page's left edge at 16. + expect(root.scrollLeft).toBe(16 + 330 - 250); + expect(ref.current?.scrollToHighlight("missing")).toBe(false); +}); + +it("keeps the same spot of the same page in view across a zoom change", () => { + const { document } = fakeDocument(10); + let zoom = 1; + const { container, rerender } = render( + , + ); + const root = layOut(container, () => zoom); + show([0]); + // Half way down page 0. + root.scrollTop = 400; + act(() => { + root.dispatchEvent(new Event("scroll")); + }); + zoom = 2; + rerender( + , + ); + expect(root.scrollTop).toBe(800); +}); + +function scrollTo(root: HTMLElement, top: number) { + root.scrollTop = top; + act(() => { + root.dispatchEvent(new Event("scroll")); + }); +} + +it("reports the page showing most of itself", () => { + const { document } = fakeDocument(10); + const onCurrentPageChange = vi.fn(); + const { container } = render( + , + ); + const root = layOut(container, () => 1); + // Page 4 spans 3264-4064 and page 5 4080-4880: 100 px of 4, 284 px of 5. + scrollTo(root, 3964); + expect(onCurrentPageChange).toHaveBeenLastCalledWith(5); + // 300 px of 4, 84 px of 5. + scrollTo(root, 3764); + expect(onCurrentPageChange).toHaveBeenLastCalledWith(4); +}); + +it("counts a short page shown whole over part of a taller one", () => { + const { document } = fakeDocument(3); + const onCurrentPageChange = vi.fn(); + const heights = [1000, 200, 1000]; + const { container } = render( + ({ width: 600, height }))} + zoom={1} + onCurrentPageChange={onCurrentPageChange} + />, + ); + const root = layOut( + container, + () => 1, + () => 600, + ); + // Pages 0-1000, 1016-1216 and 1232-2232, laid out by hand. + const tops = [16, 1032, 1248]; + for (const page of container.querySelectorAll( + "[data-page-index]", + )) { + const index = Number(page.getAttribute("data-page-index")); + Object.defineProperties(page, { + offsetTop: { get: () => tops[index] }, + offsetHeight: { get: () => heights[index] }, + }); + } + Object.defineProperty(root, "scrollHeight", { get: () => 2264 }); + // 700-1300: 300 px of page 0 (half the view), all 200 px of page 1, and + // 52 px of page 2. Page 1 shows the most of itself. + scrollTo(root, 700); + expect(onCurrentPageChange).toHaveBeenLastCalledWith(1); + // 400-1000: page 0 fills the view, so it counts as shown whole. + scrollTo(root, 400); + expect(onCurrentPageChange).toHaveBeenLastCalledWith(0); +}); + +it("keeps the current page right for a page much taller than the viewer", () => { + const { document } = fakeDocument(10); + const onCurrentPageChange = vi.fn(); + const { container } = render( + , + ); + // At 400% a page is 3200 px in a 400 px viewer, so no intersection ratio + // ever reaches a quarter. Page 2 spans 6432-9632. + const root = layOut(container, () => 4); + scrollTo(root, 9332); + expect(onCurrentPageChange).toHaveBeenLastCalledWith(2); + scrollTo(root, 9600); + expect(onCurrentPageChange).toHaveBeenLastCalledWith(3); +}); + +it("reports the last page once the end is reached, even when others show as much", () => { + const { document } = fakeDocument(10); + const onCurrentPageChange = vi.fn(); + const { container } = render( + , + ); + // Two whole pages fit at the bottom; without the end rule page 8 would win + // the tie and Next would never reach page 9. + const root = layOut( + container, + () => 1, + () => 2000, + ); + scrollTo(root, root.scrollHeight - 2000); + expect(onCurrentPageChange).toHaveBeenLastCalledWith(9); +}); + +it("pages back from the last page when the last pages fit together", () => { + const { document } = fakeDocument(10); + const ref = createRef(); + const onCurrentPageChange = vi.fn(); + const { container } = render( + , + ); + const root = layOut( + container, + () => 1, + () => 2000, + ); + // Clamp scrolling at the end, as a browser does. + let scrollTop = 0; + Object.defineProperty(root, "scrollTop", { + get: () => scrollTop, + set: (value: number) => { + scrollTop = Math.min( + Math.max(0, value), + root.scrollHeight - root.clientHeight, + ); + }, + }); + act(() => ref.current?.scrollToPage(9)); + expect(onCurrentPageChange).toHaveBeenLastCalledWith(9); + // Page 8's top is past the end of the scroll, so the jump lands at the end. + act(() => ref.current?.scrollToPage(8)); + act(() => { + root.dispatchEvent(new Event("scroll")); + }); + expect(onCurrentPageChange).toHaveBeenLastCalledWith(8); + act(() => ref.current?.scrollToPage(7)); + expect(onCurrentPageChange).toHaveBeenLastCalledWith(7); + // A scroll by the reader goes back to measuring. + scrollTo(root, root.scrollHeight - root.clientHeight); + expect(onCurrentPageChange).toHaveBeenLastCalledWith(9); +}); + +it("updates the current page when the viewer changes height without scrolling", () => { + const { document } = fakeDocument(10); + const onCurrentPageChange = vi.fn(); + const { container } = render( + , + ); + let height = 400; + const root = layOut( + container, + () => 1, + () => height, + ); + // Page 2 spans 1648-2448 and page 3 2464-3264: 300 px of 2, 84 px of 3. + scrollTo(root, 2148); + expect(onCurrentPageChange).toHaveBeenLastCalledWith(2); + // Taller: 300 px of 2, all 800 of 3. + height = 1200; + act(() => viewerResized?.()); + expect(onCurrentPageChange).toHaveBeenLastCalledWith(3); +}); + +it("reports the page jumped to without waiting for a scroll event", () => { + const { document } = fakeDocument(10); + const ref = createRef(); + const onCurrentPageChange = vi.fn(); + const { container } = render( + , + ); + layOut(container, () => 1); + act(() => ref.current?.scrollToPage(6)); + expect(onCurrentPageChange).toHaveBeenLastCalledWith(6); +}); + +it("keeps the same spot of a later page across a zoom change", () => { + const { document } = fakeDocument(10); + let zoom = 1; + const { container, rerender } = render( + , + ); + const root = layOut(container, () => zoom); + // A quarter of the way down page 3, which starts at 3 × 816. + scrollTo(root, 3 * 816 + 200); + zoom = 2; + rerender( + , + ); + expect(root.scrollTop).toBe(3 * 1616 + 400); +}); + +it("zooms around the page jumped to, even after the jump's scroll event", () => { + const { document } = fakeDocument(200); + const ref = createRef(); + let zoom = 1; + const { container, rerender } = render( + , + ); + const root = layOut(container, () => zoom); + act(() => ref.current?.scrollToPage(100)); + // The browser fires the jump's scroll event before intersections update. + act(() => { + root.dispatchEvent(new Event("scroll")); + }); + zoom = 1.5; + rerender( + , + ); + expect(root.scrollTop).toBe(100 * (1200 + 16)); +}); + it("cleans up a page that arrives after it has left the window", async () => { const { document } = fakeDocument(10); const late = { getViewport: vi.fn(), render: vi.fn(), cleanup: vi.fn() }; @@ -486,3 +867,22 @@ it("blends highlights into a page once it has painted, and keeps it through a zo await flush(); expect(blended()).toBe(false); }); + +it("reads the page elements once, not on every scroll", async () => { + const { document } = fakeDocument(20); + const { container } = render( + , + ); + const region = container.querySelector("section"); + if (!region) { + throw new Error("no region"); + } + const query = vi.spyOn(region, "querySelectorAll"); + for (let step = 0; step < 10; step++) { + act(() => { + region.scrollTop = step * 100; + region.dispatchEvent(new Event("scroll")); + }); + } + expect(query.mock.calls.length).toBeLessThanOrEqual(1); +}); diff --git a/frontend/src/v1/components/patterns/document-viewer/PdfPageList.tsx b/frontend/src/v1/components/patterns/document-viewer/PdfPageList.tsx index 13e12aef57..2be77b2789 100644 --- a/frontend/src/v1/components/patterns/document-viewer/PdfPageList.tsx +++ b/frontend/src/v1/components/patterns/document-viewer/PdfPageList.tsx @@ -4,11 +4,29 @@ import { cn } from "@v1/lib/cn"; import { type Highlight, highlightsByPage } from "@v1/lib/pdf/highlights"; import type { PageSize } from "@v1/lib/pdf/types"; import type { PDFDocumentProxy } from "pdfjs-dist"; -import { type ReactNode, useMemo } from "react"; +import { + type KeyboardEvent, + type ReactNode, + type Ref, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} 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; + /** Centres a highlight in the viewer. False when no highlight has that id. */ + scrollToHighlight: (id: string) => boolean; +} + export interface PdfPageListProps { document: PDFDocumentProxy; pageSizes: readonly PageSize[]; @@ -19,18 +37,115 @@ export interface PdfPageListProps { activeHighlightId?: string | null; /** Keep it stable (useCallback): a new function re-renders every page. */ onHighlightClick?: (highlight: Highlight) => void; + /** Called with the 0-based page showing most of itself. */ + onCurrentPageChange?: (index: number) => void; + onKeyDown?: (event: KeyboardEvent) => void; /** * Drawn over a page, above its highlights; receives the 0-based page index. * Keep it stable (useCallback): a new function re-renders every page. */ renderPageOverlay?: (index: number) => ReactNode; className?: string; + ref?: Ref; +} + +/** Where the reader is, kept across a zoom change: a page and a share of it. */ +interface Anchor { + index: number; + fraction: number; +} + +function pageElement(root: HTMLElement, index: number): HTMLElement | null { + return root.querySelector(`[data-page-index="${index}"]`); +} + +/** A page's top in scroll coordinates, so that page 0 sits at 0. */ +function pageTop(root: HTMLElement, page: HTMLElement): number { + const first = pageElement(root, 0); + return page.offsetTop - (first?.offsetTop ?? 0); +} + +/** The page elements in index order, read with one query per measurement. */ +function pageElements(root: HTMLElement): HTMLElement[] { + return Array.from(root.querySelectorAll("[data-page-index]")); +} + +/** + * The first index whose `edge` is past `y`, or the page count when none is, + * found by halving the range. + */ +function firstPast( + pages: readonly HTMLElement[], + y: number, + edge: (page: HTMLElement) => number, +): number { + let low = 0; + let high = pages.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + const page = pages[middle]; + if (page && edge(page) > y) { + high = middle; + } else { + low = middle + 1; + } + } + return low; +} + +/** + * The page showing the largest share of itself in view (a page taller than + * the view counts the view as whole); the lower index wins a tie, and + * the last page wins once the end is reached, where a short last page could + * otherwise never show the most. Read from the layout, so it is exact for + * pages of any height and right straight after a jump, unlike intersection + * ratios, which only arrive at thresholds and a frame later. + */ +function mostVisiblePage( + root: HTMLElement, + pages: readonly HTMLElement[], +): number { + const last = pages.length - 1; + const scrollable = root.scrollHeight > root.clientHeight; + if ( + scrollable && + root.scrollTop >= root.scrollHeight - root.clientHeight - 1 + ) { + return last; + } + // In the content's own coordinates, where the padding sits above page 0. + const top = root.scrollTop; + const bottom = top + root.clientHeight; + let best = Math.min( + last, + firstPast(pages, top, (page) => page.offsetTop + page.offsetHeight), + ); + let bestShown = Number.NEGATIVE_INFINITY; + for (let index = best; index <= last; index++) { + const page = pages[index]; + if (!page || page.offsetTop >= bottom) { + break; + } + const shown = + (Math.min(page.offsetTop + page.offsetHeight, bottom) - + Math.max(page.offsetTop, top)) / + // A share of what the page could show: of itself, or of the view when + // it is taller than the view. Raw pixels would let part of a tall page + // beat a short page shown whole. + Math.max(1, Math.min(page.offsetHeight, root.clientHeight)); + if (shown > bestShown) { + best = index; + bestShown = shown; + } + } + return best; } /** * Every page of a document in one scrolling column. All pages are laid out at - * their real size from the start, so the scrollbar is right, but only the - * pages in view (± 2) hold a canvas. + * their real size from the start, so the scrollbar is right and scrolling to + * any page or highlight lands exactly without waiting for a render, but only + * the pages in view (± 2) hold a canvas. */ export function PdfPageList({ document, @@ -39,11 +154,142 @@ export function PdfPageList({ highlights = NO_HIGHLIGHTS, activeHighlightId, onHighlightClick, + onCurrentPageChange, + onKeyDown, renderPageOverlay, className, + ref, }: PdfPageListProps) { - const { scrollRef, observePage, rendered } = usePageWindow(pageSizes.length); + const pageCount = pageSizes.length; + const { scrollRef, observePage, rendered } = usePageWindow(pageCount); const byPage = useMemo(() => highlightsByPage(highlights), [highlights]); + const rootRef = useRef(null); + const anchorRef = useRef({ index: 0, fraction: 0 }); + // The last page jumped to and where the jump landed. While the view stays + // there, that page is current, even when the browser clamped the jump at + // the end and another page shows more: otherwise Previous could never + // leave the last page when the last few pages fit together. + const jumpRef = useRef<{ index: number; scrollTop: number } | null>(null); + const [current, setCurrent] = useState(0); + // The page elements, read once: the list is keyed per document and its + // pages never remount, so scrolling never has to query the DOM again. + const pagesRef = useRef([]); + + const setRoot = useCallback( + (element: HTMLElement | null) => { + rootRef.current = element; + scrollRef(element); + }, + [scrollRef], + ); + + useEffect(() => { + onCurrentPageChange?.(current); + }, [current, onCurrentPageChange]); + + // Reads where the reader is from the layout: the page at the top, for the + // zoom anchor, and the page showing most, for the toolbar. Runs on every + // scroll and straight after a jump, so neither waits for a scroll event. + const sync = useCallback(() => { + const root = rootRef.current; + if (!root) { + return; + } + if (pagesRef.current.length !== pageCount) { + pagesRef.current = pageElements(root); + } + const pages = pagesRef.current; + if (pages.length === 0) { + return; + } + // The anchor uses the same coordinates as the jumps and the restore: + // page 0's top at 0. + const origin = pages[0]?.offsetTop ?? 0; + const y = root.scrollTop + origin; + const index = Math.max( + 0, + firstPast(pages, y, (page) => page.offsetTop) - 1, + ); + const page = pages[index]; + if (page && page.offsetHeight > 0) { + anchorRef.current = { + index, + fraction: (y - page.offsetTop) / page.offsetHeight, + }; + } + const jump = jumpRef.current; + if (jump && Math.abs(root.scrollTop - jump.scrollTop) < 1) { + setCurrent(jump.index); + } else { + jumpRef.current = null; + setCurrent(mostVisiblePage(root, pages)); + } + }, [pageCount]); + + // A taller or shorter viewer changes which page shows most without any + // scroll event. + useEffect(() => { + const root = rootRef.current; + if (!root || typeof ResizeObserver === "undefined") { + return; + } + const observer = new ResizeObserver(() => sync()); + observer.observe(root); + return () => observer.disconnect(); + }, [sync]); + + // A zoom change resizes every page; keep the same spot of the same page at + // the top. The pages took their new size from their style at commit. + const previousZoom = useRef(zoom); + useLayoutEffect(() => { + if (previousZoom.current === zoom) { + return; + } + previousZoom.current = zoom; + const root = rootRef.current; + const { index, fraction } = anchorRef.current; + const page = root && pageElement(root, index); + if (root && page) { + root.scrollTop = pageTop(root, page) + fraction * page.offsetHeight; + sync(); + } + }, [zoom, sync]); + + useImperativeHandle( + ref, + () => ({ + scrollToPage(index) { + const root = rootRef.current; + const clamped = Math.min(Math.max(0, index), pageCount - 1); + const page = root && pageElement(root, clamped); + if (root && page) { + root.scrollTop = pageTop(root, page); + jumpRef.current = { index: clamped, scrollTop: root.scrollTop }; + sync(); + } + }, + scrollToHighlight(id) { + const highlight = highlights.find((candidate) => candidate.id === id); + const root = rootRef.current; + const page = highlight && root && pageElement(root, highlight.page); + if (!highlight || !root || !page) { + return false; + } + const centreY = (highlight.y + highlight.h / 2) * page.offsetHeight; + // The page's own top, padding included: centred exactly, unlike a + // page jump, which keeps the gap above the page. + root.scrollTop = page.offsetTop + centreY - root.clientHeight / 2; + if (highlight.variant === "box") { + const centreX = (highlight.x + highlight.w / 2) * page.offsetWidth; + root.scrollLeft = page.offsetLeft + centreX - root.clientWidth / 2; + } + sync(); + return true; + }, + }), + [highlights, pageCount, sync], + ); + // Only the page holding the active highlight hears its id, so moving the // active highlight re-renders the page it left and the page it reached. const activePage = useMemo( @@ -55,10 +301,22 @@ export function PdfPageList({ [highlights, activeHighlightId], ); const pixelRatio = useDevicePixelRatio(); + + // A scrollable region must be focusable for keyboard users (WCAG 2.1.1), + // and its focus is where the viewer's paging and zoom keys are read. return ( -
{/* w-max with min-w-full: centred while pages fit, scrollable both ways once a zoomed page is wider than the viewer. */} @@ -83,6 +341,6 @@ export function PdfPageList({ /> ))}
- + ); } diff --git a/frontend/src/v1/components/patterns/document-viewer/ViewerToolbar.test.tsx b/frontend/src/v1/components/patterns/document-viewer/ViewerToolbar.test.tsx new file mode 100644 index 0000000000..da3af729aa --- /dev/null +++ b/frontend/src/v1/components/patterns/document-viewer/ViewerToolbar.test.tsx @@ -0,0 +1,130 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { expect, it, vi } from "vitest"; +import { ViewerToolbar, type ViewerToolbarProps } from "./ViewerToolbar"; + +function setup(overrides: Partial = {}) { + const props: ViewerToolbarProps = { + pageIndex: 0, + pageCount: 12, + zoom: 1.25, + zoomMode: "custom", + canZoomIn: true, + canZoomOut: true, + onPageChange: vi.fn(), + onZoomIn: vi.fn(), + onZoomOut: vi.fn(), + onFitWidth: vi.fn(), + onFitPage: vi.fn(), + ...overrides, + }; + const { rerender } = render(); + return { + ...props, + rerender: (next: Partial) => + rerender(), + }; +} + +it("shows the page as n / N and the zoom as a percentage", () => { + setup({ pageIndex: 2 }); + expect(screen.getByLabelText("Page").value).toBe("3"); + expect(screen.getByText("/ 12")).toBeTruthy(); + expect(screen.getByLabelText("Zoom level").textContent).toBe("125%"); +}); + +it("pages back and forward, and stops at either end", async () => { + const first = setup(); + expect( + screen.getByRole("button", { name: "Previous page" }) + .disabled, + ).toBe(true); + await userEvent.click(screen.getByRole("button", { name: "Next page" })); + expect(first.onPageChange).toHaveBeenCalledWith(1); +}); + +it("pages back from the middle of the document", async () => { + const props = setup({ pageIndex: 3 }); + await userEvent.click(screen.getByRole("button", { name: "Previous page" })); + expect(props.onPageChange).toHaveBeenCalledWith(2); +}); + +it("disables Next on the last page", () => { + setup({ pageIndex: 11 }); + expect( + screen.getByRole("button", { name: "Next page" }) + .disabled, + ).toBe(true); +}); + +it("jumps to a typed page on Enter and restores a page out of range", async () => { + const props = setup(); + const box = screen.getByLabelText("Page"); + await userEvent.clear(box); + await userEvent.type(box, "5{Enter}"); + expect(props.onPageChange).toHaveBeenCalledWith(4); + + await userEvent.clear(box); + await userEvent.type(box, "99{Enter}"); + expect(props.onPageChange).toHaveBeenCalledTimes(1); + expect(box.value).toBe("1"); +}); + +it("zooms, fits, and shows the active fit as pressed", async () => { + const props = setup({ zoomMode: "fit-width", canZoomIn: false }); + expect( + screen.getByRole("button", { name: "Zoom in" }).disabled, + ).toBe(true); + await userEvent.click(screen.getByRole("button", { name: "Zoom out" })); + await userEvent.click(screen.getByRole("button", { name: "Fit page" })); + expect(props.onZoomOut).toHaveBeenCalled(); + expect(props.onFitPage).toHaveBeenCalled(); + expect( + screen + .getByRole("button", { name: "Fit width" }) + .getAttribute("aria-pressed"), + ).toBe("true"); +}); + +it("is reachable with Tab, control by control", async () => { + setup({ pageIndex: 3 }); + const order = [ + "Previous page", + "Page", + "Next page", + "Zoom out", + "Zoom in", + "Fit width", + "Fit page", + ]; + for (const name of order) { + await userEvent.tab(); + const focused = document.activeElement; + const label = + focused?.getAttribute("aria-label") ?? + (focused?.id + ? document.querySelector(`label[for="${focused.id}"]`)?.textContent + : null); + expect(label).toBe(name); + } +}); + +it("keeps a half-typed page and the focus when the current page changes", async () => { + const props = setup(); + const box = screen.getByLabelText("Page"); + await userEvent.clear(box); + await userEvent.type(box, "1"); + props.rerender({ pageIndex: 3 }); + const after = screen.getByLabelText("Page"); + expect(after).toBe(box); + expect(document.activeElement).toBe(box); + expect(box.value).toBe("1"); + await userEvent.type(box, "2{Enter}"); + expect(props.onPageChange).toHaveBeenCalledWith(11); +}); + +it("follows the current page while not being edited", () => { + const props = setup(); + props.rerender({ pageIndex: 6 }); + expect(screen.getByLabelText("Page").value).toBe("7"); +}); diff --git a/frontend/src/v1/components/patterns/document-viewer/ViewerToolbar.tsx b/frontend/src/v1/components/patterns/document-viewer/ViewerToolbar.tsx new file mode 100644 index 0000000000..9100d765c9 --- /dev/null +++ b/frontend/src/v1/components/patterns/document-viewer/ViewerToolbar.tsx @@ -0,0 +1,179 @@ +import { cn } from "@v1/lib/cn"; +import { formatZoom, type ZoomMode } from "@v1/lib/pdf/zoom"; +import { + ChevronLeft, + ChevronRight, + type LucideIcon, + MoveHorizontal, + Scan, + ZoomIn, + ZoomOut, +} from "lucide-react"; +import { type FormEvent, useId, useState } from "react"; + +export interface ViewerToolbarProps { + /** 0-based. */ + pageIndex: number; + pageCount: number; + zoom: number; + zoomMode: ZoomMode; + canZoomIn: boolean; + canZoomOut: boolean; + onPageChange: (index: number) => void; + onZoomIn: () => void; + onZoomOut: () => void; + onFitWidth: () => void; + onFitPage: () => void; + className?: string; +} + +const iconButton = + "ui:inline-flex ui:size-8 ui:shrink-0 ui:items-center ui:justify-center ui:rounded-md ui:text-foreground ui:hover:bg-accent ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:aria-pressed:bg-accent ui:focus-visible:focus-ring"; + +function ToolbarButton({ + icon: Icon, + label, + onClick, + disabled, + pressed, +}: { + icon: LucideIcon; + label: string; + onClick: () => void; + disabled?: boolean; + pressed?: boolean; +}) { + return ( + + ); +} + +/** The page box: shows the current page, and jumps to a typed one on Enter. */ +function PageInput({ + pageIndex, + pageCount, + onPageChange, +}: Pick) { + // What the reader is typing, or null when they are not editing: then the + // box follows the current page, and a page change never discards a draft. + const [draft, setDraft] = useState(null); + const id = useId(); + const commit = (event: FormEvent) => { + event.preventDefault(); + const page = Number.parseInt(draft ?? "", 10); + if (Number.isInteger(page) && page >= 1 && page <= pageCount) { + onPageChange(page - 1); + } + setDraft(null); + }; + return ( +
+ + setDraft(event.target.value)} + onBlur={() => setDraft(null)} + className="ui:w-10 ui:rounded-md ui:border ui:border-input ui:bg-background ui:px-1 ui:py-1 ui:text-center ui:text-foreground ui:focus-visible:focus-ring" + /> + / {pageCount} +
+ ); +} + +/** Paging and zoom controls for the document viewer. */ +export function ViewerToolbar({ + pageIndex, + pageCount, + zoom, + zoomMode, + canZoomIn, + canZoomOut, + onPageChange, + onZoomIn, + onZoomOut, + onFitWidth, + onFitPage, + className, +}: ViewerToolbarProps) { + return ( +
+
+ onPageChange(pageIndex - 1)} + /> + + = pageCount - 1} + onClick={() => onPageChange(pageIndex + 1)} + /> +
+
+ + + {formatZoom(zoom)} + + + + +
+
+ ); +} diff --git a/frontend/src/v1/hooks/useElementSize.test.ts b/frontend/src/v1/hooks/useElementSize.test.ts new file mode 100644 index 0000000000..f0f118c915 --- /dev/null +++ b/frontend/src/v1/hooks/useElementSize.test.ts @@ -0,0 +1,46 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { useElementSize } from "./useElementSize"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +it("measures once where ResizeObserver is missing, rather than throwing", () => { + vi.stubGlobal("ResizeObserver", undefined); + const element = document.createElement("div"); + vi.spyOn(element, "clientWidth", "get").mockReturnValue(640); + vi.spyOn(element, "clientHeight", "get").mockReturnValue(480); + const { result } = renderHook(() => useElementSize()); + act(() => result.current[0](element)); + expect(result.current[1]).toEqual({ width: 640, height: 480 }); +}); + +it("follows the element's size with a ResizeObserver", () => { + let report: ResizeObserverCallback = () => undefined; + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: ResizeObserverCallback) { + report = callback; + } + observe() { + // Sizes arrive only through report(). + } + disconnect() { + // Nothing to release. + } + }, + ); + const { result } = renderHook(() => useElementSize()); + act(() => result.current[0](document.createElement("div"))); + expect(result.current[1]).toBeNull(); + act(() => + report( + [{ contentRect: { width: 300, height: 200 } } as ResizeObserverEntry], + {} as ResizeObserver, + ), + ); + expect(result.current[1]).toEqual({ width: 300, height: 200 }); +}); diff --git a/frontend/src/v1/hooks/useElementSize.ts b/frontend/src/v1/hooks/useElementSize.ts new file mode 100644 index 0000000000..c88efaade6 --- /dev/null +++ b/frontend/src/v1/hooks/useElementSize.ts @@ -0,0 +1,46 @@ +import { useCallback, useEffect, useState } from "react"; + +export interface ElementSize { + width: number; + height: number; +} + +/** + * An element's content-box size, kept current with a ResizeObserver. Returns a + * ref for the element and its size, null until it has been measured. Where + * ResizeObserver is missing (old embedded webviews) it measures once, so a + * caller waiting for a size is never left waiting. + */ +export function useElementSize(): [ + (element: T | null) => void, + ElementSize | null, +] { + const [element, setElement] = useState(null); + const [size, setSize] = useState(null); + + useEffect(() => { + if (element === null) { + return; + } + if (typeof ResizeObserver === "undefined") { + setSize({ width: element.clientWidth, height: element.clientHeight }); + return; + } + const observer = new ResizeObserver(([entry]) => { + if (!entry) { + return; + } + const { width, height } = entry.contentRect; + setSize((previous) => + previous?.width === width && previous.height === height + ? previous + : { width, height }, + ); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [element]); + + const ref = useCallback((next: T | null) => setElement(next), []); + return [ref, size]; +} diff --git a/frontend/src/v1/lib/pdf/keyboard.test.ts b/frontend/src/v1/lib/pdf/keyboard.test.ts new file mode 100644 index 0000000000..a8323ea18a --- /dev/null +++ b/frontend/src/v1/lib/pdf/keyboard.test.ts @@ -0,0 +1,36 @@ +import { expect, it } from "vitest"; +import { viewerKeyAction } from "./keyboard"; + +it("maps paging and zoom keys", () => { + expect(viewerKeyAction({ key: "PageUp" })).toBe("previous-page"); + expect(viewerKeyAction({ key: "PageDown" })).toBe("next-page"); + expect(viewerKeyAction({ key: "Home" })).toBe("first-page"); + expect(viewerKeyAction({ key: "End" })).toBe("last-page"); + expect(viewerKeyAction({ key: "+" })).toBe("zoom-in"); + expect(viewerKeyAction({ key: "=" })).toBe("zoom-in"); + expect(viewerKeyAction({ key: "-" })).toBe("zoom-out"); +}); + +it("leaves other keys and modified keys to the browser", () => { + expect(viewerKeyAction({ key: "ArrowDown" })).toBeNull(); + expect(viewerKeyAction({ key: "+", ctrlKey: true })).toBeNull(); + expect(viewerKeyAction({ key: "-", metaKey: true })).toBeNull(); + expect(viewerKeyAction({ key: "Home", altKey: true })).toBeNull(); +}); + +it("leaves keys typed into a field to the field", () => { + const input = document.createElement("input"); + const textarea = document.createElement("textarea"); + const select = document.createElement("select"); + for (const target of [input, textarea, select]) { + expect(viewerKeyAction({ key: "-", target })).toBeNull(); + expect(viewerKeyAction({ key: "End", target })).toBeNull(); + } + // happy-dom does not compute isContentEditable, so give it the shape. + expect( + viewerKeyAction({ key: "PageDown", target: { isContentEditable: true } }), + ).toBeNull(); + expect( + viewerKeyAction({ key: "PageDown", target: document.createElement("div") }), + ).toBe("next-page"); +}); diff --git a/frontend/src/v1/lib/pdf/keyboard.ts b/frontend/src/v1/lib/pdf/keyboard.ts new file mode 100644 index 0000000000..3c3ce1f9a9 --- /dev/null +++ b/frontend/src/v1/lib/pdf/keyboard.ts @@ -0,0 +1,61 @@ +/** What a key does in the document viewer, when the viewer has focus. */ +export type ViewerKeyAction = + | "previous-page" + | "next-page" + | "first-page" + | "last-page" + | "zoom-in" + | "zoom-out"; + +const TEXT_FIELDS = new Set(["INPUT", "TEXTAREA", "SELECT"]); + +/** Whether a key pressed here belongs to a field the user is typing in. */ +function isEditable(target: unknown): boolean { + if (typeof target !== "object" || target === null) { + return false; + } + const element = target as { tagName?: unknown; isContentEditable?: unknown }; + return ( + element.isContentEditable === true || + (typeof element.tagName === "string" && TEXT_FIELDS.has(element.tagName)) + ); +} + +/** + * Maps a key to a viewer action. Keys pressed with Ctrl, Meta or Alt are left + * to the browser, so its own zoom and shortcuts keep working, and keys typed + * into a field (an input, textarea, select or contenteditable drawn over the + * pages) stay with the field. + */ +export function viewerKeyAction(event: { + key: string; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + target?: unknown; +}): ViewerKeyAction | null { + if (event.ctrlKey || event.metaKey || event.altKey) { + return null; + } + if (isEditable(event.target)) { + return null; + } + switch (event.key) { + case "PageUp": + return "previous-page"; + case "PageDown": + return "next-page"; + case "Home": + return "first-page"; + case "End": + return "last-page"; + case "+": + case "=": + return "zoom-in"; + case "-": + case "_": + return "zoom-out"; + default: + return null; + } +} diff --git a/frontend/src/v1/lib/pdf/pdfjs.ts b/frontend/src/v1/lib/pdf/pdfjs.ts index 66397540e3..6335de1d50 100644 --- a/frontend/src/v1/lib/pdf/pdfjs.ts +++ b/frontend/src/v1/lib/pdf/pdfjs.ts @@ -66,8 +66,21 @@ export function toDocumentParams(source: PdfSource): DocumentInitParameters { return { ...shared, data: bytes }; } -const dataSourceIds = new WeakMap(); -let nextDataSourceId = 0; +const objectKeys = new WeakMap(); +let nextObjectKey = 0; + +/** + * A number for an object's identity, the same for as long as the object + * lives. The one notion of identity for documents and byte sources. + */ +export function objectKey(value: object): number { + let key = objectKeys.get(value); + if (key === undefined) { + key = nextObjectKey++; + objectKeys.set(value, key); + } + return key; +} /** * A key that changes only when the document would: the URL and request options @@ -89,12 +102,7 @@ export function pdfSourceKey(source: PdfSource | null): string { headers, ]); } - let id = dataSourceIds.get(source.data); - if (id === undefined) { - id = nextDataSourceId++; - dataSourceIds.set(source.data, id); - } - return `data:${id}`; + return `data:${objectKey(source.data)}`; } function pageSize(page: PDFPageProxy): PageSize { diff --git a/frontend/src/v1/lib/pdf/zoom.test.ts b/frontend/src/v1/lib/pdf/zoom.test.ts new file mode 100644 index 0000000000..36878094aa --- /dev/null +++ b/frontend/src/v1/lib/pdf/zoom.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + clampZoom, + fitZoom, + formatZoom, + MAX_ZOOM, + MIN_ZOOM, + zoomIn, + zoomOut, +} from "./zoom"; + +describe("zoomIn / zoomOut", () => { + it("steps through the levels", () => { + expect(zoomIn(1)).toBe(1.25); + expect(zoomOut(1)).toBe(0.75); + }); + + it("goes to the nearest level from a fitted zoom between levels", () => { + expect(zoomIn(1.1)).toBe(1.25); + expect(zoomOut(1.1)).toBe(1); + expect(zoomIn(0.9999)).toBe(1.25); + }); + + it("stops at the ends", () => { + expect(zoomIn(MAX_ZOOM)).toBe(MAX_ZOOM); + expect(zoomOut(MIN_ZOOM)).toBe(MIN_ZOOM); + }); +}); + +describe("fitZoom", () => { + const letter = { width: 612, height: 792 }; + + it("fits the page width inside the gutter", () => { + // 612 pt at 96/72 is 816 CSS px. + expect(fitZoom(letter, 816 + 32, null, 32)).toBeCloseTo(1); + expect(fitZoom(letter, 408 + 32, null, 32)).toBeCloseTo(0.5); + }); + + it("fits the whole page, limited by the tighter side", () => { + expect(fitZoom(letter, 2000, 1056 + 32, 32)).toBeCloseTo(1); + }); + + it("stays within the zoom range", () => { + expect(fitZoom(letter, 10, null, 32)).toBe(MIN_ZOOM); + expect(fitZoom(letter, 100_000, null, 32)).toBe(MAX_ZOOM); + }); +}); + +it("clamps and formats", () => { + expect(clampZoom(10)).toBe(MAX_ZOOM); + expect(clampZoom(Number.NaN)).toBe(1); + expect(formatZoom(1.25)).toBe("125%"); + expect(formatZoom(0.333)).toBe("33%"); +}); diff --git a/frontend/src/v1/lib/pdf/zoom.ts b/frontend/src/v1/lib/pdf/zoom.ts new file mode 100644 index 0000000000..bc23e9f553 --- /dev/null +++ b/frontend/src/v1/lib/pdf/zoom.ts @@ -0,0 +1,63 @@ +/* + * Zoom levels for the document viewer. Zoom 1 is 100%: a page at its printed + * size (see CSS_PIXELS_PER_POINT). + */ +import type { PageSize } from "./types"; +import { CSS_PIXELS_PER_POINT } from "./windowing"; + +/** The levels the − and + buttons step through. */ +export const ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4] as const; +export const MIN_ZOOM = ZOOM_STEPS[0]; +export const MAX_ZOOM = ZOOM_STEPS[ZOOM_STEPS.length - 1]; + +/** How the zoom is chosen: a fixed level, or fitted to the viewer. */ +export type ZoomMode = "custom" | "fit-width" | "fit-page"; + +// Two steps a hair apart (a fitted 0.9999) should not count as different. +const EPSILON = 1e-3; + +export function clampZoom(zoom: number): number { + if (!Number.isFinite(zoom)) { + return 1; + } + return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom)); +} + +/** The next level above the current zoom, which may be between steps. */ +export function zoomIn(zoom: number): number { + return ZOOM_STEPS.find((step) => step > zoom + EPSILON) ?? MAX_ZOOM; +} + +/** The next level below the current zoom, which may be between steps. */ +export function zoomOut(zoom: number): number { + for (let index = ZOOM_STEPS.length - 1; index >= 0; index--) { + const step = ZOOM_STEPS[index]; + if (step !== undefined && step < zoom - EPSILON) { + return step; + } + } + return MIN_ZOOM; +} + +/** + * The zoom at which a page fills the given width, or the given box when a + * height is given too. `gutter` is the space kept around the page, in CSS px. + */ +export function fitZoom( + page: PageSize, + width: number, + height: number | null, + gutter: number, +): number { + const byWidth = (width - gutter) / (page.width * CSS_PIXELS_PER_POINT); + if (height === null) { + return clampZoom(byWidth); + } + const byHeight = (height - gutter) / (page.height * CSS_PIXELS_PER_POINT); + return clampZoom(Math.min(byWidth, byHeight)); +} + +/** The zoom as the toolbar shows it, e.g. "125%". */ +export function formatZoom(zoom: number): string { + return `${Math.round(zoom * 100)}%`; +} diff --git a/frontend/src/v1/lib/scrollbar.test.ts b/frontend/src/v1/lib/scrollbar.test.ts new file mode 100644 index 0000000000..b9fed1c25b --- /dev/null +++ b/frontend/src/v1/lib/scrollbar.test.ts @@ -0,0 +1,34 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { resetScrollbarWidth, scrollbarWidth } from "./scrollbar"; + +afterEach(() => { + resetScrollbarWidth(); + vi.restoreAllMocks(); +}); + +function withScrollbar(width: number) { + vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(100); + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue( + 100 - width, + ); +} + +it("measures a classic scrollbar, and removes its probe", () => { + withScrollbar(17); + const before = document.body.childElementCount; + expect(scrollbarWidth()).toBe(17); + expect(document.body.childElementCount).toBe(before); +}); + +it("is 0 with overlay scrollbars", () => { + withScrollbar(0); + expect(scrollbarWidth()).toBe(0); +}); + +it("measures once", () => { + withScrollbar(15); + const append = vi.spyOn(document.body, "appendChild"); + scrollbarWidth(); + scrollbarWidth(); + expect(append).toHaveBeenCalledTimes(1); +}); diff --git a/frontend/src/v1/lib/scrollbar.ts b/frontend/src/v1/lib/scrollbar.ts new file mode 100644 index 0000000000..0e8ed359e3 --- /dev/null +++ b/frontend/src/v1/lib/scrollbar.ts @@ -0,0 +1,29 @@ +let measured: number | null = null; + +/** + * The width of a classic vertical scrollbar on this platform, in CSS px: 0 + * with overlay scrollbars (macOS, mobile), usually 15 to 17 elsewhere. + * Measured once with an off-screen probe, so nothing has to guess it. + */ +export function scrollbarWidth(doc: Document = document): number { + if (measured !== null) { + return measured; + } + const body = doc.body; + if (!body) { + return 0; + } + const probe = doc.createElement("div"); + // A probe, not page styling: it never paints and is removed at once. + probe.style.cssText = + "position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;visibility:hidden"; + body.appendChild(probe); + measured = Math.max(0, probe.offsetWidth - probe.clientWidth); + probe.remove(); + return measured; +} + +/** Forgets the measurement. For tests. */ +export function resetScrollbarWidth(): void { + measured = null; +}