Media Viewer: Brand new slide, zoom and pan on mobile (#1485)

This commit is contained in:
Alexander Zinchuk 2021-12-10 18:32:35 +01:00
parent bf9f2daac0
commit b8bf4cf833
11 changed files with 1197 additions and 306 deletions

View File

@ -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;

View File

@ -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,

View 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;
}
}

View 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>
);
}

View 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;
}
}

View 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)});`;
}

View 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;

View File

@ -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;

View File

@ -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,

View File

@ -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();
};
}

View File

@ -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);