From bf2e72347b3b131ba400f935833aa87375571de5 Mon Sep 17 00:00:00 2001 From: Julian Boilen Date: Sun, 22 Dec 2024 15:25:41 -0500 Subject: [PATCH] Use forked version of react-zoom-pan-pinch Remove a bunch of hacks and hopefully make it work on mobile now --- frontend/package-lock.json | 51 +++- frontend/package.json | 2 +- .../App/screens/ViewerPane/ViewerPane.less | 11 + .../screens/App/screens/ViewerPane/index.tsx | 275 +++++++++--------- .../ViewerPane/shared/utils/useCanHover.ts | 27 ++ .../utils}/useEventForwarding.ts | 0 frontend/webpack.common.js | 10 +- 7 files changed, 215 insertions(+), 161 deletions(-) create mode 100644 frontend/src/screens/App/screens/ViewerPane/shared/utils/useCanHover.ts rename frontend/src/screens/App/screens/ViewerPane/{shared:utils => shared/utils}/useEventForwarding.ts (100%) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8d4abe3a..ead58703 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.1.2", "license": "UNLICENSED", "dependencies": { + "@jboolean/react-zoom-pan-pinch": "^3.7.0", "@sentry/react": "^7.119.2", "@sentry/tracing": "^7.114.0", "@stripe/stripe-js": "^1.42.0", @@ -34,7 +35,7 @@ "react-virtualized-auto-sizer": "^1.0.7", "react-window": "^1.8.7", "react-window-infinite-loader": "^1.0.9", - "react-zoom-pan-pinch": "^3.6.1", + "react-zoom-pan-pinch": "file:.yalc/react-zoom-pan-pinch", "zustand": "^4.1.5" }, "devDependencies": { @@ -115,6 +116,18 @@ "node": "^20" } }, + ".yalc/react-zoom-pan-pinch": { + "version": "0.0.0", + "license": "MIT", + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, "node_modules/@ampproject/remapping": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", @@ -2109,6 +2122,19 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@jboolean/react-zoom-pan-pinch": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@jboolean/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz", + "integrity": "sha512-E4FrYltATpGL21zdN9wVILzfzDsAV2EJBUhNA5rp+ePHfvZVRReVVthiWELK+U1OlOL6WuKb4duAZ44iRFh3KA==", + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -10645,17 +10671,8 @@ } }, "node_modules/react-zoom-pan-pinch": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.6.1.tgz", - "integrity": "sha512-SdPqdk7QDSV7u/WulkFOi+cnza8rEZ0XX4ZpeH7vx3UZEg7DoyuAy3MCmm+BWv/idPQL2Oe73VoC0EhfCN+sZQ==", - "engines": { - "node": ">=8", - "npm": ">=5" - }, - "peerDependencies": { - "react": "*", - "react-dom": "*" - } + "resolved": ".yalc/react-zoom-pan-pinch", + "link": true }, "node_modules/readable-stream": { "version": "3.6.0", @@ -14795,6 +14812,12 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "@jboolean/react-zoom-pan-pinch": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@jboolean/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz", + "integrity": "sha512-E4FrYltATpGL21zdN9wVILzfzDsAV2EJBUhNA5rp+ePHfvZVRReVVthiWELK+U1OlOL6WuKb4duAZ44iRFh3KA==", + "requires": {} + }, "@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -21225,9 +21248,7 @@ "requires": {} }, "react-zoom-pan-pinch": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.6.1.tgz", - "integrity": "sha512-SdPqdk7QDSV7u/WulkFOi+cnza8rEZ0XX4ZpeH7vx3UZEg7DoyuAy3MCmm+BWv/idPQL2Oe73VoC0EhfCN+sZQ==", + "version": "file:.yalc/react-zoom-pan-pinch", "requires": {} }, "readable-stream": { diff --git a/frontend/package.json b/frontend/package.json index 54d59c69..e1917e37 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "author": "Julian Boilen", "license": "UNLICENSED", "dependencies": { + "@jboolean/react-zoom-pan-pinch": "^3.7.0", "@sentry/react": "^7.119.2", "@sentry/tracing": "^7.114.0", "@stripe/stripe-js": "^1.42.0", @@ -42,7 +43,6 @@ "react-virtualized-auto-sizer": "^1.0.7", "react-window": "^1.8.7", "react-window-infinite-loader": "^1.0.9", - "react-zoom-pan-pinch": "^3.6.1", "zustand": "^4.1.5" }, "devDependencies": { diff --git a/frontend/src/screens/App/screens/ViewerPane/ViewerPane.less b/frontend/src/screens/App/screens/ViewerPane/ViewerPane.less index 3e8787b3..986549fc 100644 --- a/frontend/src/screens/App/screens/ViewerPane/ViewerPane.less +++ b/frontend/src/screens/App/screens/ViewerPane/ViewerPane.less @@ -96,6 +96,8 @@ box-sizing: border-box; color: white; + + user-select: all; } .buttons { @@ -136,6 +138,15 @@ grid-area: zoomHitArea; } + // Class name to exclude from pinch zoom + .panPinchExcluded { + user-select: text; + } + + .panning { + cursor: grabbing !important; + } + .transformWrapper, .transformContent { width: 100% !important; diff --git a/frontend/src/screens/App/screens/ViewerPane/index.tsx b/frontend/src/screens/App/screens/ViewerPane/index.tsx index ff1b084e..96ff880e 100644 --- a/frontend/src/screens/App/screens/ViewerPane/index.tsx +++ b/frontend/src/screens/App/screens/ViewerPane/index.tsx @@ -2,13 +2,14 @@ import React, { useEffect } from 'react'; import classnames from 'classnames'; -import { useHistory, useParams } from 'react-router'; import { + InnerTransformedContent, ReactZoomPanPinchContentRef, ReactZoomPanPinchProps, TransformComponent, TransformWrapper, -} from 'react-zoom-pan-pinch'; +} from '@jboolean/react-zoom-pan-pinch'; +import { useHistory, useParams } from 'react-router'; import Alternates from './components/Alternates'; import ImageButtons from './components/ImageButtons'; import * as ImageStack from './components/ImageStack'; @@ -17,34 +18,65 @@ import Overlay from './components/Overlay'; import Stories from './components/Stories'; import stylesheet from './ViewerPane.less'; -import useEventForwarding from './shared:utils/useEventForwarding'; - -// react-zoom-pan-pinch relies on these events -// We need to forward them from the overlay to the wrapper -const ZOOM_PAN_PINCH_EVENT_TYPES = [ - 'wheel', - 'dblclick', - 'touchstart', - 'touchend', - 'touchmove', - 'pointerup', - 'pointerover', - 'pointerleave', - 'pointerdown', - 'mousedown', - 'mouseup', - 'mousemove', - 'mouseleave', - 'mouseenter', - 'keyup', - 'keydown', - // 'click', -] as const; - -const HIT_AREA_EVENT_TYPES = ['pointerdown', 'click'] as const; +import useCanHover from './shared/utils/useCanHover'; const INITIAL_SCALE = 1.05; +const ClickToZoomHitArea = ({ + wrapper, + isZoomed, + isPanning, +}: { + wrapper: ReactZoomPanPinchContentRef; + isZoomed: boolean; + isPanning: boolean; +}): JSX.Element | null => { + const canHover = useCanHover(); + + const startXRef = React.useRef(0); + const startYRef = React.useRef(0); + + const handleZoomHitAreaMouseDown = ( + e: React.MouseEvent + ): void => { + startXRef.current = e.clientX; + startYRef.current = e.clientY; + }; + + const handleZoomHitAreaClick = ( + e: React.MouseEvent + ): void => { + if (!wrapper) return; + const { clientX, clientY } = e; + + const deltaX = clientX - startXRef.current; + const deltaY = clientY - startYRef.current; + const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2); + // If the pointer moved more than 10 pixels, ignore + if (distance > 10) { + return; + } + + if (isZoomed) wrapper.resetTransform(); + else wrapper.zoomToPoint(clientX, clientY, 3.8, 200, 'easeOut'); + }; + + if (!canHover) { + return null; + } + + return ( +
+ ); +}; + export default function ViewerPane({ className, }: { @@ -54,24 +86,10 @@ export default function ViewerPane({ const history = useHistory(); const overlayRef = React.useRef(null); - const zoomHitAreaRef = React.useRef(null); - const zoomHitAreaEl = zoomHitAreaRef.current; const wrapperRef = React.useRef(null); - const wrapperComponent = wrapperRef.current?.instance?.wrapperComponent; const [isZoomed, setIsZoomed] = React.useState(false); - - // This is just to trigger a re-render so the effect is re-run - const setIsHitAreaRefSet = React.useState(false)[1]; - - // The overlay is on top and normally would capture all events, but we also need the zooming wrapper to see these events - // This hook forwards events from the overlay to the wrapper - useEventForwarding( - overlayRef.current, - wrapperComponent, - ZOOM_PAN_PINCH_EVENT_TYPES - ); - - useEventForwarding(zoomHitAreaEl, wrapperComponent, HIT_AREA_EVENT_TYPES); + const [isPanning, setIsPanning] = React.useState(false); + const canHover = useCanHover(); // Reset the transform when the photo changes useEffect(() => { @@ -87,127 +105,104 @@ export default function ViewerPane({ setIsZoomed(scale > INITIAL_SCALE); }; - // Simulate a double click upon a single click - useEffect(() => { - let startX = 0; - let startY = 0; - - const toggleZoom = (e: PointerEvent): void => { - if (e.target !== e.currentTarget) return; - // If not a mouse, ignore - if (e.pointerType !== 'mouse') return; - // If not a single click, ignore - if (e.detail !== 1) return; - const deltaX = e.clientX - startX; - const deltaY = e.clientY - startY; - const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2); - // If the pointer moved more than 10 pixels, ignore - if (distance > 10) { - return; - } - - const wrapper = wrapperRef.current; - if (!wrapper) return; - wrapper.instance.onDoubleClick(e); - }; - - const recordDown = (event: MouseEvent): void => { - startX = event.clientX; - startY = event.clientY; - }; - - if (wrapperComponent) { - wrapperComponent.addEventListener('mousedown', recordDown); - wrapperComponent.addEventListener('click', toggleZoom); - return () => { - wrapperComponent.removeEventListener('mousedown', recordDown); - wrapperComponent.removeEventListener('click', toggleZoom); - }; - } - }, [wrapperComponent, isZoomed]); - return ( setIsPanning(true)} + onPanningStop={() => setIsPanning(false)} >
- -
-
- - - -
- -
- -
{ - zoomHitAreaRef.current = el; - setIsHitAreaRefSet(!!el); - }} - className={classnames( - stylesheet.zoomHitArea, - stylesheet.zoomable, - { - [stylesheet.zoomed]: isZoomed, - } - )} - >
- -
- -
- -
- -
- -

- Photo courtesy NYC Municipal Archives{' '} -

-
- - + +
+
+ + + +
+ +
+ + + +
+ +
+ +
+ +
+ +

+ Photo courtesy NYC Municipal Archives{' '} +

+
+ + + + +
diff --git a/frontend/src/screens/App/screens/ViewerPane/shared/utils/useCanHover.ts b/frontend/src/screens/App/screens/ViewerPane/shared/utils/useCanHover.ts new file mode 100644 index 00000000..16dd65ba --- /dev/null +++ b/frontend/src/screens/App/screens/ViewerPane/shared/utils/useCanHover.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; + +export default function useCanHover(): boolean { + const [hasFinePointer, setHasFinePointer] = useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia('(hover: hover)'); + + // Set initial value + setHasFinePointer(mediaQuery.matches); + + // Define a handler for changes + const handleChange = (event: MediaQueryListEvent): void => { + setHasFinePointer(event.matches); + }; + + // Add event listener for changes + mediaQuery.addEventListener('change', handleChange); + + // Cleanup event listener on unmount + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + }, []); + + return hasFinePointer; +} diff --git a/frontend/src/screens/App/screens/ViewerPane/shared:utils/useEventForwarding.ts b/frontend/src/screens/App/screens/ViewerPane/shared/utils/useEventForwarding.ts similarity index 100% rename from frontend/src/screens/App/screens/ViewerPane/shared:utils/useEventForwarding.ts rename to frontend/src/screens/App/screens/ViewerPane/shared/utils/useEventForwarding.ts diff --git a/frontend/webpack.common.js b/frontend/webpack.common.js index 6c4d6a43..18155227 100755 --- a/frontend/webpack.common.js +++ b/frontend/webpack.common.js @@ -27,20 +27,20 @@ module.exports = { }), new webpack.IgnorePlugin({ resourceRegExp: /^\.\/locale$/ }), new CopyPlugin({ - patterns: [{ from: '_redirects' }, {from: 'terms.html'}], + patterns: [{ from: '_redirects' }, { from: 'terms.html' }], }), - new ESLintPlugin({ fix: true, }), + new ESLintPlugin({ fix: true, exclude: ['node_modules', '.yalc'] }), ], module: { rules: [ { test: /\.jsx?$/, - exclude: /node_modules/, + exclude: /node_modules|.yalc/, use: ['babel-loader'], }, { test: /\.tsx?$/, - exclude: /node_modules/, + exclude: /node_modules|.yalc/, loader: 'ts-loader', }, { @@ -67,7 +67,7 @@ module.exports = { { test: /\.svg$/, issuer: /\.(j|t)sx$/, - exclude: /node_modules/, + exclude: /node_modules|.yalc/, loader: 'svg-react-loader', resourceQuery: { not: [/asset/] }, options: {