feat(viewer): add ZoomGestureWrapper and implement wheel-based zooming for PDF viewer

This commit is contained in:
Balázs Szücs
2026-08-26 22:24:01 +02:00
parent caeca0b88a
commit 66a6f681ac
7 changed files with 315 additions and 181 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
<link rel="icon" href="modern-logo/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<meta name="theme-color" content="#000000" />
<meta
@@ -40,7 +40,6 @@ import {
RulerOverlay,
type RulerOverlayHandle,
} from "@app/components/viewer/RulerOverlay";
import { useWheelZoom } from "@app/hooks/useWheelZoom";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import { FormSaveBar } from "@app/tools/formFill/FormSaveBar";
import { FORM_APPLY_EVENT } from "@app/tools/formFill/formFillEvents";
@@ -351,12 +350,6 @@ const EmbedPdfViewerContent = ({
.filter(Boolean) as string[];
}, [activeFiles, previewFile, bookmarkCacheKey]);
useWheelZoom({
ref: viewerRef,
onZoomIn: zoomActions.zoomIn,
onZoomOut: zoomActions.zoomOut,
});
const viewerKeyCommand = useViewerKeyCommand();
const policyFileBadges = usePolicyFileBadges();
@@ -20,7 +20,11 @@ import {
import { Scroller, ScrollPluginPackage } from "@embedpdf/plugin-scroll/react";
import { DocumentManagerPluginPackage } from "@embedpdf/plugin-document-manager/react";
import { RenderPluginPackage } from "@embedpdf/plugin-render/react";
import { ZoomPluginPackage, ZoomMode } from "@embedpdf/plugin-zoom/react";
import {
ZoomPluginPackage,
ZoomMode,
ZoomGestureWrapper,
} from "@embedpdf/plugin-zoom/react";
import {
InteractionManagerPluginPackage,
PagePointerProvider,
@@ -1074,183 +1078,185 @@ export function LocalEmbedPDF({
contain: "strict",
}}
>
<Scroller
documentId={documentId}
renderPage={({ width, height, pageIndex }) => {
return (
<Rotate
key={`${documentId}-${pageIndex}`}
documentId={documentId}
pageIndex={pageIndex}
>
<PagePointerProvider
<ZoomGestureWrapper documentId={documentId}>
<Scroller
documentId={documentId}
renderPage={({ width, height, pageIndex }) => {
return (
<Rotate
key={`${documentId}-${pageIndex}`}
documentId={documentId}
pageIndex={pageIndex}
>
<ViewerPageContainer
<PagePointerProvider
documentId={documentId}
pageIndex={pageIndex}
width={width}
height={height}
>
<div
style={{
position: "absolute",
inset: 0,
transition: "filter 0.25s ease",
filter:
pdfRenderMode === "dark"
? "invert(1) hue-rotate(180deg)"
: pdfRenderMode === "sepia"
? "sepia(0.7) brightness(0.85)"
: undefined,
}}
<ViewerPageContainer
documentId={documentId}
pageIndex={pageIndex}
width={width}
height={height}
>
<TilingLayer
<div
style={{
position: "absolute",
inset: 0,
transition: "filter 0.25s ease",
filter:
pdfRenderMode === "dark"
? "invert(1) hue-rotate(180deg)"
: pdfRenderMode === "sepia"
? "sepia(0.7) brightness(0.85)"
: undefined,
}}
>
<TilingLayer
documentId={documentId}
pageIndex={pageIndex}
/>
</div>
<CustomSearchLayer
documentId={documentId}
pageIndex={pageIndex}
/>
</div>
<CustomSearchLayer
documentId={documentId}
pageIndex={pageIndex}
/>
<div
className="pdf-selection-layer"
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
}}
>
<SelectionLayer
<div
className="pdf-selection-layer"
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
}}
>
<SelectionLayer
documentId={documentId}
pageIndex={pageIndex}
background="var(--pdf-selection-bg)"
selectionMenu={(props) => (
<TextSelectionMenu {...props} />
)}
/>
</div>
<TextSelectionHandler
documentId={documentId}
pageIndex={pageIndex}
background="var(--pdf-selection-bg)"
selectionMenu={(props) => (
<TextSelectionMenu {...props} />
)}
/>
</div>
<TextSelectionHandler
documentId={documentId}
pageIndex={pageIndex}
/>
{/* ButtonAppearanceOverlay — renders PDF-native button visuals as bitmaps */}
{enableFormFill && file && (
<ButtonAppearanceOverlay
pageIndex={pageIndex}
pdfSource={file}
pageWidth={width}
pageHeight={height}
/>
)}
{/* ButtonAppearanceOverlay — renders PDF-native button visuals as bitmaps */}
{enableFormFill && file && (
<ButtonAppearanceOverlay
pageIndex={pageIndex}
pdfSource={file}
pageWidth={width}
pageHeight={height}
/>
)}
{/* FormFieldOverlay for interactive form filling */}
{enableFormFill && (
<FormFieldOverlay
{/* FormFieldOverlay for interactive form filling */}
{enableFormFill && (
<FormFieldOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* Create-mode: drag to place new fields */}
{enableFormFill && formEditingActive && (
<FormFieldCreationOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* Modify-mode: select / move / resize existing fields */}
{enableFormFill && formEditingActive && (
<FormFieldEditOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* SignatureFieldOverlay — bitmaps of digital-signature appearances */}
{file && (
<SignatureFieldOverlay
documentId={documentId}
pageIndex={pageIndex}
pdfSource={file}
pageWidth={width}
pageHeight={height}
/>
)}
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
{(enableAnnotations || enableRedaction) && (
<AnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
selectionOutline={{ color: "#007ACC" }}
selectionMenu={(props) => (
<AnnotationSelectionMenu {...props} />
)}
style={
!showBakedAnnotations
? {
opacity: 0,
pointerEvents: "none",
}
: undefined
}
/>
)}
{enableRedaction && (
<RedactionLayer
documentId={documentId}
pageIndex={pageIndex}
selectionMenu={(props) => (
<RedactionSelectionMenu {...props} />
)}
/>
)}
{/* LinkLayer uses EmbedPDF annotation state for link rendering */}
<LinkLayer
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* Create-mode: drag to place new fields */}
{enableFormFill && formEditingActive && (
<FormFieldCreationOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* Modify-mode: select / move / resize existing fields */}
{enableFormFill && formEditingActive && (
<FormFieldEditOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* SignatureFieldOverlay — bitmaps of digital-signature appearances */}
{file && (
<SignatureFieldOverlay
documentId={documentId}
pageIndex={pageIndex}
pdfSource={file}
pageWidth={width}
pageHeight={height}
/>
)}
{/* AnnotationLayer for annotation editing and annotation-based redactions */}
{(enableAnnotations || enableRedaction) && (
<AnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
selectionOutline={{ color: "#007ACC" }}
selectionMenu={(props) => (
<AnnotationSelectionMenu {...props} />
)}
style={
!showBakedAnnotations
? {
opacity: 0,
pointerEvents: "none",
}
: undefined
}
/>
)}
{enableRedaction && (
<RedactionLayer
documentId={documentId}
pageIndex={pageIndex}
selectionMenu={(props) => (
<RedactionSelectionMenu {...props} />
)}
/>
)}
{/* LinkLayer uses EmbedPDF annotation state for link rendering */}
<LinkLayer
documentId={documentId}
pageIndex={pageIndex}
/>
{/* Signature preview overlay (opt-in; off by default) */}
{signatureOverlayEnabled && (
<SignaturePreviewLayer
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
previews={localSignaturePreviews}
readOnly={signaturePreviewsReadOnly}
placementMode={signaturePlacementMode}
placementData={signaturePlacementData}
placementType={signaturePlacementType}
onChange={handleSignaturePreviewsChange}
selectedId={selectedSignatureId}
onSelect={setSelectedSignatureId}
/>
)}
</ViewerPageContainer>
</PagePointerProvider>
</Rotate>
);
}}
/>
{/* Signature preview overlay (opt-in; off by default) */}
{signatureOverlayEnabled && (
<SignaturePreviewLayer
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
previews={localSignaturePreviews}
readOnly={signaturePreviewsReadOnly}
placementMode={signaturePlacementMode}
placementData={signaturePlacementData}
placementType={signaturePlacementType}
onChange={handleSignaturePreviewsChange}
selectedId={selectedSignatureId}
onSelect={setSelectedSignatureId}
/>
)}
</ViewerPageContainer>
</PagePointerProvider>
</Rotate>
);
}}
/>
</ZoomGestureWrapper>
</Viewport>
</GlobalPointerProvider>
{enableAnnotations && (
@@ -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();
});
});
+7 -15
View File
@@ -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();
@@ -1,3 +1,9 @@
html,
body,
#root {
touch-action: pan-x pan-y;
}
html,
body {
margin: 0;
+12
View File
@@ -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 });