Skip to content
Merged
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
143 changes: 140 additions & 3 deletions apps/web/src/components/PdfViewer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { ApiClient } from '../lib/api';
import { PdfViewer } from './PdfViewer';
Expand All @@ -12,8 +12,8 @@ function deferred<T>() {
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
function document() {
return { numPages: 1, getPage: vi.fn(async () => ({ getViewport: () => ({ width: 200, height: 300 }) })) };
function document(numPages = 1) {
return { numPages, getPage: vi.fn(async () => ({ getViewport: ({ scale }: { scale: number }) => ({ width: 200 * scale, height: 300 * scale }) })) };
}
function properties(data = new ArrayBuffer(1)) {
return {
Expand All @@ -24,6 +24,7 @@ function properties(data = new ArrayBuffer(1)) {
}
beforeEach(() => {
vi.resetAllMocks();
window.localStorage.clear();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1; });
});
Expand Down Expand Up @@ -59,6 +60,142 @@ it('does not report a cancelled old parser failure against replacement bytes', a
expect(screen.queryByText('Old PDF was invalid')).not.toBeInTheDocument();
});

async function gestureViewer(focusMode = true, numPages = 1) {
parser.getDocument.mockImplementation(() => ({ promise: Promise.resolve(document(numPages)), destroy: vi.fn() }));
const props = properties();
const view = render(<PdfViewer {...props} focusMode={focusMode}/>);
// The callback runs before React commits rendering=false and installs touch listeners.
await act(async () => {
await waitFor(() => expect(props.onDocumentStatus).toHaveBeenCalledWith(props.data, 'ready'));
});
const root = view.container.querySelector<HTMLDivElement>('.pdf-scroll')!;
const page = screen.getByLabelText('PDF page 1');
vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({ left: 10, top: 20 } as DOMRect);
[...view.container.querySelectorAll<HTMLElement>('.pdf-page')].forEach((element, index, elements) => {
Object.defineProperties(element, {
offsetLeft: { get: () => 16 },
offsetTop: { get: () => 35 + elements.slice(0, index).reduce((top, previous) => top + parseFloat(previous.style.height) + 18, 0) },
offsetHeight: { get: () => parseFloat(element.style.height) }
});
});
root.scrollLeft = 60;
root.scrollTop = 90;
return { ...view, root, page, props };
}
const fingers = (left: number, right: number, y = 170) => [
{ identifier: 1, clientX: left, clientY: y }, { identifier: 2, clientX: right, clientY: y }
];

it('previews a fullscreen pinch at its moving midpoint and renders sharp pages only on release', async () => {
const { root, page } = await gestureViewer();
expect(fireEvent.touchStart(root, { touches: fingers(60, 160) })).toBe(false);
fireEvent.touchMove(root, { touches: fingers(30, 230, 200) });
expect(page.style.width).toBe('400px');
expect(page.querySelector('canvas')!.style.height).toBe('600px');
expect(root.scrollLeft).toBe(184);
expect(root.scrollTop).toBe(265);
expect(parser.getDocument).toHaveBeenCalledTimes(1);
fireEvent.touchEnd(root, { touches: [] });
await waitFor(() => expect(screen.getByText('200%')).toBeInTheDocument());
await waitFor(() => expect(parser.getDocument).toHaveBeenCalledTimes(2));
expect(screen.getByLabelText('PDF page 1').style.width).toBe('400px');
expect(root.scrollLeft).toBe(184);
expect(root.scrollTop).toBe(265);
expect(JSON.parse(window.localStorage.getItem('overroot.pdfView.pdf-validation-test')!)).toMatchObject({ zoom: 2, scrollLeft: 184, scrollTop: 265 });
});

it('keeps a later page under the fingers as preceding pages grow', async () => {
const { root } = await gestureViewer(true, 2);
root.scrollTop = 400;
fireEvent.touchStart(root, { touches: fingers(60, 160) });
fireEvent.touchMove(root, { touches: fingers(10, 210) });
expect(root.scrollTop).toBe(897);
fireEvent.touchEnd(root, { touches: [] });
await waitFor(() => expect(parser.getDocument).toHaveBeenCalledTimes(2));
expect(root.scrollTop).toBe(897);
expect(screen.getByLabelText('PDF page 2').style.height).toBe('600px');
});

it('commits the preview when leaving fullscreen during a pinch', async () => {
const { root, props, rerender } = await gestureViewer();
fireEvent.touchStart(root, { touches: fingers(60, 160) });
fireEvent.touchMove(root, { touches: fingers(10, 210) });
expect(screen.getByLabelText('PDF page 1').style.width).toBe('400px');
rerender(<PdfViewer {...props} focusMode={false}/>);
await screen.findByText('200%');
await waitFor(() => expect(parser.getDocument).toHaveBeenCalledTimes(2));
expect(screen.getByLabelText('PDF page 1').style.width).toBe('400px');
expect(fireEvent.touchStart(root, { touches: fingers(60, 160) })).toBe(true);
});

it.each([
{ left: -140, right: 360, width: '500px', readout: '250%' },
{ left: 105, right: 115, width: '100px', readout: '50%' }
])('clamps pinch zoom to $readout and commits on cancellation', async ({ left, right, width, readout }) => {
const { root, page } = await gestureViewer();
fireEvent.touchStart(root, { touches: fingers(60, 160) });
fireEvent.touchMove(root, { touches: fingers(left, right) });
expect(page.style.width).toBe(width);
fireEvent.touchCancel(root, { touches: [] });
await screen.findByText(readout);
await waitFor(() => expect(parser.getDocument).toHaveBeenCalledTimes(2));
});

it('ends a pinch when a third finger joins and ignores a replacement pair', async () => {
const { root } = await gestureViewer();
fireEvent.touchStart(root, { touches: fingers(60, 160) });
fireEvent.touchMove(root, { touches: fingers(10, 210) });
const third = { identifier: 3, clientX: 300, clientY: 170 };
fireEvent.touchStart(root, { touches: [...fingers(10, 210), third] });
const replacement = [fingers(10, 210)[1]!, third];
fireEvent.touchEnd(root, { touches: replacement });
fireEvent.touchMove(root, { touches: [replacement[0]!, { ...third, clientX: 500 }] });
await screen.findByText('200%');
await waitFor(() => expect(parser.getDocument).toHaveBeenCalledTimes(2));
expect(screen.getByLabelText('PDF page 1').style.width).toBe('400px');
});

it.each(['touchEnd', 'touchMove', 'touchCancel'] as const)('ends the active pinch on %s with a changed or cancelled pair', async (event) => {
const { root } = await gestureViewer();
fireEvent.touchStart(root, { touches: fingers(60, 160) });
fireEvent.touchMove(root, { touches: fingers(10, 210) });
const remaining = fingers(10, 210);
if (event !== 'touchCancel') remaining[1]!.identifier = 3;
fireEvent[event](root, { touches: remaining });
await screen.findByText('200%');
await waitFor(() => expect(parser.getDocument).toHaveBeenCalledTimes(2));
fireEvent.touchMove(root, { touches: fingers(-100, 300) });
expect(screen.getByLabelText('PDF page 1').style.width).toBe('400px');
expect(parser.getDocument).toHaveBeenCalledTimes(2);
});

it('accepts reordered touches belonging to the same active pair', async () => {
const { root, page } = await gestureViewer();
fireEvent.touchStart(root, { touches: fingers(60, 160) });
fireEvent.touchMove(root, { touches: fingers(10, 210).reverse() });
expect(page.style.width).toBe('400px');
fireEvent.touchEnd(root, { touches: [] });
await screen.findByText('200%');
});

it('leaves ordinary one-finger scrolling to the browser', async () => {
const { root, page } = await gestureViewer();
expect(fireEvent.touchStart(root, { touches: fingers(60, 160).slice(0, 1) })).toBe(true);
expect(fireEvent.touchMove(root, { touches: fingers(80, 180).slice(0, 1) })).toBe(true);
fireEvent.scroll(root, { target: { scrollLeft: 100, scrollTop: 150 } });
expect(page.style.width).toBe('200px');
expect(parser.getDocument).toHaveBeenCalledTimes(1);
});

it('does not intercept pinches outside fullscreen', async () => {
const { root, page } = await gestureViewer(false);
expect(fireEvent.touchStart(root, { touches: fingers(60, 160) })).toBe(true);
expect(fireEvent.touchMove(root, { touches: fingers(10, 210) })).toBe(true);
fireEvent.touchEnd(root, { touches: [] });
expect(page.style.width).toBe('200px');
expect(parser.getDocument).toHaveBeenCalledTimes(1);
});

it('uses the latest validation callback without restarting a pending document', async () => {
const pending = deferred<ReturnType<typeof document>>();
parser.getDocument.mockReturnValue({ promise: pending.promise, destroy: vi.fn() });
Expand Down
92 changes: 92 additions & 0 deletions apps/web/src/components/PdfViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,98 @@ export function PdfViewer({
};
}, [projectHash]);

useEffect(() => {
const root = scroller.current;
const content = pages.current;
if (!focusMode || rendering || !pageCount || !root || !content) return;

type Pinch = {
distance: number; zoom: number; nextZoom: number; ids: [number, number];
anchor: HTMLElement; x: number; y: number;
sizes: { element: HTMLElement; width: number; height: number }[];
};
let pinch: Pinch | null = null;
const midpoint = (touches: TouchList) => {
const bounds = root.getBoundingClientRect();
return {
x: (touches[0]!.clientX + touches[1]!.clientX) / 2 - bounds.left,
y: (touches[0]!.clientY + touches[1]!.clientY) / 2 - bounds.top
};
};
const distance = (touches: TouchList) => Math.hypot(
touches[0]!.clientX - touches[1]!.clientX, touches[0]!.clientY - touches[1]!.clientY
);
const finish = () => {
if (!pinch) return;
const next = pinch.nextZoom;
pinch = null;
zoomRef.current = next;
canPersist.current = true;
setZoom(next);
persistViewState();
};
const samePair = (touches: TouchList) => pinch && touches.length === 2 &&
[...touches].every((touch) => pinch!.ids.includes(touch.identifier));
const start = (event: TouchEvent) => {
if (pinch) {
if (!samePair(event.touches)) finish();
return;
}
if (event.touches.length !== 2) return;
const span = distance(event.touches);
if (!span) return;
const point = midpoint(event.touches);
const elements = [...content.querySelectorAll<HTMLElement>('.pdf-page')];
const anchor = elements.find((element) => element.offsetTop + element.offsetHeight > root.scrollTop + point.y) ?? elements.at(-1);
if (!anchor) return;
event.preventDefault();
const currentZoom = zoomRef.current;
pinch = {
distance: span, zoom: currentZoom, nextZoom: currentZoom, anchor,
ids: [event.touches[0]!.identifier, event.touches[1]!.identifier],
x: (root.scrollLeft + point.x - anchor.offsetLeft) / currentZoom,
y: (root.scrollTop + point.y - anchor.offsetTop) / currentZoom,
sizes: elements.map((element) => ({ element, width: parseFloat(element.style.width), height: parseFloat(element.style.height) }))
};
canPersist.current = false;
};
const move = (event: TouchEvent) => {
if (!samePair(event.touches)) {
finish();
return;
}
if (pinch) {
event.preventDefault();
const point = midpoint(event.touches);
const next = Math.min(2.5, Math.max(0.5, pinch.zoom * distance(event.touches) / pinch.distance));
// Resize the existing canvases for a smooth preview; render sharp pixels only on release.
for (const { element, width, height } of pinch.sizes) {
const canvas = element.querySelector('canvas')!;
element.style.width = canvas.style.width = `${width * next / pinch.zoom}px`;
element.style.height = canvas.style.height = `${height * next / pinch.zoom}px`;
}
root.scrollLeft = pinch.anchor.offsetLeft + pinch.x * next - point.x;
root.scrollTop = pinch.anchor.offsetTop + pinch.y * next - point.y;
pinch.nextZoom = next;
paintPdfHighlights(content, sourceHighlights, next);
}
};
const end = (event: TouchEvent) => {
if (!samePair(event.touches)) finish();
};
root.addEventListener('touchstart', start, { passive: false });
root.addEventListener('touchmove', move, { passive: false });
root.addEventListener('touchend', end);
root.addEventListener('touchcancel', finish);
return () => {
root.removeEventListener('touchstart', start);
root.removeEventListener('touchmove', move);
root.removeEventListener('touchend', end);
root.removeEventListener('touchcancel', finish);
finish();
};
}, [focusMode, rendering, pageCount, sourceHighlights]);

function visiblePage() {
const root = scroller.current;
if (!root) return 1;
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ button:disabled { cursor: default; opacity: .42; }
.pdf-viewer--focus { position: fixed; z-index: 100; inset: 0; width: 100vw; height: 100dvh; grid-template-rows: 0 minmax(0, 1fr); }
.workspace-shell--pdf-focus .pdf-wrap { container-type: normal; overflow: visible; }
.pdf-viewer--focus .pdf-toolbar { visibility: hidden; }
.pdf-viewer--focus .pdf-scroll { touch-action: pan-x pan-y; }
.pdf-viewer--focus .pdf-pages { padding-top: 35px; }

@container (max-width: 400px) {
Expand Down
Loading