From b8bf4cf8333fafd87874475f2cb9df121a5573ad Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 10 Dec 2021 18:32:35 +0100 Subject: [PATCH] Media Viewer: Brand new slide, zoom and pan on mobile (#1485) --- src/components/mediaViewer/MediaViewer.scss | 74 +-- src/components/mediaViewer/MediaViewer.tsx | 399 ++++++--------- .../mediaViewer/MediaViewerContent.scss | 42 ++ .../mediaViewer/MediaViewerContent.tsx | 330 +++++++++++++ .../mediaViewer/MediaViewerSlides.scss | 37 ++ .../mediaViewer/MediaViewerSlides.tsx | 461 ++++++++++++++++++ .../mediaViewer/SlideTransition.tsx | 20 + .../mediaViewer/helpers/ghostAnimation.ts | 2 +- src/components/ui/Transition.tsx | 5 +- src/util/animation.ts | 63 +++ src/util/captureEvents.ts | 70 ++- 11 files changed, 1197 insertions(+), 306 deletions(-) create mode 100644 src/components/mediaViewer/MediaViewerContent.scss create mode 100644 src/components/mediaViewer/MediaViewerContent.tsx create mode 100644 src/components/mediaViewer/MediaViewerSlides.scss create mode 100644 src/components/mediaViewer/MediaViewerSlides.tsx create mode 100644 src/components/mediaViewer/SlideTransition.tsx diff --git a/src/components/mediaViewer/MediaViewer.scss b/src/components/mediaViewer/MediaViewer.scss index e6bfc6f45..b2ad92004 100644 --- a/src/components/mediaViewer/MediaViewer.scss +++ b/src/components/mediaViewer/MediaViewer.scss @@ -7,16 +7,19 @@ background: rgba(0, 0, 0, .9); color: #fff; z-index: var(--z-media-viewer); - padding: 0.5rem 0; display: grid; grid-template-columns: auto; - grid-template-rows: 2.75rem 1fr; + grid-template-rows: auto 1fr; grid-column-gap: 0; grid-row-gap: 0; justify-items: stretch; align-items: center; + @media (max-width: 600px) { + background: rgba(0, 0, 0, 1); + } + // Potential perf improvement &:not(.shown) { display: block !important; @@ -50,7 +53,7 @@ z-index: 2; } - .media-viewer-content { + .MediaViewerSlide { position: fixed; top: 0; left: 0; @@ -64,7 +67,7 @@ .media-viewer-head { display: flex; grid-area: 1 / 1 / 2 / -2; - padding: 0 1.25rem; + padding: 0.5rem 1.25rem; position: relative; z-index: var(--z-media-viewer-head); min-width: 0; @@ -75,10 +78,10 @@ } @media (max-width: 600px) { - padding: 0 0.5rem; + padding: 0.5rem; @supports (padding: 0 env(safe-area-inset-left)) { - padding: 0 #{"max(0.5rem, env(safe-area-inset-left))"}; + padding: 0.5rem #{"max(0.5rem, env(safe-area-inset-left))"}; } .media-viewer-close { @@ -86,8 +89,8 @@ } } - @supports (padding: 0 env(safe-area-inset-left)) { - padding: 0 #{"max(1.25rem, env(safe-area-inset-left))"}; + @supports (padding: 0.5rem env(safe-area-inset-left)) { + padding: 0.5rem #{"max(1.25rem, env(safe-area-inset-left))"}; } } @@ -108,61 +111,6 @@ overflow: hidden; } - .media-viewer-content { - position: relative; - z-index: 1; - padding: 3.25rem 0; - height: 100%; - display: inline-flex; - justify-content: center; - align-items: center; - - &.has-footer { - padding: 7rem 0; - @media (min-width: 600px) { - min-width: 600px; - } - - @media (max-height: 640px) { - padding: 4rem 0; - } - - > img { - max-height: calc(100vh - 15rem); - @media (max-height: 640px) { - max-height: calc(100vh - 10rem); - } - } - } - - .thumbnail { - position: relative; - - img { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } - } - - > img { - max-width: 100vw; - max-height: calc(100vh - 8.25rem); - object-fit: contain; - transition: transform .2s; - } - - .spinner-wrapper { - max-width: 100vw; - margin: auto; - } - - .Spinner { - margin: auto; - } - } - .navigation { position: fixed; top: 4rem; diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index 1b3b78311..61504f8a7 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -1,22 +1,40 @@ +import { + ApiChat, ApiDimensions, ApiMediaFormat, ApiMessage, ApiUser, +} from '../../api/types'; + +import { ANIMATION_END_DELAY } from '../../config'; + +import { GlobalActions } from '../../global/types'; +import useBlurSync from '../../hooks/useBlurSync'; +import useForceUpdate from '../../hooks/useForceUpdate'; +import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'; +import useHistoryBack from '../../hooks/useHistoryBack'; +import useLang from '../../hooks/useLang'; +import useMedia from '../../hooks/useMedia'; +import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress'; + +import usePrevious from '../../hooks/usePrevious'; import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState, } from '../../lib/teact/teact'; import { withGlobal } from '../../lib/teact/teactn'; - -import { GlobalActions } from '../../global/types'; import { - ApiChat, ApiMediaFormat, ApiMessage, ApiUser, ApiDimensions, -} from '../../api/types'; -import { MediaViewerOrigin } from '../../types'; - -import { ANIMATION_END_DELAY } from '../../config'; -import { IS_IOS, IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../util/environment'; -import windowSize from '../../util/windowSize'; -import { - AVATAR_FULL_DIMENSIONS, - MEDIA_VIEWER_MEDIA_QUERY, - calculateMediaViewerDimensions, -} from '../common/helpers/mediaDimensions'; + getChatAvatarHash, + getChatMediaMessageIds, + getMessageDocument, + getMessageFileName, + getMessageMediaFormat, + getMessageMediaHash, + getMessageMediaThumbDataUri, + getMessagePhoto, + getMessageVideo, + getMessageWebPagePhoto, + getMessageWebPageVideo, + getPhotoFullDimensions, + getVideoDimensions, + isMessageDocumentPhoto, + isMessageDocumentVideo, +} from '../../modules/helpers'; import { selectChat, selectChatMessage, @@ -28,50 +46,27 @@ import { selectScheduledMessages, selectUser, } from '../../modules/selectors'; -import { - getChatAvatarHash, - getChatMediaMessageIds, - getMessageFileName, - getMessageMediaFormat, - getMessageMediaHash, - getMessageMediaThumbDataUri, - getMessagePhoto, - getMessageVideo, - getMessageDocument, - isMessageDocumentPhoto, - isMessageDocumentVideo, - getMessageWebPagePhoto, - getMessageWebPageVideo, - getPhotoFullDimensions, - getVideoDimensions, getMessageFileSize, -} from '../../modules/helpers'; -import { pick } from '../../util/iteratees'; -import { captureEvents, SwipeDirection } from '../../util/captureEvents'; -import captureEscKeyListener from '../../util/captureEscKeyListener'; +import { MediaViewerOrigin } from '../../types'; import { stopCurrentAudio } from '../../util/audioPlayer'; -import useForceUpdate from '../../hooks/useForceUpdate'; -import useMedia from '../../hooks/useMedia'; -import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress'; -import useBlurSync from '../../hooks/useBlurSync'; -import usePrevious from '../../hooks/usePrevious'; -import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'; +import captureEscKeyListener from '../../util/captureEscKeyListener'; +import { captureEvents } from '../../util/captureEvents'; +import { IS_IOS, IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../util/environment'; +import { pick } from '../../util/iteratees'; +import windowSize from '../../util/windowSize'; +import { AVATAR_FULL_DIMENSIONS, MEDIA_VIEWER_MEDIA_QUERY } from '../common/helpers/mediaDimensions'; import { renderMessageText } from '../common/helpers/renderMessageText'; -import { animateClosing, animateOpening } from './helpers/ghostAnimation'; -import useLang from '../../hooks/useLang'; -import useHistoryBack from '../../hooks/useHistoryBack'; - -import Spinner from '../ui/Spinner'; +import Button from '../ui/Button'; import ShowTransition from '../ui/ShowTransition'; import Transition from '../ui/Transition'; -import Button from '../ui/Button'; -import SenderInfo from './SenderInfo'; -import MediaViewerActions from './MediaViewerActions'; -import MediaViewerFooter from './MediaViewerFooter'; -import VideoPlayer from './VideoPlayer'; -import ZoomControls from './ZoomControls'; -import PanZoom from './PanZoom'; +import { animateClosing, animateOpening } from './helpers/ghostAnimation'; import './MediaViewer.scss'; +import MediaViewerActions from './MediaViewerActions'; +import MediaViewerSlides from './MediaViewerSlides'; +import PanZoom from './PanZoom'; +import SenderInfo from './SenderInfo'; +import SlideTransition from './SlideTransition'; +import ZoomControls from './ZoomControls'; type StateProps = { chatId?: string; @@ -121,8 +116,8 @@ const MediaViewer: FC = ({ const isDocumentPhoto = message ? isMessageDocumentPhoto(message) : false; const isDocumentVideo = message ? isMessageDocumentVideo(message) : false; const isVideo = Boolean(video || webPageVideo || isDocumentVideo); - const isPhoto = Boolean(!isVideo && (photo || webPagePhoto || isDocumentPhoto)); const { isGif } = video || webPageVideo || {}; + const isPhoto = Boolean(!isVideo && (photo || webPagePhoto || isDocumentPhoto)); const isAvatar = Boolean(avatarOwner); /* Navigation */ @@ -143,16 +138,18 @@ const MediaViewer: FC = ({ if (isOpen && (!prevSenderId || prevSenderId !== senderId || !animationKey.current)) { animationKey.current = selectedMediaMessageIndex; } - const slideAnimation = animationLevel >= 1 ? 'mv-slide' : 'none'; + const slideAnimation = animationLevel >= 1 && !IS_TOUCH_ENV ? 'mv-slide' : 'none'; const headerAnimation = animationLevel === 2 ? 'slide-fade' : 'none'; const isGhostAnimation = animationLevel === 2; /* Controls */ - const [isFooterHidden, setIsFooterHidden] = useState(false); const [canPanZoomWrap, setCanPanZoomWrap] = useState(false); const [isZoomed, setIsZoomed] = useState(false); const [zoomLevel, setZoomLevel] = useState(1); - const [panDelta, setPanDelta] = useState({ x: 0, y: 0 }); + const [panDelta, setPanDelta] = useState({ + x: 0, + y: 0, + }); /* Media data */ function getMediaHash(isFull?: boolean) { @@ -181,7 +178,7 @@ const MediaViewer: FC = ({ undefined, isGhostAnimation && ANIMATION_DURATION, ); - const { mediaData: fullMediaBlobUrl, loadProgress } = useMediaWithLoadProgress( + const { mediaData: fullMediaBlobUrl } = useMediaWithLoadProgress( getMediaHash(true), undefined, message && getMessageMediaFormat(message, 'viewerFull'), @@ -196,7 +193,6 @@ const MediaViewer: FC = ({ bestImageData = thumbDataUri; } - const videoSize = message ? getMessageFileSize(message) : undefined; const fileName = message ? getMessageFileName(message) : isAvatar @@ -246,11 +242,12 @@ const MediaViewer: FC = ({ const prevOrigin = usePrevious(origin); const prevAvatarOwner = usePrevious(avatarOwner); const prevBestImageData = usePrevious(bestImageData); + const textParts = message ? renderMessageText(message) : undefined; + const hasFooter = Boolean(textParts); + useEffect(() => { if (isGhostAnimation && isOpen && !prevMessage && !prevAvatarOwner) { dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY); - const textParts = message ? renderMessageText(message) : undefined; - const hasFooter = Boolean(textParts); animateOpening(hasFooter, origin!, bestImageData!, dimensions, isVideo, message); } @@ -260,7 +257,7 @@ const MediaViewer: FC = ({ } }, [ isGhostAnimation, isOpen, origin, prevOrigin, message, prevMessage, prevAvatarOwner, - bestImageData, prevBestImageData, dimensions, isVideo, + bestImageData, prevBestImageData, dimensions, isVideo, hasFooter, ]); useEffect(() => { @@ -284,14 +281,20 @@ const MediaViewer: FC = ({ const closeZoom = () => { setIsZoomed(false); setZoomLevel(1); - setPanDelta({ x: 0, y: 0 }); + setPanDelta({ + x: 0, + y: 0, + }); }; const handleZoomToggle = useCallback(() => { setIsZoomed(!isZoomed); setZoomLevel(!isZoomed ? 1.5 : 1); if (isZoomed) { - setPanDelta({ x: 0, y: 0 }); + setPanDelta({ + x: 0, + y: 0, + }); } }, [isZoomed]); @@ -309,14 +312,28 @@ const MediaViewer: FC = ({ const handleFooterClick = useCallback(() => { close(); - focusMessage({ chatId, threadId, messageId }); + focusMessage({ + chatId, + threadId, + messageId, + }); }, [close, chatId, threadId, focusMessage, messageId]); const handleForward = useCallback(() => { - openForwardMenu({ fromChatId: chatId, messageIds: [messageId] }); + openForwardMenu({ + fromChatId: chatId, + messageIds: [messageId], + }); closeZoom(); }, [openForwardMenu, chatId, messageId]); + const selectMessage = useCallback((id?: number) => openMediaViewer({ + chatId, + threadId, + messageId: id, + origin, + }), [chatId, openMediaViewer, origin, threadId]); + useEffect(() => (isOpen ? captureEscKeyListener(() => { if (isZoomed) { closeZoom(); @@ -344,106 +361,25 @@ const MediaViewer: FC = ({ }; }, [isOpen]); - const getMessageId = useCallback((fromId: number, direction: number): number => { - let index = messageIds.indexOf(fromId); + const getMessageId = useCallback((fromId?: number, direction?: number): number | undefined => { + if (!fromId) return undefined; + const index = messageIds.indexOf(fromId); if ((direction === -1 && index > 0) || (direction === 1 && index < messageIds.length - 1)) { - index += direction; + return messageIds[index + direction]; } - - return messageIds[index]; + return undefined; }, [messageIds]); - const selectPreviousMedia = useCallback(() => { - if (isFirst) { - return; - } - - openMediaViewer({ - chatId, - threadId, - messageId: messageId ? getMessageId(messageId, -1) : undefined, - origin, - }); - }, [chatId, threadId, getMessageId, isFirst, messageId, openMediaViewer, origin]); - - const selectNextMedia = useCallback(() => { - if (isLast) { - return; - } - - openMediaViewer({ - chatId, - threadId, - messageId: messageId ? getMessageId(messageId, 1) : undefined, - origin, - }); - }, [chatId, threadId, getMessageId, isLast, messageId, openMediaViewer, origin]); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - switch (e.key) { - case 'Left': // IE/Edge specific value - case 'ArrowLeft': - selectPreviousMedia(); - break; - - case 'Right': // IE/Edge specific value - case 'ArrowRight': - selectNextMedia(); - break; - } - }; - - document.addEventListener('keydown', handleKeyDown, false); - - return () => { - document.removeEventListener('keydown', handleKeyDown, false); - }; - }); - - // Support for swipe gestures and closing on click - useEffect(() => { - const element = document.querySelector( - '.slide-container > .Transition__slide--active, .slide-container > .to', - ); - if (!element) { - return undefined; - } - - const shouldCloseOnVideo = isGif && !IS_IOS; - - return captureEvents(element, { - // eslint-disable-next-line max-len - excludedClosestSelector: `.backdrop, .navigation, .media-viewer-head, .media-viewer-footer${!shouldCloseOnVideo ? ', .VideoPlayer' : ''}`, - onClick: () => { - if (!isZoomed && !IS_TOUCH_ENV) { - close(); - } - }, - onSwipe: IS_TOUCH_ENV ? (e, direction) => { - if (direction === SwipeDirection.Right) { - selectPreviousMedia(); - } else if (direction === SwipeDirection.Left) { - selectNextMedia(); - } else if (!(e.target && (e.target as HTMLElement).closest('.MediaViewerFooter'))) { - close(); - } - - return true; - } : undefined, - }); - }, [close, isFooterHidden, isGif, isPhoto, isZoomed, selectNextMedia, selectPreviousMedia]); + const nextMessageId = getMessageId(messageId, 1); + const previousMessageId = getMessageId(messageId, -1); const handlePan = useCallback((x: number, y: number) => { - setPanDelta({ x, y }); + setPanDelta({ + x, + y, + }); }, []); - const handleToggleFooterVisibility = useCallback(() => { - if (IS_TOUCH_ENV && (isPhoto || isGif)) { - setIsFooterHidden(!isFooterHidden); - } - }, [isFooterHidden, isGif, isPhoto]); - const lang = useLang(); useHistoryBack(isOpen, closeMediaViewer, openMediaViewer, { @@ -454,60 +390,43 @@ const MediaViewer: FC = ({ avatarOwnerId: avatarOwner && avatarOwner.id, }); - function renderSlide(isActive: boolean) { - if (isAvatar) { - return ( -
- {renderPhoto( - fullMediaBlobUrl || previewBlobUrl, - calculateMediaViewerDimensions(AVATAR_FULL_DIMENSIONS, false), - !IS_SINGLE_COLUMN_LAYOUT && !isZoomed, - )} -
- ); - } else if (message) { - const textParts = renderMessageText(message); - const hasFooter = Boolean(textParts); + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Left': // IE/Edge specific value + case 'ArrowLeft': + selectMessage(previousMessageId); + break; - return ( -
- {isPhoto && renderPhoto( - localBlobUrl || fullMediaBlobUrl || previewBlobUrl || pictogramBlobUrl, - message && calculateMediaViewerDimensions(dimensions!, hasFooter), - !IS_SINGLE_COLUMN_LAYOUT && !isZoomed, - )} - {isVideo && ( - - )} - {textParts && ( - - )} -
- ); + case 'Right': // IE/Edge specific value + case 'ArrowRight': + selectMessage(nextMessageId); + break; + } + }; + + document.addEventListener('keydown', handleKeyDown, false); + + return () => { + document.removeEventListener('keydown', handleKeyDown, false); + }; + }, [nextMessageId, previousMessageId, selectMessage]); + + useEffect(() => { + if (isZoomed || IS_TOUCH_ENV) return undefined; + const element = document.querySelector('.MediaViewerSlide.active'); + if (!element) { + return undefined; } - return undefined; - } + const shouldCloseOnVideo = isGif && !IS_IOS; + + return captureEvents(element, { + // eslint-disable-next-line max-len + excludedClosestSelector: `.backdrop, .navigation, .media-viewer-head, .media-viewer-footer${!shouldCloseOnVideo ? ', .VideoPlayer' : ''}`, + onClick: close, + }); + }, [close, isGif, isZoomed, messageId]); function renderSenderInfo() { return isAvatar ? ( @@ -569,30 +488,49 @@ const MediaViewer: FC = ({ zoomLevel={zoomLevel} onPan={handlePan} > - - {renderSlide} - + {(isActive) => ( + + )} + - {!isFirst && ( + {!isFirst && !IS_TOUCH_ENV && (