mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
feat(viewer): add ZoomGestureWrapper and implement wheel-based zooming for PDF viewer
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user