diff --git a/apps/web/src/components/PdfViewer.test.tsx b/apps/web/src/components/PdfViewer.test.tsx index 29abb2b..ae6af51 100644 --- a/apps/web/src/components/PdfViewer.test.tsx +++ b/apps/web/src/components/PdfViewer.test.tsx @@ -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'; @@ -12,8 +12,8 @@ function deferred() { const promise = new Promise((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 { @@ -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; }); }); @@ -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(); + // 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('.pdf-scroll')!; + const page = screen.getByLabelText('PDF page 1'); + vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({ left: 10, top: 20 } as DOMRect); + [...view.container.querySelectorAll('.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(); + 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>(); parser.getDocument.mockReturnValue({ promise: pending.promise, destroy: vi.fn() }); diff --git a/apps/web/src/components/PdfViewer.tsx b/apps/web/src/components/PdfViewer.tsx index 8c7f58e..4023308 100644 --- a/apps/web/src/components/PdfViewer.tsx +++ b/apps/web/src/components/PdfViewer.tsx @@ -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('.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; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 1f0b632..a155ab0 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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) {