From 2d7999d9dc8928d098dcd852d538df3f5cb255dc Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Sat, 4 Nov 2023 20:33:33 +0600 Subject: [PATCH] enable pitch zoom --- .../common/ImageLoadingWrapper/index.tsx | 25 ++-- src/components/media/PinchZoom/index.tsx | 108 ++++++++++++++++++ .../node/NodeImageSwiperBlock/index.tsx | 50 +++++--- 3 files changed, 153 insertions(+), 30 deletions(-) create mode 100644 src/components/media/PinchZoom/index.tsx diff --git a/src/components/common/ImageLoadingWrapper/index.tsx b/src/components/common/ImageLoadingWrapper/index.tsx index a8fe5749..8c45cba9 100644 --- a/src/components/common/ImageLoadingWrapper/index.tsx +++ b/src/components/common/ImageLoadingWrapper/index.tsx @@ -1,4 +1,10 @@ -import React, { CSSProperties, FC, useMemo, useReducer } from 'react'; +import React, { + CSSProperties, + ReactNode, + forwardRef, + useMemo, + useReducer, +} from 'react'; import classNames from 'classnames'; @@ -8,17 +14,14 @@ import { DivProps } from '~/utils/types'; import styles from './styles.module.scss'; interface ImageLoadingWrapperProps extends Omit { - children: (props: { loading: boolean; onLoad: () => void }) => void; + children: (props: { loading: boolean; onLoad: () => void }) => ReactNode; preview?: string; } -const ImageLoadingWrapper: FC = ({ - className, - children, - preview, - color, - ...props -}) => { +const ImageLoadingWrapper = forwardRef< + HTMLDivElement, + ImageLoadingWrapperProps +>(({ className, children, preview, color, ...props }, ref) => { const [loading, onLoad] = useReducer(() => false, true); const style = useMemo( @@ -30,7 +33,7 @@ const ImageLoadingWrapper: FC = ({ ); return ( -
+
{!!loading && !!preview && (
@@ -40,6 +43,6 @@ const ImageLoadingWrapper: FC = ({ {children({ loading, onLoad })}
); -}; +}); export { ImageLoadingWrapper }; diff --git a/src/components/media/PinchZoom/index.tsx b/src/components/media/PinchZoom/index.tsx new file mode 100644 index 00000000..3da8468d --- /dev/null +++ b/src/components/media/PinchZoom/index.tsx @@ -0,0 +1,108 @@ +import { + FC, + ReactElement, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +interface Props { + children: (props: { + setRef: (ref: HTMLElement | null) => void; + }) => ReactElement; +} + +const getDistance = (event: TouchEvent) => { + return Math.hypot( + event.touches[0].pageX - event.touches[1].pageX, + event.touches[0].pageY - event.touches[1].pageY, + ); +}; + +interface Start { + x: number; + y: number; + distance: number; +} + +const PinchZoom: FC = ({ children }) => { + const start = useRef({ x: 0, y: 0, distance: 0 }); + const [ref, setRef] = useState(null); + const imageElementScale = useRef(1); + + const onTouchStart = useCallback((event: TouchEvent) => { + if (event.touches.length !== 2) { + return; + } + event.preventDefault(); // Prevent page scroll + + // Calculate where the fingers have started on the X and Y axis + start.current.x = (event.touches[0].pageX + event.touches[1].pageX) / 2; + start.current.y = (event.touches[0].pageY + event.touches[1].pageY) / 2; + start.current.distance = getDistance(event); + }, []); + + const onTouchMove = useCallback( + (event) => { + if (event.touches.length !== 2 || !ref) { + return; + } + event.preventDefault(); // Prevent page scroll + + // Safari provides event.scale as two fingers move on the screen + // For other browsers just calculate the scale manually + const scale = event.scale ?? getDistance(event) / start.current.distance; + imageElementScale.current = Math.min(Math.max(1, scale), 4); + + // Calculate how much the fingers have moved on the X and Y axis + const deltaX = + ((event.touches[0].pageX + event.touches[1].pageX) / 2 - + start.current.x) * + 2; // x2 for accelarated movement + const deltaY = + ((event.touches[0].pageY + event.touches[1].pageY) / 2 - + start.current.y) * + 2; // x2 for accelarated movement + + // Transform the image to make it grow and move with fingers + const transform = `translate3d(${deltaX}px, ${deltaY}px, 0) scale(${imageElementScale})`; + ref.style.transform = transform; + ref.style.zIndex = '9999'; + }, + [ref], + ); + + const onTouchEnd = useCallback( + (event) => { + if (!ref) { + return; + } + + // Reset image to it's original format + ref.style.transform = ''; + ref.style.zIndex = ''; + }, + [ref], + ); + + useEffect(() => { + if (!ref) { + return; + } + + ref.addEventListener('touchstart', onTouchStart); + ref.addEventListener('touchmove', onTouchMove); + ref.addEventListener('touchend', onTouchEnd); + + return () => { + ref.removeEventListener('touchstart', onTouchStart); + ref.removeEventListener('touchmove', onTouchMove); + ref.removeEventListener('touchend', onTouchEnd); + }; + }, [onTouchEnd, onTouchMove, onTouchStart, ref]); + + return children({ setRef }); +}; + +export { PinchZoom }; diff --git a/src/components/node/NodeImageSwiperBlock/index.tsx b/src/components/node/NodeImageSwiperBlock/index.tsx index e78bf12e..80b8789c 100644 --- a/src/components/node/NodeImageSwiperBlock/index.tsx +++ b/src/components/node/NodeImageSwiperBlock/index.tsx @@ -7,6 +7,7 @@ import { Swiper, SwiperSlide } from 'swiper/react'; import SwiperClass from 'swiper/types/swiper-class'; import { ImageLoadingWrapper } from '~/components/common/ImageLoadingWrapper/index'; +import { PinchZoom } from '~/components/media/PinchZoom'; import { NodeComponentProps } from '~/constants/node'; import { imagePresets } from '~/constants/urls'; import { useWindowSize } from '~/hooks/dom/useWindowSize'; @@ -100,26 +101,37 @@ const NodeImageSwiperBlock: FC = observer(({ node }) => { > {images.map((file, index) => ( - - {({ loading, onLoad }) => ( - onOpenPhotoSwipe(index)} - className={classNames(styles.image, 'swiper-lazy', { - [styles.loading]: loading, - })} - sizes={getNodeSwiperImageSizes(file, innerWidth, innerHeight)} - quality={90} - /> + + {({ setRef }) => ( + + {({ loading, onLoad }) => ( + onOpenPhotoSwipe(index)} + className={classNames(styles.image, 'swiper-lazy', { + [styles.loading]: loading, + })} + sizes={getNodeSwiperImageSizes( + file, + innerWidth, + innerHeight, + )} + quality={90} + /> + )} + )} - + ))}