Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
46570ea
UN-4506 [FEAT] Show text and images in the v1 DocumentViewer, with a …
muhammad-ali-e Oct 29, 2026
e26e3db
UN-4506 Merge UN-4505 Biome import-order fix
muhammad-ali-e Oct 29, 2026
3bbaf84
UN-4506 Merge UN-4505-v1-pdf-toolbar into UN-4506-v1-text-image-views
muhammad-ali-e Oct 29, 2026
53eb9bb
UN-4506 Merge UN-4505-v1-pdf-toolbar into UN-4506-v1-text-image-views
muhammad-ali-e Oct 29, 2026
2c294bd
UN-4506 [FEAT] Size the v1 image view through style custom properties
muhammad-ali-e Oct 29, 2026
000bd91
UN-4506 Merge UN-4505-v1-pdf-toolbar into UN-4506-v1-text-image-views
muhammad-ali-e Oct 29, 2026
115db19
UN-4506 Merge UN-4505-v1-pdf-toolbar into UN-4506-v1-text-image-views
muhammad-ali-e Nov 2, 2026
09efd02
UN-4506 Merge UN-4505-v1-pdf-toolbar into UN-4506-v1-text-image-views
muhammad-ali-e Nov 2, 2026
31845e5
UN-4506 Merge UN-4505-v1-pdf-toolbar into UN-4506-v1-text-image-views
muhammad-ali-e Nov 2, 2026
2aaca5b
UN-4506 [FEAT] Fix keys, scroll timing and highlights for text and im…
muhammad-ali-e Nov 2, 2026
060b0fe
UN-4506 Merge UN-4505 fixes; key waiting scrolls and the text view pe…
muhammad-ali-e Nov 2, 2026
061d8f1
UN-4506 Merge UN-4505 round-two fixes; stamp waiting scrolls for text…
muhammad-ali-e Nov 2, 2026
d5afeb4
Merge branch 'UN-4505-v1-pdf-toolbar' into UN-4506-v1-text-image-views
muhammad-ali-e Nov 2, 2026
d6c0c64
Merge branch 'UN-4505-v1-pdf-toolbar' into UN-4506-v1-text-image-views
muhammad-ali-e Nov 2, 2026
02aa9cf
UN-4506 Give every text row one line-number gutter
muhammad-ali-e Nov 2, 2026
d1ee9f1
Merge branch 'UN-4505-v1-pdf-toolbar' into UN-4506-v1-text-image-views
muhammad-ali-e Nov 2, 2026
a419658
Merge branch 'UN-4505-v1-pdf-toolbar' into UN-4506-v1-text-image-views
muhammad-ali-e Nov 2, 2026
68af057
UN-4506 Keep the horizontal place when jumping to a line on an image
muhammad-ali-e Nov 2, 2026
4d4af20
Merge branch 'UN-4505-v1-pdf-toolbar' into UN-4506-v1-text-image-views
muhammad-ali-e Nov 3, 2026
c51fbef
UN-4506 [FIX] Address review: text identity, image zoom and loading, …
muhammad-ali-e Nov 3, 2026
8721564
Merge branch 'UN-4505-v1-pdf-toolbar' into UN-4506-v1-text-image-views
muhammad-ali-e Nov 3, 2026
07a75d0
UN-4506 [FIX] Retry a waiting scroll when a text fills in, and anchor…
muhammad-ali-e Nov 3, 2026
cf902ef
Merge branch 'UN-4505-v1-pdf-toolbar' into UN-4506-v1-text-image-views
muhammad-ali-e Nov 3, 2026
55d3cc2
Merge branch 'UN-4505-v1-pdf-toolbar' into UN-4506-v1-text-image-views
muhammad-ali-e Nov 3, 2026
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
Expand Up @@ -229,6 +229,54 @@
}
});

