Skip to content

Commit 20014b7

Browse files
committed
Improve PDF viewer zoom and touch scrolling
1 parent 7ddc081 commit 20014b7

2 files changed

Lines changed: 66 additions & 74 deletions

File tree

apps/desktop/src/features/pdf/PdfTabView.test.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -416,11 +416,12 @@ describe("PdfTabView", () => {
416416

417417
expect(scrollSurface!.scrollTop).toBe(425);
418418
expect(scrollSurface!.scrollLeft).toBe(95);
419-
expect(content!.style.transformOrigin).toBe("0 0");
420-
expect(content!.style.transform).toBe("scale(1.25)");
419+
expect(screen.getByText("125%")).toBeInTheDocument();
420+
expect(content!.style.transformOrigin).toBe("");
421+
expect(content!.style.transform).toBe("");
421422
});
422423

423-
it("exposes native pinch-zoom touch action on the PDF surface", async () => {
424+
it("allows native pan gestures on the PDF surface", async () => {
424425
const pdfDocument = {
425426
destroy: vi.fn(),
426427
getPage: vi.fn().mockImplementation(async () => createMockPage()),
@@ -458,7 +459,7 @@ describe("PdfTabView", () => {
458459
"canvas",
459460
) as HTMLCanvasElement | null;
460461

461-
expect(scrollSurface?.style.touchAction).toBe("none");
462-
expect(canvas?.style.touchAction).toBe("none");
462+
expect(scrollSurface?.style.touchAction).toBe("pan-x pan-y pinch-zoom");
463+
expect(canvas?.style.touchAction).not.toBe("none");
463464
});
464465
});

apps/desktop/src/features/pdf/PdfTabView.tsx

Lines changed: 60 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
3535
const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2];
3636
const MIN_ZOOM = 0.25;
3737
const MAX_ZOOM = 4;
38-
const PINCH_SENSITIVITY = 0.0025;
39-
const PINCH_COMMIT_DELAY = 150;
38+
const WHEEL_ZOOM_SENSITIVITY = 0.0025;
39+
const WHEEL_ZOOM_COMMIT_DELAY = 150;
4040
const CONTINUOUS_PAGE_GAP = 20;
4141
const CONTINUOUS_OVERSCAN_PX = 1200;
4242
const CONTINUOUS_MAX_RENDERED_PAGES = 15;
@@ -260,9 +260,7 @@ function PdfViewer({ tab }: { tab: PdfTab }) {
260260
const pageRefs = useRef<Record<number, HTMLDivElement | null>>({});
261261
const previousViewModeRef = useRef(tab.viewMode);
262262
const pendingProgrammaticPageRef = useRef<number | null>(null);
263-
const pinchZoomRef = useRef(tab.zoom);
264-
const pinchTimerRef = useRef(0);
265-
const pendingWheelZoomRef = useRef<number | null>(null);
263+
const wheelZoomTimerRef = useRef(0);
266264
const wheelZoomModifierRef = useWheelZoomModifier();
267265
const [contextMenu, setContextMenu] = useState<ContextMenuState<{
268266
pageNumber: number;
@@ -277,6 +275,7 @@ function PdfViewer({ tab }: { tab: PdfTab }) {
277275
null,
278276
);
279277
const [retryCount, setRetryCount] = useState(0);
278+
const [liveZoom, setLiveZoom] = useState<number | null>(null);
280279
const [scrollTop, setScrollTop] = useState(0);
281280
const [scrollContainer, setScrollContainer] =
282281
useState<HTMLDivElement | null>(null);
@@ -303,9 +302,11 @@ function PdfViewer({ tab }: { tab: PdfTab }) {
303302
const loading = !error && !activePdf;
304303
const pdf = activePdf?.pdf ?? null;
305304
const numPages = activePdf?.numPages ?? 0;
305+
const effectiveZoom = liveZoom ?? tab.zoom;
306+
const effectiveZoomRef = useRef(effectiveZoom);
306307
const continuousLayouts = useMemo(
307-
() => (pageMetrics ? buildPageLayouts(pageMetrics, tab.zoom) : []),
308-
[pageMetrics, tab.zoom],
308+
() => (pageMetrics ? buildPageLayouts(pageMetrics, effectiveZoom) : []),
309+
[effectiveZoom, pageMetrics],
309310
);
310311
const effectiveViewportHeight = Math.max(
311312
viewportHeight,
@@ -396,7 +397,29 @@ function PdfViewer({ tab }: { tab: PdfTab }) {
396397

397398
useEffect(() => {
398399
pageRefs.current = {};
399-
}, [tab.path, tab.viewMode, tab.zoom, retryCount]);
400+
}, [effectiveZoom, retryCount, tab.path, tab.viewMode]);
401+
402+
useEffect(() => {
403+
effectiveZoomRef.current = effectiveZoom;
404+
}, [effectiveZoom]);
405+
406+
useEffect(() => {
407+
if (liveZoom === null) return;
408+
if (Math.abs(tab.zoom - liveZoom) > 0.0001) return;
409+
setLiveZoom(null);
410+
}, [liveZoom, tab.zoom]);
411+
412+
useEffect(() => {
413+
return () => {
414+
window.clearTimeout(wheelZoomTimerRef.current);
415+
};
416+
}, []);
417+
418+
useEffect(() => {
419+
window.clearTimeout(wheelZoomTimerRef.current);
420+
setLiveZoom(null);
421+
effectiveZoomRef.current = tab.zoom;
422+
}, [tab.id, tab.path]);
400423

401424
useEffect(() => {
402425
queueMicrotask(() => setPageMetrics(null));
@@ -614,12 +637,16 @@ function PdfViewer({ tab }: { tab: PdfTab }) {
614637
}, [numPages, scrollToPage, tab.id, tab.page, tab.viewMode, updatePdfPage]);
615638

616639
const zoomIn = useCallback(() => {
617-
updatePdfZoom(tab.id, clampZoom(tab.zoom, "in"));
618-
}, [tab.id, tab.zoom, updatePdfZoom]);
640+
window.clearTimeout(wheelZoomTimerRef.current);
641+
setLiveZoom(null);
642+
updatePdfZoom(tab.id, clampZoom(effectiveZoom, "in"));
643+
}, [effectiveZoom, tab.id, updatePdfZoom]);
619644

620645
const zoomOut = useCallback(() => {
621-
updatePdfZoom(tab.id, clampZoom(tab.zoom, "out"));
622-
}, [tab.id, tab.zoom, updatePdfZoom]);
646+
window.clearTimeout(wheelZoomTimerRef.current);
647+
setLiveZoom(null);
648+
updatePdfZoom(tab.id, clampZoom(effectiveZoom, "out"));
649+
}, [effectiveZoom, tab.id, updatePdfZoom]);
623650

624651
const toggleViewMode = useCallback(() => {
625652
const nextViewMode =
@@ -676,24 +703,6 @@ function PdfViewer({ tab }: { tab: PdfTab }) {
676703
});
677704
}, []);
678705

