import useDebounce from '../../hooks/useDebounce'; import useForceUpdate from '../../hooks/useForceUpdate'; import { FC, memo, useCallback, useEffect, useRef, useState, } from '../../lib/teact/teact'; import React from '../../lib/teact/teactn'; import { MediaViewerOrigin } from '../../types'; import { animateNumber, timingFunctions } from '../../util/animation'; import arePropsShallowEqual from '../../util/arePropsShallowEqual'; import { captureEvents } from '../../util/captureEvents'; import { IS_TOUCH_ENV } from '../../util/environment'; import { debounce } from '../../util/schedulers'; import MediaViewerContent from './MediaViewerContent'; import './MediaViewerSlides.scss'; type OwnProps = { messageId?: number; getMessageId: (fromId?: number, direction?: number) => number | undefined; isVideo?: boolean; isGif?: boolean; isPhoto?: boolean; isOpen?: boolean; selectMessage: (id?: number) => void; chatId?: string; threadId?: number; isActive?: boolean; avatarOwnerId?: string; profilePhotoIndex?: number; origin?: MediaViewerOrigin; isZoomed?: boolean; animationLevel: 0 | 1 | 2; onClose: () => void; hasFooter?: boolean; onFooterClick: () => void; }; const SWIPE_X_THRESHOLD = 50; const SWIPE_Y_THRESHOLD = 50; const SLIDES_GAP = 40; const ANIMATION_DURATION = 350; const DEBOUNCE_MESSAGE = 350; const DEBOUNCE_SWIPE = 500; const DEBOUNCE_ACTIVE = 800; const MAX_ZOOM = 4; const MIN_ZOOM = 0.6; const DOUBLE_TAP_ZOOM = 3; let cancelAnimation: Function | undefined; type Transform = { x: number; y: number; scale: number; }; const INITIAL_TRANSFORM = { x: 0, y: 0, scale: 1, }; const MediaViewerSlides: FC = ({ messageId, getMessageId, selectMessage, isVideo, isGif, isPhoto, isOpen, isActive, hasFooter, ...rest }) => { // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); // eslint-disable-next-line no-null/no-null const activeSlideRef = useRef(null); const transformRef = useRef(INITIAL_TRANSFORM); const isSwipingRef = useRef(false); const isActiveRef = useRef(true); const [activeMessageId, setActiveMessageId] = useState(messageId); const forceUpdate = useForceUpdate(); const [isFooterHidden, setIsFooterHidden] = useState(false); const { isZoomed, onClose, } = rest; const setTransform = useCallback((value: Transform) => { transformRef.current = value; forceUpdate(); }, [forceUpdate]); const setIsSwiping = useCallback((value: boolean) => { isSwipingRef.current = value; forceUpdate(); }, [forceUpdate]); const setIsActive = useCallback((value: boolean) => { isActiveRef.current = value; forceUpdate(); }, [forceUpdate]); const debounceSetMessage = useDebounce(DEBOUNCE_MESSAGE, false); const debounceSwipe = useDebounce(DEBOUNCE_SWIPE, false); const debounceActive = useDebounce(DEBOUNCE_ACTIVE, false); const handleToggleFooterVisibility = useCallback(() => { if (IS_TOUCH_ENV && (isPhoto || isGif) && hasFooter) { setIsFooterHidden(!isFooterHidden); } }, [hasFooter, isFooterHidden, isGif, isPhoto]); useEffect(() => { if (!IS_TOUCH_ENV || !containerRef.current || isZoomed || !activeMessageId) { return undefined; } let lastTransform = { ...transformRef.current }; const lastDragOffset = { x: 0, y: 0, }; const lastZoomCenter = { x: 0, y: 0 }; const panDelta = { x: 0, y: 0, }; let lastGestureTime = Date.now(); let initialContentRect: DOMRect; let content: HTMLElement | null; const setLastGestureTime = debounce(() => { lastGestureTime = Date.now(); }, 500, false, true); return captureEvents(containerRef.current, { isNotPassive: true, excludedClosestSelector: '.VideoPlayerControls, .MediaViewerFooter', onCapture: (event) => { // Prevent safari back swipe on mobile if (event.type === 'touchstart' && 'pageX' in event && !(event.pageX > 10 && event.pageX < window.innerWidth - 10)) { event.preventDefault(); } lastGestureTime = Date.now(); if (arePropsShallowEqual(transformRef.current, INITIAL_TRANSFORM)) { if (!activeSlideRef.current) return; content = activeSlideRef.current.querySelector('img, video'); if (!content) return; // Store initial content rect, without transformations initialContentRect = content.getBoundingClientRect(); } }, onDrag: (event, captureEvent, { dragOffsetX, dragOffsetY, }) => { if (cancelAnimation) { cancelAnimation(); cancelAnimation = undefined; } panDelta.x = lastDragOffset.x - dragOffsetX; panDelta.y = lastDragOffset.y - dragOffsetY; lastDragOffset.x = dragOffsetX; lastDragOffset.y = dragOffsetY; const absOffsetX = Math.abs(dragOffsetX); const absOffsetY = Math.abs(dragOffsetY); const { scale, x, y } = transformRef.current; const h = 10; // If user is inactive but is still touching the screen // we reset last gesture time setLastGestureTime(); // If image is scaled we just need to pan it if (scale !== 1) { if ('touches' in event && event.touches.length === 1) { setTransform({ x: lastTransform.x + dragOffsetX, y: lastTransform.y + dragOffsetY, scale, }); } return; } // If user is swiping horizontally or horizontal shift is dominant // we change only horizontal position if (isSwipingRef.current || Math.abs(x) > h || (absOffsetX > h && absOffsetY < h)) { isSwipingRef.current = true; isActiveRef.current = false; setTransform({ x: dragOffsetX, y: 0, scale, }); return; } if (isSwipingRef.current) return; // If vertical shift is dominant we change only vertical position if (Math.abs(y) > h || (absOffsetY > h && absOffsetX < h)) { setTransform({ x: 0, y: dragOffsetY, scale, }); } }, onZoom: (e, { zoomFactor, initialCenterX, initialCenterY, dragOffsetX, dragOffsetY, currentCenterX, currentCenterY, }) => { // Calculate current scale based on zoom factor and limits, add max zoom margin for bounce back effect const scale = Math.min(MAX_ZOOM * 3, Math.max(lastTransform.scale * zoomFactor, MIN_ZOOM)); const scaleFactor = scale / lastTransform.scale; const offsetX = Math.abs(Math.min(lastTransform.x, 0)); const offsetY = Math.abs(Math.min(lastTransform.y, 0)); // Calculate new center relative to the shifted image const scaledCenterX = offsetX + initialCenterX; const scaledCenterY = offsetY + initialCenterY; // Save last zoom center for bounce back effect lastZoomCenter.x = currentCenterX; lastZoomCenter.y = currentCenterY; // Calculate how much we need to shift the image to keep the zoom center at the same position const scaleOffsetX = (scaledCenterX - scaleFactor * scaledCenterX); const scaleOffsetY = (scaledCenterY - scaleFactor * scaledCenterY); setTransform({ x: lastTransform.x + scaleOffsetX + dragOffsetX, y: lastTransform.y + scaleOffsetY + dragOffsetY, scale, }); }, onDoubleClick(e, { centerX, centerY, }) { // Calculate how much we need to shift the image to keep the zoom center at the same position const scaleOffsetX = (centerX - DOUBLE_TAP_ZOOM * centerX); const scaleOffsetY = (centerY - DOUBLE_TAP_ZOOM * centerY); const { scale, x, y } = transformRef.current; if (scale === 1) { if (x !== 0 || y !== 0) return undefined; lastTransform = { x: scaleOffsetX, y: scaleOffsetY, scale: DOUBLE_TAP_ZOOM, }; } else { lastTransform = { x: 0, y: 0, scale: 1 }; } return animateNumber({ from: [x, y, scale], to: [lastTransform.x, lastTransform.y, lastTransform.scale], duration: ANIMATION_DURATION, timing: timingFunctions.easeOutCubic, onUpdate: (value) => setTransform({ x: value[0], y: value[1], scale: value[2], }), }); }, onRelease: () => { const absX = Math.abs(transformRef.current.x); const absY = Math.abs(transformRef.current.y); const { scale, x, y } = transformRef.current; // If scale is less than 1 we need to bounce back if (scale < 1) { lastTransform = INITIAL_TRANSFORM; return animateNumber({ from: [x, y, scale], to: [0, 0, 1], duration: ANIMATION_DURATION, timing: timingFunctions.easeOutCubic, onUpdate: (value) => setTransform({ x: value[0], y: value[1], scale: value[2], }), }); } if (scale > 1) { if (!content || !initialContentRect) { lastTransform = { x, y, scale }; return undefined; } // Get current content boundaries const boundaries = content.getBoundingClientRect(); const s1 = Math.min(scale, MAX_ZOOM); const scaleFactor = s1 / scale; // Calculate new position based on the last zoom center to keep the zoom center // at the same position when bouncing back from max zoom let x1 = x * scaleFactor + (lastZoomCenter.x - scaleFactor * lastZoomCenter.x); let y1 = y * scaleFactor + (lastZoomCenter.y - scaleFactor * lastZoomCenter.y); // Arbitrary pan velocity coefficient const k = 0.15; // If scale didn't change, we need to add inertia to pan gesture if (lastTransform.scale === scale) { // Calculate user gesture velocity const Vx = Math.abs(lastDragOffset.x) / (Date.now() - lastGestureTime); const Vy = Math.abs(lastDragOffset.y) / (Date.now() - lastGestureTime); // Add extra distance based on gesture velocity and last pan delta x1 -= Math.abs(lastDragOffset.x) * Vx * k * panDelta.x; y1 -= Math.abs(lastDragOffset.y) * Vy * k * panDelta.y; } // If content is outside window we calculate offset boundaries // based on initial content rect and current scale if (boundaries.width > window.innerWidth) { const minOffsetX = -initialContentRect.left * s1; const maxOffsetX = window.innerWidth - initialContentRect.right * s1; x1 = Math.min(minOffsetX, Math.max(maxOffsetX, x1)); } else { // Else we center the content on the screen x1 = (window.innerWidth - window.innerWidth * s1) / 2; } if (boundaries.height > window.innerHeight) { const minOffsetY = -initialContentRect.top * s1; const maxOffsetY = window.innerHeight - initialContentRect.bottom * s1; y1 = Math.min(minOffsetY, Math.max(maxOffsetY, y1)); } else { y1 = (window.innerHeight - window.innerHeight * s1) / 2; } lastTransform = { x: x1, y: y1, scale: s1, }; cancelAnimation = animateNumber({ from: [x, y, scale], to: [x1, y1, s1], duration: ANIMATION_DURATION, timing: timingFunctions.easeOutCubic, onUpdate: (value) => setTransform({ x: value[0], y: value[1], scale: value[2], }), }); return undefined; } lastTransform = { x, y, scale }; if (absY >= SWIPE_Y_THRESHOLD) return onClose(); // Bounce back if vertical swipe is below threshold if (absY > 0) { return animateNumber({ from: y, to: 0, duration: ANIMATION_DURATION, timing: timingFunctions.easeOutCubic, onUpdate: (value) => setTransform({ x: 0, y: value, scale, }), }); } // Get horizontal swipe direction const direction = x < 0 ? 1 : -1; const mId = getMessageId(activeMessageId, x < 0 ? 1 : -1); // Get the direction of the last pan gesture. // Could be different from the total horizontal swipe direction // if user starts a swipe in one direction and then changes the direction // we need to cancel slide transition const dirX = panDelta.x < 0 ? -1 : 1; if (mId && absX >= SWIPE_X_THRESHOLD && direction === dirX) { const offset = (window.innerWidth + SLIDES_GAP) * direction; // If image is shifted by more than SWIPE_X_THRESHOLD, // We shift everything by one screen width and then set new active message id transformRef.current.x += offset; setActiveMessageId(mId); debounceSetMessage(() => selectMessage(mId)); } debounceSwipe(() => setIsSwiping(false)); debounceActive(() => setIsActive(true)); // Then we always return to the original position cancelAnimation = animateNumber({ from: transformRef.current.x, to: 0, duration: ANIMATION_DURATION, timing: timingFunctions.easeOutCubic, onUpdate: (value) => setTransform({ y: 0, x: value, scale: transformRef.current.scale, }), }); return undefined; }, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ isZoomed, onClose, setTransform, getMessageId, activeMessageId, setIsSwiping, setIsActive, ]); if (!activeMessageId) return undefined; const nextMessageId = getMessageId(activeMessageId, 1); const previousMessageId = getMessageId(activeMessageId, -1); const offsetX = transformRef.current.x; const offsetY = transformRef.current.y; const { scale } = transformRef.current; return (
{previousMessageId && scale === 1 && /* @ts-ignore */ (
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
)} {activeMessageId && (
)} {nextMessageId && scale === 1 && /* @ts-ignore */ (
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
)}
); }; export default memo(MediaViewerSlides); function getAnimationStyle(x = 0, y = 0, scale = 1) { return `transform: translate3d(${x.toFixed(3)}px, ${y.toFixed(3)}px, 0px) scale(${scale.toFixed(3)});`; }