diff --git a/.changeset/pr-602-centered-zoom.md b/.changeset/pr-602-centered-zoom.md new file mode 100644 index 000000000..35c1a550e --- /dev/null +++ b/.changeset/pr-602-centered-zoom.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Image zooming is now centered on the cursor position diff --git a/.changeset/pr-602-multiplicative-zoom.md b/.changeset/pr-602-multiplicative-zoom.md new file mode 100644 index 000000000..7a4def730 --- /dev/null +++ b/.changeset/pr-602-multiplicative-zoom.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Image zooming is now multiplicative instead of additive, resulting in a consistent "zooming speed". diff --git a/.changeset/pr-602-zoom-buttons.md b/.changeset/pr-602-zoom-buttons.md new file mode 100644 index 000000000..3c5ab5df6 --- /dev/null +++ b/.changeset/pr-602-zoom-buttons.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Image zoom buttons now zoom towards the center of the screen diff --git a/src/app/components/Pdf-viewer/PdfViewer.tsx b/src/app/components/Pdf-viewer/PdfViewer.tsx index 71ab77efb..1bd90c8c2 100644 --- a/src/app/components/Pdf-viewer/PdfViewer.tsx +++ b/src/app/components/Pdf-viewer/PdfViewer.tsx @@ -39,7 +39,13 @@ export const PdfViewer = as<'div', PdfViewerProps>( const containerRef = useRef(null); const scrollRef = useRef(null); - const { zoom, zoomIn, zoomOut, setZoom, onPointerDown } = useImageGestures(true, 0.2); + const { + transforms: { zoom }, + zoomIn, + zoomOut, + setZoom, + onPointerDown, + } = useImageGestures(true, 0.2); const [pdfJSState, loadPdfJS] = usePdfJSLoader(); const [docState, loadPdfDocument] = usePdfDocumentLoader( diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 569b61df1..54b878c76 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -1,4 +1,3 @@ -import { WheelEvent } from 'react'; import FileSaver from 'file-saver'; import classNames from 'classnames'; import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds'; @@ -14,28 +13,14 @@ export type ImageViewerProps = { export const ImageViewer = as<'div', ImageViewerProps>( ({ className, alt, src, requestClose, ...props }, ref) => { - const { zoom, pan, cursor, onPointerDown, setZoom, zoomIn, zoomOut } = useImageGestures( - true, - 0.2 - ); + const { transforms, cursor, handleWheel, onPointerDown, resetTransforms, zoomIn, zoomOut } = + useImageGestures(true, 0.2); const handleDownload = async () => { const fileContent = await downloadMedia(src); FileSaver.saveAs(fileContent, alt); }; - const handleWheel = (e: WheelEvent) => { - const { deltaY } = e; - // Mouse wheel scrolls only by integer delta values, therefore - // If deltaY is an integer, then it's a mouse wheel action - if (Number.isInteger(deltaY)) { - if (deltaY < 0) { - zoomIn(); - } else zoomOut(); - } - // If it's not an integer, then it's a touchpad action, do nothing and let the browser handle the zooming - }; - return ( ( ( > - setZoom(zoom === 1 ? 2 : 1)}> - {Math.round(zoom * 100)}% + + {Math.round(transforms.zoom * 100)}% 1 ? 'Success' : 'SurfaceVariant'} - outlined={zoom > 1} + variant={transforms.zoom > 1 ? 'Success' : 'SurfaceVariant'} + outlined={transforms.zoom > 1} size="300" radii="Pill" onClick={zoomIn} @@ -104,7 +89,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( userSelect: 'none', touchAction: 'none', willChange: 'transform', - transform: `translate(${pan.translateX}px, ${pan.translateY}px) scale(${zoom})`, + transform: `translate(${transforms.pan.x}px, ${transforms.pan.y}px) scale(${transforms.zoom})`, }} src={src} alt={alt} diff --git a/src/app/hooks/useImageGestures.ts b/src/app/hooks/useImageGestures.ts index 203d35784..cf5d6fbe5 100644 --- a/src/app/hooks/useImageGestures.ts +++ b/src/app/hooks/useImageGestures.ts @@ -1,8 +1,35 @@ import { useState, useCallback, useRef, useEffect } from 'react'; +interface Vector2 { + x: number; + y: number; +} + +interface Transforms { + zoom: number; + pan: Vector2; +} + +// calculate pointer position relative to the image center +// +// use container rect & manually apply transforms as if we get two+ events quickly, +// the second one might use an outdated image rect (before new transforms are applied) +function getCursorOffsetFromImageCenter( + event: React.MouseEvent, + containerRect: DOMRect, + pan: Vector2 +): Vector2 { + return { + x: containerRect.width / 2 - (event.clientX - containerRect.x - pan.x), + y: containerRect.height / 2 - (event.clientY - containerRect.y - pan.y), + }; +} + export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5) => { - const [zoom, setZoom] = useState(1); - const [pan, setPan] = useState({ translateX: 0, translateY: 0 }); + const [transforms, setTransforms] = useState({ + zoom: 1, + pan: { x: 0, y: 0 }, + }); const [cursor, setCursor] = useState<'grab' | 'grabbing' | 'initial'>( active ? 'grab' : 'initial' ); @@ -11,29 +38,82 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 const initialDist = useRef(0); const lastTapRef = useRef(0); - const onPointerDown = (e: React.PointerEvent) => { - if (!active) return; - - e.stopPropagation(); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - - const now = Date.now(); - if (now - lastTapRef.current < 300) { - setZoom(zoom === 1 ? 2 : 1); - setPan({ translateX: 0, translateY: 0 }); - lastTapRef.current = 0; - return; - } - lastTapRef.current = now; + const setZoom = useCallback((next: number | ((prev: number) => number)) => { + setTransforms((prev) => { + if (typeof next === 'function') { + return { + ...prev, + zoom: next(prev.zoom), + }; + } + return { + ...prev, + zoom: next, + }; + }); + }, []); + + const setPan = useCallback((next: Vector2 | ((prev: Vector2) => Vector2)) => { + setTransforms((prev) => { + if (typeof next === 'function') { + return { + ...prev, + pan: next(prev.pan), + }; + } + return { + ...prev, + pan: next, + }; + }); + }, []); + + const resetTransforms = useCallback(() => { + setTransforms({ zoom: 1, pan: { x: 0, y: 0 } }); + }, []); + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + if (!active) return; + + e.stopPropagation(); + const target = e.target as HTMLElement; + target.setPointerCapture(e.pointerId); + + const now = Date.now(); + if (now - lastTapRef.current < 300) { + const container = target.parentElement ?? target; + const containerRect = container.getBoundingClientRect(); + setTransforms((prev) => { + if (prev.zoom !== 1) { + return { zoom: 1, pan: { x: 0, y: 0 } }; + } + + // pan using the pointer's offset relative to the center of the image + const offset = getCursorOffsetFromImageCenter(e, containerRect, prev.pan); + return { + zoom: 2, + pan: { + x: offset.x + prev.pan.x, + y: offset.y + prev.pan.y, + }, + }; + }); + lastTapRef.current = 0; + return; + } + lastTapRef.current = now; - activePointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); - setCursor('grabbing'); + activePointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + setCursor('grabbing'); - if (activePointers.current.size === 2) { - const points = Array.from(activePointers.current.values()); - initialDist.current = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y); - } - }; + if (activePointers.current.size === 2) { + const points = Array.from(activePointers.current.values()); + initialDist.current = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y); + } + }, + [active] + ); const handlePointerMove = useCallback( (e: PointerEvent) => { @@ -53,12 +133,12 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 if (activePointers.current.size === 1) { setPan((p) => ({ - translateX: p.translateX + e.movementX, - translateY: p.translateY + e.movementY, + x: p.x + e.movementX, + y: p.y + e.movementY, })); } }, - [min, max] + [setZoom, min, max, setPan] ); const handlePointerUp = useCallback( @@ -86,20 +166,84 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 }, [handlePointerMove, handlePointerUp]); const zoomIn = useCallback(() => { - setZoom((z) => Math.min(z + step, max)); + setTransforms((prev) => { + const newZoom = Math.min(prev.zoom * (1 + step), max); + const zoomMult = newZoom / prev.zoom; + + return { + zoom: newZoom, + pan: { + x: prev.pan.x * zoomMult, + y: prev.pan.y * zoomMult, + }, + }; + }); }, [step, max]); const zoomOut = useCallback(() => { - setZoom((z) => Math.max(z - step, min)); - }, [step, min]); + setTransforms((prev) => { + const newZoom = Math.min(prev.zoom / (1 + step), max); + const zoomMult = newZoom / prev.zoom; + + return { + zoom: newZoom, + pan: { + x: prev.pan.x * zoomMult, + y: prev.pan.y * zoomMult, + }, + }; + }); + }, [step, max]); + + const handleWheel = useCallback( + (e: React.WheelEvent) => { + const { deltaY } = e; + // Mouse wheel scrolls only by integer delta values, therefore + // If deltaY is an integer, then it's a mouse wheel action + if (!Number.isInteger(deltaY)) { + // If it's not an integer, then it's a touchpad action, do nothing and let the browser handle the zooming + return; + } + + // the wheel handler is attached to the container element, not the image + const containerRect = e.currentTarget.getBoundingClientRect(); + + setTransforms((prev) => { + // calculate multiplicative zoom + const newZoom = + deltaY < 0 + ? Math.min(prev.zoom * (1 + step), max) + : Math.max(prev.zoom / (1 + step), min); + const zoomMult = newZoom / prev.zoom - 1; + + // calculate pointer position relative to the image center + // + // manually apply transforms as if we get two+ wheel events quickly, + // the second one might use an outdated image rect (before new transforms are applied) + const offset = getCursorOffsetFromImageCenter(e, containerRect, prev.pan); + + return { + zoom: newZoom, + // magic math that happens to do what i want it to do + pan: { + x: offset.x * zoomMult + prev.pan.x, + y: offset.y * zoomMult + prev.pan.y, + }, + }; + }); + }, + [max, min, step] + ); return { - zoom, - pan, + transforms, cursor, onPointerDown, + handleWheel, setZoom, setPan, + setTransforms, + resetTransforms, zoomIn, zoomOut, };