679-
useEffect(() => {
680-
pinchZoomRef.current = tab.zoom;
681-
if (pendingWheelZoomRef.current === null) {
682-
return;
683-
}
684-
685-
if (Math.abs(tab.zoom - pendingWheelZoomRef.current) > 0.0001) {
686-
return;
687-
}
688-
689-
const content = contentRef.current;
690-
if (content) {
691-
content.style.transform = "";
692-
content.style.transformOrigin = "";
693-
}
694-
pendingWheelZoomRef.current = null;
695-
}, [tab.zoom]);
696-
697706
useEffect(() => {
698707
if (!scrollContainer) return;
699708

@@ -702,20 +711,19 @@ function PdfViewer({ tab }: { tab: PdfTab }) {
702711
if (!isWheelZoomGesture(event, wheelZoomModifierRef)) return;
703712
event.preventDefault();
704713

705-
const prev = pinchZoomRef.current;
714+
const prev = effectiveZoomRef.current;
706715
const next = Math.min(
707716
MAX_ZOOM,
708717
Math.max(
709718
MIN_ZOOM,
710-
prev * (1 - event.deltaY * PINCH_SENSITIVITY),
719+
prev * (1 - event.deltaY * WHEEL_ZOOM_SENSITIVITY),
711720
),
712721
);
713-
pinchZoomRef.current = next;
722+
if (Math.abs(next - prev) < 0.0001) return;
714723

715724
const containerRect = scrollContainer.getBoundingClientRect();
716725
const pointerOffsetX = event.clientX - containerRect.left;
717726
const pointerOffsetY = event.clientY - containerRect.top;
718-
const visualScaleRatio = next / tab.zoom;
719727
const scrollScaleRatio = next / prev;
720728
const nextScrollLeft = clampScrollOffset(
721729
(scrollContainer.scrollLeft + pointerOffsetX) *
@@ -728,42 +736,26 @@ function PdfViewer({ tab }: { tab: PdfTab }) {
728736
pointerOffsetY,
729737
);
730738

731-
const content = contentRef.current;
732-
if (content) {
733-
content.style.transformOrigin = "0 0";
734-
content.style.transform = `scale(${visualScaleRatio})`;
735-
}
736-
739+
setLiveZoom(next);
740+
effectiveZoomRef.current = next;
737741
scrollContainer.scrollLeft = nextScrollLeft;
738742
scrollContainer.scrollTop = nextScrollTop;
743+
setScrollTop(nextScrollTop);
739744

740-
window.clearTimeout(pinchTimerRef.current);
741-
pendingWheelZoomRef.current = next;
742-
pinchTimerRef.current = window.setTimeout(() => {
745+
window.clearTimeout(wheelZoomTimerRef.current);
746+
wheelZoomTimerRef.current = window.setTimeout(() => {
743747
updatePdfZoom(tab.id, persistWheelZoom(next));
744-
}, PINCH_COMMIT_DELAY);
748+
}, WHEEL_ZOOM_COMMIT_DELAY);
745749
}
746750

747751
scrollContainer.addEventListener("wheel", handleWheel, {
748752
passive: false,
749753
});
750-
const capturedContent = contentRef.current;
751754
return () => {
752755
scrollContainer.removeEventListener("wheel", handleWheel);
753-
window.clearTimeout(pinchTimerRef.current);
754-
pendingWheelZoomRef.current = null;
755-
if (capturedContent) {
756-
capturedContent.style.transform = "";
757-
capturedContent.style.transformOrigin = "";
758-
}
756+
window.clearTimeout(wheelZoomTimerRef.current);
759757
};
760-
}, [
761-
scrollContainer,
762-
tab.id,
763-
tab.zoom,
764-
updatePdfZoom,
765-
wheelZoomModifierRef,
766-
]);
758+
}, [scrollContainer, tab.id, updatePdfZoom, wheelZoomModifierRef]);
767759

