From 32c2a1244dbd62cdeff4d11820eeb11ab46cfe92 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 13 Nov 2023 20:06:22 +0100 Subject: [PATCH 01/47] move ImageTransformer component --- .../Pager/AttachmentCarouselPage.js | 10 +++--- .../ImageTransformer.js => ImageLightbox.js} | 35 +++++++++++++------ 2 files changed, 30 insertions(+), 15 deletions(-) rename src/components/Attachments/{AttachmentCarousel/Pager/ImageTransformer.js => ImageLightbox.js} (94%) diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js index 7a083d71b591..1a233b5c8b49 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js @@ -2,10 +2,10 @@ import PropTypes from 'prop-types'; import React, {useContext, useEffect, useRef, useState} from 'react'; import {ActivityIndicator, PixelRatio, StyleSheet, View} from 'react-native'; +import ImageLightbox from '@components/Attachments/ImageLightbox'; import * as AttachmentsPropTypes from '@components/Attachments/propTypes'; import Image from '@components/Image'; import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext'; -import ImageTransformer from './ImageTransformer'; import ImageWrapper from './ImageWrapper'; function getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}) { @@ -68,7 +68,7 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI <> {isActive && ( - - + )} - {/* Keep rendering the image without gestures as fallback while ImageTransformer is loading the image */} + {/* Keep rendering the image without gestures as fallback while ImageLightbox is loading the image */} {(showFallback || !isActive) && ( )} - {/* Show activity indicator while ImageTransfomer is still loading the image. */} + {/* Show activity indicator while ImageLightbox is still loading the image. */} {isActive && isFallbackLoading && !isImageLoaded.current && ( Math.min(imageScaleX, imageScaleY), [imageScaleX, imageScaleY]); const maxImageScale = useMemo(() => Math.max(imageScaleX, imageScaleY), [imageScaleX, imageScaleY]); @@ -572,8 +587,8 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc ); } -ImageTransformer.propTypes = imageTransformerPropTypes; -ImageTransformer.defaultProps = imageTransformerDefaultProps; -ImageTransformer.displayName = 'ImageTransformer'; +ImageLightbox.propTypes = imageLightboxPropTypes; +ImageLightbox.defaultProps = imageLightboxDefaultProps; +ImageLightbox.displayName = 'ImageLightbox'; -export default ImageTransformer; +export default ImageLightbox; From 52b5d1427ea039022ac8cacde04fdcf58ab0cd5a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 14 Nov 2023 14:05:45 +0100 Subject: [PATCH 02/47] feat: extract ImageLightbox to separate component and use everywhere --- .../Pager/AttachmentCarouselPagerContext.js | 4 +- .../AttachmentViewImage/index.native.js | 13 +- .../ImageLightbox/ImageLightboxUtils.js | 12 + .../ImageTransformer.js} | 45 ++-- .../Pager => ImageLightbox}/ImageWrapper.js | 0 .../index.js} | 126 ++++++---- src/components/ImageView/index.js | 24 +- src/components/ImageView/index.native.js | 237 ++---------------- src/components/ImageView/propTypes.js | 25 ++ 9 files changed, 161 insertions(+), 325 deletions(-) create mode 100644 src/components/ImageLightbox/ImageLightboxUtils.js rename src/components/{Attachments/ImageLightbox.js => ImageLightbox/ImageTransformer.js} (94%) rename src/components/{Attachments/AttachmentCarousel/Pager => ImageLightbox}/ImageWrapper.js (100%) rename src/components/{Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js => ImageLightbox/index.js} (58%) create mode 100644 src/components/ImageView/propTypes.js diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js index 39535288e22d..abaf06900853 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js @@ -1,5 +1,5 @@ import {createContext} from 'react'; -const AttachmentCarouselContextPager = createContext(null); +const AttachmentCarouselPagerContext = createContext(null); -export default AttachmentCarouselContextPager; +export default AttachmentCarouselPagerContext; diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js index 8b29d8d5ba3d..70fe8530bd8d 100755 --- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js @@ -1,5 +1,4 @@ import React, {memo} from 'react'; -import AttachmentCarouselPage from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage'; import ImageView from '@components/ImageView'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; @@ -13,20 +12,14 @@ const propTypes = { ...withLocalizePropTypes, }; -function AttachmentViewImage({source, file, isAuthTokenRequired, isFocused, isUsedInCarousel, loadComplete, onPress, isImage, onScaleChanged, translate}) { - const children = isUsedInCarousel ? ( - - ) : ( +function AttachmentViewImage({source, file, isAuthTokenRequired, isFocused, loadComplete, onPress, isImage, onScaleChanged, translate}) { + const children = ( ); diff --git a/src/components/ImageLightbox/ImageLightboxUtils.js b/src/components/ImageLightbox/ImageLightboxUtils.js new file mode 100644 index 000000000000..694737cdb1f6 --- /dev/null +++ b/src/components/ImageLightbox/ImageLightboxUtils.js @@ -0,0 +1,12 @@ +function getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}) { + const scaleX = canvasWidth / imageWidth; + const scaleY = canvasHeight / imageHeight; + + return {scaleX, scaleY}; +} + +const ImageLightboxUtils = { + getCanvasFitScale, +}; + +export default ImageLightboxUtils; diff --git a/src/components/Attachments/ImageLightbox.js b/src/components/ImageLightbox/ImageTransformer.js similarity index 94% rename from src/components/Attachments/ImageLightbox.js rename to src/components/ImageLightbox/ImageTransformer.js index 28ee9f6294de..4094f76b2687 100644 --- a/src/components/Attachments/ImageLightbox.js +++ b/src/components/ImageLightbox/ImageTransformer.js @@ -15,9 +15,9 @@ import Animated, { withDecay, withSpring, } from 'react-native-reanimated'; +import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; import styles from '@styles/styles'; -import AttachmentCarouselPagerContext from './AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; -import ImageWrapper from './AttachmentCarousel/Pager/ImageWrapper'; +import ImageWrapper from './ImageWrapper'; const MIN_ZOOM_SCALE_WITHOUT_BOUNCE = 1; const MAX_ZOOM_SCALE_WITHOUT_BOUNCE = 20; @@ -39,7 +39,9 @@ function clamp(value, lowerBound, upperBound) { return Math.min(Math.max(lowerBound, value), upperBound); } -const imageLightboxPropTypes = { +const imageTransformerPropTypes = { + canvasWidth: PropTypes.number.isRequired, + canvasHeight: PropTypes.number.isRequired, imageWidth: PropTypes.number, imageHeight: PropTypes.number, imageScaleX: PropTypes.number, @@ -50,7 +52,7 @@ const imageLightboxPropTypes = { children: PropTypes.node.isRequired, }; -const imageLightboxDefaultProps = { +const imageTransformerDefaultProps = { imageWidth: 0, imageHeight: 0, imageScaleX: 1, @@ -59,23 +61,20 @@ const imageLightboxDefaultProps = { scaledImageHeight: 0, }; -function ImageLightbox({imageWidth, imageHeight, imageScaleX, imageScaleY, scaledImageWidth, scaledImageHeight, isActive, children}) { +function ImageTransformer({canvasWidth, canvasHeight, imageWidth, imageHeight, imageScaleX, imageScaleY, scaledImageWidth, scaledImageHeight, isActive = true, children, ...props}) { const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); - const {canvasWidth, canvasHeight, onTap, onSwipe, onSwipeSuccess, pagerRef, shouldPagerScroll, isScrolling, onPinchGestureChange} = - attachmentCarouselPagerContext != null - ? attachmentCarouselPagerContext - : { - canvasWidth: 0, - canvasHeight: 0, - onTap: null, - onSwipe: null, - onSwipeSuccess: null, - pagerRef: null, - shouldPagerScroll: null, - isScrolling: null, - onPinchGestureChange: null, - }; + const pagerRefFallback = useRef(null); + const {onTap, onSwipe, onSwipeSuccess, pagerRef, shouldPagerScroll, isScrolling, onPinchGestureChange} = attachmentCarouselPagerContext || { + onTap: () => undefined, + onSwipe: () => undefined, + onSwipeSuccess: () => undefined, + onPinchGestureChange: () => undefined, + pagerRef: pagerRefFallback, + shouldPagerScroll: false, + isScrolling: false, + ...props, + }; const minImageScale = useMemo(() => Math.min(imageScaleX, imageScaleY), [imageScaleX, imageScaleY]); const maxImageScale = useMemo(() => Math.max(imageScaleX, imageScaleY), [imageScaleX, imageScaleY]); @@ -587,8 +586,8 @@ function ImageLightbox({imageWidth, imageHeight, imageScaleX, imageScaleY, scale ); } -ImageLightbox.propTypes = imageLightboxPropTypes; -ImageLightbox.defaultProps = imageLightboxDefaultProps; -ImageLightbox.displayName = 'ImageLightbox'; +ImageTransformer.propTypes = imageTransformerPropTypes; +ImageTransformer.defaultProps = imageTransformerDefaultProps; +ImageTransformer.displayName = 'ImageTransformer'; -export default ImageLightbox; +export default ImageTransformer; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js b/src/components/ImageLightbox/ImageWrapper.js similarity index 100% rename from src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js rename to src/components/ImageLightbox/ImageWrapper.js diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js b/src/components/ImageLightbox/index.js similarity index 58% rename from src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js rename to src/components/ImageLightbox/index.js index 1a233b5c8b49..60be10e957fc 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js +++ b/src/components/ImageLightbox/index.js @@ -1,42 +1,63 @@ /* eslint-disable es/no-optional-chaining */ import PropTypes from 'prop-types'; -import React, {useContext, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {ActivityIndicator, PixelRatio, StyleSheet, View} from 'react-native'; -import ImageLightbox from '@components/Attachments/ImageLightbox'; import * as AttachmentsPropTypes from '@components/Attachments/propTypes'; import Image from '@components/Image'; -import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext'; +import ImageLightboxUtils from './ImageLightboxUtils'; +import ImageTransformer from './ImageTransformer'; import ImageWrapper from './ImageWrapper'; -function getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}) { - const imageScaleX = canvasWidth / imageWidth; - const imageScaleY = canvasHeight / imageHeight; +const cachedDimensions = new Map(); - return {imageScaleX, imageScaleY}; -} +/** + * On the native layer, we use a image library to handle zoom functionality + */ +const propTypes = { + /** Function for handle on press */ + onPress: PropTypes.func, -const cachedDimensions = new Map(); + /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */ + source: AttachmentsPropTypes.attachmentSourcePropType.isRequired, -const pagePropTypes = { /** Whether source url requires authentication */ isAuthTokenRequired: PropTypes.bool, - /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */ - source: AttachmentsPropTypes.attachmentSourcePropType.isRequired, + isActive: PropTypes.bool, - isActive: PropTypes.bool.isRequired, + canvasWidth: PropTypes.number.isRequired, + + canvasHeight: PropTypes.number.isRequired, + + // imageDimensions: PropTypes.shape({ + // width: PropTypes.number, + // height: PropTypes.number, + // scaledWidth: PropTypes.number, + // scaledHeight: PropTypes.number, + // scaleX: PropTypes.number, + // scaleY: PropTypes.number, + // }), + + // setImageDimensions: PropTypes.func.isRequired, + + /** Additional styles to add to the component */ + style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), }; const defaultProps = { isAuthTokenRequired: false, + isActive: true, + // imageDimensions: undefined, + onPress: () => {}, + style: {}, }; -function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialIsActive}) { - const {canvasWidth, canvasHeight} = useContext(AttachmentCarouselPagerContext); +function ImageLightbox({isAuthTokenRequired, source, onScaleChanged, onPress, style, isActive: initialIsActive, canvasWidth, canvasHeight}) { + const [isActive, setIsActive] = useState(initialIsActive); - const dimensions = cachedDimensions.get(source); + const imageDimensions = useMemo(() => cachedDimensions.get(source), [source]); + const setImageDimensions = useCallback((newDimensions) => cachedDimensions.set(source, newDimensions), [source]); - const [isActive, setIsActive] = useState(initialIsActive); // We delay setting a page to active state by a (few) millisecond(s), // to prevent the image transformer from flashing while still rendering // Instead, we show the fallback image while the image transformer is loading the image @@ -68,19 +89,20 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI <> {isActive && ( - { setIsImageLoading(true); @@ -94,21 +116,21 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI const imageWidth = (evt.nativeEvent?.width || 0) / PixelRatio.get(); const imageHeight = (evt.nativeEvent?.height || 0) / PixelRatio.get(); - const {imageScaleX, imageScaleY} = getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}); + const {scaleX, scaleY} = ImageLightboxUtils.getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}); // Don't update the dimensions if they are already set if ( - dimensions?.imageWidth !== imageWidth || - dimensions?.imageHeight !== imageHeight || - dimensions?.imageScaleX !== imageScaleX || - dimensions?.imageScaleY !== imageScaleY + imageDimensions?.width !== imageWidth || + imageDimensions?.height !== imageHeight || + imageDimensions?.scaleX !== scaleX || + imageDimensions?.scaleY !== scaleY ) { - cachedDimensions.set(source, { - ...dimensions, - imageWidth, - imageHeight, - imageScaleX, - imageScaleY, + setImageDimensions({ + ...imageDimensions, + width: imageWidth, + height: imageHeight, + scaleX, + scaleY, }); } @@ -122,7 +144,7 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI } }} /> - + )} @@ -149,24 +171,24 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI const imageWidth = evt.nativeEvent.width; const imageHeight = evt.nativeEvent.height; - const {imageScaleX, imageScaleY} = getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}); - const minImageScale = Math.min(imageScaleX, imageScaleY); + const {scaleX, scaleY} = ImageLightboxUtils.getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}); + const minImageScale = Math.min(scaleX, scaleY); - const scaledImageWidth = imageWidth * minImageScale; - const scaledImageHeight = imageHeight * minImageScale; + const scaledWidth = imageWidth * minImageScale; + const scaledHeight = imageHeight * minImageScale; // Don't update the dimensions if they are already set - if (dimensions?.scaledImageWidth === scaledImageWidth && dimensions?.scaledImageHeight === scaledImageHeight) { + if (imageDimensions?.scaledWidth === scaledWidth && imageDimensions?.scaledHeight === scaledHeight) { return; } - cachedDimensions.set(source, { - ...dimensions, - scaledImageWidth, - scaledImageHeight, + setImageDimensions({ + ...imageDimensions, + scaledWidth, + scaledHeight, }); }} - style={dimensions == null ? undefined : {width: dimensions.scaledImageWidth, height: dimensions.scaledImageHeight}} + style={imageDimensions == null ? undefined : {width: imageDimensions.scaledWidth, height: imageDimensions.scaledHeight}} /> )} @@ -182,8 +204,8 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI ); } -AttachmentCarouselPage.propTypes = pagePropTypes; -AttachmentCarouselPage.defaultProps = defaultProps; -AttachmentCarouselPage.displayName = 'AttachmentCarouselPage'; +ImageLightbox.propTypes = propTypes; +ImageLightbox.defaultProps = defaultProps; +ImageLightbox.displayName = 'AttachmentCarouselPage'; -export default AttachmentCarouselPage; +export default ImageLightbox; diff --git a/src/components/ImageView/index.js b/src/components/ImageView/index.js index a733466e1ae2..db6264f193cd 100644 --- a/src/components/ImageView/index.js +++ b/src/components/ImageView/index.js @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; @@ -8,28 +7,7 @@ import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import styles from '@styles/styles'; import * as StyleUtils from '@styles/StyleUtils'; import CONST from '@src/CONST'; - -const propTypes = { - /** Whether source url requires authentication */ - isAuthTokenRequired: PropTypes.bool, - - /** Handles scale changed event in image zoom component. Used on native only */ - // eslint-disable-next-line react/no-unused-prop-types - onScaleChanged: PropTypes.func.isRequired, - - /** URL to full-sized image */ - url: PropTypes.string.isRequired, - - /** image file name */ - fileName: PropTypes.string.isRequired, - - onError: PropTypes.func, -}; - -const defaultProps = { - isAuthTokenRequired: false, - onError: () => {}, -}; +import {defaultProps, propTypes} from './propTypes'; function ImageView({isAuthTokenRequired, url, fileName, onError}) { const [isLoading, setIsLoading] = useState(true); diff --git a/src/components/ImageView/index.native.js b/src/components/ImageView/index.native.js index dd17e2d27a4e..9de541d213ee 100644 --- a/src/components/ImageView/index.native.js +++ b/src/components/ImageView/index.native.js @@ -1,27 +1,16 @@ +/* eslint-disable es/no-optional-chaining */ import PropTypes from 'prop-types'; -import React, {useEffect, useRef, useState} from 'react'; -import {PanResponder, View} from 'react-native'; -import ImageZoom from 'react-native-image-pan-zoom'; -import _ from 'underscore'; -import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; -import Image from '@components/Image'; +import React, {useContext} from 'react'; +import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; +import ImageLightbox from '@components/ImageLightbox'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import styles from '@styles/styles'; -import variables from '@styles/variables'; +import {imageViewDefaultProps, imageViewPropTypes} from './propTypes'; /** * On the native layer, we use a image library to handle zoom functionality */ const propTypes = { - /** Whether source url requires authentication */ - isAuthTokenRequired: PropTypes.bool, - - /** URL to full-sized image */ - url: PropTypes.string.isRequired, - - /** Handles scale changed event in image zoom component. Used on native only */ - onScaleChanged: PropTypes.func.isRequired, - + ...imageViewPropTypes, /** Function for handle on press */ onPress: PropTypes.func, @@ -30,212 +19,30 @@ const propTypes = { }; const defaultProps = { - isAuthTokenRequired: false, + ...imageViewDefaultProps, onPress: () => {}, style: {}, }; -// Use the default double click interval from the ImageZoom library -// https://github.com/ascoders/react-native-image-zoom/blob/master/src/image-zoom/image-zoom.type.ts#L79 -const DOUBLE_CLICK_INTERVAL = 175; - function ImageView({isAuthTokenRequired, url, onScaleChanged, onPress, style}) { - const {windowWidth, windowHeight} = useWindowDimensions(); - - const [isLoading, setIsLoading] = useState(true); - const [imageDimensions, setImageDimensions] = useState({ - width: 0, - height: 0, - }); - const [containerHeight, setContainerHeight] = useState(null); - - const imageZoomScale = useRef(1); - const lastClickTime = useRef(0); - const numberOfTouches = useRef(0); - const zoom = useRef(null); - - /** - * Updates the amount of active touches on the PanResponder on our ImageZoom overlay View - * - * @param {Event} e - * @param {GestureState} gestureState - * @returns {Boolean} - */ - const updatePanResponderTouches = (e, gestureState) => { - if (_.isNumber(gestureState.numberActiveTouches)) { - numberOfTouches.current = gestureState.numberActiveTouches; - } - - // We don't need to set the panResponder since all we care about is checking the gestureState, so return false - return false; - }; - - // PanResponder used to capture how many touches are active on the attachment image - const panResponder = useRef( - PanResponder.create({ - onStartShouldSetPanResponder: updatePanResponderTouches, - }), - ).current; - - /** - * When the url changes and the image must load again, - * this resets the zoom to ensure the next image loads with the correct dimensions. - */ - const resetImageZoom = () => { - if (imageZoomScale.current !== 1) { - imageZoomScale.current = 1; - } - - if (zoom.current) { - zoom.current.centerOn({ - x: 0, - y: 0, - scale: 1, - duration: 0, - }); - } - }; - - const imageLoadingStart = () => { - if (isLoading) { - return; - } - - resetImageZoom(); - setImageDimensions({ - width: 0, - height: 0, - }); - setIsLoading(true); - }; + let {windowWidth: canvasWidth, windowHeight: canvasHeight} = useWindowDimensions(); + const attachmenCarouselPagerContext = useContext(AttachmentCarouselPagerContext); - useEffect(() => { - imageLoadingStart(); - // eslint-disable-next-line react-hooks/exhaustive-deps -- this effect only needs to run when the url changes - }, [url]); + if (attachmenCarouselPagerContext != null) { + canvasWidth = attachmenCarouselPagerContext.canvasWidth; + canvasHeight = attachmenCarouselPagerContext.canvasHeight; + } - /** - * The `ImageZoom` component requires image dimensions which - * are calculated here from the natural image dimensions produced by - * the `onLoad` event - * - * @param {Object} nativeEvent - */ - const configureImageZoom = ({nativeEvent}) => { - let imageZoomWidth = nativeEvent.width; - let imageZoomHeight = nativeEvent.height; - const roundedContainerWidth = Math.round(windowWidth); - const roundedContainerHeight = Math.round(containerHeight || windowHeight); - - const aspectRatio = Math.min(roundedContainerHeight / imageZoomHeight, roundedContainerWidth / imageZoomWidth); - - imageZoomHeight *= aspectRatio; - imageZoomWidth *= aspectRatio; - - // Resize the image to max dimensions possible on the Native platforms to prevent crashes on Android. To keep the same behavior, apply to IOS as well. - const maxDimensionsScale = 11; - imageZoomWidth = Math.min(imageZoomWidth, roundedContainerWidth * maxDimensionsScale); - imageZoomHeight = Math.min(imageZoomHeight, roundedContainerHeight * maxDimensionsScale); - - setImageDimensions({ - height: imageZoomHeight, - width: imageZoomWidth, - }); - setIsLoading(false); - }; - - const configurePanResponder = () => { - const currentTimestamp = new Date().getTime(); - const isDoubleClick = currentTimestamp - lastClickTime.current <= DOUBLE_CLICK_INTERVAL; - lastClickTime.current = currentTimestamp; - - // Let ImageZoom handle the event if the tap is more than one touchPoint or if we are zoomed in - if (numberOfTouches.current === 2 || imageZoomScale.current !== 1) { - return true; - } - - // When we have a double click and the zoom scale is 1 then programmatically zoom the image - // but let the tap fall through to the parent so we can register a swipe down to dismiss - if (isDoubleClick) { - zoom.current.centerOn({ - x: 0, - y: 0, - scale: 2, - duration: 100, - }); - - // onMove will be called after the zoom animation. - // So it's possible to zoom and swipe and stuck in between the images. - // Sending scale just when we actually trigger the animation makes this nearly impossible. - // you should be really fast to catch in between state updates. - // And this lucky case will be fixed by migration to UI thread only code - // with gesture handler and reanimated. - onScaleChanged(2); - } - - // We must be either swiping down or double tapping since we are at zoom scale 1 - return false; - }; - - // Default windowHeight accounts for the modal header height - const calculatedWindowHeight = windowHeight - variables.contentHeaderHeight; - const hasImageDimensions = imageDimensions.width !== 0 && imageDimensions.height !== 0; - const shouldShowLoadingIndicator = isLoading || !hasImageDimensions; - - // Zoom view should be loaded only after measuring actual image dimensions, otherwise it causes blurred images on Android return ( - { - const layout = event.nativeEvent.layout; - setContainerHeight(layout.height); - }} - > - {Boolean(containerHeight) && ( - { - onScaleChanged(scale); - imageZoomScale.current = scale; - }} - > - - {/** - Create an invisible view on top of the image so we can capture and set the amount of touches before - the ImageZoom's PanResponder does. Children will be triggered first, so this needs to be inside the - ImageZoom to work - */} - - - )} - {shouldShowLoadingIndicator && } - + ); } diff --git a/src/components/ImageView/propTypes.js b/src/components/ImageView/propTypes.js new file mode 100644 index 000000000000..04c0a28b4e9f --- /dev/null +++ b/src/components/ImageView/propTypes.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; + +const imageViewPropTypes = { + /** Whether source url requires authentication */ + isAuthTokenRequired: PropTypes.bool, + + /** Handles scale changed event in image zoom component. Used on native only */ + // eslint-disable-next-line react/no-unused-prop-types + onScaleChanged: PropTypes.func.isRequired, + + /** URL to full-sized image */ + url: PropTypes.string.isRequired, + + /** image file name */ + fileName: PropTypes.string.isRequired, + + onError: PropTypes.func, +}; + +const imageViewDefaultProps = { + isAuthTokenRequired: false, + onError: () => {}, +}; + +export {imageViewPropTypes, imageViewDefaultProps}; From dff2a96e91c683caf714632b07f2ed3bb1a9c80e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 14 Nov 2023 14:23:13 +0100 Subject: [PATCH 03/47] fix: image not loading initially --- src/components/ImageLightbox/index.js | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/src/components/ImageLightbox/index.js b/src/components/ImageLightbox/index.js index 60be10e957fc..1ec038c669eb 100644 --- a/src/components/ImageLightbox/index.js +++ b/src/components/ImageLightbox/index.js @@ -1,6 +1,6 @@ /* eslint-disable es/no-optional-chaining */ import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {ActivityIndicator, PixelRatio, StyleSheet, View} from 'react-native'; import * as AttachmentsPropTypes from '@components/Attachments/propTypes'; import Image from '@components/Image'; @@ -25,21 +25,12 @@ const propTypes = { isActive: PropTypes.bool, + /** Width of the canvas */ canvasWidth: PropTypes.number.isRequired, + /** Height of the canvas */ canvasHeight: PropTypes.number.isRequired, - // imageDimensions: PropTypes.shape({ - // width: PropTypes.number, - // height: PropTypes.number, - // scaledWidth: PropTypes.number, - // scaledHeight: PropTypes.number, - // scaleX: PropTypes.number, - // scaleY: PropTypes.number, - // }), - - // setImageDimensions: PropTypes.func.isRequired, - /** Additional styles to add to the component */ style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), }; @@ -55,8 +46,8 @@ const defaultProps = { function ImageLightbox({isAuthTokenRequired, source, onScaleChanged, onPress, style, isActive: initialIsActive, canvasWidth, canvasHeight}) { const [isActive, setIsActive] = useState(initialIsActive); - const imageDimensions = useMemo(() => cachedDimensions.get(source), [source]); - const setImageDimensions = useCallback((newDimensions) => cachedDimensions.set(source, newDimensions), [source]); + const imageDimensions = cachedDimensions.get(source); + const setImageDimensions = (newDimensions) => cachedDimensions.set(source, newDimensions); // We delay setting a page to active state by a (few) millisecond(s), // to prevent the image transformer from flashing while still rendering @@ -155,16 +146,9 @@ function ImageLightbox({isAuthTokenRequired, source, onScaleChanged, onPress, st source={{uri: source}} isAuthTokenRequired={isAuthTokenRequired} onLoadStart={() => { - setIsImageLoading(true); - if (isImageLoaded.current) { - return; - } setIsFallbackLoading(true); }} onLoadEnd={() => { - if (isImageLoaded.current) { - return; - } setIsFallbackLoading(false); }} onLoad={(evt) => { From 2ef7f8b466a9e05133bae7286500ae5a3452b250 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 14 Nov 2023 14:41:17 +0100 Subject: [PATCH 04/47] fix: simplify --- .../AttachmentCarousel/CarouselItem.js | 1 - .../AttachmentCarousel/Pager/index.js | 21 +--------- .../AttachmentCarousel/index.native.js | 39 +++++++------------ .../Attachments/AttachmentView/index.js | 3 -- .../Attachments/AttachmentView/propTypes.js | 4 -- .../ImageLightbox/ImageTransformer.js | 3 +- src/components/ImageLightbox/index.js | 22 ++++++----- src/components/ImageView/index.native.js | 14 +------ 8 files changed, 33 insertions(+), 74 deletions(-) diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.js b/src/components/Attachments/AttachmentCarousel/CarouselItem.js index 38f70057be61..4e7a1d978366 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselItem.js +++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.js @@ -99,7 +99,6 @@ function CarouselItem({item, isFocused, onPress}) { isAuthTokenRequired={item.isAuthTokenRequired} isFocused={isFocused} onPress={onPress} - isUsedInCarousel transactionID={item.transactionID} /> diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.js b/src/components/Attachments/AttachmentCarousel/Pager/index.js index 59fd7596f0ad..15576da119d4 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.js @@ -51,8 +51,6 @@ const pagerPropTypes = { onSwipeDown: PropTypes.func, onPinchGestureChange: PropTypes.func, forwardedRef: refPropTypes, - containerWidth: PropTypes.number.isRequired, - containerHeight: PropTypes.number.isRequired, }; const pagerDefaultProps = { @@ -66,20 +64,7 @@ const pagerDefaultProps = { forwardedRef: null, }; -function AttachmentCarouselPager({ - items, - renderItem, - initialIndex, - onPageSelected, - onTap, - onSwipe = noopWorklet, - onSwipeSuccess, - onSwipeDown, - onPinchGestureChange, - forwardedRef, - containerWidth, - containerHeight, -}) { +function AttachmentCarouselPager({items, renderItem, initialIndex, onPageSelected, onTap, onSwipe = noopWorklet, onSwipeSuccess, onSwipeDown, onPinchGestureChange, forwardedRef}) { const shouldPagerScroll = useSharedValue(true); const pagerRef = useRef(null); @@ -127,8 +112,6 @@ function AttachmentCarouselPager({ const contextValue = useMemo( () => ({ - canvasWidth: containerWidth, - canvasHeight: containerHeight, isScrolling, pagerRef, shouldPagerScroll, @@ -138,7 +121,7 @@ function AttachmentCarouselPager({ onSwipeSuccess, onSwipeDown, }), - [containerWidth, containerHeight, isScrolling, pagerRef, shouldPagerScroll, onPinchGestureChange, onTap, onSwipe, onSwipeSuccess, onSwipeDown], + [isScrolling, pagerRef, shouldPagerScroll, onPinchGestureChange, onTap, onSwipe, onSwipeSuccess, onSwipeDown], ); return ( diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js index b86c9b1c786e..dd72cb8fda03 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.js +++ b/src/components/Attachments/AttachmentCarousel/index.native.js @@ -1,5 +1,5 @@ import React, {useCallback, useEffect, useRef, useState} from 'react'; -import {Keyboard, PixelRatio, View} from 'react-native'; +import {Keyboard, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import BlockingView from '@components/BlockingViews/BlockingView'; @@ -20,8 +20,6 @@ import useCarouselArrows from './useCarouselArrows'; function AttachmentCarousel({report, reportActions, source, onNavigate, onClose, setDownloadButtonVisibility, translate}) { const pagerRef = useRef(null); - - const [containerDimensions, setContainerDimensions] = useState({width: 0, height: 0}); const [page, setPage] = useState(0); const [attachments, setAttachments] = useState([]); const [activeSource, setActiveSource] = useState(source); @@ -119,9 +117,6 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, onClose, return ( - setContainerDimensions({width: PixelRatio.roundToNearestPixel(nativeEvent.layout.width), height: PixelRatio.roundToNearestPixel(nativeEvent.layout.height)}) - } onMouseEnter={() => setShouldShowArrows(true)} onMouseLeave={() => setShouldShowArrows(false)} > @@ -144,24 +139,20 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, onClose, cancelAutoHideArrow={cancelAutoHideArrows} /> - {containerDimensions.width > 0 && containerDimensions.height > 0 && ( - updatePage(newPage)} - onPinchGestureChange={(newIsPinchGestureRunning) => { - setIsPinchGestureRunning(newIsPinchGestureRunning); - if (!newIsPinchGestureRunning && !shouldShowArrows) { - setShouldShowArrows(true); - } - }} - onSwipeDown={onClose} - containerWidth={containerDimensions.width} - containerHeight={containerDimensions.height} - ref={pagerRef} - /> - )} + updatePage(newPage)} + onPinchGestureChange={(newIsPinchGestureRunning) => { + setIsPinchGestureRunning(newIsPinchGestureRunning); + if (!newIsPinchGestureRunning && !shouldShowArrows) { + setShouldShowArrows(true); + } + }} + onSwipeDown={onClose} + ref={pagerRef} + /> )} diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js index de8c0fff45e8..4a1b5ce616f5 100755 --- a/src/components/Attachments/AttachmentView/index.js +++ b/src/components/Attachments/AttachmentView/index.js @@ -64,7 +64,6 @@ function AttachmentView({ source, file, isAuthTokenRequired, - isUsedInCarousel, onPress, shouldShowLoadingSpinnerIcon, shouldShowDownloadIcon, @@ -132,7 +131,6 @@ function AttachmentView({ file={file} isAuthTokenRequired={isAuthTokenRequired} encryptedSourceUrl={encryptedSourceUrl} - isUsedInCarousel={isUsedInCarousel} isFocused={isFocused} onPress={onPress} onScaleChanged={onScaleChanged} @@ -159,7 +157,6 @@ function AttachmentView({ source={imageError ? fallbackSource : source} file={file} isAuthTokenRequired={isAuthTokenRequired} - isUsedInCarousel={isUsedInCarousel} loadComplete={loadComplete} isFocused={isFocused} isImage={isImage} diff --git a/src/components/Attachments/AttachmentView/propTypes.js b/src/components/Attachments/AttachmentView/propTypes.js index 0c7c8814267f..02ac177dc762 100644 --- a/src/components/Attachments/AttachmentView/propTypes.js +++ b/src/components/Attachments/AttachmentView/propTypes.js @@ -14,9 +14,6 @@ const attachmentViewPropTypes = { /** Whether this view is the active screen */ isFocused: PropTypes.bool, - /** Whether this AttachmentView is shown as part of a AttachmentCarousel */ - isUsedInCarousel: PropTypes.bool, - /** Function for handle on press */ onPress: PropTypes.func, @@ -33,7 +30,6 @@ const attachmentViewDefaultProps = { name: '', }, isFocused: false, - isUsedInCarousel: false, onPress: undefined, onScaleChanged: () => {}, isUsedInAttachmentModal: false, diff --git a/src/components/ImageLightbox/ImageTransformer.js b/src/components/ImageLightbox/ImageTransformer.js index 4094f76b2687..b33b75283159 100644 --- a/src/components/ImageLightbox/ImageTransformer.js +++ b/src/components/ImageLightbox/ImageTransformer.js @@ -570,6 +570,7 @@ function ImageTransformer({canvasWidth, canvasHeight, imageWidth, imageHeight, i styles.flex1, { width: canvasWidth, + overflow: 'hidden', }, ]} > @@ -577,7 +578,7 @@ function ImageTransformer({canvasWidth, canvasHeight, imageWidth, imageHeight, i {children} diff --git a/src/components/ImageLightbox/index.js b/src/components/ImageLightbox/index.js index 1ec038c669eb..7803eb7efdfa 100644 --- a/src/components/ImageLightbox/index.js +++ b/src/components/ImageLightbox/index.js @@ -25,12 +25,6 @@ const propTypes = { isActive: PropTypes.bool, - /** Width of the canvas */ - canvasWidth: PropTypes.number.isRequired, - - /** Height of the canvas */ - canvasHeight: PropTypes.number.isRequired, - /** Additional styles to add to the component */ style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), }; @@ -43,7 +37,7 @@ const defaultProps = { style: {}, }; -function ImageLightbox({isAuthTokenRequired, source, onScaleChanged, onPress, style, isActive: initialIsActive, canvasWidth, canvasHeight}) { +function ImageLightbox({isAuthTokenRequired, source, onScaleChanged, onPress, style, isActive: initialIsActive}) { const [isActive, setIsActive] = useState(initialIsActive); const imageDimensions = cachedDimensions.get(source); @@ -76,8 +70,18 @@ function ImageLightbox({isAuthTokenRequired, source, onScaleChanged, onPress, st // eslint-disable-next-line react-hooks/exhaustive-deps }, [isImageLoading]); + const [containerDimensions, setContainerDimensions] = useState({width: 0, height: 0}); + const canvasWidth = containerDimensions.width; + const canvasHeight = containerDimensions.height; + const isLoadingLayout = canvasWidth === 0 || canvasHeight === 0; + return ( - <> + + setContainerDimensions({width: PixelRatio.roundToNearestPixel(nativeEvent.layout.width), height: PixelRatio.roundToNearestPixel(nativeEvent.layout.height)}) + } + > {isActive && ( )} - + ); } diff --git a/src/components/ImageView/index.native.js b/src/components/ImageView/index.native.js index 9de541d213ee..f81578ec44a8 100644 --- a/src/components/ImageView/index.native.js +++ b/src/components/ImageView/index.native.js @@ -1,9 +1,7 @@ /* eslint-disable es/no-optional-chaining */ import PropTypes from 'prop-types'; -import React, {useContext} from 'react'; -import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; +import React from 'react'; import ImageLightbox from '@components/ImageLightbox'; -import useWindowDimensions from '@hooks/useWindowDimensions'; import {imageViewDefaultProps, imageViewPropTypes} from './propTypes'; /** @@ -25,19 +23,9 @@ const defaultProps = { }; function ImageView({isAuthTokenRequired, url, onScaleChanged, onPress, style}) { - let {windowWidth: canvasWidth, windowHeight: canvasHeight} = useWindowDimensions(); - const attachmenCarouselPagerContext = useContext(AttachmentCarouselPagerContext); - - if (attachmenCarouselPagerContext != null) { - canvasWidth = attachmenCarouselPagerContext.canvasWidth; - canvasHeight = attachmenCarouselPagerContext.canvasHeight; - } - return ( Date: Wed, 15 Nov 2023 12:01:38 +0100 Subject: [PATCH 05/47] fix: restructure props and unify zoom scale --- .../AttachmentCarousel/CarouselItem.js | 1 + .../AttachmentViewImage/index.native.js | 8 +- .../Attachments/AttachmentView/index.js | 4 +- .../Attachments/AttachmentView/propTypes.js | 12 +- src/components/ImageLightbox/Constants.js | 4 + .../ImageLightbox/ImageTransformer.js | 74 +++--- src/components/ImageLightbox/index.js | 243 ++++++++---------- src/components/ImageLightbox/propTypes.js | 74 ++++++ src/components/ImageView/Constants.js | 13 + src/components/ImageView/index.js | 6 +- src/components/ImageView/index.native.js | 9 +- 11 files changed, 267 insertions(+), 181 deletions(-) create mode 100644 src/components/ImageLightbox/Constants.js create mode 100644 src/components/ImageLightbox/propTypes.js create mode 100644 src/components/ImageView/Constants.js diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.js b/src/components/Attachments/AttachmentCarousel/CarouselItem.js index 4e7a1d978366..0692f47c0996 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselItem.js +++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.js @@ -97,6 +97,7 @@ function CarouselItem({item, isFocused, onPress}) { source={item.source} file={item.file} isAuthTokenRequired={item.isAuthTokenRequired} + isUsedInCarousel isFocused={isFocused} onPress={onPress} transactionID={item.transactionID} diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js index 70fe8530bd8d..dc9ba9985526 100755 --- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js @@ -1,5 +1,6 @@ import React, {memo} from 'react'; import ImageView from '@components/ImageView'; +import {carouselZoomScale, modalZoomScale} from '@components/ImageView/Constants'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import compose from '@libs/compose'; @@ -12,14 +13,19 @@ const propTypes = { ...withLocalizePropTypes, }; -function AttachmentViewImage({source, file, isAuthTokenRequired, isFocused, loadComplete, onPress, isImage, onScaleChanged, translate}) { +function AttachmentViewImage({source, file, isAuthTokenRequired, isUsedInCarousel, isFocused, loadComplete, onPress, isImage, onScaleChanged, translate}) { + const {minZoomScale, maxZoomScale} = isUsedInCarousel ? carouselZoomScale : modalZoomScale; + const children = ( ); diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js index 4a1b5ce616f5..e403e5dc1496 100755 --- a/src/components/Attachments/AttachmentView/index.js +++ b/src/components/Attachments/AttachmentView/index.js @@ -71,11 +71,12 @@ function AttachmentView({ onScaleChanged, onToggleKeyboard, translate, + isUsedInCarousel, isFocused, + isUsedInAttachmentModal, isWorkspaceAvatar, fallbackSource, transaction, - isUsedInAttachmentModal, }) { const [loadComplete, setLoadComplete] = useState(false); const [imageError, setImageError] = useState(false); @@ -158,6 +159,7 @@ function AttachmentView({ file={file} isAuthTokenRequired={isAuthTokenRequired} loadComplete={loadComplete} + isUsedInCarousel={isUsedInCarousel} isFocused={isFocused} isImage={isImage} onPress={onPress} diff --git a/src/components/Attachments/AttachmentView/propTypes.js b/src/components/Attachments/AttachmentView/propTypes.js index 02ac177dc762..d3a46b1c9ad3 100644 --- a/src/components/Attachments/AttachmentView/propTypes.js +++ b/src/components/Attachments/AttachmentView/propTypes.js @@ -11,6 +11,12 @@ const attachmentViewPropTypes = { /** File object can be an instance of File or Object */ file: AttachmentsPropTypes.attachmentFilePropType, + /** Whether this AttachmentView is shown as part of a AttachmentCarousel */ + isUsedInCarousel: PropTypes.bool, + + /** Whether this AttachmentView is shown as part of an AttachmentModal */ + isUsedInAttachmentModal: PropTypes.bool, + /** Whether this view is the active screen */ isFocused: PropTypes.bool, @@ -19,9 +25,6 @@ const attachmentViewPropTypes = { /** Handles scale changed event */ onScaleChanged: PropTypes.func, - - /** Whether this AttachmentView is shown as part of an AttachmentModal */ - isUsedInAttachmentModal: PropTypes.bool, }; const attachmentViewDefaultProps = { @@ -29,10 +32,11 @@ const attachmentViewDefaultProps = { file: { name: '', }, + isUsedInCarousel: false, isFocused: false, + isUsedInAttachmentModal: false, onPress: undefined, onScaleChanged: () => {}, - isUsedInAttachmentModal: false, }; export {attachmentViewPropTypes, attachmentViewDefaultProps}; diff --git a/src/components/ImageLightbox/Constants.js b/src/components/ImageLightbox/Constants.js new file mode 100644 index 000000000000..59ea6fc0b237 --- /dev/null +++ b/src/components/ImageLightbox/Constants.js @@ -0,0 +1,4 @@ +const DEFAULT_MIN_ZOOM_SCALE = 1; +const DEFAULT_MAX_ZOOM_SCALE = 20; + +export {DEFAULT_MIN_ZOOM_SCALE, DEFAULT_MAX_ZOOM_SCALE}; diff --git a/src/components/ImageLightbox/ImageTransformer.js b/src/components/ImageLightbox/ImageTransformer.js index b33b75283159..c23206121975 100644 --- a/src/components/ImageLightbox/ImageTransformer.js +++ b/src/components/ImageLightbox/ImageTransformer.js @@ -1,5 +1,3 @@ -/* eslint-disable es/no-optional-chaining */ -import PropTypes from 'prop-types'; import React, {useContext, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; @@ -18,12 +16,18 @@ import Animated, { import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; import styles from '@styles/styles'; import ImageWrapper from './ImageWrapper'; +import {imageTransformerDefaultProps, imageTransformerPropTypes} from './propTypes'; -const MIN_ZOOM_SCALE_WITHOUT_BOUNCE = 1; -const MAX_ZOOM_SCALE_WITHOUT_BOUNCE = 20; +const getMinZoomScaleWithBounce = (minZoomScaleWithoutBounce) => { + 'worklet'; -const MIN_ZOOM_SCALE_WITH_BOUNCE = MIN_ZOOM_SCALE_WITHOUT_BOUNCE * 0.7; -const MAX_ZOOM_SCALE_WITH_BOUNCE = MAX_ZOOM_SCALE_WITHOUT_BOUNCE * 1.5; + return minZoomScaleWithoutBounce * 0.7; +}; +const getMaxZoomScaleWithBounce = (maxZoomScaleWithoutBounce) => { + 'worklet'; + + return maxZoomScaleWithoutBounce * 1.5; +}; const DOUBLE_TAP_SCALE = 3; @@ -39,29 +43,21 @@ function clamp(value, lowerBound, upperBound) { return Math.min(Math.max(lowerBound, value), upperBound); } -const imageTransformerPropTypes = { - canvasWidth: PropTypes.number.isRequired, - canvasHeight: PropTypes.number.isRequired, - imageWidth: PropTypes.number, - imageHeight: PropTypes.number, - imageScaleX: PropTypes.number, - imageScaleY: PropTypes.number, - scaledImageWidth: PropTypes.number, - scaledImageHeight: PropTypes.number, - isActive: PropTypes.bool.isRequired, - children: PropTypes.node.isRequired, -}; - -const imageTransformerDefaultProps = { - imageWidth: 0, - imageHeight: 0, - imageScaleX: 1, - imageScaleY: 1, - scaledImageWidth: 0, - scaledImageHeight: 0, -}; - -function ImageTransformer({canvasWidth, canvasHeight, imageWidth, imageHeight, imageScaleX, imageScaleY, scaledImageWidth, scaledImageHeight, isActive = true, children, ...props}) { +function ImageTransformer({ + canvasWidth, + canvasHeight, + imageWidth, + imageHeight, + imageScaleX, + imageScaleY, + scaledImageWidth, + scaledImageHeight, + minZoomScale, + maxZoomScale, + isActive = true, + children, + ...props +}) { const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); const pagerRefFallback = useRef(null); @@ -179,7 +175,7 @@ function ImageTransformer({canvasWidth, canvasHeight, imageWidth, imageHeight, i const deceleration = 0.9915; if (isInBoundaryX) { - if (Math.abs(panVelocityX.value) > 0 && zoomScale.value <= MAX_ZOOM_SCALE_WITHOUT_BOUNCE) { + if (Math.abs(panVelocityX.value) > 0 && zoomScale.value <= maxZoomScale) { offsetX.value = withDecay({ velocity: panVelocityX.value, clamp: [minVector.x, maxVector.x], @@ -194,7 +190,7 @@ function ImageTransformer({canvasWidth, canvasHeight, imageWidth, imageHeight, i if (isInBoundaryY) { if ( Math.abs(panVelocityY.value) > 0 && - zoomScale.value <= MAX_ZOOM_SCALE_WITHOUT_BOUNCE && + zoomScale.value <= maxZoomScale && // limit vertical pan only when image is smaller than screen offsetY.value !== minVector.y && offsetY.value !== maxVector.y @@ -468,7 +464,7 @@ function ImageTransformer({canvasWidth, canvasHeight, imageWidth, imageHeight, i .onChange((evt) => { const newZoomScale = pinchScaleOffset.value * evt.scale; - if (zoomScale.value >= MIN_ZOOM_SCALE_WITH_BOUNCE && zoomScale.value <= MAX_ZOOM_SCALE_WITH_BOUNCE) { + if (zoomScale.value >= getMinZoomScaleWithBounce(minZoomScale) && zoomScale.value <= getMaxZoomScaleWithBounce(maxZoomScale)) { zoomScale.value = newZoomScale; pinchGestureScale.value = evt.scale; } @@ -477,7 +473,7 @@ function ImageTransformer({canvasWidth, canvasHeight, imageWidth, imageHeight, i const newPinchTranslateX = adjustedFocal.x + pinchGestureScale.value * origin.x.value * -1; const newPinchTranslateY = adjustedFocal.y + pinchGestureScale.value * origin.y.value * -1; - if (zoomScale.value >= MIN_ZOOM_SCALE_WITHOUT_BOUNCE && zoomScale.value <= MAX_ZOOM_SCALE_WITHOUT_BOUNCE) { + if (zoomScale.value >= minZoomScale && zoomScale.value <= maxZoomScale) { pinchTranslateX.value = newPinchTranslateX; pinchTranslateY.value = newPinchTranslateY; } else { @@ -493,12 +489,12 @@ function ImageTransformer({canvasWidth, canvasHeight, imageWidth, imageHeight, i pinchScaleOffset.value = zoomScale.value; pinchGestureScale.value = 1; - if (pinchScaleOffset.value < MIN_ZOOM_SCALE_WITHOUT_BOUNCE) { - pinchScaleOffset.value = MIN_ZOOM_SCALE_WITHOUT_BOUNCE; - zoomScale.value = withSpring(MIN_ZOOM_SCALE_WITHOUT_BOUNCE, SPRING_CONFIG); - } else if (pinchScaleOffset.value > MAX_ZOOM_SCALE_WITHOUT_BOUNCE) { - pinchScaleOffset.value = MAX_ZOOM_SCALE_WITHOUT_BOUNCE; - zoomScale.value = withSpring(MAX_ZOOM_SCALE_WITHOUT_BOUNCE, SPRING_CONFIG); + if (pinchScaleOffset.value < minZoomScale) { + pinchScaleOffset.value = minZoomScale; + zoomScale.value = withSpring(minZoomScale, SPRING_CONFIG); + } else if (pinchScaleOffset.value > maxZoomScale) { + pinchScaleOffset.value = maxZoomScale; + zoomScale.value = withSpring(maxZoomScale, SPRING_CONFIG); } if (pinchBounceTranslateX.value !== 0 || pinchBounceTranslateY.value !== 0) { diff --git a/src/components/ImageLightbox/index.js b/src/components/ImageLightbox/index.js index 7803eb7efdfa..f0d383fc615a 100644 --- a/src/components/ImageLightbox/index.js +++ b/src/components/ImageLightbox/index.js @@ -1,43 +1,15 @@ /* eslint-disable es/no-optional-chaining */ -import PropTypes from 'prop-types'; import React, {useEffect, useRef, useState} from 'react'; import {ActivityIndicator, PixelRatio, StyleSheet, View} from 'react-native'; -import * as AttachmentsPropTypes from '@components/Attachments/propTypes'; import Image from '@components/Image'; import ImageLightboxUtils from './ImageLightboxUtils'; import ImageTransformer from './ImageTransformer'; import ImageWrapper from './ImageWrapper'; +import {imageLightboxDefaultProps, imageLightboxPropTypes} from './propTypes'; const cachedDimensions = new Map(); -/** - * On the native layer, we use a image library to handle zoom functionality - */ -const propTypes = { - /** Function for handle on press */ - onPress: PropTypes.func, - - /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */ - source: AttachmentsPropTypes.attachmentSourcePropType.isRequired, - - /** Whether source url requires authentication */ - isAuthTokenRequired: PropTypes.bool, - - isActive: PropTypes.bool, - - /** Additional styles to add to the component */ - style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), -}; - -const defaultProps = { - isAuthTokenRequired: false, - isActive: true, - // imageDimensions: undefined, - onPress: () => {}, - style: {}, -}; - -function ImageLightbox({isAuthTokenRequired, source, onScaleChanged, onPress, style, isActive: initialIsActive}) { +function ImageLightbox({isAuthTokenRequired, source, onScaleChanged, onPress, style, isActive: initialIsActive, minZoomScale, maxZoomScale}) { const [isActive, setIsActive] = useState(initialIsActive); const imageDimensions = cachedDimensions.get(source); @@ -82,118 +54,125 @@ function ImageLightbox({isAuthTokenRequired, source, onScaleChanged, onPress, st setContainerDimensions({width: PixelRatio.roundToNearestPixel(nativeEvent.layout.width), height: PixelRatio.roundToNearestPixel(nativeEvent.layout.height)}) } > - {isActive && ( - - - { - setIsImageLoading(true); - }} - onLoadEnd={() => { - setShowFallback(false); - setIsImageLoading(false); - isImageLoaded.current = true; - }} - onLoad={(evt) => { - const imageWidth = (evt.nativeEvent?.width || 0) / PixelRatio.get(); - const imageHeight = (evt.nativeEvent?.height || 0) / PixelRatio.get(); - - const {scaleX, scaleY} = ImageLightboxUtils.getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}); - - // Don't update the dimensions if they are already set - if ( - imageDimensions?.width !== imageWidth || - imageDimensions?.height !== imageHeight || - imageDimensions?.scaleX !== scaleX || - imageDimensions?.scaleY !== scaleY - ) { + {!isLoadingLayout && ( + <> + {isActive && ( + + + { + setIsImageLoading(true); + }} + onLoadEnd={() => { + setShowFallback(false); + setIsImageLoading(false); + isImageLoaded.current = true; + }} + onLoad={(evt) => { + const imageWidth = (evt.nativeEvent?.width || 0) / PixelRatio.get(); + const imageHeight = (evt.nativeEvent?.height || 0) / PixelRatio.get(); + + const {scaleX, scaleY} = ImageLightboxUtils.getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}); + + // Don't update the dimensions if they are already set + if ( + imageDimensions?.width !== imageWidth || + imageDimensions?.height !== imageHeight || + imageDimensions?.scaleX !== scaleX || + imageDimensions?.scaleY !== scaleY + ) { + setImageDimensions({ + ...imageDimensions, + width: imageWidth, + height: imageHeight, + scaleX, + scaleY, + }); + } + + // On the initial render of the active page, the onLoadEnd event is never fired. + // That's why we instead set isImageLoading to false in the onLoad event. + if (initialActivePageLoad) { + setInitialActivePageLoad(false); + setIsImageLoading(false); + setTimeout(() => setShowFallback(false), 100); + isImageLoaded.current = true; + } + }} + /> + + + )} + + {/* Keep rendering the image without gestures as fallback while ImageLightbox is loading the image */} + {(showFallback || !isActive) && ( + + { + setIsFallbackLoading(true); + }} + onLoadEnd={() => { + setIsFallbackLoading(false); + }} + onLoad={(evt) => { + const imageWidth = evt.nativeEvent.width; + const imageHeight = evt.nativeEvent.height; + + const {scaleX, scaleY} = ImageLightboxUtils.getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}); + const minImageScale = Math.min(scaleX, scaleY); + + const scaledWidth = imageWidth * minImageScale; + const scaledHeight = imageHeight * minImageScale; + + // Don't update the dimensions if they are already set + if (imageDimensions?.scaledWidth === scaledWidth && imageDimensions?.scaledHeight === scaledHeight) { + return; + } + setImageDimensions({ ...imageDimensions, - width: imageWidth, - height: imageHeight, - scaleX, - scaleY, + scaledWidth, + scaledHeight, }); - } - - // On the initial render of the active page, the onLoadEnd event is never fired. - // That's why we instead set isImageLoading to false in the onLoad event. - if (initialActivePageLoad) { - setInitialActivePageLoad(false); - setIsImageLoading(false); - setTimeout(() => setShowFallback(false), 100); - isImageLoaded.current = true; - } - }} - /> - - - )} - - {/* Keep rendering the image without gestures as fallback while ImageLightbox is loading the image */} - {(showFallback || !isActive) && ( - - { - setIsFallbackLoading(true); - }} - onLoadEnd={() => { - setIsFallbackLoading(false); - }} - onLoad={(evt) => { - const imageWidth = evt.nativeEvent.width; - const imageHeight = evt.nativeEvent.height; - - const {scaleX, scaleY} = ImageLightboxUtils.getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}); - const minImageScale = Math.min(scaleX, scaleY); - - const scaledWidth = imageWidth * minImageScale; - const scaledHeight = imageHeight * minImageScale; - - // Don't update the dimensions if they are already set - if (imageDimensions?.scaledWidth === scaledWidth && imageDimensions?.scaledHeight === scaledHeight) { - return; - } - - setImageDimensions({ - ...imageDimensions, - scaledWidth, - scaledHeight, - }); - }} - style={imageDimensions == null ? undefined : {width: imageDimensions.scaledWidth, height: imageDimensions.scaledHeight}} - /> - + }} + style={imageDimensions == null ? undefined : {width: imageDimensions.scaledWidth, height: imageDimensions.scaledHeight}} + /> + + )} + )} {/* Show activity indicator while ImageLightbox is still loading the image. */} - {isActive && isFallbackLoading && !isImageLoaded.current && ( - - )} + {isLoadingLayout || + (isActive && isFallbackLoading && !isImageLoaded.current && ( + + ))} ); } -ImageLightbox.propTypes = propTypes; -ImageLightbox.defaultProps = defaultProps; +ImageLightbox.propTypes = imageLightboxPropTypes; +ImageLightbox.defaultProps = imageLightboxDefaultProps; ImageLightbox.displayName = 'AttachmentCarouselPage'; export default ImageLightbox; diff --git a/src/components/ImageLightbox/propTypes.js b/src/components/ImageLightbox/propTypes.js new file mode 100644 index 000000000000..58bba067e899 --- /dev/null +++ b/src/components/ImageLightbox/propTypes.js @@ -0,0 +1,74 @@ +import PropTypes from 'prop-types'; +import * as AttachmentsPropTypes from '@components/Attachments/propTypes'; +import * as Constants from './Constants'; + +const scalePropTypes = { + minZoomScale: PropTypes.number, + maxZoomScale: PropTypes.number, +}; + +const imageTransformerPropTypes = { + ...scalePropTypes, + + canvasWidth: PropTypes.number.isRequired, + canvasHeight: PropTypes.number.isRequired, + imageWidth: PropTypes.number, + imageHeight: PropTypes.number, + imageScaleX: PropTypes.number, + imageScaleY: PropTypes.number, + scaledImageWidth: PropTypes.number, + scaledImageHeight: PropTypes.number, + isActive: PropTypes.bool, + children: PropTypes.node.isRequired, +}; + +const scaleDefaultProps = { + minZoomScale: Constants.DEFAULT_MIN_ZOOM_SCALE, + maxZoomScale: Constants.DEFAULT_MAX_ZOOM_SCALE, +}; + +const imageTransformerDefaultProps = { + ...scaleDefaultProps, + + isActive: true, + imageWidth: 0, + imageHeight: 0, + imageScaleX: 1, + imageScaleY: 1, + scaledImageWidth: 0, + scaledImageHeight: 0, + minZoomScale: Constants.DEFAULT_MIN_ZOOM_SCALE, + maxZoomScale: Constants.DEFAULT_MAX_ZOOM_SCALE, +}; + +/** + * On the native layer, we use a image library to handle zoom functionality + */ +const imageLightboxPropTypes = { + ...scalePropTypes, + + /** Function for handle on press */ + onPress: PropTypes.func, + + /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */ + source: AttachmentsPropTypes.attachmentSourcePropType.isRequired, + + /** Whether source url requires authentication */ + isAuthTokenRequired: PropTypes.bool, + + isActive: PropTypes.bool, + + /** Additional styles to add to the component */ + style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), +}; + +const imageLightboxDefaultProps = { + ...scaleDefaultProps, + + isAuthTokenRequired: false, + isActive: true, + onPress: () => {}, + style: {}, +}; + +export {scalePropTypes, imageTransformerPropTypes, imageLightboxPropTypes, scaleDefaultProps, imageTransformerDefaultProps, imageLightboxDefaultProps}; diff --git a/src/components/ImageView/Constants.js b/src/components/ImageView/Constants.js new file mode 100644 index 000000000000..5ccbca8cb6c9 --- /dev/null +++ b/src/components/ImageView/Constants.js @@ -0,0 +1,13 @@ +const {DEFAULT_MIN_ZOOM_SCALE, DEFAULT_MAX_ZOOM_SCALE} = require('@components/ImageLightbox/Constants'); + +const modalZoomScale = { + minZoomScale: DEFAULT_MIN_ZOOM_SCALE, + maxZoomScale: DEFAULT_MAX_ZOOM_SCALE * 1.19, // equals to 23.8, which was tested to be the same perceived zoom scale compared to the attachment carousel +}; + +const carouselZoomScale = { + minZoomScale: DEFAULT_MIN_ZOOM_SCALE, + maxZoomScale: DEFAULT_MAX_ZOOM_SCALE, +}; + +export {modalZoomScale, carouselZoomScale}; diff --git a/src/components/ImageView/index.js b/src/components/ImageView/index.js index db6264f193cd..3350bf3f324c 100644 --- a/src/components/ImageView/index.js +++ b/src/components/ImageView/index.js @@ -7,7 +7,7 @@ import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import styles from '@styles/styles'; import * as StyleUtils from '@styles/StyleUtils'; import CONST from '@src/CONST'; -import {defaultProps, propTypes} from './propTypes'; +import {imageViewDefaultProps, imageViewPropTypes} from './propTypes'; function ImageView({isAuthTokenRequired, url, fileName, onError}) { const [isLoading, setIsLoading] = useState(true); @@ -259,8 +259,8 @@ function ImageView({isAuthTokenRequired, url, fileName, onError}) { ); } -ImageView.propTypes = propTypes; -ImageView.defaultProps = defaultProps; +ImageView.propTypes = imageViewPropTypes; +ImageView.defaultProps = imageViewDefaultProps; ImageView.displayName = 'ImageView'; export default ImageView; diff --git a/src/components/ImageView/index.native.js b/src/components/ImageView/index.native.js index f81578ec44a8..5c9ea11f5f81 100644 --- a/src/components/ImageView/index.native.js +++ b/src/components/ImageView/index.native.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import ImageLightbox from '@components/ImageLightbox'; +import {scaleDefaultProps, scalePropTypes} from '@components/ImageLightbox/propTypes'; import {imageViewDefaultProps, imageViewPropTypes} from './propTypes'; /** @@ -9,6 +10,8 @@ import {imageViewDefaultProps, imageViewPropTypes} from './propTypes'; */ const propTypes = { ...imageViewPropTypes, + ...scalePropTypes, + /** Function for handle on press */ onPress: PropTypes.func, @@ -18,14 +21,18 @@ const propTypes = { const defaultProps = { ...imageViewDefaultProps, + ...scaleDefaultProps, + onPress: () => {}, style: {}, }; -function ImageView({isAuthTokenRequired, url, onScaleChanged, onPress, style}) { +function ImageView({isAuthTokenRequired, url, onScaleChanged, onPress, style, minZoomScale, maxZoomScale}) { return ( Date: Wed, 15 Nov 2023 14:10:28 +0100 Subject: [PATCH 06/47] simplify bounce --- src/components/ImageLightbox/ImageTransformer.js | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/components/ImageLightbox/ImageTransformer.js b/src/components/ImageLightbox/ImageTransformer.js index c23206121975..0551726ceca4 100644 --- a/src/components/ImageLightbox/ImageTransformer.js +++ b/src/components/ImageLightbox/ImageTransformer.js @@ -15,20 +15,10 @@ import Animated, { } from 'react-native-reanimated'; import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; import styles from '@styles/styles'; +import * as Constants from './Constants'; import ImageWrapper from './ImageWrapper'; import {imageTransformerDefaultProps, imageTransformerPropTypes} from './propTypes'; -const getMinZoomScaleWithBounce = (minZoomScaleWithoutBounce) => { - 'worklet'; - - return minZoomScaleWithoutBounce * 0.7; -}; -const getMaxZoomScaleWithBounce = (maxZoomScaleWithoutBounce) => { - 'worklet'; - - return maxZoomScaleWithoutBounce * 1.5; -}; - const DOUBLE_TAP_SCALE = 3; const SPRING_CONFIG = { @@ -464,7 +454,7 @@ function ImageTransformer({ .onChange((evt) => { const newZoomScale = pinchScaleOffset.value * evt.scale; - if (zoomScale.value >= getMinZoomScaleWithBounce(minZoomScale) && zoomScale.value <= getMaxZoomScaleWithBounce(maxZoomScale)) { + if (zoomScale.value >= minZoomScale * Constants.MIN_ZOOM_SCALE_BOUNCE && zoomScale.value <= maxZoomScale * Constants.MAX_ZOOM_SCALE_BOUNCE) { zoomScale.value = newZoomScale; pinchGestureScale.value = evt.scale; } From bbf43b893522ff14a67ee8e422c3d4dd21ca4496 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 15 Nov 2023 14:10:40 +0100 Subject: [PATCH 07/47] move bounce variables to constants --- src/components/ImageLightbox/Constants.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/ImageLightbox/Constants.js b/src/components/ImageLightbox/Constants.js index 59ea6fc0b237..c4db9fe51ef0 100644 --- a/src/components/ImageLightbox/Constants.js +++ b/src/components/ImageLightbox/Constants.js @@ -1,4 +1,7 @@ const DEFAULT_MIN_ZOOM_SCALE = 1; const DEFAULT_MAX_ZOOM_SCALE = 20; -export {DEFAULT_MIN_ZOOM_SCALE, DEFAULT_MAX_ZOOM_SCALE}; +const MIN_ZOOM_SCALE_BOUNCE = 0.7; +const MAX_ZOOM_SCALE_BOUNCE = 1.5; + +export {DEFAULT_MIN_ZOOM_SCALE, DEFAULT_MAX_ZOOM_SCALE, MIN_ZOOM_SCALE_BOUNCE, MAX_ZOOM_SCALE_BOUNCE}; From cbb6ad7fae5006942081b64181dedb811ce0cc1a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 15 Nov 2023 14:23:48 +0100 Subject: [PATCH 08/47] further improve ImageLightbox component --- src/components/ImageLightbox/index.js | 65 +++++++++++++-------------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/src/components/ImageLightbox/index.js b/src/components/ImageLightbox/index.js index f0d383fc615a..4ba408e495a5 100644 --- a/src/components/ImageLightbox/index.js +++ b/src/components/ImageLightbox/index.js @@ -1,5 +1,5 @@ /* eslint-disable es/no-optional-chaining */ -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {ActivityIndicator, PixelRatio, StyleSheet, View} from 'react-native'; import Image from '@components/Image'; import ImageLightboxUtils from './ImageLightboxUtils'; @@ -12,6 +12,10 @@ const cachedDimensions = new Map(); function ImageLightbox({isAuthTokenRequired, source, onScaleChanged, onPress, style, isActive: initialIsActive, minZoomScale, maxZoomScale}) { const [isActive, setIsActive] = useState(initialIsActive); + const [containerDimensions, setContainerDimensions] = useState({width: 0, height: 0}); + const canvasWidth = containerDimensions.width; + const canvasHeight = containerDimensions.height; + const imageDimensions = cachedDimensions.get(source); const setImageDimensions = (newDimensions) => cachedDimensions.set(source, newDimensions); @@ -27,26 +31,23 @@ function ImageLightbox({isAuthTokenRequired, source, onScaleChanged, onPress, st }, [initialIsActive]); const [initialActivePageLoad, setInitialActivePageLoad] = useState(isActive); - const isImageLoaded = useRef(null); const [isImageLoading, setIsImageLoading] = useState(false); const [isFallbackLoading, setIsFallbackLoading] = useState(false); const [showFallback, setShowFallback] = useState(true); + const isFallbackVisible = showFallback || !isActive; + const isCanvasLoading = canvasWidth === 0 || canvasHeight === 0; + const isLoading = isCanvasLoading || (isActive && isFallbackLoading && !isImageLoading); + // We delay hiding the fallback image while image transformer is still rendering useEffect(() => { - if (isImageLoading || showFallback) { + if (isImageLoading) { setShowFallback(true); } else { setTimeout(() => setShowFallback(false), 100); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [isImageLoading]); - const [containerDimensions, setContainerDimensions] = useState({width: 0, height: 0}); - const canvasWidth = containerDimensions.width; - const canvasHeight = containerDimensions.height; - const isLoadingLayout = canvasWidth === 0 || canvasHeight === 0; - return ( - {!isLoadingLayout && ( + {!isCanvasLoading && ( <> {isActive && ( - + { - setShowFallback(false); + setInitialActivePageLoad(false); setIsImageLoading(false); - isImageLoaded.current = true; + setShowFallback(false); }} onLoad={(evt) => { const imageWidth = (evt.nativeEvent?.width || 0) / PixelRatio.get(); @@ -89,7 +90,6 @@ function ImageLightbox({isAuthTokenRequired, source, onScaleChanged, onPress, st const {scaleX, scaleY} = ImageLightboxUtils.getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}); - // Don't update the dimensions if they are already set if ( imageDimensions?.width !== imageWidth || imageDimensions?.height !== imageHeight || @@ -111,7 +111,6 @@ function ImageLightbox({isAuthTokenRequired, source, onScaleChanged, onPress, st setInitialActivePageLoad(false); setIsImageLoading(false); setTimeout(() => setShowFallback(false), 100); - isImageLoaded.current = true; } }} /> @@ -120,7 +119,7 @@ function ImageLightbox({isAuthTokenRequired, source, onScaleChanged, onPress, st )} {/* Keep rendering the image without gestures as fallback while ImageLightbox is loading the image */} - {(showFallback || !isActive) && ( + {isFallbackVisible && ( )} + + {/* Show activity indicator while ImageLightbox is still loading the image. */} + {isLoading && ( + + )} )} - - {/* Show activity indicator while ImageLightbox is still loading the image. */} - {isLoadingLayout || - (isActive && isFallbackLoading && !isImageLoaded.current && ( - - ))} ); } ImageLightbox.propTypes = imageLightboxPropTypes; ImageLightbox.defaultProps = imageLightboxDefaultProps; -ImageLightbox.displayName = 'AttachmentCarouselPage'; +ImageLightbox.displayName = 'ImageLightbox'; export default ImageLightbox; From a1953e832a724e4766a03c5204c67101a4177d6e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 15 Nov 2023 16:11:13 +0100 Subject: [PATCH 09/47] fix: restructure ImageTransformer and ImageLightbox components --- .../AttachmentViewImage/Constants.js | 13 ++ .../AttachmentViewImage/index.native.js | 7 +- .../ImageLightbox/ImageLightboxUtils.js | 12 - src/components/ImageLightbox/index.js | 173 -------------- src/components/ImageLightbox/propTypes.js | 74 ------ src/components/ImageView/Constants.js | 13 -- src/components/ImageView/index.native.js | 15 +- src/components/Lightbox.js | 214 ++++++++++++++++++ .../Constants.js | 0 .../MultiGestureCanvasContentWrapper.js} | 8 +- .../index.js} | 176 +++++++------- .../MultiGestureCanvas/propTypes.js | 53 +++++ 12 files changed, 392 insertions(+), 366 deletions(-) create mode 100644 src/components/Attachments/AttachmentView/AttachmentViewImage/Constants.js delete mode 100644 src/components/ImageLightbox/ImageLightboxUtils.js delete mode 100644 src/components/ImageLightbox/index.js delete mode 100644 src/components/ImageLightbox/propTypes.js delete mode 100644 src/components/ImageView/Constants.js create mode 100644 src/components/Lightbox.js rename src/components/{ImageLightbox => MultiGestureCanvas}/Constants.js (100%) rename src/components/{ImageLightbox/ImageWrapper.js => MultiGestureCanvas/MultiGestureCanvasContentWrapper.js} (66%) rename src/components/{ImageLightbox/ImageTransformer.js => MultiGestureCanvas/index.js} (73%) create mode 100644 src/components/MultiGestureCanvas/propTypes.js diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/Constants.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/Constants.js new file mode 100644 index 000000000000..8ec9e5669103 --- /dev/null +++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/Constants.js @@ -0,0 +1,13 @@ +import {DEFAULT_MAX_ZOOM_SCALE, DEFAULT_MIN_ZOOM_SCALE} from '@components/MultiGestureCanvas/Constants'; + +const modalZoomRange = { + min: DEFAULT_MIN_ZOOM_SCALE, + max: DEFAULT_MAX_ZOOM_SCALE * 1.19, // equals to 23.8, which was tested to be the same perceived zoom scale compared to the attachment carousel +}; + +const carouselZoomRange = { + min: DEFAULT_MIN_ZOOM_SCALE, + max: DEFAULT_MAX_ZOOM_SCALE, +}; + +export {modalZoomRange, carouselZoomRange}; diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js index dc9ba9985526..ae1bd2c52f98 100755 --- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js @@ -1,11 +1,11 @@ import React, {memo} from 'react'; import ImageView from '@components/ImageView'; -import {carouselZoomScale, modalZoomScale} from '@components/ImageView/Constants'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import compose from '@libs/compose'; import styles from '@styles/styles'; import CONST from '@src/CONST'; +import * as Constants from './Constants'; import {attachmentViewImageDefaultProps, attachmentViewImagePropTypes} from './propTypes'; const propTypes = { @@ -14,7 +14,7 @@ const propTypes = { }; function AttachmentViewImage({source, file, isAuthTokenRequired, isUsedInCarousel, isFocused, loadComplete, onPress, isImage, onScaleChanged, translate}) { - const {minZoomScale, maxZoomScale} = isUsedInCarousel ? carouselZoomScale : modalZoomScale; + const zoomRange = isUsedInCarousel ? Constants.carouselZoomRange : Constants.modalZoomRange; const children = ( ); diff --git a/src/components/ImageLightbox/ImageLightboxUtils.js b/src/components/ImageLightbox/ImageLightboxUtils.js deleted file mode 100644 index 694737cdb1f6..000000000000 --- a/src/components/ImageLightbox/ImageLightboxUtils.js +++ /dev/null @@ -1,12 +0,0 @@ -function getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}) { - const scaleX = canvasWidth / imageWidth; - const scaleY = canvasHeight / imageHeight; - - return {scaleX, scaleY}; -} - -const ImageLightboxUtils = { - getCanvasFitScale, -}; - -export default ImageLightboxUtils; diff --git a/src/components/ImageLightbox/index.js b/src/components/ImageLightbox/index.js deleted file mode 100644 index 4ba408e495a5..000000000000 --- a/src/components/ImageLightbox/index.js +++ /dev/null @@ -1,173 +0,0 @@ -/* eslint-disable es/no-optional-chaining */ -import React, {useEffect, useState} from 'react'; -import {ActivityIndicator, PixelRatio, StyleSheet, View} from 'react-native'; -import Image from '@components/Image'; -import ImageLightboxUtils from './ImageLightboxUtils'; -import ImageTransformer from './ImageTransformer'; -import ImageWrapper from './ImageWrapper'; -import {imageLightboxDefaultProps, imageLightboxPropTypes} from './propTypes'; - -const cachedDimensions = new Map(); - -function ImageLightbox({isAuthTokenRequired, source, onScaleChanged, onPress, style, isActive: initialIsActive, minZoomScale, maxZoomScale}) { - const [isActive, setIsActive] = useState(initialIsActive); - - const [containerDimensions, setContainerDimensions] = useState({width: 0, height: 0}); - const canvasWidth = containerDimensions.width; - const canvasHeight = containerDimensions.height; - - const imageDimensions = cachedDimensions.get(source); - const setImageDimensions = (newDimensions) => cachedDimensions.set(source, newDimensions); - - // We delay setting a page to active state by a (few) millisecond(s), - // to prevent the image transformer from flashing while still rendering - // Instead, we show the fallback image while the image transformer is loading the image - useEffect(() => { - if (initialIsActive) { - setTimeout(() => setIsActive(true), 1); - } else { - setIsActive(false); - } - }, [initialIsActive]); - - const [initialActivePageLoad, setInitialActivePageLoad] = useState(isActive); - const [isImageLoading, setIsImageLoading] = useState(false); - const [isFallbackLoading, setIsFallbackLoading] = useState(false); - const [showFallback, setShowFallback] = useState(true); - - const isFallbackVisible = showFallback || !isActive; - const isCanvasLoading = canvasWidth === 0 || canvasHeight === 0; - const isLoading = isCanvasLoading || (isActive && isFallbackLoading && !isImageLoading); - - // We delay hiding the fallback image while image transformer is still rendering - useEffect(() => { - if (isImageLoading) { - setShowFallback(true); - } else { - setTimeout(() => setShowFallback(false), 100); - } - }, [isImageLoading]); - - return ( - - setContainerDimensions({width: PixelRatio.roundToNearestPixel(nativeEvent.layout.width), height: PixelRatio.roundToNearestPixel(nativeEvent.layout.height)}) - } - > - {!isCanvasLoading && ( - <> - {isActive && ( - - - { - setIsImageLoading(true); - }} - onLoadEnd={() => { - setInitialActivePageLoad(false); - setIsImageLoading(false); - setShowFallback(false); - }} - onLoad={(evt) => { - const imageWidth = (evt.nativeEvent?.width || 0) / PixelRatio.get(); - const imageHeight = (evt.nativeEvent?.height || 0) / PixelRatio.get(); - - const {scaleX, scaleY} = ImageLightboxUtils.getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}); - - if ( - imageDimensions?.width !== imageWidth || - imageDimensions?.height !== imageHeight || - imageDimensions?.scaleX !== scaleX || - imageDimensions?.scaleY !== scaleY - ) { - setImageDimensions({ - ...imageDimensions, - width: imageWidth, - height: imageHeight, - scaleX, - scaleY, - }); - } - - // On the initial render of the active page, the onLoadEnd event is never fired. - // That's why we instead set isImageLoading to false in the onLoad event. - if (initialActivePageLoad) { - setInitialActivePageLoad(false); - setIsImageLoading(false); - setTimeout(() => setShowFallback(false), 100); - } - }} - /> - - - )} - - {/* Keep rendering the image without gestures as fallback while ImageLightbox is loading the image */} - {isFallbackVisible && ( - - { - setIsFallbackLoading(true); - }} - onLoadEnd={() => { - setIsFallbackLoading(false); - }} - onLoad={(evt) => { - const imageWidth = evt.nativeEvent.width; - const imageHeight = evt.nativeEvent.height; - - const {scaleX, scaleY} = ImageLightboxUtils.getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}); - const minImageScale = Math.min(scaleX, scaleY); - - const scaledWidth = imageWidth * minImageScale; - const scaledHeight = imageHeight * minImageScale; - - if (imageDimensions?.scaledWidth !== scaledWidth || imageDimensions?.scaledHeight !== scaledHeight) { - setImageDimensions({ - ...imageDimensions, - scaledWidth, - scaledHeight, - }); - } - }} - style={imageDimensions == null ? undefined : {width: imageDimensions.scaledWidth, height: imageDimensions.scaledHeight}} - /> - - )} - - {/* Show activity indicator while ImageLightbox is still loading the image. */} - {isLoading && ( - - )} - - )} - - ); -} - -ImageLightbox.propTypes = imageLightboxPropTypes; -ImageLightbox.defaultProps = imageLightboxDefaultProps; -ImageLightbox.displayName = 'ImageLightbox'; - -export default ImageLightbox; diff --git a/src/components/ImageLightbox/propTypes.js b/src/components/ImageLightbox/propTypes.js deleted file mode 100644 index 58bba067e899..000000000000 --- a/src/components/ImageLightbox/propTypes.js +++ /dev/null @@ -1,74 +0,0 @@ -import PropTypes from 'prop-types'; -import * as AttachmentsPropTypes from '@components/Attachments/propTypes'; -import * as Constants from './Constants'; - -const scalePropTypes = { - minZoomScale: PropTypes.number, - maxZoomScale: PropTypes.number, -}; - -const imageTransformerPropTypes = { - ...scalePropTypes, - - canvasWidth: PropTypes.number.isRequired, - canvasHeight: PropTypes.number.isRequired, - imageWidth: PropTypes.number, - imageHeight: PropTypes.number, - imageScaleX: PropTypes.number, - imageScaleY: PropTypes.number, - scaledImageWidth: PropTypes.number, - scaledImageHeight: PropTypes.number, - isActive: PropTypes.bool, - children: PropTypes.node.isRequired, -}; - -const scaleDefaultProps = { - minZoomScale: Constants.DEFAULT_MIN_ZOOM_SCALE, - maxZoomScale: Constants.DEFAULT_MAX_ZOOM_SCALE, -}; - -const imageTransformerDefaultProps = { - ...scaleDefaultProps, - - isActive: true, - imageWidth: 0, - imageHeight: 0, - imageScaleX: 1, - imageScaleY: 1, - scaledImageWidth: 0, - scaledImageHeight: 0, - minZoomScale: Constants.DEFAULT_MIN_ZOOM_SCALE, - maxZoomScale: Constants.DEFAULT_MAX_ZOOM_SCALE, -}; - -/** - * On the native layer, we use a image library to handle zoom functionality - */ -const imageLightboxPropTypes = { - ...scalePropTypes, - - /** Function for handle on press */ - onPress: PropTypes.func, - - /** URL to full-sized attachment, SVG function, or numeric static image on native platforms */ - source: AttachmentsPropTypes.attachmentSourcePropType.isRequired, - - /** Whether source url requires authentication */ - isAuthTokenRequired: PropTypes.bool, - - isActive: PropTypes.bool, - - /** Additional styles to add to the component */ - style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), -}; - -const imageLightboxDefaultProps = { - ...scaleDefaultProps, - - isAuthTokenRequired: false, - isActive: true, - onPress: () => {}, - style: {}, -}; - -export {scalePropTypes, imageTransformerPropTypes, imageLightboxPropTypes, scaleDefaultProps, imageTransformerDefaultProps, imageLightboxDefaultProps}; diff --git a/src/components/ImageView/Constants.js b/src/components/ImageView/Constants.js deleted file mode 100644 index 5ccbca8cb6c9..000000000000 --- a/src/components/ImageView/Constants.js +++ /dev/null @@ -1,13 +0,0 @@ -const {DEFAULT_MIN_ZOOM_SCALE, DEFAULT_MAX_ZOOM_SCALE} = require('@components/ImageLightbox/Constants'); - -const modalZoomScale = { - minZoomScale: DEFAULT_MIN_ZOOM_SCALE, - maxZoomScale: DEFAULT_MAX_ZOOM_SCALE * 1.19, // equals to 23.8, which was tested to be the same perceived zoom scale compared to the attachment carousel -}; - -const carouselZoomScale = { - minZoomScale: DEFAULT_MIN_ZOOM_SCALE, - maxZoomScale: DEFAULT_MAX_ZOOM_SCALE, -}; - -export {modalZoomScale, carouselZoomScale}; diff --git a/src/components/ImageView/index.native.js b/src/components/ImageView/index.native.js index 5c9ea11f5f81..ee132d0956e5 100644 --- a/src/components/ImageView/index.native.js +++ b/src/components/ImageView/index.native.js @@ -1,8 +1,8 @@ /* eslint-disable es/no-optional-chaining */ import PropTypes from 'prop-types'; import React from 'react'; -import ImageLightbox from '@components/ImageLightbox'; -import {scaleDefaultProps, scalePropTypes} from '@components/ImageLightbox/propTypes'; +import Lightbox from '@components/Lightbox'; +import {zoomRangeDefaultProps, zoomRangePropTypes} from '@components/MultiGestureCanvas/propTypes'; import {imageViewDefaultProps, imageViewPropTypes} from './propTypes'; /** @@ -10,7 +10,7 @@ import {imageViewDefaultProps, imageViewPropTypes} from './propTypes'; */ const propTypes = { ...imageViewPropTypes, - ...scalePropTypes, + ...zoomRangePropTypes, /** Function for handle on press */ onPress: PropTypes.func, @@ -21,18 +21,17 @@ const propTypes = { const defaultProps = { ...imageViewDefaultProps, - ...scaleDefaultProps, + ...zoomRangeDefaultProps, onPress: () => {}, style: {}, }; -function ImageView({isAuthTokenRequired, url, onScaleChanged, onPress, style, minZoomScale, maxZoomScale}) { +function ImageView({isAuthTokenRequired, url, onScaleChanged, onPress, style, zoomRange}) { return ( - {}, + style: {}, +}; + +function Lightbox({isAuthTokenRequired, source, onScaleChanged, onPress, style, isActive: initialIsActive, zoomRange}) { + const [isActive, setIsActive] = useState(initialIsActive); + + const [canvasSize, setCanvasSize] = useState({width: 0, height: 0}); + + const imageDimensions = cachedDimensions.get(source); + const setImageDimensions = (newDimensions) => cachedDimensions.set(source, newDimensions); + + // We delay setting a page to active state by a (few) millisecond(s), + // to prevent the image transformer from flashing while still rendering + // Instead, we show the fallback image while the image transformer is loading the image + useEffect(() => { + if (initialIsActive) { + setTimeout(() => setIsActive(true), 1); + } else { + setIsActive(false); + } + }, [initialIsActive]); + + const [initialActivePageLoad, setInitialActivePageLoad] = useState(isActive); + const [isImageLoading, setIsImageLoading] = useState(false); + const [isFallbackLoading, setIsFallbackLoading] = useState(false); + const [showFallback, setShowFallback] = useState(true); + + const isFallbackVisible = showFallback || !isActive; + const isCanvasLoading = canvasSize.width === 0 || canvasSize.height === 0; + const isLoading = isCanvasLoading || (isActive && isFallbackVisible && isFallbackLoading) || isImageLoading; + + // We delay hiding the fallback image while image transformer is still rendering + useEffect(() => { + if (isImageLoading || showFallback) { + setShowFallback(true); + } else { + setTimeout(() => setShowFallback(false), 100); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isImageLoading]); + + return ( + setCanvasSize({width: PixelRatio.roundToNearestPixel(nativeEvent.layout.width), height: PixelRatio.roundToNearestPixel(nativeEvent.layout.height)})} + > + {!isCanvasLoading && ( + <> + {isActive && ( + + + { + setIsImageLoading(true); + }} + onLoadEnd={() => { + setInitialActivePageLoad(false); + setIsImageLoading(false); + setShowFallback(false); + }} + onLoad={(evt) => { + const width = (evt.nativeEvent?.width || 0) / PixelRatio.get(); + const height = (evt.nativeEvent?.height || 0) / PixelRatio.get(); + const contentSize = {width, height}; + + const {scaleX, scaleY} = getCanvasFitScale({canvasSize, contentSize}); + + if ( + imageDimensions?.contentSize?.width !== width || + imageDimensions?.contentSize?.height !== height || + imageDimensions?.contentScaling?.scaleX !== scaleX || + imageDimensions?.contentScaling?.scaleY !== scaleY + ) { + setImageDimensions({ + ...imageDimensions, + contentSize, + contentScaling: { + ...imageDimensions?.contentScaling, + scaleX, + scaleY, + }, + }); + } + + // On the initial render of the active page, the onLoadEnd event is never fired. + // That's why we instead set isImageLoading to false in the onLoad event. + if (initialActivePageLoad) { + setInitialActivePageLoad(false); + setIsImageLoading(false); + setTimeout(() => setShowFallback(false), 100); + } + }} + /> + + + )} + + {/* Keep rendering the image without gestures as fallback while ImageLightbox is loading the image */} + {isFallbackVisible && ( + + { + setIsFallbackLoading(true); + }} + onLoadEnd={() => { + setIsFallbackLoading(false); + }} + onLoad={(evt) => { + const width = evt.nativeEvent.width; + const height = evt.nativeEvent.height; + + const {scaleX, scaleY} = getCanvasFitScale({canvasSize, contentSize: {width, height}}); + const minImageScale = Math.min(scaleX, scaleY); + + const scaledWidth = width * minImageScale; + const scaledHeight = height * minImageScale; + + if (imageDimensions?.contentScaling?.scaledWidth !== scaledWidth || imageDimensions?.contentScaling?.scaledHeight !== scaledHeight) { + setImageDimensions({ + ...imageDimensions, + contentScaling: { + ...imageDimensions?.contentScaling, + scaledWidth, + scaledHeight, + }, + }); + } + }} + style={ + imageDimensions?.contentScaling == null + ? undefined + : {width: imageDimensions.contentScaling.scaledWidth, height: imageDimensions.contentScaling.scaledHeight} + } + /> + + )} + + {/* Show activity indicator while ImageLightbox is still loading the image. */} + {isLoading && ( + + )} + + )} + + ); +} + +Lightbox.propTypes = propTypes; +Lightbox.defaultProps = defaultProps; +Lightbox.displayName = 'Lightbox'; + +export default Lightbox; diff --git a/src/components/ImageLightbox/Constants.js b/src/components/MultiGestureCanvas/Constants.js similarity index 100% rename from src/components/ImageLightbox/Constants.js rename to src/components/MultiGestureCanvas/Constants.js diff --git a/src/components/ImageLightbox/ImageWrapper.js b/src/components/MultiGestureCanvas/MultiGestureCanvasContentWrapper.js similarity index 66% rename from src/components/ImageLightbox/ImageWrapper.js rename to src/components/MultiGestureCanvas/MultiGestureCanvasContentWrapper.js index 3a27d80c5509..8ab588f1100b 100644 --- a/src/components/ImageLightbox/ImageWrapper.js +++ b/src/components/MultiGestureCanvas/MultiGestureCanvasContentWrapper.js @@ -8,7 +8,7 @@ const imageWrapperPropTypes = { children: PropTypes.node.isRequired, }; -function ImageWrapper({children}) { +function MultiGestureCanvasContentWrapper({children}) { return ( Math.min(imageScaleX, imageScaleY), [imageScaleX, imageScaleY]); - const maxImageScale = useMemo(() => Math.max(imageScaleX, imageScaleY), [imageScaleX, imageScaleY]); + const minContentScale = useMemo(() => Math.min(contentScaling.scaleX, contentScaling.scaleY), [contentScaling.scaleX, contentScaling.scaleY]); + const maxContentScale = useMemo(() => Math.max(contentScaling.scaleX, contentScaling.scaleY), [contentScaling.scaleX, contentScaling.scaleY]); // On double tap zoom to fill, but at least 3x zoom - const doubleTapScale = useMemo(() => Math.max(maxImageScale / minImageScale, DOUBLE_TAP_SCALE), [maxImageScale, minImageScale]); + const doubleTapScale = useMemo(() => Math.max(maxContentScale / minContentScale, DOUBLE_TAP_SCALE), [maxContentScale, minContentScale]); const zoomScale = useSharedValue(1); - // Adding together the pinch zoom scale and the initial scale to fit the image into the canvas - // Using the smaller imageScale, so that the immage is not bigger than the canvas + // Adding together the pinch zoom scale and the initial scale to fit the content into the canvas + // Using the smaller content scale, so that the immage is not bigger than the canvas // and not smaller than needed to fit - const totalScale = useDerivedValue(() => zoomScale.value * minImageScale, [minImageScale]); + const totalScale = useDerivedValue(() => zoomScale.value * minContentScale, [minContentScale]); - const zoomScaledImageWidth = useDerivedValue(() => imageWidth * totalScale.value, [imageWidth]); - const zoomScaledImageHeight = useDerivedValue(() => imageHeight * totalScale.value, [imageHeight]); + const zoomScaledContentWidth = useDerivedValue(() => contentSize.width * totalScale.value, [contentSize.width]); + const zoomScaledContentHeight = useDerivedValue(() => contentSize.height * totalScale.value, [contentSize.height]); // used for pan gesture const translateY = useSharedValue(0); @@ -103,22 +115,22 @@ function ImageTransformer({ // store scale in between gestures const pinchScaleOffset = useSharedValue(1); - // disable pan vertically when image is smaller than screen - const canPanVertically = useDerivedValue(() => canvasHeight < zoomScaledImageHeight.value, [canvasHeight]); + // disable pan vertically when content is smaller than screen + const canPanVertically = useDerivedValue(() => canvasSize.height < zoomScaledContentHeight.value, [canvasSize.height]); - // calculates bounds of the scaled image + // calculates bounds of the scaled content // can we pan left/right/up/down // can be used to limit gesture or implementing tension effect const getBounds = useWorkletCallback(() => { let rightBoundary = 0; let topBoundary = 0; - if (canvasWidth < zoomScaledImageWidth.value) { - rightBoundary = Math.abs(canvasWidth - zoomScaledImageWidth.value) / 2; + if (canvasSize.width < zoomScaledContentWidth.value) { + rightBoundary = Math.abs(canvasSize.width - zoomScaledContentWidth.value) / 2; } - if (canvasHeight < zoomScaledImageHeight.value) { - topBoundary = Math.abs(zoomScaledImageHeight.value - canvasHeight) / 2; + if (canvasSize.height < zoomScaledContentHeight.value) { + topBoundary = Math.abs(zoomScaledContentHeight.value - canvasSize.height) / 2; } const maxVector = {x: rightBoundary, y: topBoundary}; @@ -141,7 +153,7 @@ function ImageTransformer({ canPanLeft: target.x < maxVector.x, canPanRight: target.x > minVector.x, }; - }, [canvasWidth, canvasHeight]); + }, [canvasSize.width, canvasSize.height]); const afterPanGesture = useWorkletCallback(() => { const {target, isInBoundaryX, isInBoundaryY, minVector, maxVector} = getBounds(); @@ -165,7 +177,7 @@ function ImageTransformer({ const deceleration = 0.9915; if (isInBoundaryX) { - if (Math.abs(panVelocityX.value) > 0 && zoomScale.value <= maxZoomScale) { + if (Math.abs(panVelocityX.value) > 0 && zoomScale.value <= zoomRange.max) { offsetX.value = withDecay({ velocity: panVelocityX.value, clamp: [minVector.x, maxVector.x], @@ -180,8 +192,8 @@ function ImageTransformer({ if (isInBoundaryY) { if ( Math.abs(panVelocityY.value) > 0 && - zoomScale.value <= maxZoomScale && - // limit vertical pan only when image is smaller than screen + zoomScale.value <= zoomRange.max && + // limit vertical pan only when content is smaller than screen offsetY.value !== minVector.y && offsetY.value !== maxVector.y ) { @@ -209,42 +221,42 @@ function ImageTransformer({ stopAnimation(); - const canvasOffsetX = Math.max(0, (canvasWidth - scaledImageWidth) / 2); - const canvasOffsetY = Math.max(0, (canvasHeight - scaledImageHeight) / 2); + const canvasOffsetX = Math.max(0, (canvasSize.width - contentScaling.scaledWidth) / 2); + const canvasOffsetY = Math.max(0, (canvasSize.height - contentScaling.scaledHeight) / 2); - const imageFocal = { - x: clamp(canvasFocalX - canvasOffsetX, 0, scaledImageWidth), - y: clamp(canvasFocalY - canvasOffsetY, 0, scaledImageHeight), + const contentFocal = { + x: clamp(canvasFocalX - canvasOffsetX, 0, contentScaling.scaledWidth), + y: clamp(canvasFocalY - canvasOffsetY, 0, contentScaling.scaledHeight), }; const canvasCenter = { - x: canvasWidth / 2, - y: canvasHeight / 2, + x: canvasSize.width / 2, + y: canvasSize.height / 2, }; - const originImageCenter = { - x: scaledImageWidth / 2, - y: scaledImageHeight / 2, + const originContentCenter = { + x: contentScaling.scaledWidth / 2, + y: contentScaling.scaledHeight / 2, }; - const targetImageSize = { - width: scaledImageWidth * doubleTapScale, - height: scaledImageHeight * doubleTapScale, + const targetContentSize = { + width: contentScaling.scaledWidth * doubleTapScale, + height: contentScaling.scaledHeight * doubleTapScale, }; - const targetImageCenter = { - x: targetImageSize.width / 2, - y: targetImageSize.height / 2, + const targetContentCenter = { + x: targetContentSize.width / 2, + y: targetContentSize.height / 2, }; const currentOrigin = { - x: (targetImageCenter.x - canvasCenter.x) * -1, - y: (targetImageCenter.y - canvasCenter.y) * -1, + x: (targetContentCenter.x - canvasCenter.x) * -1, + y: (targetContentCenter.y - canvasCenter.y) * -1, }; const koef = { - x: (1 / originImageCenter.x) * imageFocal.x - 1, - y: (1 / originImageCenter.y) * imageFocal.y - 1, + x: (1 / originContentCenter.x) * contentFocal.x - 1, + y: (1 / originContentCenter.y) * contentFocal.y - 1, }; const target = { @@ -252,7 +264,7 @@ function ImageTransformer({ y: currentOrigin.y * koef.y, }; - if (targetImageSize.height < canvasHeight) { + if (targetContentSize.height < canvasSize.height) { target.y = 0; } @@ -261,7 +273,7 @@ function ImageTransformer({ zoomScale.value = withSpring(doubleTapScale, SPRING_CONFIG); pinchScaleOffset.value = doubleTapScale; }, - [scaledImageWidth, scaledImageHeight, canvasWidth, canvasHeight], + [contentScaling.scaledWidth, contentScaling.scaledHeight, canvasSize.width, canvasSize.height], ); const reset = useWorkletCallback((animated) => { @@ -294,6 +306,10 @@ function ImageTransformer({ } else { zoomToCoordinates(evt.x, evt.y); } + + if (onScaleChanged != null) { + runOnJS(onScaleChanged)(zoomScale.value); + } }); const panGestureRef = useRef(Gesture.Pan()); @@ -395,7 +411,7 @@ function ImageTransformer({ }; offsetY.value = withSpring( - maybeInvert(imageHeight * 2), + maybeInvert(contentSize.height * 2), { stiffness: 50, damping: 30, @@ -422,10 +438,10 @@ function ImageTransformer({ const getAdjustedFocal = useWorkletCallback( (focalX, focalY) => ({ - x: focalX - (canvasWidth / 2 + offsetX.value), - y: focalY - (canvasHeight / 2 + offsetY.value), + x: focalX - (canvasSize.width / 2 + offsetX.value), + y: focalY - (canvasSize.height / 2 + offsetY.value), }), - [canvasWidth, canvasHeight], + [canvasSize.width, canvasSize.height], ); // used to store event scale value when we limit scale @@ -454,7 +470,7 @@ function ImageTransformer({ .onChange((evt) => { const newZoomScale = pinchScaleOffset.value * evt.scale; - if (zoomScale.value >= minZoomScale * Constants.MIN_ZOOM_SCALE_BOUNCE && zoomScale.value <= maxZoomScale * Constants.MAX_ZOOM_SCALE_BOUNCE) { + if (zoomScale.value >= zoomRange.min * Constants.MIN_ZOOM_SCALE_BOUNCE && zoomScale.value <= zoomRange.max * Constants.MAX_ZOOM_SCALE_BOUNCE) { zoomScale.value = newZoomScale; pinchGestureScale.value = evt.scale; } @@ -463,7 +479,7 @@ function ImageTransformer({ const newPinchTranslateX = adjustedFocal.x + pinchGestureScale.value * origin.x.value * -1; const newPinchTranslateY = adjustedFocal.y + pinchGestureScale.value * origin.y.value * -1; - if (zoomScale.value >= minZoomScale && zoomScale.value <= maxZoomScale) { + if (zoomScale.value >= zoomRange.min && zoomScale.value <= zoomRange.max) { pinchTranslateX.value = newPinchTranslateX; pinchTranslateY.value = newPinchTranslateY; } else { @@ -479,12 +495,12 @@ function ImageTransformer({ pinchScaleOffset.value = zoomScale.value; pinchGestureScale.value = 1; - if (pinchScaleOffset.value < minZoomScale) { - pinchScaleOffset.value = minZoomScale; - zoomScale.value = withSpring(minZoomScale, SPRING_CONFIG); - } else if (pinchScaleOffset.value > maxZoomScale) { - pinchScaleOffset.value = maxZoomScale; - zoomScale.value = withSpring(maxZoomScale, SPRING_CONFIG); + if (pinchScaleOffset.value < zoomRange.min) { + pinchScaleOffset.value = zoomRange.min; + zoomScale.value = withSpring(zoomRange.min, SPRING_CONFIG); + } else if (pinchScaleOffset.value > zoomRange.max) { + pinchScaleOffset.value = zoomRange.max; + zoomScale.value = withSpring(zoomRange.max, SPRING_CONFIG); } if (pinchBounceTranslateX.value !== 0 || pinchBounceTranslateY.value !== 0) { @@ -493,6 +509,10 @@ function ImageTransformer({ } pinchGestureRunning.value = false; + + if (onScaleChanged != null) { + runOnJS(onScaleChanged)(zoomScale.value); + } }); const [isPinchGestureInUse, setIsPinchGestureInUse] = useState(false); @@ -555,26 +575,26 @@ function ImageTransformer({ style={[ styles.flex1, { - width: canvasWidth, + width: canvasSize.width, overflow: 'hidden', }, ]} > - + {children} - + ); } -ImageTransformer.propTypes = imageTransformerPropTypes; -ImageTransformer.defaultProps = imageTransformerDefaultProps; -ImageTransformer.displayName = 'ImageTransformer'; +MultiGestureCanvas.propTypes = multiGestureCanvasPropTypes; +MultiGestureCanvas.defaultProps = multiGestureCanvasDefaultProps; +MultiGestureCanvas.displayName = 'MultiGestureCanvas'; -export default ImageTransformer; +export default MultiGestureCanvas; diff --git a/src/components/MultiGestureCanvas/propTypes.js b/src/components/MultiGestureCanvas/propTypes.js new file mode 100644 index 000000000000..3eb633d78de2 --- /dev/null +++ b/src/components/MultiGestureCanvas/propTypes.js @@ -0,0 +1,53 @@ +import PropTypes from 'prop-types'; +import * as Constants from './Constants'; + +const zoomRangePropTypes = { + zoomRange: PropTypes.shape({ + min: PropTypes.number, + max: PropTypes.number, + }), +}; + +const zoomRangeDefaultProps = { + zoomRange: { + min: Constants.DEFAULT_MIN_ZOOM_SCALE, + max: Constants.DEFAULT_MAX_ZOOM_SCALE, + }, +}; + +const multiGestureCanvasPropTypes = { + ...zoomRangePropTypes, + + isActive: PropTypes.bool, + + onScaleChanged: PropTypes.func, + + canvasSize: PropTypes.shape({ + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + }).isRequired, + + contentSize: PropTypes.shape({ + width: PropTypes.number, + height: PropTypes.number, + }), + + contentScaling: PropTypes.shape({ + scaleX: PropTypes.number, + scaleY: PropTypes.number, + scaledWidth: PropTypes.number, + scaledHeight: PropTypes.number, + }), + + children: PropTypes.node.isRequired, +}; + +const multiGestureCanvasDefaultProps = { + isActive: true, + onScaleChanged: () => undefined, + contentSize: undefined, + contentScaling: undefined, + zoomRange: undefined, +}; + +export {zoomRangePropTypes, zoomRangeDefaultProps, multiGestureCanvasPropTypes, multiGestureCanvasDefaultProps}; From bcc8539176e781f666e1ea18ea2d5961e1ac179f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 15 Nov 2023 17:19:36 +0100 Subject: [PATCH 10/47] fix: move GestureHandlerRootView to AttachmentModal --- src/components/AttachmentModal.js | 137 +++++++++--------- .../AttachmentCarousel/Pager/index.js | 46 +++--- 2 files changed, 92 insertions(+), 91 deletions(-) diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index f82fec156f9f..81305ae80bd3 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -4,6 +4,7 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {Animated, Keyboard, View} from 'react-native'; +import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import useLocalize from '@hooks/useLocalize'; @@ -426,77 +427,79 @@ function AttachmentModal(props) { }} propagateSwipe > - {props.isSmallScreenWidth && } - downloadAttachment(source)} - shouldShowCloseButton={!props.isSmallScreenWidth} - shouldShowBackButton={props.isSmallScreenWidth} - onBackButtonPress={closeModal} - onCloseButtonPress={closeModal} - shouldShowThreeDotsButton={shouldShowThreeDotsButton} - threeDotsAnchorPosition={styles.threeDotsPopoverOffsetAttachmentModal(windowWidth)} - threeDotsMenuItems={threeDotsMenuItems} - shouldOverlay - /> - - {!_.isEmpty(props.report) ? ( - - ) : ( - Boolean(sourceForAttachmentView) && - shouldLoadAttachment && ( - + {props.isSmallScreenWidth && } + downloadAttachment(source)} + shouldShowCloseButton={!props.isSmallScreenWidth} + shouldShowBackButton={props.isSmallScreenWidth} + onBackButtonPress={closeModal} + onCloseButtonPress={closeModal} + shouldShowThreeDotsButton={shouldShowThreeDotsButton} + threeDotsAnchorPosition={styles.threeDotsPopoverOffsetAttachmentModal(windowWidth)} + threeDotsMenuItems={threeDotsMenuItems} + shouldOverlay + /> + + {!_.isEmpty(props.report) ? ( + - ) - )} - - {/* If we have an onConfirm method show a confirmation button */} - {Boolean(props.onConfirm) && ( - - {({safeAreaPaddingBottomStyle}) => ( - -