diff --git a/frontend/src/v1/components/patterns/document-viewer/HighlightLayer.test.tsx b/frontend/src/v1/components/patterns/document-viewer/HighlightLayer.test.tsx new file mode 100644 index 0000000000..80fe212fc5 --- /dev/null +++ b/frontend/src/v1/components/patterns/document-viewer/HighlightLayer.test.tsx @@ -0,0 +1,110 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Highlight } from "@v1/lib/pdf/highlights"; +import { expect, it, vi } from "vitest"; +import { HighlightLayer } from "./HighlightLayer"; + +const line: Highlight = { + id: "line-0", + page: 0, + x: 0, + y: 0.25, + w: 1, + h: 0.02, + variant: "line", + tone: "default", + confidence: 0.97, +}; +const word: Highlight = { + id: "word-0", + page: 0, + x: 0.5, + y: 0.25, + w: 0.1, + h: 0.02, + variant: "box", + tone: "low-confidence", + confidence: 0.62, +}; + +function box(container: HTMLElement, id: string): HTMLElement { + const element = container.querySelector( + `[data-highlight-id="${id}"]`, + ); + if (!element) { + throw new Error(`no highlight ${id}`); + } + return element; +} + +it("places highlights as percentages of the page", () => { + const { container } = render(); + const element = box(container, "word-0"); + expect(element.style.getPropertyValue("--hl-x")).toBe("50%"); + expect(element.style.getPropertyValue("--hl-y")).toBe("25%"); + expect(element.style.getPropertyValue("--hl-w")).toBe("10%"); + expect(element.style.getPropertyValue("--hl-h")).toBe("2%"); + expect(element.getAttribute("data-variant")).toBe("box"); + expect(box(container, "line-0").getAttribute("data-variant")).toBe("line"); +}); + +it("shows each highlight's tone, and the active one as active", () => { + const { container } = render( + , + ); + expect(box(container, "line-0").getAttribute("data-tone")).toBe("active"); + expect(box(container, "word-0").getAttribute("data-tone")).toBe( + "low-confidence", + ); +}); + +it("is not interactive without a click handler", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); +}); + +it("reports clicks with the highlight, and labels each button", async () => { + const onHighlightClick = vi.fn(); + render( + , + ); + const button = screen.getByRole("button", { + name: "Highlight on page 1, 62% confidence", + }); + expect(button.getAttribute("aria-current")).toBe("true"); + await userEvent.click(button); + expect(onHighlightClick).toHaveBeenCalledWith(word); +}); + +it("renders nothing for a page without highlights", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); +}); + +it("blends into the page only once the page has painted", () => { + const { container, rerender } = render( + , + ); + // On the placeholder (dark in dark themes) multiply would hide the box. + expect(box(container, "line-0").className).not.toContain( + "mix-blend-multiply", + ); + rerender(); + expect(box(container, "line-0").className).toContain("mix-blend-multiply"); +}); + +it("gives the clickable highlight the same geometry and data as the plain one", () => { + const plain = render(); + const button = render( + , + ); + const a = box(plain.container, "word-0"); + const b = box(button.container, "word-0"); + expect(b.tagName).toBe("BUTTON"); + expect(b.getAttribute("style")).toBe(a.getAttribute("style")); + expect(b.dataset).toEqual(a.dataset); +}); diff --git a/frontend/src/v1/components/patterns/document-viewer/HighlightLayer.tsx b/frontend/src/v1/components/patterns/document-viewer/HighlightLayer.tsx new file mode 100644 index 0000000000..647335fdfb --- /dev/null +++ b/frontend/src/v1/components/patterns/document-viewer/HighlightLayer.tsx @@ -0,0 +1,108 @@ +import { cn } from "@v1/lib/cn"; +import type { Highlight, HighlightTone } from "@v1/lib/pdf/highlights"; + +export interface HighlightLayerProps { + /** This page's highlights. */ + highlights: readonly Highlight[]; + /** Shown in the active tone, whatever its own tone. */ + activeId?: string | null; + /** When given, highlights are buttons that report clicks. */ + onHighlightClick?: (highlight: Highlight) => void; + /** + * Whether the page's canvas has painted. Until then the boxes sit on the + * page placeholder, which is dark in dark themes, so they skip the multiply + * blend (it would hide them) and show as plain translucent fills. + */ + painted?: boolean; +} + +const TONES: Record = { + default: "ui:bg-primary/25", + active: "ui:bg-primary/40 ui:ring-2 ui:ring-primary", + "low-confidence": "ui:bg-destructive/25", +}; + +function percent(fraction: number): string { + return `${fraction * 100}%`; +} + +function label(highlight: Highlight, pageNumber: number): string { + const confidence = + highlight.confidence === undefined + ? "" + : `, ${Math.round(highlight.confidence * 100)}% confidence`; + return `Highlight on page ${pageNumber}${confidence}`; +} + +function HighlightBox({ + highlight, + active, + painted, + onClick, +}: { + highlight: Highlight; + active: boolean; + painted: boolean; + onClick?: (highlight: Highlight) => void; +}) { + // Page fractions as percentages of the page box, so the box needs no update + // when the zoom changes. Runtime geometry: custom properties the classes read. + const tone: HighlightTone = active ? "active" : highlight.tone; + const shared = { + style: { + "--hl-x": percent(highlight.x), + "--hl-y": percent(highlight.y), + "--hl-w": percent(highlight.w), + "--hl-h": percent(highlight.h), + }, + "data-highlight-id": highlight.id, + "data-variant": highlight.variant, + "data-tone": tone, + }; + const className = cn( + "ui:absolute ui:top-(--hl-y) ui:left-(--hl-x) ui:h-(--hl-h) ui:w-(--hl-w) ui:rounded-sm", + painted && "ui:mix-blend-multiply", + TONES[tone], + ); + if (onClick === undefined) { + return
; + } + return ( +