768760
useEffect(() => {
769761
function handleKeyDown(event: KeyboardEvent) {
@@ -940,7 +932,7 @@ function PdfViewer({ tab }: { tab: PdfTab }) {
940932

941933
<ToolbarButton
942934
onClick={zoomOut}
943-
disabled={tab.zoom <= ZOOM_STEPS[0]}
935+
disabled={effectiveZoom <= ZOOM_STEPS[0]}
944936
title="Zoom out"
945937
>
946938
<MinusIcon />
@@ -952,11 +944,13 @@ function PdfViewer({ tab }: { tab: PdfTab }) {
952944
fontVariantNumeric: "tabular-nums",
953945
}}
954946
>
955-
{formatZoomPercentage(tab.zoom)}
947+
{formatZoomPercentage(effectiveZoom)}
956948
</span>
957949
<ToolbarButton
958950
onClick={zoomIn}
959-
disabled={tab.zoom >= ZOOM_STEPS[ZOOM_STEPS.length - 1]}
951+
disabled={
952+
effectiveZoom >= ZOOM_STEPS[ZOOM_STEPS.length - 1]
953+
}
960954
title="Zoom in"
961955
>
962956
<PlusIcon />
@@ -1021,15 +1015,14 @@ function PdfViewer({ tab }: { tab: PdfTab }) {
10211015
padding: 24,
10221016
background:
10231017
"color-mix(in srgb, var(--bg-primary) 92%, #000)",
1024-
touchAction: "none",
1018+
touchAction: "pan-x pan-y pinch-zoom",
10251019
}}
10261020
>
10271021
{tab.viewMode === "continuous" ? (
10281022
<div
10291023
ref={contentRef}
10301024
className="w-full"
10311025
style={{
1032-
willChange: "transform",
10331026
filter: activeFilter.css,
10341027
position:
10351028
continuousLayouts.length > 0
@@ -1051,10 +1044,10 @@ function PdfViewer({ tab }: { tab: PdfTab }) {
10511044
) : (
10521045
visibleContinuousLayouts.map((layout) => (
10531046
<PdfPageCanvas
1054-
key={`${tab.path}:${retryCount}:${layout.pageNumber}:${tab.zoom}`}
1047+
key={`${tab.path}:${retryCount}:${layout.pageNumber}:${effectiveZoom}`}
10551048
pdf={pdf}
10561049
pageNumber={layout.pageNumber}
1057-
zoom={tab.zoom}
1050+
zoom={effectiveZoom}
10581051
onRenderError={setPdfError}
10591052
onContextMenu={handlePdfContextMenu}
10601053
registerElement={registerPageElement}
@@ -1072,15 +1065,14 @@ function PdfViewer({ tab }: { tab: PdfTab }) {
10721065
ref={contentRef}
10731066
className="w-full flex justify-center"
10741067
style={{
1075-
willChange: "transform",
10761068
filter: activeFilter.css,
10771069
}}
10781070
>
10791071
<PdfPageCanvas
1080-
key={`${tab.path}:${retryCount}:${tab.page}:${tab.zoom}`}
1072+
key={`${tab.path}:${retryCount}:${tab.page}:${effectiveZoom}`}
10811073
pdf={pdf}
10821074
pageNumber={tab.page}
1083-
zoom={tab.zoom}
1075+
zoom={effectiveZoom}
10841076
onRenderError={setPdfError}
10851077
onContextMenu={handlePdfContextMenu}
10861078
/>
@@ -1242,7 +1234,6 @@ function PdfPageCanvas({
12421234
style={{
12431235
boxShadow: "0 2px 16px rgba(0,0,0,0.15)",
12441236
background: "#fff",
1245-
touchAction: "none",
12461237
}}
12471238
/>
12481239
<div

0 commit comments

Comments
 (0)