Media Viewer: Brand new slide, zoom and pan on mobile (#1485)
This commit is contained in:
parent
bf9f2daac0
commit
b8bf4cf833
@ -7,16 +7,19 @@
|
|||||||
background: rgba(0, 0, 0, .9);
|
background: rgba(0, 0, 0, .9);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
z-index: var(--z-media-viewer);
|
z-index: var(--z-media-viewer);
|
||||||
padding: 0.5rem 0;
|
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto;
|
grid-template-columns: auto;
|
||||||
grid-template-rows: 2.75rem 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
grid-column-gap: 0;
|
grid-column-gap: 0;
|
||||||
grid-row-gap: 0;
|
grid-row-gap: 0;
|
||||||
justify-items: stretch;
|
justify-items: stretch;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
background: rgba(0, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
// Potential perf improvement
|
// Potential perf improvement
|
||||||
&:not(.shown) {
|
&:not(.shown) {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
@ -50,7 +53,7 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-viewer-content {
|
.MediaViewerSlide {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@ -64,7 +67,7 @@
|
|||||||
.media-viewer-head {
|
.media-viewer-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
grid-area: 1 / 1 / 2 / -2;
|
grid-area: 1 / 1 / 2 / -2;
|
||||||
padding: 0 1.25rem;
|
padding: 0.5rem 1.25rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: var(--z-media-viewer-head);
|
z-index: var(--z-media-viewer-head);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -75,10 +78,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
padding: 0 0.5rem;
|
padding: 0.5rem;
|
||||||
|
|
||||||
@supports (padding: 0 env(safe-area-inset-left)) {
|
@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 {
|
.media-viewer-close {
|
||||||
@ -86,8 +89,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports (padding: 0 env(safe-area-inset-left)) {
|
@supports (padding: 0.5rem env(safe-area-inset-left)) {
|
||||||
padding: 0 #{"max(1.25rem, env(safe-area-inset-left))"};
|
padding: 0.5rem #{"max(1.25rem, env(safe-area-inset-left))"};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,61 +111,6 @@
|
|||||||
overflow: hidden;
|
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 {
|
.navigation {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 4rem;
|
top: 4rem;
|
||||||
|
|||||||
@ -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, {
|
import React, {
|
||||||
FC, memo, useCallback, useEffect, useMemo, useRef, useState,
|
FC, memo, useCallback, useEffect, useMemo, useRef, useState,
|
||||||
} from '../../lib/teact/teact';
|
} from '../../lib/teact/teact';
|
||||||
import { withGlobal } from '../../lib/teact/teactn';
|
import { withGlobal } from '../../lib/teact/teactn';
|
||||||
|
|
||||||
import { GlobalActions } from '../../global/types';
|
|
||||||
import {
|
import {
|
||||||
ApiChat, ApiMediaFormat, ApiMessage, ApiUser, ApiDimensions,
|
getChatAvatarHash,
|
||||||
} from '../../api/types';
|
getChatMediaMessageIds,
|
||||||
import { MediaViewerOrigin } from '../../types';
|
getMessageDocument,
|
||||||
|
getMessageFileName,
|
||||||
import { ANIMATION_END_DELAY } from '../../config';
|
getMessageMediaFormat,
|
||||||
import { IS_IOS, IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../util/environment';
|
getMessageMediaHash,
|
||||||
import windowSize from '../../util/windowSize';
|
getMessageMediaThumbDataUri,
|
||||||
import {
|
getMessagePhoto,
|
||||||
AVATAR_FULL_DIMENSIONS,
|
getMessageVideo,
|
||||||
MEDIA_VIEWER_MEDIA_QUERY,
|
getMessageWebPagePhoto,
|
||||||
calculateMediaViewerDimensions,
|
getMessageWebPageVideo,
|
||||||
} from '../common/helpers/mediaDimensions';
|
getPhotoFullDimensions,
|
||||||
|
getVideoDimensions,
|
||||||
|
isMessageDocumentPhoto,
|
||||||
|
isMessageDocumentVideo,
|
||||||
|
} from '../../modules/helpers';
|
||||||
import {
|
import {
|
||||||
selectChat,
|
selectChat,
|
||||||
selectChatMessage,
|
selectChatMessage,
|
||||||
@ -28,50 +46,27 @@ import {
|
|||||||
selectScheduledMessages,
|
selectScheduledMessages,
|
||||||
selectUser,
|
selectUser,
|
||||||
} from '../../modules/selectors';
|
} from '../../modules/selectors';
|
||||||
import {
|
import { MediaViewerOrigin } from '../../types';
|
||||||
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 { stopCurrentAudio } from '../../util/audioPlayer';
|
import { stopCurrentAudio } from '../../util/audioPlayer';
|
||||||
import useForceUpdate from '../../hooks/useForceUpdate';
|
import captureEscKeyListener from '../../util/captureEscKeyListener';
|
||||||
import useMedia from '../../hooks/useMedia';
|
import { captureEvents } from '../../util/captureEvents';
|
||||||
import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
|
import { IS_IOS, IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../util/environment';
|
||||||
import useBlurSync from '../../hooks/useBlurSync';
|
import { pick } from '../../util/iteratees';
|
||||||
import usePrevious from '../../hooks/usePrevious';
|
import windowSize from '../../util/windowSize';
|
||||||
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
|
import { AVATAR_FULL_DIMENSIONS, MEDIA_VIEWER_MEDIA_QUERY } from '../common/helpers/mediaDimensions';
|
||||||
import { renderMessageText } from '../common/helpers/renderMessageText';
|
import { renderMessageText } from '../common/helpers/renderMessageText';
|
||||||
import { animateClosing, animateOpening } from './helpers/ghostAnimation';
|
import Button from '../ui/Button';
|
||||||
import useLang from '../../hooks/useLang';
|
|
||||||
import useHistoryBack from '../../hooks/useHistoryBack';
|
|
||||||
|
|
||||||
import Spinner from '../ui/Spinner';
|
|
||||||
import ShowTransition from '../ui/ShowTransition';
|
import ShowTransition from '../ui/ShowTransition';
|
||||||
import Transition from '../ui/Transition';
|
import Transition from '../ui/Transition';
|
||||||
import Button from '../ui/Button';
|
import { animateClosing, animateOpening } from './helpers/ghostAnimation';
|
||||||
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 './MediaViewer.scss';
|
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 = {
|
type StateProps = {
|
||||||
chatId?: string;
|
chatId?: string;
|
||||||
@ -121,8 +116,8 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
|
|||||||
const isDocumentPhoto = message ? isMessageDocumentPhoto(message) : false;
|
const isDocumentPhoto = message ? isMessageDocumentPhoto(message) : false;
|
||||||
const isDocumentVideo = message ? isMessageDocumentVideo(message) : false;
|
const isDocumentVideo = message ? isMessageDocumentVideo(message) : false;
|
||||||
const isVideo = Boolean(video || webPageVideo || isDocumentVideo);
|
const isVideo = Boolean(video || webPageVideo || isDocumentVideo);
|
||||||
const isPhoto = Boolean(!isVideo && (photo || webPagePhoto || isDocumentPhoto));
|
|
||||||
const { isGif } = video || webPageVideo || {};
|
const { isGif } = video || webPageVideo || {};
|
||||||
|
const isPhoto = Boolean(!isVideo && (photo || webPagePhoto || isDocumentPhoto));
|
||||||
const isAvatar = Boolean(avatarOwner);
|
const isAvatar = Boolean(avatarOwner);
|
||||||
|
|
||||||
/* Navigation */
|
/* Navigation */
|
||||||
@ -143,16 +138,18 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
|
|||||||
if (isOpen && (!prevSenderId || prevSenderId !== senderId || !animationKey.current)) {
|
if (isOpen && (!prevSenderId || prevSenderId !== senderId || !animationKey.current)) {
|
||||||
animationKey.current = selectedMediaMessageIndex;
|
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 headerAnimation = animationLevel === 2 ? 'slide-fade' : 'none';
|
||||||
const isGhostAnimation = animationLevel === 2;
|
const isGhostAnimation = animationLevel === 2;
|
||||||
|
|
||||||
/* Controls */
|
/* Controls */
|
||||||
const [isFooterHidden, setIsFooterHidden] = useState<boolean>(false);
|
|
||||||
const [canPanZoomWrap, setCanPanZoomWrap] = useState(false);
|
const [canPanZoomWrap, setCanPanZoomWrap] = useState(false);
|
||||||
const [isZoomed, setIsZoomed] = useState<boolean>(false);
|
const [isZoomed, setIsZoomed] = useState<boolean>(false);
|
||||||
const [zoomLevel, setZoomLevel] = useState<number>(1);
|
const [zoomLevel, setZoomLevel] = useState<number>(1);
|
||||||
const [panDelta, setPanDelta] = useState({ x: 0, y: 0 });
|
const [panDelta, setPanDelta] = useState({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
/* Media data */
|
/* Media data */
|
||||||
function getMediaHash(isFull?: boolean) {
|
function getMediaHash(isFull?: boolean) {
|
||||||
@ -181,7 +178,7 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
|
|||||||
undefined,
|
undefined,
|
||||||
isGhostAnimation && ANIMATION_DURATION,
|
isGhostAnimation && ANIMATION_DURATION,
|
||||||
);
|
);
|
||||||
const { mediaData: fullMediaBlobUrl, loadProgress } = useMediaWithLoadProgress(
|
const { mediaData: fullMediaBlobUrl } = useMediaWithLoadProgress(
|
||||||
getMediaHash(true),
|
getMediaHash(true),
|
||||||
undefined,
|
undefined,
|
||||||
message && getMessageMediaFormat(message, 'viewerFull'),
|
message && getMessageMediaFormat(message, 'viewerFull'),
|
||||||
@ -196,7 +193,6 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
|
|||||||
bestImageData = thumbDataUri;
|
bestImageData = thumbDataUri;
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoSize = message ? getMessageFileSize(message) : undefined;
|
|
||||||
const fileName = message
|
const fileName = message
|
||||||
? getMessageFileName(message)
|
? getMessageFileName(message)
|
||||||
: isAvatar
|
: isAvatar
|
||||||
@ -246,11 +242,12 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
|
|||||||
const prevOrigin = usePrevious(origin);
|
const prevOrigin = usePrevious(origin);
|
||||||
const prevAvatarOwner = usePrevious<ApiChat | ApiUser | undefined>(avatarOwner);
|
const prevAvatarOwner = usePrevious<ApiChat | ApiUser | undefined>(avatarOwner);
|
||||||
const prevBestImageData = usePrevious(bestImageData);
|
const prevBestImageData = usePrevious(bestImageData);
|
||||||
|
const textParts = message ? renderMessageText(message) : undefined;
|
||||||
|
const hasFooter = Boolean(textParts);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isGhostAnimation && isOpen && !prevMessage && !prevAvatarOwner) {
|
if (isGhostAnimation && isOpen && !prevMessage && !prevAvatarOwner) {
|
||||||
dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY);
|
dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY);
|
||||||
const textParts = message ? renderMessageText(message) : undefined;
|
|
||||||
const hasFooter = Boolean(textParts);
|
|
||||||
animateOpening(hasFooter, origin!, bestImageData!, dimensions, isVideo, message);
|
animateOpening(hasFooter, origin!, bestImageData!, dimensions, isVideo, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -260,7 +257,7 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
isGhostAnimation, isOpen, origin, prevOrigin, message, prevMessage, prevAvatarOwner,
|
isGhostAnimation, isOpen, origin, prevOrigin, message, prevMessage, prevAvatarOwner,
|
||||||
bestImageData, prevBestImageData, dimensions, isVideo,
|
bestImageData, prevBestImageData, dimensions, isVideo, hasFooter,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -284,14 +281,20 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
|
|||||||
const closeZoom = () => {
|
const closeZoom = () => {
|
||||||
setIsZoomed(false);
|
setIsZoomed(false);
|
||||||
setZoomLevel(1);
|
setZoomLevel(1);
|
||||||
setPanDelta({ x: 0, y: 0 });
|
setPanDelta({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleZoomToggle = useCallback(() => {
|
const handleZoomToggle = useCallback(() => {
|
||||||
setIsZoomed(!isZoomed);
|
setIsZoomed(!isZoomed);
|
||||||
setZoomLevel(!isZoomed ? 1.5 : 1);
|
setZoomLevel(!isZoomed ? 1.5 : 1);
|
||||||
if (isZoomed) {
|
if (isZoomed) {
|
||||||
setPanDelta({ x: 0, y: 0 });
|
setPanDelta({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [isZoomed]);
|
}, [isZoomed]);
|
||||||
|
|
||||||
@ -309,14 +312,28 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
|
|||||||
|
|
||||||
const handleFooterClick = useCallback(() => {
|
const handleFooterClick = useCallback(() => {
|
||||||
close();
|
close();
|
||||||
focusMessage({ chatId, threadId, messageId });
|
focusMessage({
|
||||||
|
chatId,
|
||||||
|
threadId,
|
||||||
|
messageId,
|
||||||
|
});
|
||||||
}, [close, chatId, threadId, focusMessage, messageId]);
|
}, [close, chatId, threadId, focusMessage, messageId]);
|
||||||
|
|
||||||
const handleForward = useCallback(() => {
|
const handleForward = useCallback(() => {
|
||||||
openForwardMenu({ fromChatId: chatId, messageIds: [messageId] });
|
openForwardMenu({
|
||||||
|
fromChatId: chatId,
|
||||||
|
messageIds: [messageId],
|
||||||
|
});
|
||||||
closeZoom();
|
closeZoom();
|
||||||
}, [openForwardMenu, chatId, messageId]);
|
}, [openForwardMenu, chatId, messageId]);
|
||||||
|
|
||||||
|
const selectMessage = useCallback((id?: number) => openMediaViewer({
|
||||||
|
chatId,
|
||||||
|
threadId,
|
||||||
|
messageId: id,
|
||||||
|
origin,
|
||||||
|
}), [chatId, openMediaViewer, origin, threadId]);
|
||||||
|
|
||||||
useEffect(() => (isOpen ? captureEscKeyListener(() => {
|
useEffect(() => (isOpen ? captureEscKeyListener(() => {
|
||||||
if (isZoomed) {
|
if (isZoomed) {
|
||||||
closeZoom();
|
closeZoom();
|
||||||
@ -344,106 +361,25 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const getMessageId = useCallback((fromId: number, direction: number): number => {
|
const getMessageId = useCallback((fromId?: number, direction?: number): number | undefined => {
|
||||||
let index = messageIds.indexOf(fromId);
|
if (!fromId) return undefined;
|
||||||
|
const index = messageIds.indexOf(fromId);
|
||||||
if ((direction === -1 && index > 0) || (direction === 1 && index < messageIds.length - 1)) {
|
if ((direction === -1 && index > 0) || (direction === 1 && index < messageIds.length - 1)) {
|
||||||
index += direction;
|
return messageIds[index + direction];
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
return messageIds[index];
|
|
||||||
}, [messageIds]);
|
}, [messageIds]);
|
||||||
|
|
||||||
const selectPreviousMedia = useCallback(() => {
|
const nextMessageId = getMessageId(messageId, 1);
|
||||||
if (isFirst) {
|
const previousMessageId = getMessageId(messageId, -1);
|
||||||
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<HTMLDivElement>(
|
|
||||||
'.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 handlePan = useCallback((x: number, y: number) => {
|
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();
|
const lang = useLang();
|
||||||
|
|
||||||
useHistoryBack(isOpen, closeMediaViewer, openMediaViewer, {
|
useHistoryBack(isOpen, closeMediaViewer, openMediaViewer, {
|
||||||
@ -454,60 +390,43 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
|
|||||||
avatarOwnerId: avatarOwner && avatarOwner.id,
|
avatarOwnerId: avatarOwner && avatarOwner.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderSlide(isActive: boolean) {
|
useEffect(() => {
|
||||||
if (isAvatar) {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
return (
|
switch (e.key) {
|
||||||
<div key={chatId} className="media-viewer-content">
|
case 'Left': // IE/Edge specific value
|
||||||
{renderPhoto(
|
case 'ArrowLeft':
|
||||||
fullMediaBlobUrl || previewBlobUrl,
|
selectMessage(previousMessageId);
|
||||||
calculateMediaViewerDimensions(AVATAR_FULL_DIMENSIONS, false),
|
break;
|
||||||
!IS_SINGLE_COLUMN_LAYOUT && !isZoomed,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (message) {
|
|
||||||
const textParts = renderMessageText(message);
|
|
||||||
const hasFooter = Boolean(textParts);
|
|
||||||
|
|
||||||
return (
|
case 'Right': // IE/Edge specific value
|
||||||
<div
|
case 'ArrowRight':
|
||||||
key={messageId}
|
selectMessage(nextMessageId);
|
||||||
className={`media-viewer-content ${hasFooter ? 'has-footer' : ''}`}
|
break;
|
||||||
onClick={handleToggleFooterVisibility}
|
}
|
||||||
>
|
};
|
||||||
{isPhoto && renderPhoto(
|
|
||||||
localBlobUrl || fullMediaBlobUrl || previewBlobUrl || pictogramBlobUrl,
|
document.addEventListener('keydown', handleKeyDown, false);
|
||||||
message && calculateMediaViewerDimensions(dimensions!, hasFooter),
|
|
||||||
!IS_SINGLE_COLUMN_LAYOUT && !isZoomed,
|
return () => {
|
||||||
)}
|
document.removeEventListener('keydown', handleKeyDown, false);
|
||||||
{isVideo && (
|
};
|
||||||
<VideoPlayer
|
}, [nextMessageId, previousMessageId, selectMessage]);
|
||||||
key={messageId}
|
|
||||||
url={localBlobUrl || fullMediaBlobUrl}
|
useEffect(() => {
|
||||||
isGif={isGif}
|
if (isZoomed || IS_TOUCH_ENV) return undefined;
|
||||||
posterData={bestImageData}
|
const element = document.querySelector<HTMLDivElement>('.MediaViewerSlide.active');
|
||||||
posterSize={message && calculateMediaViewerDimensions(dimensions!, hasFooter, true)}
|
if (!element) {
|
||||||
loadProgress={loadProgress}
|
return undefined;
|
||||||
fileSize={videoSize!}
|
|
||||||
isMediaViewerOpen={isOpen}
|
|
||||||
noPlay={!isActive}
|
|
||||||
onClose={close}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{textParts && (
|
|
||||||
<MediaViewerFooter
|
|
||||||
text={textParts}
|
|
||||||
onClick={handleFooterClick}
|
|
||||||
isHidden={isFooterHidden && (!isVideo || isGif)}
|
|
||||||
isForVideo={isVideo && !isGif}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
function renderSenderInfo() {
|
||||||
return isAvatar ? (
|
return isAvatar ? (
|
||||||
@ -569,30 +488,49 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
|
|||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
onPan={handlePan}
|
onPan={handlePan}
|
||||||
>
|
>
|
||||||
<Transition
|
<SlideTransition
|
||||||
className="slide-container"
|
|
||||||
activeKey={selectedMediaMessageIndex}
|
activeKey={selectedMediaMessageIndex}
|
||||||
name={slideAnimation}
|
name={slideAnimation}
|
||||||
>
|
>
|
||||||
{renderSlide}
|
{(isActive) => (
|
||||||
</Transition>
|
<MediaViewerSlides
|
||||||
|
messageId={messageId}
|
||||||
|
getMessageId={getMessageId}
|
||||||
|
chatId={chatId}
|
||||||
|
isPhoto={isPhoto}
|
||||||
|
isGif={isGif}
|
||||||
|
threadId={threadId}
|
||||||
|
avatarOwnerId={avatarOwner && avatarOwner.id}
|
||||||
|
profilePhotoIndex={profilePhotoIndex}
|
||||||
|
origin={origin}
|
||||||
|
isOpen={isOpen}
|
||||||
|
hasFooter={hasFooter}
|
||||||
|
isZoomed={isZoomed}
|
||||||
|
isActive={isActive}
|
||||||
|
animationLevel={animationLevel}
|
||||||
|
onClose={close}
|
||||||
|
selectMessage={selectMessage}
|
||||||
|
onFooterClick={handleFooterClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SlideTransition>
|
||||||
</PanZoom>
|
</PanZoom>
|
||||||
{!isFirst && (
|
{!isFirst && !IS_TOUCH_ENV && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`navigation prev ${isVideo && !isGif && 'inline'}`}
|
className={`navigation prev ${isVideo && !isGif && 'inline'}`}
|
||||||
aria-label={lang('AccDescrPrevious')}
|
aria-label={lang('AccDescrPrevious')}
|
||||||
dir={lang.isRtl ? 'rtl' : undefined}
|
dir={lang.isRtl ? 'rtl' : undefined}
|
||||||
onClick={selectPreviousMedia}
|
onClick={() => selectMessage(previousMessageId)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isLast && (
|
{!isLast && !IS_TOUCH_ENV && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`navigation next ${isVideo && !isGif && 'inline'}`}
|
className={`navigation next ${isVideo && !isGif && 'inline'}`}
|
||||||
aria-label={lang('Next')}
|
aria-label={lang('Next')}
|
||||||
dir={lang.isRtl ? 'rtl' : undefined}
|
dir={lang.isRtl ? 'rtl' : undefined}
|
||||||
onClick={selectNextMedia}
|
onClick={() => selectMessage(nextMessageId)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ZoomControls
|
<ZoomControls
|
||||||
@ -605,32 +543,15 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderPhoto(blobUrl?: string, imageSize?: ApiDimensions, canDrag?: boolean) {
|
|
||||||
return blobUrl
|
|
||||||
? (
|
|
||||||
<img
|
|
||||||
src={blobUrl}
|
|
||||||
alt=""
|
|
||||||
// @ts-ignore teact feature
|
|
||||||
style={imageSize ? `width: ${imageSize.width}px` : ''}
|
|
||||||
draggable={Boolean(canDrag)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div
|
|
||||||
className="spinner-wrapper"
|
|
||||||
// @ts-ignore teact feature
|
|
||||||
style={imageSize ? `width: ${imageSize.width}px` : ''}
|
|
||||||
>
|
|
||||||
<Spinner color="white" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(withGlobal(
|
export default memo(withGlobal(
|
||||||
(global): StateProps => {
|
(global): StateProps => {
|
||||||
const {
|
const {
|
||||||
chatId, threadId, messageId, avatarOwnerId, profilePhotoIndex, origin,
|
chatId,
|
||||||
|
threadId,
|
||||||
|
messageId,
|
||||||
|
avatarOwnerId,
|
||||||
|
profilePhotoIndex,
|
||||||
|
origin,
|
||||||
} = global.mediaViewer;
|
} = global.mediaViewer;
|
||||||
const {
|
const {
|
||||||
animationLevel,
|
animationLevel,
|
||||||
|
|||||||
42
src/components/mediaViewer/MediaViewerContent.scss
Normal file
42
src/components/mediaViewer/MediaViewerContent.scss
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
.MediaViewerContent {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
transform: none;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 3.25rem 0;
|
||||||
|
height: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
330
src/components/mediaViewer/MediaViewerContent.tsx
Normal file
330
src/components/mediaViewer/MediaViewerContent.tsx
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
import {
|
||||||
|
ApiChat, ApiDimensions, ApiMediaFormat, ApiMessage, ApiUser,
|
||||||
|
} from '../../api/types';
|
||||||
|
import useBlurSync from '../../hooks/useBlurSync';
|
||||||
|
import useMedia from '../../hooks/useMedia';
|
||||||
|
import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
|
||||||
|
import React, { FC, memo } from '../../lib/teact/teact';
|
||||||
|
import { withGlobal } from '../../lib/teact/teactn';
|
||||||
|
import {
|
||||||
|
getChatAvatarHash,
|
||||||
|
getMessageDocument,
|
||||||
|
getMessageFileSize,
|
||||||
|
getMessageMediaFormat,
|
||||||
|
getMessageMediaHash,
|
||||||
|
getMessageMediaThumbDataUri,
|
||||||
|
getMessagePhoto,
|
||||||
|
getMessageVideo,
|
||||||
|
getMessageWebPagePhoto,
|
||||||
|
getMessageWebPageVideo,
|
||||||
|
getPhotoFullDimensions,
|
||||||
|
getVideoDimensions,
|
||||||
|
isMessageDocumentPhoto,
|
||||||
|
isMessageDocumentVideo,
|
||||||
|
} from '../../modules/helpers';
|
||||||
|
import {
|
||||||
|
selectChat, selectChatMessage, selectScheduledMessage, selectUser,
|
||||||
|
} from '../../modules/selectors';
|
||||||
|
import { MediaViewerOrigin } from '../../types';
|
||||||
|
import { AVATAR_FULL_DIMENSIONS, calculateMediaViewerDimensions } from '../common/helpers/mediaDimensions';
|
||||||
|
import { renderMessageText } from '../common/helpers/renderMessageText';
|
||||||
|
import Spinner from '../ui/Spinner';
|
||||||
|
import './MediaViewerContent.scss';
|
||||||
|
import MediaViewerFooter from './MediaViewerFooter';
|
||||||
|
import VideoPlayer from './VideoPlayer';
|
||||||
|
|
||||||
|
type OwnProps = {
|
||||||
|
messageId?: number;
|
||||||
|
chatId?: string;
|
||||||
|
threadId?: number;
|
||||||
|
avatarOwnerId?: string;
|
||||||
|
profilePhotoIndex?: number;
|
||||||
|
origin?: MediaViewerOrigin;
|
||||||
|
isActive?: boolean;
|
||||||
|
animationLevel: 0 | 1 | 2;
|
||||||
|
onClose: () => void;
|
||||||
|
onFooterClick: () => void;
|
||||||
|
isFooterHidden?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StateProps = {
|
||||||
|
chatId?: string;
|
||||||
|
messageId?: number;
|
||||||
|
senderId?: string;
|
||||||
|
threadId?: number;
|
||||||
|
avatarOwner?: ApiChat | ApiUser;
|
||||||
|
profilePhotoIndex?: number;
|
||||||
|
message?: ApiMessage;
|
||||||
|
origin?: MediaViewerOrigin;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANIMATION_DURATION = 350;
|
||||||
|
|
||||||
|
const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
|
||||||
|
const {
|
||||||
|
messageId,
|
||||||
|
isActive,
|
||||||
|
avatarOwner,
|
||||||
|
chatId,
|
||||||
|
message,
|
||||||
|
profilePhotoIndex,
|
||||||
|
origin,
|
||||||
|
animationLevel,
|
||||||
|
onClose,
|
||||||
|
onFooterClick,
|
||||||
|
isFooterHidden,
|
||||||
|
} = props;
|
||||||
|
/* Content */
|
||||||
|
const photo = message ? getMessagePhoto(message) : undefined;
|
||||||
|
const video = message ? getMessageVideo(message) : undefined;
|
||||||
|
const webPagePhoto = message ? getMessageWebPagePhoto(message) : undefined;
|
||||||
|
const webPageVideo = message ? getMessageWebPageVideo(message) : undefined;
|
||||||
|
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 isOpen = Boolean(avatarOwner || messageId);
|
||||||
|
const isAvatar = Boolean(avatarOwner);
|
||||||
|
|
||||||
|
const isFromSharedMedia = origin === MediaViewerOrigin.SharedMedia;
|
||||||
|
const isFromSearch = origin === MediaViewerOrigin.SearchResult;
|
||||||
|
|
||||||
|
const isGhostAnimation = animationLevel === 2;
|
||||||
|
|
||||||
|
/* Media data */
|
||||||
|
function getMediaHash(isFull?: boolean) {
|
||||||
|
if (isAvatar && profilePhotoIndex !== undefined) {
|
||||||
|
const { photos } = avatarOwner!;
|
||||||
|
return photos && photos[profilePhotoIndex]
|
||||||
|
? `photo${photos[profilePhotoIndex].id}?size=c`
|
||||||
|
: getChatAvatarHash(avatarOwner!, isFull ? 'big' : 'normal');
|
||||||
|
}
|
||||||
|
|
||||||
|
return message && getMessageMediaHash(message, isFull ? 'viewerFull' : 'viewerPreview');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pictogramBlobUrl = useMedia(
|
||||||
|
message && (isFromSharedMedia || isFromSearch) && getMessageMediaHash(message, 'pictogram'),
|
||||||
|
undefined,
|
||||||
|
ApiMediaFormat.BlobUrl,
|
||||||
|
undefined,
|
||||||
|
isGhostAnimation && ANIMATION_DURATION,
|
||||||
|
);
|
||||||
|
const previewMediaHash = getMediaHash();
|
||||||
|
const previewBlobUrl = useMedia(
|
||||||
|
previewMediaHash,
|
||||||
|
undefined,
|
||||||
|
ApiMediaFormat.BlobUrl,
|
||||||
|
undefined,
|
||||||
|
isGhostAnimation && ANIMATION_DURATION,
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
mediaData: fullMediaBlobUrl,
|
||||||
|
loadProgress,
|
||||||
|
} = useMediaWithLoadProgress(
|
||||||
|
getMediaHash(true),
|
||||||
|
undefined,
|
||||||
|
message && getMessageMediaFormat(message, 'viewerFull'),
|
||||||
|
undefined,
|
||||||
|
isGhostAnimation && ANIMATION_DURATION,
|
||||||
|
);
|
||||||
|
|
||||||
|
const localBlobUrl = (photo || video) ? (photo || video)!.blobUrl : undefined;
|
||||||
|
let bestImageData = (!isVideo && (localBlobUrl || fullMediaBlobUrl)) || previewBlobUrl || pictogramBlobUrl;
|
||||||
|
const thumbDataUri = useBlurSync(!bestImageData && message && getMessageMediaThumbDataUri(message));
|
||||||
|
if (!bestImageData && origin !== MediaViewerOrigin.SearchResult) {
|
||||||
|
bestImageData = thumbDataUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoSize = message ? getMessageFileSize(message) : undefined;
|
||||||
|
|
||||||
|
let dimensions!: ApiDimensions;
|
||||||
|
if (message) {
|
||||||
|
if (isDocumentPhoto || isDocumentVideo) {
|
||||||
|
dimensions = getMessageDocument(message)!.mediaSize!;
|
||||||
|
} else if (photo || webPagePhoto) {
|
||||||
|
dimensions = getPhotoFullDimensions((photo || webPagePhoto)!)!;
|
||||||
|
} else if (video || webPageVideo) {
|
||||||
|
dimensions = getVideoDimensions((video || webPageVideo)!)!;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dimensions = AVATAR_FULL_DIMENSIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAvatar) {
|
||||||
|
return (
|
||||||
|
<div key={chatId} className="MediaViewerContent">
|
||||||
|
{renderPhoto(
|
||||||
|
fullMediaBlobUrl || previewBlobUrl,
|
||||||
|
calculateMediaViewerDimensions(AVATAR_FULL_DIMENSIONS, false),
|
||||||
|
false,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message) return undefined;
|
||||||
|
const textParts = renderMessageText(message);
|
||||||
|
const hasFooter = Boolean(textParts);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`MediaViewerContent ${hasFooter ? 'has-footer' : ''}`}
|
||||||
|
>
|
||||||
|
{isPhoto && renderPhoto(
|
||||||
|
localBlobUrl || fullMediaBlobUrl || previewBlobUrl || pictogramBlobUrl,
|
||||||
|
message && calculateMediaViewerDimensions(dimensions!, hasFooter),
|
||||||
|
false,
|
||||||
|
)}
|
||||||
|
{isVideo && (isActive ? (
|
||||||
|
<VideoPlayer
|
||||||
|
key={messageId}
|
||||||
|
url={localBlobUrl || fullMediaBlobUrl}
|
||||||
|
isGif={isGif}
|
||||||
|
posterData={bestImageData}
|
||||||
|
posterSize={message && calculateMediaViewerDimensions(dimensions!, hasFooter, true)}
|
||||||
|
loadProgress={loadProgress}
|
||||||
|
fileSize={videoSize!}
|
||||||
|
isMediaViewerOpen={isOpen}
|
||||||
|
noPlay={!isActive}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
) : renderVideoPreview(
|
||||||
|
bestImageData,
|
||||||
|
message && calculateMediaViewerDimensions(dimensions!, hasFooter, true),
|
||||||
|
false,
|
||||||
|
))}
|
||||||
|
{textParts && (
|
||||||
|
<MediaViewerFooter
|
||||||
|
text={textParts}
|
||||||
|
onClick={onFooterClick}
|
||||||
|
isHidden={isFooterHidden && (!isVideo || isGif)}
|
||||||
|
isForVideo={isVideo && !isGif}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(withGlobal<OwnProps>(
|
||||||
|
(global, ownProps): StateProps => {
|
||||||
|
const {
|
||||||
|
chatId,
|
||||||
|
threadId,
|
||||||
|
messageId,
|
||||||
|
avatarOwnerId,
|
||||||
|
profilePhotoIndex,
|
||||||
|
origin,
|
||||||
|
} = ownProps;
|
||||||
|
|
||||||
|
if (origin === MediaViewerOrigin.SearchResult) {
|
||||||
|
if (!(chatId && messageId)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = selectChatMessage(global, chatId, messageId);
|
||||||
|
if (!message) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
senderId: message.senderId,
|
||||||
|
origin,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avatarOwnerId) {
|
||||||
|
const sender = selectUser(global, avatarOwnerId) || selectChat(global, avatarOwnerId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId: -1,
|
||||||
|
senderId: avatarOwnerId,
|
||||||
|
avatarOwner: sender,
|
||||||
|
profilePhotoIndex: profilePhotoIndex || 0,
|
||||||
|
origin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(chatId && threadId && messageId)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let message: ApiMessage | undefined;
|
||||||
|
if (origin && [MediaViewerOrigin.ScheduledAlbum, MediaViewerOrigin.ScheduledInline].includes(origin)) {
|
||||||
|
message = selectScheduledMessage(global, chatId, messageId);
|
||||||
|
} else {
|
||||||
|
message = selectChatMessage(global, chatId, messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chatId,
|
||||||
|
threadId,
|
||||||
|
messageId,
|
||||||
|
senderId: message.senderId,
|
||||||
|
origin,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
)(MediaViewerContent));
|
||||||
|
|
||||||
|
function renderPhoto(blobUrl?: string, imageSize?: ApiDimensions, canDrag?: boolean) {
|
||||||
|
return blobUrl
|
||||||
|
? (
|
||||||
|
<img
|
||||||
|
src={blobUrl}
|
||||||
|
alt=""
|
||||||
|
// @ts-ignore teact feature
|
||||||
|
style={imageSize ? `width: ${imageSize.width}px` : ''}
|
||||||
|
draggable={Boolean(canDrag)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div
|
||||||
|
className="spinner-wrapper"
|
||||||
|
// @ts-ignore teact feature
|
||||||
|
style={imageSize ? `width: ${imageSize.width}px` : ''}
|
||||||
|
>
|
||||||
|
<Spinner color="white" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVideoPreview(blobUrl?: string, imageSize?: ApiDimensions, canDrag?: boolean) {
|
||||||
|
const wrapperStyle = imageSize && `width: ${imageSize.width}px; height: ${imageSize.height}px`;
|
||||||
|
const videoStyle = `background-image: url(${blobUrl})`;
|
||||||
|
return blobUrl
|
||||||
|
? (
|
||||||
|
<div
|
||||||
|
className="VideoPlayer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
// @ts-ignore
|
||||||
|
style={wrapperStyle}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||||
|
<video
|
||||||
|
// @ts-ignore
|
||||||
|
style={videoStyle}
|
||||||
|
draggable={Boolean(canDrag)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div
|
||||||
|
className="spinner-wrapper"
|
||||||
|
// @ts-ignore teact feature
|
||||||
|
style={imageSize ? `width: ${imageSize.width}px` : ''}
|
||||||
|
>
|
||||||
|
<Spinner color="white" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/mediaViewer/MediaViewerSlides.scss
Normal file
37
src/components/mediaViewer/MediaViewerSlides.scss
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
.MediaViewerSlides {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100vh;
|
||||||
|
min-height: -moz-available;
|
||||||
|
max-height: -webkit-fill-available;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
-ms-touch-action: none;
|
||||||
|
touch-action: none;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
& * {
|
||||||
|
-ms-scroll-chaining: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.MediaViewerSlide {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 100%;
|
||||||
|
flex: 100% 0 0;
|
||||||
|
z-index: 0;
|
||||||
|
touch-action: none;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
461
src/components/mediaViewer/MediaViewerSlides.tsx
Normal file
461
src/components/mediaViewer/MediaViewerSlides.tsx
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
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<OwnProps> = ({
|
||||||
|
messageId,
|
||||||
|
getMessageId,
|
||||||
|
selectMessage,
|
||||||
|
isVideo,
|
||||||
|
isGif,
|
||||||
|
isPhoto,
|
||||||
|
isOpen,
|
||||||
|
isActive,
|
||||||
|
hasFooter,
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
|
// eslint-disable-next-line no-null/no-null
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
// eslint-disable-next-line no-null/no-null
|
||||||
|
const activeSlideRef = useRef<HTMLDivElement>(null);
|
||||||
|
const transformRef = useRef<Transform>(INITIAL_TRANSFORM);
|
||||||
|
const isSwipingRef = useRef(false);
|
||||||
|
const isActiveRef = useRef(true);
|
||||||
|
const [activeMessageId, setActiveMessageId] = useState<number | undefined>(messageId);
|
||||||
|
const forceUpdate = useForceUpdate();
|
||||||
|
const [isFooterHidden, setIsFooterHidden] = useState<boolean>(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 (
|
||||||
|
<div className="MediaViewerSlides" ref={containerRef}>
|
||||||
|
{previousMessageId && scale === 1 && /* @ts-ignore */ (
|
||||||
|
<div className="MediaViewerSlide" style={getAnimationStyle(-window.innerWidth + offsetX - SLIDES_GAP)}>
|
||||||
|
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||||
|
<MediaViewerContent {...rest} messageId={previousMessageId} isFooterHidden={isFooterHidden} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeMessageId && (
|
||||||
|
<div
|
||||||
|
className={`MediaViewerSlide ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={handleToggleFooterVisibility}
|
||||||
|
ref={activeSlideRef}
|
||||||
|
/* @ts-ignore */
|
||||||
|
style={getAnimationStyle(offsetX, offsetY, scale)}
|
||||||
|
>
|
||||||
|
<MediaViewerContent
|
||||||
|
/* eslint-disable-next-line react/jsx-props-no-spreading */
|
||||||
|
{...rest}
|
||||||
|
messageId={activeMessageId}
|
||||||
|
isActive={isActiveRef.current}
|
||||||
|
isFooterHidden={isFooterHidden || isZoomed || scale !== 1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{nextMessageId && scale === 1 && /* @ts-ignore */ (
|
||||||
|
<div className="MediaViewerSlide" style={getAnimationStyle(window.innerWidth + offsetX + SLIDES_GAP)}>
|
||||||
|
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||||
|
<MediaViewerContent {...rest} messageId={nextMessageId} isFooterHidden={isFooterHidden} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
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)});`;
|
||||||
|
}
|
||||||
20
src/components/mediaViewer/SlideTransition.tsx
Normal file
20
src/components/mediaViewer/SlideTransition.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import React, { FC } from '../../lib/teact/teact';
|
||||||
|
import { IS_TOUCH_ENV } from '../../util/environment';
|
||||||
|
import Transition, { TransitionProps } from '../ui/Transition';
|
||||||
|
|
||||||
|
const SlideTransition: FC<TransitionProps> = ({ children, ...props }) => {
|
||||||
|
if (IS_TOUCH_ENV) {
|
||||||
|
// Return dummy container to keep existing DOM structure, needed to preserve ghost animation
|
||||||
|
return (
|
||||||
|
<div className="Transition">
|
||||||
|
<div className="Transition__slide--active">
|
||||||
|
{children(true, true, 1)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
return <Transition {...props}>{children}</Transition>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SlideTransition;
|
||||||
@ -96,7 +96,7 @@ export function animateClosing(origin: MediaViewerOrigin, bestImageData: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fromImage = document.getElementById('MediaViewer')!.querySelector<HTMLImageElement>(
|
const fromImage = document.getElementById('MediaViewer')!.querySelector<HTMLImageElement>(
|
||||||
'.Transition__slide--active .media-viewer-content img, .Transition__slide--active .media-viewer-content video',
|
'.MediaViewerSlide.active img, .MediaViewerSlide.active video',
|
||||||
);
|
);
|
||||||
if (!fromImage || !toImage) {
|
if (!fromImage || !toImage) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'
|
|||||||
import './Transition.scss';
|
import './Transition.scss';
|
||||||
|
|
||||||
type ChildrenFn = (isActive: boolean, isFrom: boolean, currentKey: number) => any;
|
type ChildrenFn = (isActive: boolean, isFrom: boolean, currentKey: number) => any;
|
||||||
type OwnProps = {
|
export type TransitionProps = {
|
||||||
ref?: RefObject<HTMLDivElement>;
|
ref?: RefObject<HTMLDivElement>;
|
||||||
activeKey: number;
|
activeKey: number;
|
||||||
name: (
|
name: (
|
||||||
@ -26,6 +26,7 @@ type OwnProps = {
|
|||||||
shouldRestoreHeight?: boolean;
|
shouldRestoreHeight?: boolean;
|
||||||
shouldCleanup?: boolean;
|
shouldCleanup?: boolean;
|
||||||
cleanupExceptionKey?: number;
|
cleanupExceptionKey?: number;
|
||||||
|
isDisabled?: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
onStart?: NoneToVoidFunction;
|
onStart?: NoneToVoidFunction;
|
||||||
@ -39,7 +40,7 @@ const classNames = {
|
|||||||
active: 'Transition__slide--active',
|
active: 'Transition__slide--active',
|
||||||
};
|
};
|
||||||
|
|
||||||
const Transition: FC<OwnProps> = ({
|
const Transition: FC<TransitionProps> = ({
|
||||||
ref,
|
ref,
|
||||||
activeKey,
|
activeKey,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@ -30,3 +30,66 @@ export function animate(tick: Function) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TimingFn = (t: number) => number;
|
||||||
|
|
||||||
|
export type AnimateNumberProps = {
|
||||||
|
to: number | number[];
|
||||||
|
from: number | number[];
|
||||||
|
duration: number;
|
||||||
|
onUpdate: (value: any) => void;
|
||||||
|
timing?: TimingFn;
|
||||||
|
onEnd?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const timingFunctions = {
|
||||||
|
linear: (t: number) => t,
|
||||||
|
easeIn: (t: number) => t ** 1.675,
|
||||||
|
easeOut: (t: number) => 1 - (1 - t ** 1.675),
|
||||||
|
easeInOut: (t: number) => 0.5 * (Math.sin((t - 0.5) * Math.PI) + 1),
|
||||||
|
easeInQuad: (t: number) => t * t,
|
||||||
|
easeOutQuad: (t: number) => t * (2 - t),
|
||||||
|
easeInOutQuad: (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
||||||
|
easeInCubic: (t: number) => t * t * t,
|
||||||
|
easeOutCubic: (t: number) => (--t) * t * t + 1,
|
||||||
|
easeInOutCubic: (t: number) => (t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1),
|
||||||
|
easeInQuart: (t: number) => t * t * t * t,
|
||||||
|
easeOutQuart: (t: number) => 1 - (--t) * t * t * t,
|
||||||
|
easeInOutQuart: (t: number) => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t),
|
||||||
|
easeInQuint: (t: number) => t * t * t * t * t,
|
||||||
|
easeOutQuint: (t: number) => 1 + (--t) * t * t * t * t,
|
||||||
|
easeInOutQuint: (t: number) => (t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * (--t) * t * t * t * t),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function animateNumber({
|
||||||
|
timing = timingFunctions.linear,
|
||||||
|
onUpdate,
|
||||||
|
duration,
|
||||||
|
onEnd,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
}: AnimateNumberProps) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
let canceled = false;
|
||||||
|
|
||||||
|
animate(() => {
|
||||||
|
if (canceled) return false;
|
||||||
|
const t1 = Date.now();
|
||||||
|
let t = (t1 - t0) / duration;
|
||||||
|
if (t > 1) t = 1;
|
||||||
|
const progress = timing(t);
|
||||||
|
if (typeof from === 'number' && typeof to === 'number') {
|
||||||
|
onUpdate(from + ((to - from) * progress));
|
||||||
|
} else if (Array.isArray(from) && Array.isArray(to)) {
|
||||||
|
const result = from.map((f, i) => f + ((to[i] - f) * progress));
|
||||||
|
onUpdate(result);
|
||||||
|
}
|
||||||
|
if (t === 1 && onEnd) onEnd();
|
||||||
|
return t < 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
if (onEnd) onEnd();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -19,9 +19,29 @@ interface CaptureOptions {
|
|||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
onSwipe?: (e: Event, direction: SwipeDirection) => boolean;
|
onSwipe?: (e: Event, direction: SwipeDirection) => boolean;
|
||||||
|
onZoom?: (e: TouchEvent, params: {
|
||||||
|
// Relative zoom factor
|
||||||
|
zoomFactor: number;
|
||||||
|
|
||||||
|
// center coordinate of the initial pinch
|
||||||
|
initialCenterX: number;
|
||||||
|
initialCenterY: number;
|
||||||
|
|
||||||
|
// offset of the pinch center (current from initial)
|
||||||
|
dragOffsetX: number;
|
||||||
|
dragOffsetY: number;
|
||||||
|
|
||||||
|
// center coordinate of the current pinch
|
||||||
|
currentCenterX: number;
|
||||||
|
currentCenterY: number;
|
||||||
|
}) => void;
|
||||||
onClick?: (e: MouseEvent | TouchEvent) => void;
|
onClick?: (e: MouseEvent | TouchEvent) => void;
|
||||||
|
onDoubleClick?: (e: MouseEvent | RealTouchEvent, params: { centerX: number; centerY: number }) => void;
|
||||||
excludedClosestSelector?: string;
|
excludedClosestSelector?: string;
|
||||||
selectorToPreventScroll?: string;
|
selectorToPreventScroll?: string;
|
||||||
|
maxZoom?: number;
|
||||||
|
minZoom?: number;
|
||||||
|
isNotPassive?: boolean;
|
||||||
withCursor?: boolean;
|
withCursor?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,10 +61,26 @@ const IOS_SCREEN_EDGE_THRESHOLD = 20;
|
|||||||
const MOVED_THRESHOLD = 15;
|
const MOVED_THRESHOLD = 15;
|
||||||
const SWIPE_THRESHOLD = 50;
|
const SWIPE_THRESHOLD = 50;
|
||||||
|
|
||||||
|
function getDistance(a: Touch, b?: Touch) {
|
||||||
|
if (!b) return 0;
|
||||||
|
return Math.sqrt((b.pageX - a.pageX) ** 2 + (b.pageY - a.pageY) ** 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTouchCenter(a: Touch, b: Touch) {
|
||||||
|
return {
|
||||||
|
x: (a.pageX + b.pageX) / 2,
|
||||||
|
y: (a.pageY + b.pageY) / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastClickTime = 0;
|
||||||
|
|
||||||
export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
||||||
let captureEvent: MouseEvent | RealTouchEvent | undefined;
|
let captureEvent: MouseEvent | RealTouchEvent | undefined;
|
||||||
let hasMoved = false;
|
let hasMoved = false;
|
||||||
let hasSwiped = false;
|
let hasSwiped = false;
|
||||||
|
let initialDistance = 0;
|
||||||
|
let initialTouchCenter = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
||||||
let initialSwipeAxis: TSwipeAxis | undefined;
|
let initialSwipeAxis: TSwipeAxis | undefined;
|
||||||
|
|
||||||
function onCapture(e: MouseEvent | RealTouchEvent) {
|
function onCapture(e: MouseEvent | RealTouchEvent) {
|
||||||
@ -60,6 +96,13 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
|||||||
if (e.type === 'mousedown') {
|
if (e.type === 'mousedown') {
|
||||||
document.addEventListener('mousemove', onMove);
|
document.addEventListener('mousemove', onMove);
|
||||||
document.addEventListener('mouseup', onRelease);
|
document.addEventListener('mouseup', onRelease);
|
||||||
|
if (options.onDoubleClick && Date.now() - lastClickTime < 300) {
|
||||||
|
options.onDoubleClick(e, {
|
||||||
|
centerX: e.pageX!,
|
||||||
|
centerY: e.pageY!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lastClickTime = Date.now();
|
||||||
} else if (e.type === 'touchstart') {
|
} else if (e.type === 'touchstart') {
|
||||||
// We need to always listen on `touchstart` target:
|
// We need to always listen on `touchstart` target:
|
||||||
// https://stackoverflow.com/questions/33298828/touch-move-event-dont-fire-after-touch-start-target-is-removed
|
// https://stackoverflow.com/questions/33298828/touch-move-event-dont-fire-after-touch-start-target-is-removed
|
||||||
@ -76,6 +119,11 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
|||||||
if (e.pageY === undefined) {
|
if (e.pageY === undefined) {
|
||||||
e.pageY = e.touches[0].pageY;
|
e.pageY = e.touches[0].pageY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
initialDistance = getDistance(e.touches[0], e.touches[1]);
|
||||||
|
initialTouchCenter = getTouchCenter(e.touches[0], e.touches[1]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,7 +169,9 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
|||||||
|
|
||||||
hasMoved = false;
|
hasMoved = false;
|
||||||
hasSwiped = false;
|
hasSwiped = false;
|
||||||
|
initialDistance = 0;
|
||||||
initialSwipeAxis = undefined;
|
initialSwipeAxis = undefined;
|
||||||
|
initialTouchCenter = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMove(e: MouseEvent | RealTouchEvent) {
|
function onMove(e: MouseEvent | RealTouchEvent) {
|
||||||
@ -134,6 +184,24 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
|||||||
if (e.pageY === undefined) {
|
if (e.pageY === undefined) {
|
||||||
e.pageY = e.touches[0].pageY;
|
e.pageY = e.touches[0].pageY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.onZoom && initialDistance > 0 && e.touches.length === 2) {
|
||||||
|
const endDistance = getDistance(e.touches[0], e.touches[1]);
|
||||||
|
const touchCenter = getTouchCenter(e.touches[0], e.touches[1]);
|
||||||
|
const dragOffsetX = touchCenter.x - initialTouchCenter.x;
|
||||||
|
const dragOffsetY = touchCenter.y - initialTouchCenter.y;
|
||||||
|
const zoomFactor = endDistance / initialDistance;
|
||||||
|
options.onZoom(e, {
|
||||||
|
zoomFactor,
|
||||||
|
initialCenterX: initialTouchCenter.x,
|
||||||
|
initialCenterY: initialTouchCenter.y,
|
||||||
|
dragOffsetX,
|
||||||
|
dragOffsetY,
|
||||||
|
currentCenterX: touchCenter.x,
|
||||||
|
currentCenterY: touchCenter.y,
|
||||||
|
});
|
||||||
|
if (zoomFactor !== 1) hasMoved = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dragOffsetX = e.pageX! - captureEvent.pageX!;
|
const dragOffsetX = e.pageX! - captureEvent.pageX!;
|
||||||
@ -205,7 +273,7 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
element.addEventListener('mousedown', onCapture);
|
element.addEventListener('mousedown', onCapture);
|
||||||
element.addEventListener('touchstart', onCapture, { passive: true });
|
element.addEventListener('touchstart', onCapture, { passive: !options.isNotPassive });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
element.removeEventListener('mousedown', onCapture);
|
element.removeEventListener('mousedown', onCapture);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user