Repository navigation
UN-4504 [DEV] Add v1 PDF highlight layer and highlight normalisers #2407
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: UN-4503-v1-pdf-pages
Are you sure you want to change the base?
Changes from all commits
215cd80
aee0bc6
24f39e9
f65af28
a66001e
aa214dc
79b7401
cd47c0e
e94aa7d
419c96a
3520be3
5a7a049
e8f70be
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HTMLElement>( | ||
| `[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(<HighlightLayer highlights={[line, word]} />); | ||
| 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( | ||
| <HighlightLayer highlights={[line, word]} activeId="line-0" />, | ||
| ); | ||
| 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(<HighlightLayer highlights={[line]} />); | ||
| expect(screen.queryByRole("button")).toBeNull(); | ||
| }); | ||
|
|
||
| it("reports clicks with the highlight, and labels each button", async () => { | ||
| const onHighlightClick = vi.fn(); | ||
| render( | ||
| <HighlightLayer | ||
| highlights={[line, word]} | ||
| activeId="word-0" | ||
| onHighlightClick={onHighlightClick} | ||
| />, | ||
| ); | ||
| 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(<HighlightLayer highlights={[]} />); | ||
| expect(container.firstChild).toBeNull(); | ||
| }); | ||
|
|
||
| it("blends into the page only once the page has painted", () => { | ||
| const { container, rerender } = render( | ||
| <HighlightLayer highlights={[line]} painted={false} />, | ||
| ); | ||
| // On the placeholder (dark in dark themes) multiply would hide the box. | ||
| expect(box(container, "line-0").className).not.toContain( | ||
| "mix-blend-multiply", | ||
| ); | ||
| rerender(<HighlightLayer highlights={[line]} painted />); | ||
| 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(<HighlightLayer highlights={[word]} />); | ||
| const button = render( | ||
| <HighlightLayer highlights={[word]} onHighlightClick={vi.fn()} />, | ||
| ); | ||
| 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); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HighlightTone, string> = { | ||
| 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; | ||
| }) { | ||
|
Check warning on line 47 in frontend/src/v1/components/patterns/document-viewer/HighlightLayer.tsx
|
||
| // 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 <div className={className} {...shared} />; | ||
| } | ||
| return ( | ||
| <button | ||
| type="button" | ||
| {...shared} | ||
| aria-label={label(highlight, highlight.page + 1)} | ||
| aria-current={active ? "true" : undefined} | ||
| onClick={() => onClick(highlight)} | ||
| className={cn( | ||
| className, | ||
| "ui:pointer-events-auto ui:cursor-pointer ui:focus-visible:focus-ring", | ||
| )} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| /** The highlights of one page, laid over it. Sized by its page, not by itself. */ | ||
| export function HighlightLayer({ | ||
| highlights, | ||
| activeId, | ||
| onHighlightClick, | ||
| painted = true, | ||
| }: HighlightLayerProps) { | ||
|
Check warning on line 91 in frontend/src/v1/components/patterns/document-viewer/HighlightLayer.tsx
|
||
| if (highlights.length === 0) { | ||
| return null; | ||
| } | ||
| return ( | ||
| <div className="ui:pointer-events-none ui:absolute ui:inset-0"> | ||
| {highlights.map((highlight) => ( | ||
| <HighlightBox | ||
| key={highlight.id} | ||
| highlight={highlight} | ||
| active={highlight.id === activeId} | ||
| painted={painted} | ||
| onClick={onHighlightClick} | ||
| /> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.