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);
|
||||
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;
|
||||
|
||||
@ -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<StateProps & DispatchProps> = ({
|
||||
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<StateProps & DispatchProps> = ({
|
||||
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<boolean>(false);
|
||||
const [canPanZoomWrap, setCanPanZoomWrap] = useState(false);
|
||||
const [isZoomed, setIsZoomed] = useState<boolean>(false);
|
||||
const [zoomLevel, setZoomLevel] = useState<number>(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<StateProps & DispatchProps> = ({
|
||||
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<StateProps & DispatchProps> = ({
|
||||
bestImageData = thumbDataUri;
|
||||
}
|
||||
|
||||
const videoSize = message ? getMessageFileSize(message) : undefined;
|
||||
const fileName = message
|
||||
? getMessageFileName(message)
|
||||
: isAvatar
|
||||
@ -246,11 +242,12 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
|
||||
const prevOrigin = usePrevious(origin);
|
||||
const prevAvatarOwner = usePrevious<ApiChat | ApiUser | undefined>(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<StateProps & DispatchProps> = ({
|
||||
}
|
||||
}, [
|
||||
isGhostAnimation, isOpen, origin, prevOrigin, message, prevMessage, prevAvatarOwner,
|
||||
bestImageData, prevBestImageData, dimensions, isVideo,
|
||||
bestImageData, prevBestImageData, dimensions, isVideo, hasFooter,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -284,14 +281,20 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
|
||||
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<StateProps & DispatchProps> = ({
|
||||
|
||||
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<StateProps & DispatchProps> = ({
|
||||
};
|
||||
}, [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<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 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<StateProps & DispatchProps> = ({
|
||||
avatarOwnerId: avatarOwner && avatarOwner.id,
|
||||
});
|
||||
|
||||
function renderSlide(isActive: boolean) {
|
||||
if (isAvatar) {
|
||||
return (
|
||||
<div key={chatId} className="media-viewer-content">
|
||||
{renderPhoto(
|
||||
fullMediaBlobUrl || previewBlobUrl,
|
||||
calculateMediaViewerDimensions(AVATAR_FULL_DIMENSIONS, false),
|
||||
!IS_SINGLE_COLUMN_LAYOUT && !isZoomed,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} 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 (
|
||||
<div
|
||||
key={messageId}
|
||||
className={`media-viewer-content ${hasFooter ? 'has-footer' : ''}`}
|
||||
onClick={handleToggleFooterVisibility}
|
||||
>
|
||||
{isPhoto && renderPhoto(
|
||||
localBlobUrl || fullMediaBlobUrl || previewBlobUrl || pictogramBlobUrl,
|
||||
message && calculateMediaViewerDimensions(dimensions!, hasFooter),
|
||||
!IS_SINGLE_COLUMN_LAYOUT && !isZoomed,
|
||||
)}
|
||||
{isVideo && (
|
||||
<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={close}
|
||||
/>
|
||||
)}
|
||||
{textParts && (
|
||||
<MediaViewerFooter
|
||||
text={textParts}
|
||||
onClick={handleFooterClick}
|
||||
isHidden={isFooterHidden && (!isVideo || isGif)}
|
||||
isForVideo={isVideo && !isGif}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
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<HTMLDivElement>('.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<StateProps & DispatchProps> = ({
|
||||
zoomLevel={zoomLevel}
|
||||
onPan={handlePan}
|
||||
>
|
||||
<Transition
|
||||
className="slide-container"
|
||||
<SlideTransition
|
||||
activeKey={selectedMediaMessageIndex}
|
||||
name={slideAnimation}
|
||||
>
|
||||
{renderSlide}
|
||||
</Transition>
|
||||
{(isActive) => (
|
||||
<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>
|
||||
{!isFirst && (
|
||||
{!isFirst && !IS_TOUCH_ENV && (
|
||||
<button
|
||||
type="button"
|
||||
className={`navigation prev ${isVideo && !isGif && 'inline'}`}
|
||||
aria-label={lang('AccDescrPrevious')}
|
||||
dir={lang.isRtl ? 'rtl' : undefined}
|
||||
onClick={selectPreviousMedia}
|
||||
onClick={() => selectMessage(previousMessageId)}
|
||||
/>
|
||||
)}
|
||||
{!isLast && (
|
||||
{!isLast && !IS_TOUCH_ENV && (
|
||||
<button
|
||||
type="button"
|
||||
className={`navigation next ${isVideo && !isGif && 'inline'}`}
|
||||
aria-label={lang('Next')}
|
||||
dir={lang.isRtl ? 'rtl' : undefined}
|
||||
onClick={selectNextMedia}
|
||||
onClick={() => selectMessage(nextMessageId)}
|
||||
/>
|
||||
)}
|
||||
<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(
|
||||
(global): StateProps => {
|
||||
const {
|
||||
chatId, threadId, messageId, avatarOwnerId, profilePhotoIndex, origin,
|
||||
chatId,
|
||||
threadId,
|
||||
messageId,
|
||||
avatarOwnerId,
|
||||
profilePhotoIndex,
|
||||
origin,
|
||||
} = global.mediaViewer;
|
||||
const {
|
||||
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>(
|
||||
'.Transition__slide--active .media-viewer-content img, .Transition__slide--active .media-viewer-content video',
|
||||
'.MediaViewerSlide.active img, .MediaViewerSlide.active video',
|
||||
);
|
||||
if (!fromImage || !toImage) {
|
||||
return;
|
||||
|
||||
@ -14,7 +14,7 @@ import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'
|
||||
import './Transition.scss';
|
||||
|
||||
type ChildrenFn = (isActive: boolean, isFrom: boolean, currentKey: number) => any;
|
||||
type OwnProps = {
|
||||
export type TransitionProps = {
|
||||
ref?: RefObject<HTMLDivElement>;
|
||||
activeKey: number;
|
||||
name: (
|
||||
@ -26,6 +26,7 @@ type OwnProps = {
|
||||
shouldRestoreHeight?: boolean;
|
||||
shouldCleanup?: boolean;
|
||||
cleanupExceptionKey?: number;
|
||||
isDisabled?: boolean;
|
||||
id?: string;
|
||||
className?: string;
|
||||
onStart?: NoneToVoidFunction;
|
||||
@ -39,7 +40,7 @@ const classNames = {
|
||||
active: 'Transition__slide--active',
|
||||
};
|
||||
|
||||
const Transition: FC<OwnProps> = ({
|
||||
const Transition: FC<TransitionProps> = ({
|
||||
ref,
|
||||
activeKey,
|
||||
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;
|
||||
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;
|
||||
onDoubleClick?: (e: MouseEvent | RealTouchEvent, params: { centerX: number; centerY: number }) => void;
|
||||
excludedClosestSelector?: string;
|
||||
selectorToPreventScroll?: string;
|
||||
maxZoom?: number;
|
||||
minZoom?: number;
|
||||
isNotPassive?: boolean;
|
||||
withCursor?: boolean;
|
||||
}
|
||||
|
||||
@ -41,10 +61,26 @@ const IOS_SCREEN_EDGE_THRESHOLD = 20;
|
||||
const MOVED_THRESHOLD = 15;
|
||||
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) {
|
||||
let captureEvent: MouseEvent | RealTouchEvent | undefined;
|
||||
let hasMoved = false;
|
||||
let hasSwiped = false;
|
||||
let initialDistance = 0;
|
||||
let initialTouchCenter = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
||||
let initialSwipeAxis: TSwipeAxis | undefined;
|
||||
|
||||
function onCapture(e: MouseEvent | RealTouchEvent) {
|
||||
@ -60,6 +96,13 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
||||
if (e.type === 'mousedown') {
|
||||
document.addEventListener('mousemove', onMove);
|
||||
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') {
|
||||
// We need to always listen on `touchstart` target:
|
||||
// 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) {
|
||||
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;
|
||||
hasSwiped = false;
|
||||
initialDistance = 0;
|
||||
initialSwipeAxis = undefined;
|
||||
initialTouchCenter = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
||||
}
|
||||
|
||||
function onMove(e: MouseEvent | RealTouchEvent) {
|
||||
@ -134,6 +184,24 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
||||
if (e.pageY === undefined) {
|
||||
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!;
|
||||
@ -205,7 +273,7 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
||||
}
|
||||
|
||||
element.addEventListener('mousedown', onCapture);
|
||||
element.addEventListener('touchstart', onCapture, { passive: true });
|
||||
element.addEventListener('touchstart', onCapture, { passive: !options.isNotPassive });
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('mousedown', onCapture);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user