it("shows text line by line, with the switcher but no paging or zoom", () => {
render(
<DocumentViewer
source={{ kind: "text", text: "first\nsecond" }}
textHighlights={[{ id: "l", line: 1 }]}
switcher={<button type="button">invoice.pdf</button>}
/>,
);
expect(screen.getByRole("region", { name: "Document text" })).toBeTruthy();
expect(screen.getByRole("button", { name: "invoice.pdf" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Zoom in" })).toBeNull();
expect(screen.queryByLabelText("Page")).toBeNull();
});

it("keeps the switcher while a PDF is still loading or has failed", async () => {
render(
<DocumentViewer
source={source}
switcher={<button type="button">invoice.pdf</button>}
/>,
);
expect(screen.getByRole("button", { name: "invoice.pdf" })).toBeTruthy();
await act(async () => opening.reject(new Error("Network error")));
expect(screen.getByRole("button", { name: "invoice.pdf" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Zoom in" })).toBeNull();
});

it("zooms an image with the toolbar, fitted like a page, without paging", () => {
render(<DocumentViewer source={{ kind: "image", url: "/scan.png" }} />);
const image = screen.getByRole<HTMLImageElement>("img", { hidden: true });
Object.defineProperties(image, {
naturalWidth: { value: 1632 },
naturalHeight: { value: 2112 },
});
fireEvent.load(image);
act(() => resize?.(864, 600));
// 1632 natural px into an 816 px fit: 50%.
expect(zoomLabel()).toBe("50%");
expect(screen.queryByLabelText("Page")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
expect(zoomLabel()).toBe("75%");
});

it("is empty without a source", () => {
render(<DocumentViewer source={null} />);
expect(screen.getByText("No document")).toBeTruthy();
});

it("fits every page in fit page, so a wider page later on sets the zoom", async () => {
render(<DocumentViewer source={source} />);
await act(async () =>
Expand All @@ -248,6 +296,113 @@
expect(zoomLabel()).toBe("77%");
});

function loadImage(width: number, height: number) {
const image = screen.getByRole<HTMLImageElement>("img", { hidden: true });
Object.defineProperties(image, {
naturalWidth: { value: width },
naturalHeight: { value: height },
});
fireEvent.load(image);
}

it("leaves paging keys to the browser for text and images", () => {
const { rerender } = render(
<DocumentViewer source={{ kind: "text", text: "a\nb" }} />,
);
const text = screen.getByRole("region", { name: "Document text" });
// fireEvent returns false when the handler called preventDefault.
expect(fireEvent.keyDown(text, { key: "PageDown" })).toBe(true);
expect(fireEvent.keyDown(text, { key: "End" })).toBe(true);
expect(fireEvent.keyDown(text, { key: "+" })).toBe(true);

rerender(<DocumentViewer source={{ kind: "image", url: "/scan.png" }} />);
loadImage(1632, 2112);
const image = screen.getByRole("region", { name: "Image viewer" });
expect(fireEvent.keyDown(image, { key: "PageDown" })).toBe(true);
expect(fireEvent.keyDown(image, { key: "+" })).toBe(false);
});

it("runs a highlight scroll asked for before an image loads once it has", () => {
const ref = createRef<DocumentViewerHandle>();
render(
<DocumentViewer
ref={ref}
source={{ kind: "image", url: "/scan.png" }}
highlights={[
{
id: "h",
page: 0,
x: 0.5,
y: 0.9,
w: 0.1,
h: 0.05,
variant: "box",
tone: "default",
},
]}
/>,
);
const region = screen.getByRole("region", { name: "Image viewer" });
act(() => ref.current?.scrollToHighlight("h"));
expect(region.scrollTop).toBe(0);
const frame = region.querySelector<HTMLElement>("img")?.parentElement;
if (!frame) {
throw new Error("no frame");
}
Object.defineProperty(frame, "offsetHeight", { value: 1000 });
Object.defineProperty(region, "clientHeight", { value: 400 });
loadImage(1632, 2112);
// Centre of the box at 0.925 of 1000 px, minus half the 400 px viewport.
expect(region.scrollTop).toBeCloseTo(725);
});

it("draws only page 0's highlights over an image", () => {
const { container } = render(
<DocumentViewer
source={{ kind: "image", url: "/scan.png" }}
highlights={[
{
id: "a",
page: 0,
x: 0.1,
y: 0.1,
w: 0.1,
h: 0.1,
variant: "box",
tone: "default",
},
{
id: "b",
page: 1,
x: 0.1,
y: 0.1,
w: 0.1,
h: 0.1,
variant: "box",
tone: "default",
},
]}
/>,
);
loadImage(1632, 2112);
expect(container.querySelector('[data-highlight-id="a"]')).not.toBeNull();
expect(container.querySelector('[data-highlight-id="b"]')).toBeNull();
});

it("waits for an image to load again after another source was shown", async () => {
const image = { kind: "image", url: "/scan.png" } as const;
const { rerender } = render(<DocumentViewer source={image} />);
loadImage(1632, 2112);
act(() => resize?.(864, 600));
expect(screen.queryByRole("button", { name: "Zoom in" })).not.toBeNull();

Check warning on line 397 in frontend/src/v1/components/patterns/document-viewer/DocumentViewer.test.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

A presence assertion should use a `getBy*` query so a missing element produces Testing Library diagnostics.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaGa94Xjjxf_orAgxyqW&open=AaGa94Xjjxf_orAgxyqW&pullRequest=2409

rerender(<DocumentViewer source={{ kind: "text", text: "a" }} />);
rerender(<DocumentViewer source={image} />);
expect(screen.queryByRole("button", { name: "Zoom in" })).toBeNull();
loadImage(1632, 2112);
expect(screen.queryByRole("button", { name: "Zoom in" })).not.toBeNull();

Check warning on line 403 in frontend/src/v1/components/patterns/document-viewer/DocumentViewer.test.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

A presence assertion should use a `getBy*` query so a missing element produces Testing Library diagnostics.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaGa94Xjjxf_orAgxyqV&open=AaGa94Xjjxf_orAgxyqV&pullRequest=2409
});

it("pages forward and back with PageDown and PageUp, one page per press", async () => {
render(<DocumentViewer source={source} />);
await open();
Expand Down Expand Up @@ -400,6 +555,54 @@
}
});

it("opens another text at the top, in a fresh view", () => {
const { rerender } = render(
<DocumentViewer source={{ kind: "text", text: "first\ntext" }} />,
);
const before = screen.getByRole("region", { name: "Document text" });
before.scrollTop = 300;
rerender(<DocumentViewer source={{ kind: "text", text: "second\ntext" }} />);
const after = screen.getByRole("region", { name: "Document text" });
expect(after).not.toBe(before);
expect(after.scrollTop).toBe(0);
});

it("drops a scroll waiting on an empty text when another text is shown", () => {
const original = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"offsetTop",
);
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
configurable: true,
get(this: HTMLElement) {
const line = this.getAttribute("data-line");
return line === null ? 0 : Number(line) * 20;
},
});
try {
const ref = createRef<DocumentViewerHandle>();
const { rerender } = render(
<DocumentViewer ref={ref} source={{ kind: "text", text: "" }} />,
);
// An empty text shows a status, not lines, so this has nowhere to go.
act(() => ref.current?.scrollToHighlight("total"));
rerender(
<DocumentViewer
ref={ref}
source={{ kind: "text", text: "a\nb\nc\nd\ne\nf" }}
textHighlights={[{ id: "total", line: 5 }]}
/>,
);
expect(
screen.getByRole("region", { name: "Document text" }).scrollTop,
).toBe(0);
} finally {
if (original) {
Object.defineProperty(HTMLElement.prototype, "offsetTop", original);
}
}
});

it("does not scroll to an active highlight cleared as it arrives", async () => {
const restore = layOutEveryPage();
try {
Expand Down Expand Up @@ -508,3 +711,124 @@
// Not even one render showed page 3 of 1.
expect(toolbarRenders.filter(([index, count]) => index >= count)).toEqual([]);
});

it("keeps a live-edited text in place while its id stays the same", () => {
const { rerender } = render(
<DocumentViewer
source={{ kind: "text", id: "doc-1", text: "first\ntext" }}
/>,
);
const before = screen.getByRole("region", { name: "Document text" });
before.scrollTop = 300;
rerender(
<DocumentViewer
source={{ kind: "text", id: "doc-1", text: "first\ntexts" }}
/>,
);
const after = screen.getByRole("region", { name: "Document text" });
expect(after).toBe(before);
expect(after.scrollTop).toBe(300);
expect(after.textContent).toContain("texts");

rerender(
<DocumentViewer
source={{ kind: "text", id: "doc-2", text: "first\ntexts" }}
/>,
);
expect(screen.getByRole("region", { name: "Document text" })).not.toBe(
before,
);
});

it("ignores zoom keys until an image has loaded, so it still opens fitted", () => {
render(<DocumentViewer source={{ kind: "image", url: "/scan.png" }} />);
const region = screen.getByRole("region", { name: "Image viewer" });
// Not handled: fireEvent returns true when nothing called preventDefault.
expect(fireEvent.keyDown(region, { key: "+" })).toBe(true);
act(() => resize(864, 600));
loadImage(1632, 2112);
expect(
screen
.getByRole("button", { name: "Fit width" })
.getAttribute("aria-pressed"),
).toBe("true");
});

it("drops a scroll to a highlight on another page of an image instead of keeping it", () => {
const ref = createRef<DocumentViewerHandle>();
const box = {
id: "h",
x: 0.5,
y: 0.9,
w: 0.1,
h: 0.05,
variant: "box" as const,
tone: "default" as const,
};
const { rerender } = render(
<DocumentViewer
ref={ref}
source={{ kind: "image", url: "/scan.png" }}
highlights={[{ ...box, page: 1 }]}
/>,
);
const region = screen.getByRole("region", { name: "Image viewer" });
const frame = region.querySelector<HTMLElement>("img")?.parentElement;
if (!frame) {
throw new Error("no frame");
}
Object.defineProperty(frame, "offsetHeight", { value: 1000 });
Object.defineProperty(region, "clientHeight", { value: 400 });
loadImage(1632, 2112);
act(() => ref.current?.scrollToHighlight("h"));
// Had the request waited, it would fire here, long after it was asked for.
rerender(
<DocumentViewer
ref={ref}
source={{ kind: "image", url: "/scan.png" }}
highlights={[{ ...box, page: 0 }]}
/>,
);
expect(region.scrollTop).toBe(0);
});

it("runs a scroll waiting on an empty text once that same text fills in", () => {
const original = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"offsetTop",
);
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
configurable: true,
get(this: HTMLElement) {
const line = this.getAttribute("data-line");
return line === null ? 0 : Number(line) * 20;
},
});
try {
const ref = createRef<DocumentViewerHandle>();
const textHighlights = [{ id: "total", line: 5 }];
const { rerender } = render(
<DocumentViewer
ref={ref}
source={{ kind: "text", id: "doc", text: "" }}
textHighlights={textHighlights}
/>,
);
act(() => ref.current?.scrollToHighlight("total"));
// The same text, by id, fills in; the highlights are unchanged.
rerender(
<DocumentViewer
ref={ref}
source={{ kind: "text", id: "doc", text: "a\nb\nc\nd\ne\nf" }}
textHighlights={textHighlights}
/>,
);
expect(
screen.getByRole("region", { name: "Document text" }).scrollTop,
).toBe(100);
} finally {
if (original) {
Object.defineProperty(HTMLElement.prototype, "offsetTop", original);
}
}
});
Loading
Loading