From 66a6f681acd046e64134106369dc3ab7d00702ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= Date: Wed, 26 Aug 2026 22:24:01 +0200 Subject: [PATCH] feat(viewer): add ZoomGestureWrapper and implement wheel-based zooming for PDF viewer --- frontend/editor/index.html | 2 +- .../core/components/viewer/EmbedPdfViewer.tsx | 7 - .../core/components/viewer/LocalEmbedPDF.tsx | 322 +++++++++--------- .../src/core/hooks/useWheelZoom.test.ts | 125 +++++++ .../editor/src/core/hooks/useWheelZoom.ts | 22 +- frontend/editor/src/core/styles/index.css | 6 + frontend/editor/src/index.tsx | 12 + 7 files changed, 315 insertions(+), 181 deletions(-) create mode 100644 frontend/editor/src/core/hooks/useWheelZoom.test.ts diff --git a/frontend/editor/index.html b/frontend/editor/index.html index 1e782db2de..d827fa7c3e 100644 --- a/frontend/editor/index.html +++ b/frontend/editor/index.html @@ -6,7 +6,7 @@ - { - return ( - - + { + return ( + - -
- + +
+ + - - - -
- + ( + + )} + /> +
+ ( - - )} /> - - - {/* ButtonAppearanceOverlay — renders PDF-native button visuals as bitmaps */} - {enableFormFill && file && ( - - )} + {/* ButtonAppearanceOverlay — renders PDF-native button visuals as bitmaps */} + {enableFormFill && file && ( + + )} - {/* FormFieldOverlay for interactive form filling */} - {enableFormFill && ( - + )} + + {/* Create-mode: drag to place new fields */} + {enableFormFill && formEditingActive && ( + + )} + + {/* Modify-mode: select / move / resize existing fields */} + {enableFormFill && formEditingActive && ( + + )} + + {/* SignatureFieldOverlay — bitmaps of digital-signature appearances */} + {file && ( + + )} + + {/* AnnotationLayer for annotation editing and annotation-based redactions */} + {(enableAnnotations || enableRedaction) && ( + ( + + )} + style={ + !showBakedAnnotations + ? { + opacity: 0, + pointerEvents: "none", + } + : undefined + } + /> + )} + + {enableRedaction && ( + ( + + )} + /> + )} + + {/* LinkLayer – uses EmbedPDF annotation state for link rendering */} + - )} - {/* Create-mode: drag to place new fields */} - {enableFormFill && formEditingActive && ( - - )} - - {/* Modify-mode: select / move / resize existing fields */} - {enableFormFill && formEditingActive && ( - - )} - - {/* SignatureFieldOverlay — bitmaps of digital-signature appearances */} - {file && ( - - )} - - {/* AnnotationLayer for annotation editing and annotation-based redactions */} - {(enableAnnotations || enableRedaction) && ( - ( - - )} - style={ - !showBakedAnnotations - ? { - opacity: 0, - pointerEvents: "none", - } - : undefined - } - /> - )} - - {enableRedaction && ( - ( - - )} - /> - )} - - {/* LinkLayer – uses EmbedPDF annotation state for link rendering */} - - - {/* Signature preview overlay (opt-in; off by default) */} - {signatureOverlayEnabled && ( - - )} -
-
-
- ); - }} - /> + {/* Signature preview overlay (opt-in; off by default) */} + {signatureOverlayEnabled && ( + + )} + + + + ); + }} + /> + {enableAnnotations && ( diff --git a/frontend/editor/src/core/hooks/useWheelZoom.test.ts b/frontend/editor/src/core/hooks/useWheelZoom.test.ts new file mode 100644 index 0000000000..fe005ddeec --- /dev/null +++ b/frontend/editor/src/core/hooks/useWheelZoom.test.ts @@ -0,0 +1,125 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useWheelZoom } from "@app/hooks/useWheelZoom"; + +describe("useWheelZoom", () => { + it("triggers onZoomIn and prevents default when wheel deltaY reaches negative threshold with ctrlKey", () => { + const element = document.createElement("div"); + const ref = { current: element }; + const onZoomIn = vi.fn(); + const onZoomOut = vi.fn(); + + renderHook(() => + useWheelZoom({ + ref, + onZoomIn, + onZoomOut, + threshold: 10, + requireModifierKey: true, + }), + ); + + const event = new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + ctrlKey: true, + deltaY: -12, + }); + const preventDefaultSpy = vi.spyOn(event, "preventDefault"); + const stopPropagationSpy = vi.spyOn(event, "stopPropagation"); + + element.dispatchEvent(event); + + expect(onZoomIn).toHaveBeenCalledTimes(1); + expect(onZoomOut).not.toHaveBeenCalled(); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + expect(stopPropagationSpy).toHaveBeenCalledTimes(1); + }); + + it("triggers onZoomOut when wheel deltaY reaches positive threshold with metaKey", () => { + const element = document.createElement("div"); + const ref = { current: element }; + const onZoomIn = vi.fn(); + const onZoomOut = vi.fn(); + + renderHook(() => + useWheelZoom({ + ref, + onZoomIn, + onZoomOut, + threshold: 10, + requireModifierKey: true, + }), + ); + + const event = new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + metaKey: true, + deltaY: 15, + }); + + element.dispatchEvent(event); + + expect(onZoomOut).toHaveBeenCalledTimes(1); + expect(onZoomIn).not.toHaveBeenCalled(); + }); + + it("ignores wheel events without modifier keys when requireModifierKey is true", () => { + const element = document.createElement("div"); + const ref = { current: element }; + const onZoomIn = vi.fn(); + const onZoomOut = vi.fn(); + + renderHook(() => + useWheelZoom({ + ref, + onZoomIn, + onZoomOut, + threshold: 10, + requireModifierKey: true, + }), + ); + + const event = new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + ctrlKey: false, + metaKey: false, + deltaY: -20, + }); + const preventDefaultSpy = vi.spyOn(event, "preventDefault"); + + element.dispatchEvent(event); + + expect(onZoomIn).not.toHaveBeenCalled(); + expect(onZoomOut).not.toHaveBeenCalled(); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it("does not attach listeners when enabled is false", () => { + const element = document.createElement("div"); + const ref = { current: element }; + const onZoomIn = vi.fn(); + const onZoomOut = vi.fn(); + + renderHook(() => + useWheelZoom({ + ref, + onZoomIn, + onZoomOut, + enabled: false, + }), + ); + + const event = new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + ctrlKey: true, + deltaY: -20, + }); + element.dispatchEvent(event); + + expect(onZoomIn).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/core/hooks/useWheelZoom.ts b/frontend/editor/src/core/hooks/useWheelZoom.ts index f43a0419c2..a273a16ac5 100644 --- a/frontend/editor/src/core/hooks/useWheelZoom.ts +++ b/frontend/editor/src/core/hooks/useWheelZoom.ts @@ -19,20 +19,18 @@ interface UseWheelZoomOptions { enabled?: boolean; /** * How much delta needs to accumulate before a zoom action is triggered. - * Defaults to 10 which matches the previous implementations. + * Defaults to 10. */ threshold?: number; /** - * Whether a Ctrl/Cmd modifier is required for zooming. Defaults to true so - * we only react to pinch gestures and intentional ctrl+wheel zooming. + * Whether a Ctrl/Cmd modifier is required for zooming. Defaults to true. */ requireModifierKey?: boolean; } /** - * Shared hook for handling wheel-based zoom across components. - * It normalises accumulated delta behaviour, prevents default scrolling when - * zoom is triggered, and keeps the handler detached when disabled. + * Lightweight hook for handling wheel and trackpad pinch zoom on non-EmbedPDF + * components (such as the Page Editor thumbnail view). */ export function useWheelZoom({ ref, @@ -43,23 +41,17 @@ export function useWheelZoom({ requireModifierKey = true, }: UseWheelZoomOptions) { useEffect(() => { - if (!enabled) { - return; - } + if (!enabled) return; const element = ref.current; - if (!element) { - return; - } + if (!element) return; let accumulator = 0; const handleWheel = (event: Event) => { const wheelEvent = event as WheelEvent; const hasModifier = wheelEvent.ctrlKey || wheelEvent.metaKey; - if (requireModifierKey && !hasModifier) { - return; - } + if (requireModifierKey && !hasModifier) return; wheelEvent.preventDefault(); wheelEvent.stopPropagation(); diff --git a/frontend/editor/src/core/styles/index.css b/frontend/editor/src/core/styles/index.css index 32b2f2eaad..e205249f1e 100644 --- a/frontend/editor/src/core/styles/index.css +++ b/frontend/editor/src/core/styles/index.css @@ -1,3 +1,9 @@ +html, +body, +#root { + touch-action: pan-x pan-y; +} + html, body { margin: 0; diff --git a/frontend/editor/src/index.tsx b/frontend/editor/src/index.tsx index 1d05928f60..a869baa7e9 100644 --- a/frontend/editor/src/index.tsx +++ b/frontend/editor/src/index.tsx @@ -24,6 +24,18 @@ import { startEagerWasmCompilation } from "@app/services/wasmPrecompiler"; applyDevWorktreeLabel(); if (typeof window !== "undefined") { + // Suppress browser-level page zoom on pinch gestures so only open documents zoom + window.addEventListener( + "wheel", + (event) => { + if (event.ctrlKey || event.metaKey) { + event.preventDefault(); + } + }, + { passive: false }, + ); + window.addEventListener("gesturestart", (event) => event.preventDefault()); + const scheduleCompilation = () => requestIdleCallback(() => startEagerWasmCompilation(), { timeout: 2000 });