Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
108 changes: 108 additions & 0 deletions frontend/src/v1/components/patterns/document-viewer/HighlightLayer.tsx
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaGGqhO0nn3mpWyzKEWa&open=AaGGqhO0nn3mpWyzKEWa&pullRequest=2407
// 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,
Comment thread
muhammad-ali-e marked this conversation as resolved.
Resolved
};
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaGGqhO0nn3mpWyzKEWb&open=AaGGqhO0nn3mpWyzKEWb&pullRequest=2407
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>
);
}
28 changes: 25 additions & 3 deletions frontend/src/v1/components/patterns/document-viewer/PdfPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Highlight } from "@v1/lib/pdf/highlights";
import { isRenderCancelled } from "@v1/lib/pdf/pdfjs";
import type { PageSize } from "@v1/lib/pdf/types";
import {
Expand All @@ -7,6 +8,7 @@ import {
} from "@v1/lib/pdf/windowing";
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from "pdfjs-dist";
import { memo, type ReactNode, useEffect, useRef, useState } from "react";
import { HighlightLayer } from "./HighlightLayer";

export interface PdfPageProps {
document: PDFDocumentProxy;
Expand All @@ -22,10 +24,16 @@ export interface PdfPageProps {
/** The screen's device pixel ratio; a change redraws the page. */
pixelRatio: number;
observe: (element: HTMLElement | null) => (() => void) | undefined;
/** This page's highlights. Keep the array stable: the page is memoised. */
highlights: readonly Highlight[];
/** The active highlight's id; the list gives it only to the page holding it. */
activeHighlightId?: string | null;
/** Keep it stable (useCallback): a new function re-renders every page. */
onHighlightClick?: (highlight: Highlight) => void;
/**
* Drawn over the page, in a box the same size as the page; receives the
* page's index. Called inside the page, so keep it stable (useCallback):
* a new function re-renders every page.
* Drawn over the page, above its highlights, in a box the page's size;
* receives the page's index. Called inside the page, so keep it stable
* (useCallback): a new function re-renders every page.
*/
renderOverlay?: (index: number) => ReactNode;
}
Expand All @@ -47,12 +55,18 @@ export const PdfPage = memo(function PdfPage({
rendered,
pixelRatio,
observe,
highlights,
activeHighlightId,
onHighlightClick,
renderOverlay,
}: PdfPageProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [state, setState] = useState<RenderState>("idle");
// Bumped by Retry, to run the render again.
const [attempt, setAttempt] = useState(0);
// Whether the canvas holds a picture. It outlives a redraw (zoom keeps the
// old picture until the new one lands) and ends when the canvas is freed.
const [painted, setPainted] = useState(false);
const css = pageCssSize(size, zoom);

// Free the canvas when the page leaves the window or unmounts: zero its
Expand All @@ -66,6 +80,7 @@ export const PdfPage = memo(function PdfPage({
return () => {
canvas.width = 0;
canvas.height = 0;
setPainted(false);
};
}, [rendered]);

Expand Down Expand Up @@ -124,6 +139,7 @@ export const PdfPage = memo(function PdfPage({
throw new Error("No 2D canvas context");
}
shown.drawImage(scratch, 0, 0);
setPainted(true);
setState("rendered");
} catch (error) {
if (!cancelled && !isRenderCancelled(error)) {
Expand Down Expand Up @@ -169,6 +185,12 @@ export const PdfPage = memo(function PdfPage({
className="ui:absolute ui:inset-0 ui:block ui:size-full"
/>
)}
<HighlightLayer
Comment thread
muhammad-ali-e marked this conversation as resolved.
Resolved
highlights={highlights}
activeId={activeHighlightId}
onHighlightClick={onHighlightClick}
painted={painted}
/>
Comment thread
muhammad-ali-e marked this conversation as resolved.
Resolved
{renderOverlay?.(index)}
{state === "error" && (
<figcaption className="ui:absolute ui:inset-0 ui:flex ui:flex-col ui:items-center ui:justify-center ui:gap-2 ui:bg-card ui:text-body ui:text-muted-foreground">
Expand Down
Loading
Loading