Media Viewer: Zoom and pan with track pad and mouse wheel (#1829)
This commit is contained in:
parent
2dfac887cd
commit
7997dff459
@ -73,6 +73,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: var(--z-media-viewer-head);
|
z-index: var(--z-media-viewer-head);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
background: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0) 100%);
|
||||||
padding: 0.5rem max(1.25rem, env(safe-area-inset-left));
|
padding: 0.5rem max(1.25rem, env(safe-area-inset-left));
|
||||||
|
|
||||||
& > .Transition {
|
& > .Transition {
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import type { FC } from '../../lib/teact/teact';
|
|||||||
import React, {
|
import React, {
|
||||||
memo, useCallback, useEffect, useMemo, useRef, useState,
|
memo, useCallback, useEffect, useMemo, useRef, useState,
|
||||||
} from '../../lib/teact/teact';
|
} from '../../lib/teact/teact';
|
||||||
import { getActions, withGlobal } from '../../global';
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ApiChat, ApiDimensions, ApiMessage, ApiUser,
|
ApiChat, ApiDimensions, ApiMessage, ApiUser,
|
||||||
@ -10,17 +9,7 @@ import type {
|
|||||||
import { ApiMediaFormat } from '../../api/types';
|
import { ApiMediaFormat } from '../../api/types';
|
||||||
import { MediaViewerOrigin } from '../../types';
|
import { MediaViewerOrigin } from '../../types';
|
||||||
|
|
||||||
import { ANIMATION_END_DELAY } from '../../config';
|
import { getActions, withGlobal } from '../../global';
|
||||||
import { IS_IOS, IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../util/environment';
|
|
||||||
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 useFlag from '../../hooks/useFlag';
|
|
||||||
import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
|
|
||||||
import usePrevious from '../../hooks/usePrevious';
|
|
||||||
import {
|
import {
|
||||||
getChatAvatarHash,
|
getChatAvatarHash,
|
||||||
getChatMediaMessageIds,
|
getChatMediaMessageIds,
|
||||||
@ -52,22 +41,30 @@ import {
|
|||||||
} from '../../global/selectors';
|
} from '../../global/selectors';
|
||||||
import { stopCurrentAudio } from '../../util/audioPlayer';
|
import { stopCurrentAudio } from '../../util/audioPlayer';
|
||||||
import captureEscKeyListener from '../../util/captureEscKeyListener';
|
import captureEscKeyListener from '../../util/captureEscKeyListener';
|
||||||
import { captureEvents } from '../../util/captureEvents';
|
import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
|
||||||
import windowSize from '../../util/windowSize';
|
import { ANIMATION_END_DELAY } from '../../config';
|
||||||
import { AVATAR_FULL_DIMENSIONS, MEDIA_VIEWER_MEDIA_QUERY } from '../common/helpers/mediaDimensions';
|
import { AVATAR_FULL_DIMENSIONS, MEDIA_VIEWER_MEDIA_QUERY } from '../common/helpers/mediaDimensions';
|
||||||
import { renderMessageText } from '../common/helpers/renderMessageText';
|
import windowSize from '../../util/windowSize';
|
||||||
import { animateClosing, animateOpening } from './helpers/ghostAnimation';
|
import { animateClosing, animateOpening } from './helpers/ghostAnimation';
|
||||||
|
import { renderMessageText } from '../common/helpers/renderMessageText';
|
||||||
|
|
||||||
|
import useBlurSync from '../../hooks/useBlurSync';
|
||||||
|
import useFlag from '../../hooks/useFlag';
|
||||||
|
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 ReportModal from '../common/ReportModal';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import ShowTransition from '../ui/ShowTransition';
|
import ShowTransition from '../ui/ShowTransition';
|
||||||
import Transition from '../ui/Transition';
|
import Transition from '../ui/Transition';
|
||||||
import MediaViewerActions from './MediaViewerActions';
|
import MediaViewerActions from './MediaViewerActions';
|
||||||
import MediaViewerSlides from './MediaViewerSlides';
|
import MediaViewerSlides from './MediaViewerSlides';
|
||||||
import PanZoom from './PanZoom';
|
|
||||||
import SenderInfo from './SenderInfo';
|
import SenderInfo from './SenderInfo';
|
||||||
import SlideTransition from './SlideTransition';
|
|
||||||
import ZoomControls from './ZoomControls';
|
|
||||||
import ReportModal from '../common/ReportModal';
|
|
||||||
|
|
||||||
import './MediaViewer.scss';
|
import './MediaViewer.scss';
|
||||||
|
|
||||||
@ -137,8 +134,6 @@ const MediaViewer: FC<StateProps> = ({
|
|||||||
}, [singleMessageId, chatMessages, collectionIds, isFromSharedMedia]);
|
}, [singleMessageId, chatMessages, collectionIds, isFromSharedMedia]);
|
||||||
|
|
||||||
const selectedMediaMessageIndex = messageId ? messageIds.indexOf(messageId) : -1;
|
const selectedMediaMessageIndex = messageId ? messageIds.indexOf(messageId) : -1;
|
||||||
const isFirst = selectedMediaMessageIndex === 0 || selectedMediaMessageIndex === -1;
|
|
||||||
const isLast = selectedMediaMessageIndex === messageIds.length - 1 || selectedMediaMessageIndex === -1;
|
|
||||||
|
|
||||||
/* Animation */
|
/* Animation */
|
||||||
const animationKey = useRef<number>();
|
const animationKey = useRef<number>();
|
||||||
@ -146,19 +141,12 @@ const MediaViewer: FC<StateProps> = ({
|
|||||||
if (isOpen && (!prevSenderId || prevSenderId !== senderId || !animationKey.current)) {
|
if (isOpen && (!prevSenderId || prevSenderId !== senderId || !animationKey.current)) {
|
||||||
animationKey.current = selectedMediaMessageIndex;
|
animationKey.current = selectedMediaMessageIndex;
|
||||||
}
|
}
|
||||||
const slideAnimation = animationLevel >= 1 && !IS_TOUCH_ENV ? 'mv-slide' : 'none';
|
|
||||||
const headerAnimation = animationLevel === 2 ? 'slide-fade' : 'none';
|
const headerAnimation = animationLevel === 2 ? 'slide-fade' : 'none';
|
||||||
const isGhostAnimation = animationLevel === 2;
|
const isGhostAnimation = animationLevel === 2;
|
||||||
|
|
||||||
/* Controls */
|
/* Controls */
|
||||||
const [isReportModalOpen, openReportModal, closeReportModal] = useFlag();
|
const [isReportModalOpen, openReportModal, closeReportModal] = useFlag();
|
||||||
const [canPanZoomWrap, setCanPanZoomWrap] = useState(false);
|
const [zoomLevelChange, setZoomLevelChange] = useState<number>(1);
|
||||||
const [isZoomed, setIsZoomed] = useState<boolean>(false);
|
|
||||||
const [zoomLevel, setZoomLevel] = useState<number>(1);
|
|
||||||
const [panDelta, setPanDelta] = useState({
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
/* Media data */
|
/* Media data */
|
||||||
function getMediaHash(isFull?: boolean) {
|
function getMediaHash(isFull?: boolean) {
|
||||||
@ -271,54 +259,8 @@ const MediaViewer: FC<StateProps> = ({
|
|||||||
bestImageData, prevBestImageData, dimensions, isVideo, hasFooter,
|
bestImageData, prevBestImageData, dimensions, isVideo, hasFooter,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let timer: number | undefined;
|
|
||||||
|
|
||||||
if (isZoomed) {
|
|
||||||
setCanPanZoomWrap(true);
|
|
||||||
} else {
|
|
||||||
timer = window.setTimeout(() => {
|
|
||||||
setCanPanZoomWrap(false);
|
|
||||||
}, ANIMATION_DURATION);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timer) {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [isZoomed]);
|
|
||||||
|
|
||||||
const closeZoom = () => {
|
|
||||||
setIsZoomed(false);
|
|
||||||
setZoomLevel(1);
|
|
||||||
setPanDelta({
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleZoomToggle = useCallback(() => {
|
|
||||||
setIsZoomed(!isZoomed);
|
|
||||||
setZoomLevel(!isZoomed ? 1.5 : 1);
|
|
||||||
if (isZoomed) {
|
|
||||||
setPanDelta({
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isZoomed]);
|
|
||||||
|
|
||||||
const handleZoomValue = useCallback((level: number, canCloseZoom = false) => {
|
|
||||||
setZoomLevel(level);
|
|
||||||
if (level === 1 && canCloseZoom) {
|
|
||||||
closeZoom();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const close = useCallback(() => {
|
const close = useCallback(() => {
|
||||||
closeMediaViewer();
|
closeMediaViewer();
|
||||||
closeZoom();
|
|
||||||
}, [closeMediaViewer]);
|
}, [closeMediaViewer]);
|
||||||
|
|
||||||
const handleFooterClick = useCallback(() => {
|
const handleFooterClick = useCallback(() => {
|
||||||
@ -339,7 +281,6 @@ const MediaViewer: FC<StateProps> = ({
|
|||||||
fromChatId: chatId,
|
fromChatId: chatId,
|
||||||
messageIds: [messageId],
|
messageIds: [messageId],
|
||||||
});
|
});
|
||||||
closeZoom();
|
|
||||||
}, [openForwardMenu, chatId, messageId]);
|
}, [openForwardMenu, chatId, messageId]);
|
||||||
|
|
||||||
const selectMessage = useCallback((id?: number) => openMediaViewer({
|
const selectMessage = useCallback((id?: number) => openMediaViewer({
|
||||||
@ -352,12 +293,8 @@ const MediaViewer: FC<StateProps> = ({
|
|||||||
}), [chatId, openMediaViewer, origin, threadId]);
|
}), [chatId, openMediaViewer, origin, threadId]);
|
||||||
|
|
||||||
useEffect(() => (isOpen ? captureEscKeyListener(() => {
|
useEffect(() => (isOpen ? captureEscKeyListener(() => {
|
||||||
if (isZoomed) {
|
close();
|
||||||
closeZoom();
|
}) : undefined), [close, isOpen]);
|
||||||
} else {
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
}) : undefined), [close, isOpen, isZoomed]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVideo && !isGif) {
|
if (isVideo && !isGif) {
|
||||||
@ -387,16 +324,6 @@ const MediaViewer: FC<StateProps> = ({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [messageIds]);
|
}, [messageIds]);
|
||||||
|
|
||||||
const nextMessageId = getMessageId(messageId, 1);
|
|
||||||
const previousMessageId = getMessageId(messageId, -1);
|
|
||||||
|
|
||||||
const handlePan = useCallback((x: number, y: number) => {
|
|
||||||
setPanDelta({
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const lang = useLang();
|
const lang = useLang();
|
||||||
|
|
||||||
useHistoryBack({
|
useHistoryBack({
|
||||||
@ -404,48 +331,6 @@ const MediaViewer: FC<StateProps> = ({
|
|||||||
onBack: closeMediaViewer,
|
onBack: closeMediaViewer,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
switch (e.key) {
|
|
||||||
case 'Left': // IE/Edge specific value
|
|
||||||
case 'ArrowLeft':
|
|
||||||
selectMessage(previousMessageId);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'Right': // IE/Edge specific value
|
|
||||||
case 'ArrowRight':
|
|
||||||
selectMessage(nextMessageId);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown, false);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleKeyDown, false);
|
|
||||||
};
|
|
||||||
}, [isOpen, nextMessageId, previousMessageId, selectMessage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isZoomed || IS_TOUCH_ENV) return undefined;
|
|
||||||
const element = document.querySelector<HTMLDivElement>('.MediaViewerSlide--active');
|
|
||||||
if (!element) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldCloseOnVideo = isGif && !IS_IOS;
|
|
||||||
|
|
||||||
return captureEvents(element, {
|
|
||||||
// eslint-disable-next-line max-len
|
|
||||||
excludedClosestSelector: `.backdrop, .navigation, .media-viewer-head, .Spoiler, .media-viewer-footer${!shouldCloseOnVideo ? ', .VideoPlayer' : ''}`,
|
|
||||||
onClick: close,
|
|
||||||
});
|
|
||||||
}, [close, isGif, isZoomed, messageId]);
|
|
||||||
|
|
||||||
function renderSenderInfo() {
|
function renderSenderInfo() {
|
||||||
return isAvatar ? (
|
return isAvatar ? (
|
||||||
<SenderInfo
|
<SenderInfo
|
||||||
@ -463,11 +348,7 @@ const MediaViewer: FC<StateProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ShowTransition
|
<ShowTransition id="MediaViewer" isOpen={isOpen}>
|
||||||
id="MediaViewer"
|
|
||||||
className={isZoomed ? 'zoomed' : ''}
|
|
||||||
isOpen={isOpen}
|
|
||||||
>
|
|
||||||
<div className="media-viewer-head" dir={lang.isRtl ? 'rtl' : undefined}>
|
<div className="media-viewer-head" dir={lang.isRtl ? 'rtl' : undefined}>
|
||||||
{IS_SINGLE_COLUMN_LAYOUT && (
|
{IS_SINGLE_COLUMN_LAYOUT && (
|
||||||
<Button
|
<Button
|
||||||
@ -487,14 +368,14 @@ const MediaViewer: FC<StateProps> = ({
|
|||||||
<MediaViewerActions
|
<MediaViewerActions
|
||||||
mediaData={fullMediaBlobUrl || previewBlobUrl}
|
mediaData={fullMediaBlobUrl || previewBlobUrl}
|
||||||
isVideo={isVideo}
|
isVideo={isVideo}
|
||||||
isZoomed={isZoomed}
|
|
||||||
message={message}
|
message={message}
|
||||||
fileName={fileName}
|
fileName={fileName}
|
||||||
canReport={canReport}
|
canReport={canReport}
|
||||||
onReport={openReportModal}
|
onReport={openReportModal}
|
||||||
onCloseMediaViewer={close}
|
onCloseMediaViewer={close}
|
||||||
onForward={handleForward}
|
onForward={handleForward}
|
||||||
onZoomToggle={handleZoomToggle}
|
zoomLevelChange={zoomLevelChange}
|
||||||
|
setZoomLevelChange={setZoomLevelChange}
|
||||||
isAvatar={isAvatar}
|
isAvatar={isAvatar}
|
||||||
/>
|
/>
|
||||||
<ReportModal
|
<ReportModal
|
||||||
@ -505,63 +386,25 @@ const MediaViewer: FC<StateProps> = ({
|
|||||||
chatId={avatarOwner?.id}
|
chatId={avatarOwner?.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PanZoom
|
<MediaViewerSlides
|
||||||
noWrap={!canPanZoomWrap}
|
messageId={messageId}
|
||||||
canPan={isZoomed}
|
getMessageId={getMessageId}
|
||||||
panDeltaX={panDelta.x}
|
chatId={chatId}
|
||||||
panDeltaY={panDelta.y}
|
isPhoto={isPhoto}
|
||||||
zoomLevel={zoomLevel}
|
isGif={isGif}
|
||||||
onPan={handlePan}
|
threadId={threadId}
|
||||||
>
|
avatarOwnerId={avatarOwner && avatarOwner.id}
|
||||||
<SlideTransition
|
profilePhotoIndex={profilePhotoIndex}
|
||||||
activeKey={selectedMediaMessageIndex}
|
origin={origin}
|
||||||
name={slideAnimation}
|
isOpen={isOpen}
|
||||||
>
|
hasFooter={hasFooter}
|
||||||
{(isActive: boolean) => (
|
zoomLevelChange={zoomLevelChange}
|
||||||
<MediaViewerSlides
|
isActive
|
||||||
messageId={messageId}
|
isVideo={isVideo}
|
||||||
getMessageId={getMessageId}
|
animationLevel={animationLevel}
|
||||||
chatId={chatId}
|
onClose={close}
|
||||||
isPhoto={isPhoto}
|
selectMessage={selectMessage}
|
||||||
isGif={isGif}
|
onFooterClick={handleFooterClick}
|
||||||
threadId={threadId}
|
|
||||||
avatarOwnerId={avatarOwner && avatarOwner.id}
|
|
||||||
profilePhotoIndex={profilePhotoIndex}
|
|
||||||
origin={origin}
|
|
||||||
isOpen={isOpen}
|
|
||||||
hasFooter={hasFooter}
|
|
||||||
isZoomed={isZoomed}
|
|
||||||
isActive={isActive}
|
|
||||||
isVideo={isVideo}
|
|
||||||
animationLevel={animationLevel}
|
|
||||||
onClose={close}
|
|
||||||
selectMessage={selectMessage}
|
|
||||||
onFooterClick={handleFooterClick}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SlideTransition>
|
|
||||||
</PanZoom>
|
|
||||||
{!isFirst && !IS_TOUCH_ENV && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`navigation prev ${isVideo && !isGif && 'inline'}`}
|
|
||||||
aria-label={lang('AccDescrPrevious')}
|
|
||||||
dir={lang.isRtl ? 'rtl' : undefined}
|
|
||||||
onClick={() => selectMessage(previousMessageId)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!isLast && !IS_TOUCH_ENV && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`navigation next ${isVideo && !isGif && 'inline'}`}
|
|
||||||
aria-label={lang('Next')}
|
|
||||||
dir={lang.isRtl ? 'rtl' : undefined}
|
|
||||||
onClick={() => selectMessage(nextMessageId)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ZoomControls
|
|
||||||
isShown={isZoomed}
|
|
||||||
onChangeZoom={handleZoomValue}
|
|
||||||
/>
|
/>
|
||||||
</ShowTransition>
|
</ShowTransition>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
.Button {
|
.Button {
|
||||||
margin-inline-start: 0.25rem;
|
margin-inline-start: 0.25rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -17,4 +18,8 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.Button {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,7 @@ type StateProps = {
|
|||||||
type OwnProps = {
|
type OwnProps = {
|
||||||
mediaData?: string;
|
mediaData?: string;
|
||||||
isVideo: boolean;
|
isVideo: boolean;
|
||||||
isZoomed: boolean;
|
zoomLevelChange: number;
|
||||||
message?: ApiMessage;
|
message?: ApiMessage;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
isAvatar?: boolean;
|
isAvatar?: boolean;
|
||||||
@ -37,13 +37,12 @@ type OwnProps = {
|
|||||||
onReport: NoneToVoidFunction;
|
onReport: NoneToVoidFunction;
|
||||||
onCloseMediaViewer: NoneToVoidFunction;
|
onCloseMediaViewer: NoneToVoidFunction;
|
||||||
onForward: NoneToVoidFunction;
|
onForward: NoneToVoidFunction;
|
||||||
onZoomToggle: NoneToVoidFunction;
|
setZoomLevelChange: (change: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MediaViewerActions: FC<OwnProps & StateProps> = ({
|
const MediaViewerActions: FC<OwnProps & StateProps> = ({
|
||||||
mediaData,
|
mediaData,
|
||||||
isVideo,
|
isVideo,
|
||||||
isZoomed,
|
|
||||||
message,
|
message,
|
||||||
fileName,
|
fileName,
|
||||||
isAvatar,
|
isAvatar,
|
||||||
@ -52,8 +51,9 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
|
|||||||
canReport,
|
canReport,
|
||||||
onReport,
|
onReport,
|
||||||
onCloseMediaViewer,
|
onCloseMediaViewer,
|
||||||
|
zoomLevelChange,
|
||||||
|
setZoomLevelChange,
|
||||||
onForward,
|
onForward,
|
||||||
onZoomToggle,
|
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
downloadMessageMedia,
|
downloadMessageMedia,
|
||||||
@ -73,6 +73,16 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
|
|||||||
}
|
}
|
||||||
}, [cancelMessageMediaDownload, downloadMessageMedia, isDownloading, message]);
|
}, [cancelMessageMediaDownload, downloadMessageMedia, isDownloading, message]);
|
||||||
|
|
||||||
|
const handleZoomOut = useCallback(() => {
|
||||||
|
const change = zoomLevelChange < 0 ? zoomLevelChange : 0;
|
||||||
|
setZoomLevelChange(change - 1);
|
||||||
|
}, [setZoomLevelChange, zoomLevelChange]);
|
||||||
|
|
||||||
|
const handleZoomIn = useCallback(() => {
|
||||||
|
const change = zoomLevelChange > 0 ? zoomLevelChange : 0;
|
||||||
|
setZoomLevelChange(change + 1);
|
||||||
|
}, [setZoomLevelChange, zoomLevelChange]);
|
||||||
|
|
||||||
const lang = useLang();
|
const lang = useLang();
|
||||||
|
|
||||||
const MenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => {
|
const MenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => {
|
||||||
@ -190,10 +200,19 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
|
|||||||
round
|
round
|
||||||
size="smaller"
|
size="smaller"
|
||||||
color="translucent-white"
|
color="translucent-white"
|
||||||
ariaLabel={isZoomed ? 'Zoom Out' : 'Zoom In'}
|
ariaLabel={lang('MediaZoomOut')}
|
||||||
onClick={onZoomToggle}
|
onClick={handleZoomOut}
|
||||||
>
|
>
|
||||||
<i className={isZoomed ? 'icon-zoom-out' : 'icon-zoom-in'} />
|
<i className="icon-zoom-out" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
round
|
||||||
|
size="smaller"
|
||||||
|
color="translucent-white"
|
||||||
|
ariaLabel={lang('MediaZoomIn')}
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
>
|
||||||
|
<i className="icon-zoom-in" />
|
||||||
</Button>
|
</Button>
|
||||||
{canReport && (
|
{canReport && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -228,7 +228,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
|
|||||||
<MediaViewerFooter
|
<MediaViewerFooter
|
||||||
text={textParts}
|
text={textParts}
|
||||||
onClick={onFooterClick}
|
onClick={onFooterClick}
|
||||||
isHidden={isFooterHidden && IS_TOUCH_ENV}
|
isHidden={isFooterHidden}
|
||||||
isForVideo={isVideo && !isGif}
|
isForVideo={isVideo && !isGif}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -35,4 +35,8 @@
|
|||||||
&--active {
|
&--active {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--moving {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,21 +4,29 @@ import React, {
|
|||||||
} from '../../lib/teact/teact';
|
} from '../../lib/teact/teact';
|
||||||
|
|
||||||
import type { MediaViewerOrigin } from '../../types';
|
import type { MediaViewerOrigin } from '../../types';
|
||||||
|
import type { RealTouchEvent } from '../../util/captureEvents';
|
||||||
|
|
||||||
import useForceUpdate from '../../hooks/useForceUpdate';
|
|
||||||
import { animateNumber, timingFunctions } from '../../util/animation';
|
import { animateNumber, timingFunctions } from '../../util/animation';
|
||||||
import arePropsShallowEqual from '../../util/arePropsShallowEqual';
|
import arePropsShallowEqual from '../../util/arePropsShallowEqual';
|
||||||
import type { RealTouchEvent } from '../../util/captureEvents';
|
import buildClassName from '../../util/buildClassName';
|
||||||
import { captureEvents, IOS_SCREEN_EDGE_THRESHOLD } from '../../util/captureEvents';
|
import { captureEvents, IOS_SCREEN_EDGE_THRESHOLD } from '../../util/captureEvents';
|
||||||
import { IS_IOS, IS_TOUCH_ENV } from '../../util/environment';
|
import { IS_IOS, IS_TOUCH_ENV } from '../../util/environment';
|
||||||
|
import { clamp, isBetween, round } from '../../util/math';
|
||||||
import { debounce } from '../../util/schedulers';
|
import { debounce } from '../../util/schedulers';
|
||||||
import useTimeout from '../../hooks/useTimeout';
|
|
||||||
import useDebouncedCallback from '../../hooks/useDebouncedCallback';
|
import useDebouncedCallback from '../../hooks/useDebouncedCallback';
|
||||||
|
import useForceUpdate from '../../hooks/useForceUpdate';
|
||||||
|
import useLang from '../../hooks/useLang';
|
||||||
|
import usePrevious from '../../hooks/usePrevious';
|
||||||
|
import useTimeout from '../../hooks/useTimeout';
|
||||||
|
import useWindowSize from '../../hooks/useWindowSize';
|
||||||
|
|
||||||
import MediaViewerContent from './MediaViewerContent';
|
import MediaViewerContent from './MediaViewerContent';
|
||||||
|
|
||||||
import './MediaViewerSlides.scss';
|
import './MediaViewerSlides.scss';
|
||||||
|
|
||||||
|
const { easeOutCubic, easeOutQuart } = timingFunctions;
|
||||||
|
|
||||||
type OwnProps = {
|
type OwnProps = {
|
||||||
messageId?: number;
|
messageId?: number;
|
||||||
getMessageId: (fromId?: number, direction?: number) => number | undefined;
|
getMessageId: (fromId?: number, direction?: number) => number | undefined;
|
||||||
@ -33,26 +41,27 @@ type OwnProps = {
|
|||||||
avatarOwnerId?: string;
|
avatarOwnerId?: string;
|
||||||
profilePhotoIndex?: number;
|
profilePhotoIndex?: number;
|
||||||
origin?: MediaViewerOrigin;
|
origin?: MediaViewerOrigin;
|
||||||
isZoomed?: boolean;
|
|
||||||
animationLevel: 0 | 1 | 2;
|
animationLevel: 0 | 1 | 2;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
hasFooter?: boolean;
|
hasFooter?: boolean;
|
||||||
onFooterClick: () => void;
|
onFooterClick: () => void;
|
||||||
|
zoomLevelChange: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SWIPE_X_THRESHOLD = 50;
|
const SWIPE_X_THRESHOLD = 50;
|
||||||
const SWIPE_Y_THRESHOLD = 50;
|
const SWIPE_Y_THRESHOLD = 50;
|
||||||
const SLIDES_GAP = 40;
|
const SLIDES_GAP = IS_TOUCH_ENV ? 40 : 0;
|
||||||
const ANIMATION_DURATION = 350;
|
const ANIMATION_DURATION = 350;
|
||||||
const DEBOUNCE_MESSAGE = 350;
|
const DEBOUNCE_MESSAGE = 350;
|
||||||
const DEBOUNCE_SWIPE = 500;
|
const DEBOUNCE_SWIPE = 500;
|
||||||
const DEBOUNCE_ACTIVE = 800;
|
const DEBOUNCE_ACTIVE = 800;
|
||||||
const MAX_ZOOM = 4;
|
|
||||||
const MIN_ZOOM = 0.6;
|
|
||||||
const DOUBLE_TAP_ZOOM = 3;
|
const DOUBLE_TAP_ZOOM = 3;
|
||||||
const CLICK_X_THRESHOLD = 40;
|
|
||||||
const CLICK_Y_THRESHOLD = 80;
|
const CLICK_Y_THRESHOLD = 80;
|
||||||
|
const HEADER_HEIGHT = 60;
|
||||||
|
const MAX_ZOOM = 4;
|
||||||
|
const MIN_ZOOM = 1;
|
||||||
let cancelAnimation: Function | undefined;
|
let cancelAnimation: Function | undefined;
|
||||||
|
let cancelZoomAnimation: Function | undefined;
|
||||||
|
|
||||||
type Transform = {
|
type Transform = {
|
||||||
x: number;
|
x: number;
|
||||||
@ -75,6 +84,8 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
isOpen,
|
isOpen,
|
||||||
isActive,
|
isActive,
|
||||||
hasFooter,
|
hasFooter,
|
||||||
|
zoomLevelChange,
|
||||||
|
animationLevel,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
// eslint-disable-next-line no-null/no-null
|
// eslint-disable-next-line no-null/no-null
|
||||||
@ -82,16 +93,19 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
// eslint-disable-next-line no-null/no-null
|
// eslint-disable-next-line no-null/no-null
|
||||||
const activeSlideRef = useRef<HTMLDivElement>(null);
|
const activeSlideRef = useRef<HTMLDivElement>(null);
|
||||||
const transformRef = useRef<Transform>({ x: 0, y: 0, scale: 1 });
|
const transformRef = useRef<Transform>({ x: 0, y: 0, scale: 1 });
|
||||||
|
const lastTransformRef = useRef<Transform>({ x: 0, y: 0, scale: 1 });
|
||||||
const swipeDirectionRef = useRef<SwipeDirection | undefined>(undefined);
|
const swipeDirectionRef = useRef<SwipeDirection | undefined>(undefined);
|
||||||
const isActiveRef = useRef(true);
|
const isActiveRef = useRef(true);
|
||||||
const [activeMessageId, setActiveMessageId] = useState<number | undefined>(messageId);
|
const [activeMessageId, setActiveMessageId] = useState<number | undefined>(messageId);
|
||||||
|
const prevZoomLevelChange = usePrevious(zoomLevelChange);
|
||||||
|
const hasZoomChanged = prevZoomLevelChange !== undefined && prevZoomLevelChange !== zoomLevelChange;
|
||||||
const forceUpdate = useForceUpdate();
|
const forceUpdate = useForceUpdate();
|
||||||
const [isFooterHidden, setIsFooterHidden] = useState<boolean>(true);
|
const [isFooterHidden, setIsFooterHidden] = useState(true);
|
||||||
|
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||||
|
const { height: windowHeight, width: windowWidth, isResizing } = useWindowSize();
|
||||||
|
const { onClose } = rest;
|
||||||
|
|
||||||
const {
|
const lang = useLang();
|
||||||
isZoomed,
|
|
||||||
onClose,
|
|
||||||
} = rest;
|
|
||||||
|
|
||||||
const setTransform = useCallback((value: Transform) => {
|
const setTransform = useCallback((value: Transform) => {
|
||||||
transformRef.current = value;
|
transformRef.current = value;
|
||||||
@ -107,21 +121,24 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
forceUpdate();
|
forceUpdate();
|
||||||
}, [forceUpdate], DEBOUNCE_ACTIVE, true);
|
}, [forceUpdate], DEBOUNCE_ACTIVE, true);
|
||||||
|
|
||||||
|
const shouldCloseOnVideo = isGif && !IS_IOS;
|
||||||
|
const clickXThreshold = IS_TOUCH_ENV ? 40 : windowWidth / 10;
|
||||||
|
|
||||||
const handleToggleFooterVisibility = useCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
const handleToggleFooterVisibility = useCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||||
if (!IS_TOUCH_ENV) return;
|
if (!IS_TOUCH_ENV) return;
|
||||||
const isFooter = window.innerHeight - e.pageY < CLICK_Y_THRESHOLD;
|
const isFooter = windowHeight - e.pageY < CLICK_Y_THRESHOLD;
|
||||||
if (!isFooter && e.pageX < CLICK_X_THRESHOLD) return;
|
if (!isFooter && e.pageX < clickXThreshold) return;
|
||||||
if (!isFooter && e.pageX > window.innerWidth - CLICK_X_THRESHOLD) return;
|
if (!isFooter && e.pageX > windowWidth - clickXThreshold) return;
|
||||||
setIsFooterHidden(!isFooterHidden);
|
setIsFooterHidden(!isFooterHidden);
|
||||||
}, [isFooterHidden]);
|
}, [clickXThreshold, isFooterHidden, windowHeight, windowWidth]);
|
||||||
|
|
||||||
useTimeout(() => setIsFooterHidden(false), ANIMATION_DURATION - 150);
|
useTimeout(() => setIsFooterHidden(false), ANIMATION_DURATION - 150);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_TOUCH_ENV || !containerRef.current || isZoomed || !activeMessageId) {
|
if (!containerRef.current || !activeMessageId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
let lastTransform = { x: 0, y: 0, scale: 1 };
|
let lastTransform = lastTransformRef.current;
|
||||||
const lastDragOffset = {
|
const lastDragOffset = {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
@ -141,46 +158,110 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
lastGestureTime = Date.now();
|
lastGestureTime = Date.now();
|
||||||
}, 500, false, true);
|
}, 500, false, true);
|
||||||
|
|
||||||
const changeSlide = (e: MouseEvent) => {
|
const changeSlide = (direction: number) => {
|
||||||
if (transformRef.current.scale !== 1) return false;
|
|
||||||
let direction = 0;
|
|
||||||
if (window.innerHeight - e.pageY < CLICK_Y_THRESHOLD) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (e.pageX < CLICK_X_THRESHOLD) {
|
|
||||||
direction = -1;
|
|
||||||
} else if (e.pageX > window.innerWidth - CLICK_X_THRESHOLD) {
|
|
||||||
direction = 1;
|
|
||||||
}
|
|
||||||
const mId = getMessageId(activeMessageId, direction);
|
const mId = getMessageId(activeMessageId, direction);
|
||||||
if (mId) {
|
if (mId) {
|
||||||
const offset = (window.innerWidth + SLIDES_GAP) * direction;
|
const offset = (windowWidth + SLIDES_GAP) * direction;
|
||||||
transformRef.current.x += offset;
|
transformRef.current.x += offset;
|
||||||
isActiveRef.current = false;
|
isActiveRef.current = false;
|
||||||
setActiveMessageId(mId);
|
setActiveMessageId(mId);
|
||||||
selectMessageDebounced(mId);
|
selectMessageDebounced(mId);
|
||||||
setIsActiveDebounced(true);
|
setIsActiveDebounced(true);
|
||||||
lastTransform = { x: 0, y: 0, scale: 1 };
|
lastTransform = { x: 0, y: 0, scale: 1 };
|
||||||
|
if (animationLevel === 0) {
|
||||||
|
setTransform(lastTransform);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
cancelAnimation = animateNumber({
|
cancelAnimation = animateNumber({
|
||||||
from: transformRef.current.x,
|
from: transformRef.current.x,
|
||||||
to: 0,
|
to: 0,
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
timing: timingFunctions.easeOutCubic,
|
timing: easeOutCubic,
|
||||||
onUpdate: (value) => setTransform({
|
onUpdate: (value) => setTransform({
|
||||||
y: 0,
|
y: 0,
|
||||||
x: value,
|
x: value,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return direction !== 0;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
return captureEvents(containerRef.current, {
|
const changeSlideOnClick = (e: MouseEvent): [boolean, boolean] => {
|
||||||
|
if (transformRef.current.scale !== 1) return [false, false];
|
||||||
|
let direction = 0;
|
||||||
|
if (windowHeight - e.pageY < CLICK_Y_THRESHOLD) {
|
||||||
|
return [false, false];
|
||||||
|
}
|
||||||
|
if (e.pageX < clickXThreshold) {
|
||||||
|
direction = -1;
|
||||||
|
} else if (e.pageX > windowWidth - clickXThreshold) {
|
||||||
|
direction = 1;
|
||||||
|
}
|
||||||
|
const hasNextSlide = changeSlide(direction);
|
||||||
|
const isInThreshold = direction !== 0;
|
||||||
|
return [isInThreshold, hasNextSlide];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (transformRef.current.scale !== 1) return;
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Left': // IE/Edge specific value
|
||||||
|
case 'ArrowLeft':
|
||||||
|
changeSlide(-1);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Right': // IE/Edge specific value
|
||||||
|
case 'ArrowRight':
|
||||||
|
changeSlide(1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateOffsetBoundaries = (
|
||||||
|
{ x, y, scale }: Transform,
|
||||||
|
offsetTop = 0,
|
||||||
|
):[Transform, boolean, boolean] => {
|
||||||
|
if (!initialContentRect) return [{ x, y, scale }, true, true];
|
||||||
|
// Get current content boundaries
|
||||||
|
let inBoundsX = true;
|
||||||
|
let inBoundsY = true;
|
||||||
|
|
||||||
|
const centerX = (windowWidth - windowWidth * scale) / 2;
|
||||||
|
const centerY = (windowHeight - windowHeight * scale) / 2;
|
||||||
|
|
||||||
|
// If content is outside window we calculate offset boundaries
|
||||||
|
// based on initial content rect and current scale
|
||||||
|
const minOffsetX = Math.max(-initialContentRect.left * scale, centerX);
|
||||||
|
const maxOffsetX = windowWidth - initialContentRect.right * scale;
|
||||||
|
inBoundsX = isBetween(x, maxOffsetX, minOffsetX);
|
||||||
|
x = clamp(x, maxOffsetX, minOffsetX);
|
||||||
|
|
||||||
|
const minOffsetY = Math.max(-initialContentRect.top * scale + offsetTop, centerY);
|
||||||
|
const maxOffsetY = windowHeight - initialContentRect.bottom * scale;
|
||||||
|
inBoundsY = isBetween(y, maxOffsetY, minOffsetY);
|
||||||
|
y = clamp(y, maxOffsetY, minOffsetY);
|
||||||
|
|
||||||
|
return [{ x, y, scale }, inBoundsX, inBoundsY];
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = captureEvents(containerRef.current, {
|
||||||
isNotPassive: true,
|
isNotPassive: true,
|
||||||
excludedClosestSelector: '.MediaViewerFooter',
|
withNativeDrag: true,
|
||||||
|
excludedClosestSelector: '.MediaViewerFooter, .ZoomControls',
|
||||||
|
minZoom: MIN_ZOOM,
|
||||||
|
maxZoom: MAX_ZOOM,
|
||||||
|
doubleTapZoom: DOUBLE_TAP_ZOOM,
|
||||||
onCapture: (e) => {
|
onCapture: (e) => {
|
||||||
if (checkIfControlTarget(e)) return;
|
if (checkIfControlTarget(e)) return;
|
||||||
|
if (e.type === 'mousedown') {
|
||||||
|
setIsMouseDown(true);
|
||||||
|
if (transformRef.current.scale !== 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
lastGestureTime = Date.now();
|
lastGestureTime = Date.now();
|
||||||
if (arePropsShallowEqual(transformRef.current, { x: 0, y: 0, scale: 1 })) {
|
if (arePropsShallowEqual(transformRef.current, { x: 0, y: 0, scale: 1 })) {
|
||||||
if (!activeSlideRef.current) return;
|
if (!activeSlideRef.current) return;
|
||||||
@ -193,12 +274,12 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
onDrag: (event, captureEvent, {
|
onDrag: (event, captureEvent, {
|
||||||
dragOffsetX,
|
dragOffsetX,
|
||||||
dragOffsetY,
|
dragOffsetY,
|
||||||
}) => {
|
}, cancelDrag) => {
|
||||||
if (checkIfControlTarget(event)) return;
|
if (checkIfControlTarget(event)) return;
|
||||||
// Avoid conflicts with swipe-to-back gestures
|
// Avoid conflicts with swipe-to-back gestures
|
||||||
if (IS_IOS) {
|
if (IS_IOS && captureEvent.type === 'touchstart') {
|
||||||
const { pageX } = (captureEvent as RealTouchEvent).touches[0];
|
const { pageX } = (captureEvent as RealTouchEvent).touches[0];
|
||||||
if (pageX <= IOS_SCREEN_EDGE_THRESHOLD || pageX >= window.innerWidth - IOS_SCREEN_EDGE_THRESHOLD) {
|
if (pageX <= IOS_SCREEN_EDGE_THRESHOLD || pageX >= windowWidth - IOS_SCREEN_EDGE_THRESHOLD) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -225,15 +306,24 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
|
|
||||||
// If image is scaled we just need to pan it
|
// If image is scaled we just need to pan it
|
||||||
if (scale !== 1) {
|
if (scale !== 1) {
|
||||||
|
const x1 = lastTransform.x + dragOffsetX;
|
||||||
|
const y1 = lastTransform.y + dragOffsetY;
|
||||||
|
if (['wheel', 'mousemove'].includes(event.type)) {
|
||||||
|
const [transform, inBoundsX, inBoundsY] = calculateOffsetBoundaries({ x: x1, y: y1, scale }, HEADER_HEIGHT);
|
||||||
|
if (cancelDrag) cancelDrag(!inBoundsX, !inBoundsY);
|
||||||
|
setTransform(transform);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ('touches' in event && event.touches.length === 1) {
|
if ('touches' in event && event.touches.length === 1) {
|
||||||
setTransform({
|
setTransform({
|
||||||
x: lastTransform.x + dragOffsetX,
|
x: x1,
|
||||||
y: lastTransform.y + dragOffsetY,
|
y: y1,
|
||||||
scale,
|
scale,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (['wheel', 'mousemove'].includes(event.type)) return;
|
||||||
if (swipeDirectionRef.current !== SwipeDirection.Vertical) {
|
if (swipeDirectionRef.current !== SwipeDirection.Vertical) {
|
||||||
// If user is swiping horizontally or horizontal shift is dominant
|
// If user is swiping horizontally or horizontal shift is dominant
|
||||||
// we change only horizontal position
|
// we change only horizontal position
|
||||||
@ -241,8 +331,9 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
|| Math.abs(x) > h || (absOffsetX > h && absOffsetY < h)) {
|
|| Math.abs(x) > h || (absOffsetX > h && absOffsetY < h)) {
|
||||||
swipeDirectionRef.current = SwipeDirection.Horizontal;
|
swipeDirectionRef.current = SwipeDirection.Horizontal;
|
||||||
isActiveRef.current = false;
|
isActiveRef.current = false;
|
||||||
|
const limit = windowWidth + SLIDES_GAP;
|
||||||
setTransform({
|
setTransform({
|
||||||
x: dragOffsetX,
|
x: clamp(dragOffsetX, -limit, limit),
|
||||||
y: 0,
|
y: 0,
|
||||||
scale,
|
scale,
|
||||||
});
|
});
|
||||||
@ -253,14 +344,16 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
if (swipeDirectionRef.current === SwipeDirection.Vertical
|
if (swipeDirectionRef.current === SwipeDirection.Vertical
|
||||||
|| Math.abs(y) > h || (absOffsetY > h && absOffsetX < h)) {
|
|| Math.abs(y) > h || (absOffsetY > h && absOffsetX < h)) {
|
||||||
swipeDirectionRef.current = SwipeDirection.Vertical;
|
swipeDirectionRef.current = SwipeDirection.Vertical;
|
||||||
|
const limit = windowHeight;
|
||||||
setTransform({
|
setTransform({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: dragOffsetY,
|
y: clamp(dragOffsetY, -limit, limit),
|
||||||
scale,
|
scale,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onZoom: (e, {
|
onZoom: (e, {
|
||||||
|
zoom,
|
||||||
zoomFactor,
|
zoomFactor,
|
||||||
initialCenterX,
|
initialCenterX,
|
||||||
initialCenterY,
|
initialCenterY,
|
||||||
@ -269,45 +362,63 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
currentCenterX,
|
currentCenterX,
|
||||||
currentCenterY,
|
currentCenterY,
|
||||||
}) => {
|
}) => {
|
||||||
// Calculate current scale based on zoom factor and limits, add max zoom margin for bounce back effect
|
if (cancelAnimation) cancelAnimation();
|
||||||
const scale = Math.min(MAX_ZOOM * 3, Math.max(lastTransform.scale * zoomFactor, MIN_ZOOM));
|
initialCenterX = initialCenterX || windowWidth / 2;
|
||||||
|
initialCenterY = initialCenterY || windowHeight / 2;
|
||||||
|
currentCenterX = currentCenterX || windowWidth / 2;
|
||||||
|
currentCenterY = currentCenterY || windowHeight / 2;
|
||||||
|
|
||||||
|
// Calculate current scale based on zoom factor and limits, add zoom margin for bounce back effect
|
||||||
|
const scale = zoom ?? clamp(lastTransform.scale * zoomFactor!, MIN_ZOOM * 0.5, MAX_ZOOM * 3);
|
||||||
const scaleFactor = scale / lastTransform.scale;
|
const scaleFactor = scale / lastTransform.scale;
|
||||||
const offsetX = Math.abs(Math.min(lastTransform.x, 0));
|
const offsetX = Math.abs(Math.min(lastTransform.x, 0));
|
||||||
const offsetY = Math.abs(Math.min(lastTransform.y, 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
|
// Save last zoom center for bounce back effect
|
||||||
lastZoomCenter.x = currentCenterX;
|
lastZoomCenter.x = currentCenterX;
|
||||||
lastZoomCenter.y = currentCenterY;
|
lastZoomCenter.y = currentCenterY;
|
||||||
|
|
||||||
|
// Calculate new center relative to the shifted image
|
||||||
|
const scaledCenterX = offsetX + initialCenterX;
|
||||||
|
const scaledCenterY = offsetY + initialCenterY;
|
||||||
|
|
||||||
// Calculate how much we need to shift the image to keep the zoom center at the same position
|
// Calculate how much we need to shift the image to keep the zoom center at the same position
|
||||||
const scaleOffsetX = (scaledCenterX - scaleFactor * scaledCenterX);
|
const scaleOffsetX = (scaledCenterX - scaleFactor * scaledCenterX);
|
||||||
const scaleOffsetY = (scaledCenterY - scaleFactor * scaledCenterY);
|
const scaleOffsetY = (scaledCenterY - scaleFactor * scaledCenterY);
|
||||||
|
|
||||||
setTransform({
|
const [transform] = calculateOffsetBoundaries({
|
||||||
x: lastTransform.x + scaleOffsetX + dragOffsetX,
|
x: lastTransform.x + scaleOffsetX + dragOffsetX,
|
||||||
y: lastTransform.y + scaleOffsetY + dragOffsetY,
|
y: lastTransform.y + scaleOffsetY + dragOffsetY,
|
||||||
scale,
|
scale,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setTransform(transform);
|
||||||
},
|
},
|
||||||
onClick(e) {
|
onClick(e) {
|
||||||
if (changeSlide(e as MouseEvent)) {
|
const [isInThreshold, hasNextSlide] = changeSlideOnClick(e as MouseEvent);
|
||||||
|
if (isInThreshold) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (IS_TOUCH_ENV) return;
|
||||||
|
if (!hasNextSlide) onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastTransform.scale !== 1 || IS_TOUCH_ENV) return;
|
||||||
|
if (shouldCloseOnVideo || !checkIfInsideSelector(e.target as HTMLElement, '.VideoPlayer')) {
|
||||||
|
onClose();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDoubleClick(e, {
|
onDoubleClick(e, {
|
||||||
centerX,
|
centerX,
|
||||||
centerY,
|
centerY,
|
||||||
}) {
|
}) {
|
||||||
if (changeSlide(e as MouseEvent)) {
|
const [isInThreshold] = changeSlideOnClick(e as MouseEvent);
|
||||||
|
if (isInThreshold) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
return undefined;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!IS_TOUCH_ENV && e.type !== 'wheel') return;
|
||||||
// Calculate how much we need to shift the image to keep the zoom center at the same position
|
// 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 scaleOffsetX = (centerX - DOUBLE_TAP_ZOOM * centerX);
|
||||||
const scaleOffsetY = (centerY - DOUBLE_TAP_ZOOM * centerY);
|
const scaleOffsetY = (centerY - DOUBLE_TAP_ZOOM * centerY);
|
||||||
@ -317,12 +428,12 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
y,
|
y,
|
||||||
} = transformRef.current;
|
} = transformRef.current;
|
||||||
if (scale === 1) {
|
if (scale === 1) {
|
||||||
if (x !== 0 || y !== 0) return undefined;
|
if (x !== 0 || y !== 0) return;
|
||||||
lastTransform = {
|
lastTransform = calculateOffsetBoundaries({
|
||||||
x: scaleOffsetX,
|
x: scaleOffsetX,
|
||||||
y: scaleOffsetY,
|
y: scaleOffsetY,
|
||||||
scale: DOUBLE_TAP_ZOOM,
|
scale: DOUBLE_TAP_ZOOM,
|
||||||
};
|
})[0];
|
||||||
} else {
|
} else {
|
||||||
lastTransform = {
|
lastTransform = {
|
||||||
x: 0,
|
x: 0,
|
||||||
@ -330,19 +441,25 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return animateNumber({
|
cancelAnimation = animateNumber({
|
||||||
from: [x, y, scale],
|
from: [x, y, scale],
|
||||||
to: [lastTransform.x, lastTransform.y, lastTransform.scale],
|
to: [lastTransform.x, lastTransform.y, lastTransform.scale],
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
timing: timingFunctions.easeOutCubic,
|
timing: easeOutCubic,
|
||||||
onUpdate: (value) => setTransform({
|
onUpdate: (value) => {
|
||||||
x: value[0],
|
const transform = {
|
||||||
y: value[1],
|
x: value[0],
|
||||||
scale: value[2],
|
y: value[1],
|
||||||
}),
|
scale: value[2],
|
||||||
|
};
|
||||||
|
setTransform(transform);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onRelease: () => {
|
onRelease: (e) => {
|
||||||
|
if (e.type === 'mouseup') {
|
||||||
|
setIsMouseDown(false);
|
||||||
|
}
|
||||||
const absX = Math.abs(transformRef.current.x);
|
const absX = Math.abs(transformRef.current.x);
|
||||||
const absY = Math.abs(transformRef.current.y);
|
const absY = Math.abs(transformRef.current.y);
|
||||||
const {
|
const {
|
||||||
@ -357,29 +474,21 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
// If scale is less than 1 we need to bounce back
|
// If scale is less than 1 we need to bounce back
|
||||||
if (scale < 1) {
|
if (scale < 1) {
|
||||||
lastTransform = { x: 0, y: 0, scale: 1 };
|
lastTransform = { x: 0, y: 0, scale: 1 };
|
||||||
return animateNumber({
|
cancelAnimation = animateNumber({
|
||||||
from: [x, y, scale],
|
from: [x, y, scale],
|
||||||
to: [0, 0, 1],
|
to: [0, 0, 1],
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
timing: timingFunctions.easeOutCubic,
|
timing: easeOutCubic,
|
||||||
onUpdate: (value) => setTransform({
|
onUpdate: (value) => setTransform({
|
||||||
x: value[0],
|
x: value[0],
|
||||||
y: value[1],
|
y: value[1],
|
||||||
scale: value[2],
|
scale: value[2],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
if (scale > 1) {
|
if (scale > 1) {
|
||||||
if (!content || !initialContentRect) {
|
|
||||||
lastTransform = {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
scale,
|
|
||||||
};
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
// Get current content boundaries
|
// Get current content boundaries
|
||||||
const boundaries = content.getBoundingClientRect();
|
|
||||||
const s1 = Math.min(scale, MAX_ZOOM);
|
const s1 = Math.min(scale, MAX_ZOOM);
|
||||||
const scaleFactor = s1 / scale;
|
const scaleFactor = s1 / scale;
|
||||||
|
|
||||||
@ -392,7 +501,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
const k = 0.15;
|
const k = 0.15;
|
||||||
|
|
||||||
// If scale didn't change, we need to add inertia to pan gesture
|
// If scale didn't change, we need to add inertia to pan gesture
|
||||||
if (lastTransform.scale === scale) {
|
if (e.type !== 'wheel' && lastTransform.scale === scale) {
|
||||||
// Calculate user gesture velocity
|
// Calculate user gesture velocity
|
||||||
const Vx = Math.abs(lastDragOffset.x) / (Date.now() - lastGestureTime);
|
const Vx = Math.abs(lastDragOffset.x) / (Date.now() - lastGestureTime);
|
||||||
const Vy = Math.abs(lastDragOffset.y) / (Date.now() - lastGestureTime);
|
const Vy = Math.abs(lastDragOffset.y) / (Date.now() - lastGestureTime);
|
||||||
@ -402,34 +511,12 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
y1 -= Math.abs(lastDragOffset.y) * Vy * k * panDelta.y;
|
y1 -= Math.abs(lastDragOffset.y) * Vy * k * panDelta.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If content is outside window we calculate offset boundaries
|
[lastTransform] = calculateOffsetBoundaries({ x: x1, y: y1, scale: s1 }, HEADER_HEIGHT);
|
||||||
// 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({
|
cancelAnimation = animateNumber({
|
||||||
from: [x, y, scale],
|
from: [x, y, scale],
|
||||||
to: [x1, y1, s1],
|
to: [lastTransform.x, lastTransform.y, lastTransform.scale],
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
timing: timingFunctions.easeOutCubic,
|
timing: easeOutCubic,
|
||||||
onUpdate: (value) => setTransform({
|
onUpdate: (value) => setTransform({
|
||||||
x: value[0],
|
x: value[0],
|
||||||
y: value[1],
|
y: value[1],
|
||||||
@ -443,20 +530,21 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
y,
|
y,
|
||||||
scale,
|
scale,
|
||||||
};
|
};
|
||||||
if (absY >= SWIPE_Y_THRESHOLD) return onClose();
|
if (e.type !== 'wheel' && absY >= SWIPE_Y_THRESHOLD) return onClose();
|
||||||
// Bounce back if vertical swipe is below threshold
|
// Bounce back if vertical swipe is below threshold
|
||||||
if (absY > 0) {
|
if (absY > 0) {
|
||||||
return animateNumber({
|
cancelAnimation = animateNumber({
|
||||||
from: y,
|
from: y,
|
||||||
to: 0,
|
to: 0,
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
timing: timingFunctions.easeOutCubic,
|
timing: easeOutCubic,
|
||||||
onUpdate: (value) => setTransform({
|
onUpdate: (value) => setTransform({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: value,
|
y: value,
|
||||||
scale,
|
scale,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
// Get horizontal swipe direction
|
// Get horizontal swipe direction
|
||||||
const direction = x < 0 ? 1 : -1;
|
const direction = x < 0 ? 1 : -1;
|
||||||
@ -467,7 +555,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
// we need to cancel slide transition
|
// we need to cancel slide transition
|
||||||
const dirX = panDelta.x < 0 ? -1 : 1;
|
const dirX = panDelta.x < 0 ? -1 : 1;
|
||||||
if (mId && absX >= SWIPE_X_THRESHOLD && direction === dirX) {
|
if (mId && absX >= SWIPE_X_THRESHOLD && direction === dirX) {
|
||||||
const offset = (window.innerWidth + SLIDES_GAP) * direction;
|
const offset = (windowWidth + SLIDES_GAP) * direction;
|
||||||
// If image is shifted by more than SWIPE_X_THRESHOLD,
|
// If image is shifted by more than SWIPE_X_THRESHOLD,
|
||||||
// We shift everything by one screen width and then set new active message id
|
// We shift everything by one screen width and then set new active message id
|
||||||
transformRef.current.x += offset;
|
transformRef.current.x += offset;
|
||||||
@ -479,7 +567,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
from: transformRef.current.x,
|
from: transformRef.current.x,
|
||||||
to: 0,
|
to: 0,
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
timing: timingFunctions.easeOutCubic,
|
timing: easeOutCubic,
|
||||||
onUpdate: (value) => setTransform({
|
onUpdate: (value) => setTransform({
|
||||||
y: 0,
|
y: 0,
|
||||||
x: value,
|
x: value,
|
||||||
@ -489,17 +577,58 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
document.addEventListener('keydown', handleKeyDown, false);
|
||||||
|
return () => {
|
||||||
|
cleanup();
|
||||||
|
document.removeEventListener('keydown', handleKeyDown, false);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
isZoomed,
|
|
||||||
onClose,
|
onClose,
|
||||||
setTransform,
|
setTransform,
|
||||||
getMessageId,
|
getMessageId,
|
||||||
activeMessageId,
|
activeMessageId,
|
||||||
|
windowWidth,
|
||||||
|
windowHeight,
|
||||||
|
clickXThreshold,
|
||||||
|
shouldCloseOnVideo,
|
||||||
selectMessageDebounced,
|
selectMessageDebounced,
|
||||||
setIsActiveDebounced,
|
setIsActiveDebounced,
|
||||||
clearSwipeDirectionDebounced,
|
clearSwipeDirectionDebounced,
|
||||||
|
animationLevel,
|
||||||
|
setIsMouseDown,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || !hasZoomChanged) return;
|
||||||
|
const { scale } = transformRef.current;
|
||||||
|
const dir = zoomLevelChange > 0 ? -1 : +1;
|
||||||
|
const minZoom = MIN_ZOOM * 0.5;
|
||||||
|
const maxZoom = MAX_ZOOM * 3;
|
||||||
|
const steps = 100;
|
||||||
|
let prevValue = 0;
|
||||||
|
if (scale <= minZoom && dir > 0) return;
|
||||||
|
if (scale >= maxZoom && dir < 0) return;
|
||||||
|
if (cancelZoomAnimation) cancelZoomAnimation();
|
||||||
|
cancelZoomAnimation = animateNumber({
|
||||||
|
from: dir,
|
||||||
|
to: dir * steps,
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
timing: easeOutQuart,
|
||||||
|
onUpdate: (value) => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const delta = round(value - prevValue, 2);
|
||||||
|
prevValue = value;
|
||||||
|
// To reuse existing logic we trigger wheel event for zoom buttons
|
||||||
|
const wheelEvent = new WheelEvent('wheel', {
|
||||||
|
deltaY: delta,
|
||||||
|
ctrlKey: true,
|
||||||
|
});
|
||||||
|
containerRef.current.dispatchEvent(wheelEvent);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [zoomLevelChange, hasZoomChanged]);
|
||||||
|
|
||||||
if (!activeMessageId) return undefined;
|
if (!activeMessageId) return undefined;
|
||||||
|
|
||||||
const nextMessageId = getMessageId(activeMessageId, 1);
|
const nextMessageId = getMessageId(activeMessageId, 1);
|
||||||
@ -510,18 +639,24 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="MediaViewerSlides" ref={containerRef}>
|
<div className="MediaViewerSlides" ref={containerRef}>
|
||||||
{previousMessageId && scale === 1 && (
|
{previousMessageId && scale === 1 && !isResizing && (
|
||||||
<div className="MediaViewerSlide" style={getAnimationStyle(-window.innerWidth + offsetX - SLIDES_GAP)}>
|
<div className="MediaViewerSlide" style={getAnimationStyle(-windowWidth + offsetX - SLIDES_GAP)}>
|
||||||
<MediaViewerContent
|
<MediaViewerContent
|
||||||
/* eslint-disable-next-line react/jsx-props-no-spreading */
|
/* eslint-disable-next-line react/jsx-props-no-spreading */
|
||||||
{...rest}
|
{...rest}
|
||||||
|
animationLevel={animationLevel}
|
||||||
|
isFooterHidden={isFooterHidden}
|
||||||
messageId={previousMessageId}
|
messageId={previousMessageId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeMessageId && (
|
{activeMessageId && (
|
||||||
<div
|
<div
|
||||||
className={`MediaViewerSlide ${isActive ? 'MediaViewerSlide--active' : ''}`}
|
className={buildClassName(
|
||||||
|
'MediaViewerSlide',
|
||||||
|
isActive && 'MediaViewerSlide--active',
|
||||||
|
isMouseDown && scale > 1 && 'MediaViewerSlide--moving',
|
||||||
|
)}
|
||||||
onClick={handleToggleFooterVisibility}
|
onClick={handleToggleFooterVisibility}
|
||||||
ref={activeSlideRef}
|
ref={activeSlideRef}
|
||||||
style={getAnimationStyle(offsetX, offsetY, scale)}
|
style={getAnimationStyle(offsetX, offsetY, scale)}
|
||||||
@ -530,21 +665,40 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
/* eslint-disable-next-line react/jsx-props-no-spreading */
|
/* eslint-disable-next-line react/jsx-props-no-spreading */
|
||||||
{...rest}
|
{...rest}
|
||||||
messageId={activeMessageId}
|
messageId={activeMessageId}
|
||||||
|
animationLevel={animationLevel}
|
||||||
isActive={isActive && isActiveRef.current}
|
isActive={isActive && isActiveRef.current}
|
||||||
setIsFooterHidden={setIsFooterHidden}
|
setIsFooterHidden={setIsFooterHidden}
|
||||||
isFooterHidden={isFooterHidden || isZoomed || scale !== 1}
|
isFooterHidden={isFooterHidden || scale !== 1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{nextMessageId && scale === 1 && (
|
{nextMessageId && scale === 1 && !isResizing && (
|
||||||
<div className="MediaViewerSlide" style={getAnimationStyle(window.innerWidth + offsetX + SLIDES_GAP)}>
|
<div className="MediaViewerSlide" style={getAnimationStyle(windowWidth + offsetX + SLIDES_GAP)}>
|
||||||
<MediaViewerContent
|
<MediaViewerContent
|
||||||
/* eslint-disable-next-line react/jsx-props-no-spreading */
|
/* eslint-disable-next-line react/jsx-props-no-spreading */
|
||||||
{...rest}
|
{...rest}
|
||||||
|
animationLevel={animationLevel}
|
||||||
|
isFooterHidden={isFooterHidden}
|
||||||
messageId={nextMessageId}
|
messageId={nextMessageId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{previousMessageId && scale === 1 && !IS_TOUCH_ENV && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`navigation prev ${isVideo && !isGif && 'inline'}`}
|
||||||
|
aria-label={lang('AccDescrPrevious')}
|
||||||
|
dir={lang.isRtl ? 'rtl' : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{nextMessageId && scale === 1 && !IS_TOUCH_ENV && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`navigation next ${isVideo && !isGif && 'inline'}`}
|
||||||
|
aria-label={lang('Next')}
|
||||||
|
dir={lang.isRtl ? 'rtl' : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
.pan-wrapper,
|
|
||||||
.pan-container {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pan-wrapper {
|
|
||||||
cursor: move;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pan-container {
|
|
||||||
transition: transform 0.2s ease-in;
|
|
||||||
|
|
||||||
.pan-wrapper.move & {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoomed & {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,212 +0,0 @@
|
|||||||
/*
|
|
||||||
@source https://github.com/ajainarayanan/react-pan-zoom
|
|
||||||
|
|
||||||
Heavily inspired/lifted from this idea: https://stackoverflow.com/a/39311435/661768
|
|
||||||
without jqueryUI or jquery dependency.
|
|
||||||
*/
|
|
||||||
import type { FC } from '../../lib/teact/teact';
|
|
||||||
import React, {
|
|
||||||
memo, useEffect, useRef, useState,
|
|
||||||
} from '../../lib/teact/teact';
|
|
||||||
|
|
||||||
import { areSortedArraysEqual } from '../../util/iteratees';
|
|
||||||
|
|
||||||
import './PanZoom.scss';
|
|
||||||
|
|
||||||
export interface IDragData {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
dx: number;
|
|
||||||
dy: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OwnProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
noWrap: boolean;
|
|
||||||
canPan: boolean;
|
|
||||||
zoomLevel: number;
|
|
||||||
panDeltaX: number;
|
|
||||||
panDeltaY: number;
|
|
||||||
onPan?: (x: number, y: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const INITIAL_MATRIX = [
|
|
||||||
1, 0, 0, 1, 0, 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
const SCALE_VALUES = {
|
|
||||||
1: 1,
|
|
||||||
1.5: 1.5,
|
|
||||||
2: 2.2,
|
|
||||||
2.5: 3.3,
|
|
||||||
3: 5.5,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ZOOM_SAFE_AREA = 150;
|
|
||||||
|
|
||||||
function calculateSafeZoneOnZoom(oldScale: number, matrixData: number[], wrapper: HTMLDivElement | null) {
|
|
||||||
const image = wrapper && wrapper.querySelector('.Transition__slide--active img');
|
|
||||||
if (!wrapper || !image) {
|
|
||||||
return matrixData;
|
|
||||||
}
|
|
||||||
const wrapperRect = wrapper.getBoundingClientRect();
|
|
||||||
const imageRect = image.getBoundingClientRect();
|
|
||||||
|
|
||||||
const newImgWidth = (imageRect.width / oldScale) * matrixData[0];
|
|
||||||
const newImgHeight = (imageRect.height / oldScale) * matrixData[3];
|
|
||||||
const newImgX = (wrapperRect.width - newImgWidth) / 2 + matrixData[4];
|
|
||||||
const newImgY = (wrapperRect.height - newImgHeight) / 2 + matrixData[5];
|
|
||||||
if (wrapperRect.width && wrapperRect.width - ZOOM_SAFE_AREA < newImgX) {
|
|
||||||
matrixData[4] -= newImgX + wrapperRect.width - ZOOM_SAFE_AREA;
|
|
||||||
} else if (newImgWidth && newImgWidth + newImgX < ZOOM_SAFE_AREA) {
|
|
||||||
matrixData[4] -= newImgWidth + newImgX - ZOOM_SAFE_AREA;
|
|
||||||
}
|
|
||||||
if (wrapperRect.height && wrapperRect.height - ZOOM_SAFE_AREA < newImgY) {
|
|
||||||
matrixData[5] -= newImgY + wrapperRect.height - ZOOM_SAFE_AREA;
|
|
||||||
} else if (newImgHeight && newImgHeight + newImgY < ZOOM_SAFE_AREA) {
|
|
||||||
matrixData[5] -= newImgHeight + newImgY - ZOOM_SAFE_AREA;
|
|
||||||
}
|
|
||||||
|
|
||||||
return matrixData;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PanZoom: FC<OwnProps> = ({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
noWrap,
|
|
||||||
canPan,
|
|
||||||
zoomLevel,
|
|
||||||
panDeltaX,
|
|
||||||
panDeltaY,
|
|
||||||
onPan,
|
|
||||||
}) => {
|
|
||||||
const tunedZoomLevel = SCALE_VALUES[zoomLevel as keyof typeof SCALE_VALUES] || zoomLevel;
|
|
||||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
|
||||||
const [dragData, setDragData] = useState<IDragData>({
|
|
||||||
dx: panDeltaX, dy: panDeltaY, x: 0, y: 0,
|
|
||||||
});
|
|
||||||
// [zoom, skew, skew, zoom, dx, dy] - see https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix()
|
|
||||||
const [matrixData, setMatrixData] = useState<number[]>(INITIAL_MATRIX);
|
|
||||||
// Used to set cursor while moving.
|
|
||||||
// eslint-disable-next-line no-null/no-null
|
|
||||||
const panWrapperRef = useRef<HTMLDivElement>(null);
|
|
||||||
// Used to set transform for pan.
|
|
||||||
// eslint-disable-next-line no-null/no-null
|
|
||||||
const panContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const newZoomLevel = tunedZoomLevel || matrixData[0];
|
|
||||||
const newPandx = panDeltaX || matrixData[4];
|
|
||||||
const newPandy = panDeltaY || matrixData[5];
|
|
||||||
|
|
||||||
const newMatrixData = [...matrixData];
|
|
||||||
if (matrixData[0] !== newZoomLevel) {
|
|
||||||
newMatrixData[0] = newZoomLevel || newMatrixData[0];
|
|
||||||
newMatrixData[3] = newZoomLevel || newMatrixData[3];
|
|
||||||
}
|
|
||||||
if (matrixData[4] !== newPandx) {
|
|
||||||
newMatrixData[4] = newPandx;
|
|
||||||
}
|
|
||||||
if (matrixData[5] !== newPandy) {
|
|
||||||
newMatrixData[5] = newPandy;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!areSortedArraysEqual(matrixData, newMatrixData)) {
|
|
||||||
setMatrixData(calculateSafeZoneOnZoom(matrixData[0], newMatrixData, panWrapperRef.current));
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line
|
|
||||||
}, [panDeltaX, panDeltaY, tunedZoomLevel]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canPan) {
|
|
||||||
setMatrixData(INITIAL_MATRIX);
|
|
||||||
}
|
|
||||||
}, [canPan]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (panContainerRef.current) {
|
|
||||||
panContainerRef.current.style.transform = `matrix(${matrixData.toString()})`;
|
|
||||||
}
|
|
||||||
}, [noWrap, matrixData]);
|
|
||||||
|
|
||||||
const handleMouseDown = (e: React.MouseEvent<EventTarget>) => {
|
|
||||||
if (!canPan) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const offsetX = matrixData[4];
|
|
||||||
const offsetY = matrixData[5];
|
|
||||||
const newDragData: IDragData = {
|
|
||||||
dx: offsetX,
|
|
||||||
dy: offsetY,
|
|
||||||
x: e.pageX,
|
|
||||||
y: e.pageY,
|
|
||||||
};
|
|
||||||
setDragData(newDragData);
|
|
||||||
setIsDragging(true);
|
|
||||||
|
|
||||||
if (panWrapperRef.current) {
|
|
||||||
panWrapperRef.current.classList.add('move');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
setIsDragging(false);
|
|
||||||
|
|
||||||
if (panWrapperRef.current) {
|
|
||||||
panWrapperRef.current.classList.remove('move');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onPan) {
|
|
||||||
onPan(matrixData[4], matrixData[5]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function getNewMatrixData(x: number, y: number): number[] {
|
|
||||||
const newMatrixData = [...matrixData];
|
|
||||||
const deltaX = dragData.x - x;
|
|
||||||
const deltaY = dragData.y - y;
|
|
||||||
newMatrixData[4] = dragData.dx - deltaX;
|
|
||||||
newMatrixData[5] = dragData.dy - deltaY;
|
|
||||||
|
|
||||||
return newMatrixData;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent<EventTarget>) => {
|
|
||||||
if (isDragging) {
|
|
||||||
const newMatrixData = getNewMatrixData(e.pageX, e.pageY);
|
|
||||||
setMatrixData(newMatrixData);
|
|
||||||
|
|
||||||
if (panContainerRef.current) {
|
|
||||||
panContainerRef.current.style.transform = `matrix(${matrixData.toString()})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (noWrap) {
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={panWrapperRef}
|
|
||||||
className={`pan-wrapper ${className || ''}`}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onMouseUp={handleMouseUp}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={panContainerRef}
|
|
||||||
className="pan-container"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(PanZoom);
|
|
||||||
@ -1,14 +1,12 @@
|
|||||||
.SenderInfo {
|
.SenderInfo {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
opacity: 0.5;
|
transition: 0.15s color;
|
||||||
transition: 0.15s opacity;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 1;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Avatar {
|
.Avatar {
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1rem 0.5rem 0.5rem;
|
padding: 2rem 0.5rem 0.5rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
background: linear-gradient(to top, #000 0%, rgba(0, 0, 0, 0) 100%);
|
background: linear-gradient(to top, #000 0%, rgba(0, 0, 0, 0) 100%);
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
@ -95,7 +95,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 1rem;
|
left: 1rem;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
top: 0;
|
top: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@ -1,100 +0,0 @@
|
|||||||
.ZoomControls {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1.25rem;
|
|
||||||
left: 50%;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
border-radius: var(--border-radius-default);
|
|
||||||
width: 100%;
|
|
||||||
height: 3.375rem;
|
|
||||||
max-width: 274px;
|
|
||||||
transform: translate3d(-50%, 0, 0.625rem);
|
|
||||||
transition: opacity 0.3s ease-in;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
&.open {
|
|
||||||
z-index: var(--z-media-viewer);
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-out,
|
|
||||||
.zoom-in {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.4375rem;
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
color: #fff;
|
|
||||||
|
|
||||||
i {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-out {
|
|
||||||
left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-in {
|
|
||||||
right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.seekline {
|
|
||||||
position: absolute;
|
|
||||||
left: 3.25rem;
|
|
||||||
right: 3.25rem;
|
|
||||||
top: 1.125rem;
|
|
||||||
height: 1rem;
|
|
||||||
|
|
||||||
&-track {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: -0.25rem;
|
|
||||||
right: -0.25rem;
|
|
||||||
height: 2px;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background-color: rgba(255, 255, 255, 0.5);
|
|
||||||
border-radius: var(--border-radius-default);
|
|
||||||
}
|
|
||||||
|
|
||||||
&-played,
|
|
||||||
&-input {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: var(--border-radius-default);
|
|
||||||
}
|
|
||||||
|
|
||||||
&-played {
|
|
||||||
background: #fff;
|
|
||||||
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
|
|
||||||
transition: width 200ms;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
width: 0.75rem;
|
|
||||||
height: 0.75rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--color-white);
|
|
||||||
right: 0;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(0.325rem, -50%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-input {
|
|
||||||
width: 100%;
|
|
||||||
height: 1rem;
|
|
||||||
top: -0.375rem;
|
|
||||||
opacity: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&::-webkit-slider-thumb {
|
|
||||||
margin-top: -2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,126 +0,0 @@
|
|||||||
import type { FC } from '../../lib/teact/teact';
|
|
||||||
import React, {
|
|
||||||
memo, useCallback, useEffect, useRef, useState,
|
|
||||||
} from '../../lib/teact/teact';
|
|
||||||
|
|
||||||
import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
|
|
||||||
import buildClassName from '../../util/buildClassName';
|
|
||||||
import usePrevious from '../../hooks/usePrevious';
|
|
||||||
import useShowTransition from '../../hooks/useShowTransition';
|
|
||||||
import useLang from '../../hooks/useLang';
|
|
||||||
|
|
||||||
import Button from '../ui/Button';
|
|
||||||
|
|
||||||
import './ZoomControls.scss';
|
|
||||||
|
|
||||||
type OwnProps = {
|
|
||||||
isShown: boolean;
|
|
||||||
onChangeZoom: (level: number, canCloseZoom?: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MAX_ZOOM_LEVEL = 3;
|
|
||||||
export const MIN_ZOOM_LEVEL = 1;
|
|
||||||
const ONE_STEP_PERCENT = 100 / (MAX_ZOOM_LEVEL - MIN_ZOOM_LEVEL);
|
|
||||||
const RESET_ZOOM_LEVEL = 1.5;
|
|
||||||
|
|
||||||
const ZoomControls: FC<OwnProps> = ({ isShown, onChangeZoom }) => {
|
|
||||||
const { transitionClassNames } = useShowTransition(isShown);
|
|
||||||
const prevIsShown = usePrevious<boolean>(isShown);
|
|
||||||
// eslint-disable-next-line no-null/no-null
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [zoomLevel, setZoomLevel] = useState(1);
|
|
||||||
const isSeeking = useRef<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isShown && !prevIsShown) {
|
|
||||||
setZoomLevel(RESET_ZOOM_LEVEL);
|
|
||||||
}
|
|
||||||
}, [isShown, prevIsShown]);
|
|
||||||
|
|
||||||
const handleZoomOut = useCallback(() => {
|
|
||||||
if (inputRef.current) {
|
|
||||||
setZoomLevel(Math.max(MIN_ZOOM_LEVEL, zoomLevel - 0.5));
|
|
||||||
}
|
|
||||||
}, [zoomLevel]);
|
|
||||||
|
|
||||||
const handleZoomIn = useCallback(() => {
|
|
||||||
if (inputRef.current) {
|
|
||||||
setZoomLevel(Math.min(MAX_ZOOM_LEVEL, zoomLevel + 0.5));
|
|
||||||
}
|
|
||||||
}, [zoomLevel]);
|
|
||||||
|
|
||||||
const handleStartSeek = useCallback(() => {
|
|
||||||
isSeeking.current = true;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleStopSeek = useCallback(() => {
|
|
||||||
isSeeking.current = false;
|
|
||||||
if (zoomLevel === 1) {
|
|
||||||
onChangeZoom(zoomLevel, !isSeeking.current);
|
|
||||||
}
|
|
||||||
}, [onChangeZoom, zoomLevel]);
|
|
||||||
|
|
||||||
const handleSeeklineChange = (e:React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setZoomLevel(Math.min(MAX_ZOOM_LEVEL, Math.max(Number(e.target.value), MIN_ZOOM_LEVEL)));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onChangeZoom(zoomLevel, !isSeeking.current);
|
|
||||||
}, [zoomLevel, onChangeZoom]);
|
|
||||||
|
|
||||||
const lang = useLang();
|
|
||||||
|
|
||||||
const className = buildClassName(
|
|
||||||
'ZoomControls',
|
|
||||||
transitionClassNames,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<Button
|
|
||||||
disabled={zoomLevel === MIN_ZOOM_LEVEL}
|
|
||||||
size="tiny"
|
|
||||||
color="translucent-white"
|
|
||||||
ariaLabel={lang('ZoomOut')}
|
|
||||||
className="zoom-out"
|
|
||||||
ripple={!IS_SINGLE_COLUMN_LAYOUT}
|
|
||||||
onClick={handleZoomOut}
|
|
||||||
>
|
|
||||||
<i className="icon-zoom-out" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={zoomLevel === MAX_ZOOM_LEVEL}
|
|
||||||
size="tiny"
|
|
||||||
color="translucent-white"
|
|
||||||
ariaLabel="Zoom In"
|
|
||||||
className="zoom-in"
|
|
||||||
ripple={!IS_SINGLE_COLUMN_LAYOUT}
|
|
||||||
onClick={handleZoomIn}
|
|
||||||
>
|
|
||||||
<i className="icon-zoom-in" />
|
|
||||||
</Button>
|
|
||||||
<div className="seekline">
|
|
||||||
<div className="seekline-track">
|
|
||||||
<div
|
|
||||||
className="seekline-played"
|
|
||||||
style={`width: ${(zoomLevel - 1) * ONE_STEP_PERCENT}%`}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
min={MIN_ZOOM_LEVEL}
|
|
||||||
max={MAX_ZOOM_LEVEL}
|
|
||||||
step="0.5"
|
|
||||||
value={zoomLevel}
|
|
||||||
type="range"
|
|
||||||
className="seekline-input"
|
|
||||||
onChange={handleSeeklineChange}
|
|
||||||
onMouseDown={handleStartSeek}
|
|
||||||
onMouseUp={handleStopSeek}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(ZoomControls);
|
|
||||||
@ -8,6 +8,7 @@ import type { ApiMessage, ApiDimensions } from '../../../../api/types';
|
|||||||
|
|
||||||
import { getAvailableWidth, REM } from '../../../common/helpers/mediaDimensions';
|
import { getAvailableWidth, REM } from '../../../common/helpers/mediaDimensions';
|
||||||
import { calculateMediaDimensions } from './mediaDimensions';
|
import { calculateMediaDimensions } from './mediaDimensions';
|
||||||
|
import { clamp } from '../../../../util/math';
|
||||||
|
|
||||||
export const AlbumRectPart = {
|
export const AlbumRectPart = {
|
||||||
None: 0,
|
None: 0,
|
||||||
@ -67,12 +68,10 @@ function accumulate(list: number[], initValue: number) {
|
|||||||
return list.reduce((accumulator, item) => accumulator + item, initValue);
|
return list.reduce((accumulator, item) => accumulator + item, initValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(num: number, low: number, high: number) {
|
|
||||||
return num < low ? low : (num > high ? high : num);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cropRatios(ratios: number[], averageRatio: number) {
|
function cropRatios(ratios: number[], averageRatio: number) {
|
||||||
return ratios.map((ratio) => (averageRatio > 1.1 ? clamp(ratio, 1, 2.75) : clamp(ratio, 0.6667, 1)));
|
return ratios.map((ratio) => {
|
||||||
|
return (averageRatio > 1.1 ? clamp(ratio, 1, 2.75) : clamp(ratio, 0.6667, 1));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateContainerSize(layout: IMediaLayout[]) {
|
function calculateContainerSize(layout: IMediaLayout[]) {
|
||||||
|
|||||||
@ -1,27 +1,40 @@
|
|||||||
import { useEffect, useState } from '../lib/teact/teact';
|
import { useEffect, useState } from '../lib/teact/teact';
|
||||||
|
import type { ApiDimensions } from '../api/types';
|
||||||
|
|
||||||
import { throttle } from '../util/schedulers';
|
import { throttle } from '../util/schedulers';
|
||||||
import windowSize from '../util/windowSize';
|
import windowSize from '../util/windowSize';
|
||||||
import type { ApiDimensions } from '../api/types';
|
import useDebouncedCallback from './useDebouncedCallback';
|
||||||
|
|
||||||
const THROTTLE = 250;
|
const THROTTLE = 250;
|
||||||
|
|
||||||
const useWindowSize = () => {
|
const useWindowSize = () => {
|
||||||
const [size, setSize] = useState<ApiDimensions>(windowSize.get());
|
const [size, setSize] = useState<ApiDimensions>(windowSize.get());
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const setIsResizingDebounced = useDebouncedCallback(setIsResizing, [], THROTTLE, true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = throttle(() => {
|
const throttledSetIsResizing = throttle(() => {
|
||||||
|
setIsResizing(true);
|
||||||
|
}, THROTTLE, true);
|
||||||
|
|
||||||
|
const throttledSetSize = throttle(() => {
|
||||||
setSize(windowSize.get());
|
setSize(windowSize.get());
|
||||||
|
setIsResizingDebounced(false);
|
||||||
}, THROTTLE, false);
|
}, THROTTLE, false);
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
throttledSetIsResizing();
|
||||||
|
throttledSetSize();
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', handleResize);
|
window.removeEventListener('resize', handleResize);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [setIsResizingDebounced]);
|
||||||
|
|
||||||
return size;
|
return { ...size, isResizing };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useWindowSize;
|
export default useWindowSize;
|
||||||
|
|||||||
@ -45,20 +45,20 @@ export type AnimateNumberProps = {
|
|||||||
export const timingFunctions = {
|
export const timingFunctions = {
|
||||||
linear: (t: number) => t,
|
linear: (t: number) => t,
|
||||||
easeIn: (t: number) => t ** 1.675,
|
easeIn: (t: number) => t ** 1.675,
|
||||||
easeOut: (t: number) => 1 - (1 - t ** 1.675),
|
easeOut: (t: number) => -1 * t ** 1.675,
|
||||||
easeInOut: (t: number) => 0.5 * (Math.sin((t - 0.5) * Math.PI) + 1),
|
easeInOut: (t: number) => 0.5 * (Math.sin((t - 0.5) * Math.PI) + 1),
|
||||||
easeInQuad: (t: number) => t * t,
|
easeInQuad: (t: number) => t * t,
|
||||||
easeOutQuad: (t: number) => t * (2 - t),
|
easeOutQuad: (t: number) => t * (2 - t),
|
||||||
easeInOutQuad: (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
easeInOutQuad: (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
||||||
easeInCubic: (t: number) => t * t * t,
|
easeInCubic: (t: number) => t ** 3,
|
||||||
easeOutCubic: (t: number) => (--t) * t * t + 1,
|
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),
|
easeInOutCubic: (t: number) => (t < 0.5 ? 4 * t ** 3 : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1),
|
||||||
easeInQuart: (t: number) => t * t * t * t,
|
easeInQuart: (t: number) => t ** 4,
|
||||||
easeOutQuart: (t: number) => 1 - (--t) * t * t * t,
|
easeOutQuart: (t: number) => 1 - (--t) * t ** 3,
|
||||||
easeInOutQuart: (t: number) => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t),
|
easeInOutQuart: (t: number) => (t < 0.5 ? 8 * t ** 4 : 1 - 8 * (--t) * t ** 3),
|
||||||
easeInQuint: (t: number) => t * t * t * t * t,
|
easeInQuint: (t: number) => t ** 5,
|
||||||
easeOutQuint: (t: number) => 1 + (--t) * t * t * t * t,
|
easeOutQuint: (t: number) => 1 + (--t) * t ** 4,
|
||||||
easeInOutQuint: (t: number) => (t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * (--t) * t * t * t * t),
|
easeInOutQuint: (t: number) => (t < 0.5 ? 16 * t ** 5 : 1 + 16 * (--t) * t ** 4),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function animateNumber({
|
export function animateNumber({
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { IS_IOS } from './environment';
|
import { IS_IOS } from './environment';
|
||||||
|
import { clamp, round } from './math';
|
||||||
|
import { debounce } from './schedulers';
|
||||||
|
|
||||||
export enum SwipeDirection {
|
export enum SwipeDirection {
|
||||||
Up,
|
Up,
|
||||||
@ -11,17 +13,20 @@ interface CaptureOptions {
|
|||||||
onCapture?: (e: MouseEvent | TouchEvent) => void;
|
onCapture?: (e: MouseEvent | TouchEvent) => void;
|
||||||
onRelease?: (e: MouseEvent | TouchEvent) => void;
|
onRelease?: (e: MouseEvent | TouchEvent) => void;
|
||||||
onDrag?: (
|
onDrag?: (
|
||||||
e: MouseEvent | TouchEvent,
|
e: MouseEvent | TouchEvent | WheelEvent,
|
||||||
captureEvent: MouseEvent | TouchEvent,
|
captureEvent: MouseEvent | TouchEvent | WheelEvent,
|
||||||
params: {
|
params: {
|
||||||
dragOffsetX: number;
|
dragOffsetX: number;
|
||||||
dragOffsetY: number;
|
dragOffsetY: number;
|
||||||
},
|
},
|
||||||
|
cancelDrag?: (x: boolean, y: boolean) => void,
|
||||||
) => void;
|
) => void;
|
||||||
onSwipe?: (e: Event, direction: SwipeDirection) => boolean;
|
onSwipe?: (e: Event, direction: SwipeDirection) => boolean;
|
||||||
onZoom?: (e: TouchEvent, params: {
|
onZoom?: (e: TouchEvent | WheelEvent, params: {
|
||||||
|
// Absolute zoom level
|
||||||
|
zoom?: number;
|
||||||
// Relative zoom factor
|
// Relative zoom factor
|
||||||
zoomFactor: number;
|
zoomFactor?: number;
|
||||||
|
|
||||||
// center coordinate of the initial pinch
|
// center coordinate of the initial pinch
|
||||||
initialCenterX: number;
|
initialCenterX: number;
|
||||||
@ -39,8 +44,11 @@ interface CaptureOptions {
|
|||||||
onDoubleClick?: (e: MouseEvent | RealTouchEvent, params: { centerX: number; centerY: number }) => void;
|
onDoubleClick?: (e: MouseEvent | RealTouchEvent, params: { centerX: number; centerY: number }) => void;
|
||||||
excludedClosestSelector?: string;
|
excludedClosestSelector?: string;
|
||||||
selectorToPreventScroll?: string;
|
selectorToPreventScroll?: string;
|
||||||
|
withNativeDrag?: boolean;
|
||||||
maxZoom?: number;
|
maxZoom?: number;
|
||||||
minZoom?: number;
|
minZoom?: number;
|
||||||
|
doubleTapZoom?: number;
|
||||||
|
initialZoom?: number;
|
||||||
isNotPassive?: boolean;
|
isNotPassive?: boolean;
|
||||||
withCursor?: boolean;
|
withCursor?: boolean;
|
||||||
}
|
}
|
||||||
@ -63,7 +71,7 @@ const SWIPE_THRESHOLD = 50;
|
|||||||
|
|
||||||
function getDistance(a: Touch, b?: Touch) {
|
function getDistance(a: Touch, b?: Touch) {
|
||||||
if (!b) return 0;
|
if (!b) return 0;
|
||||||
return Math.sqrt((b.pageX - a.pageX) ** 2 + (b.pageY - a.pageY) ** 2);
|
return Math.hypot((b.pageX - a.pageX), (b.pageY - a.pageY));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTouchCenter(a: Touch, b: Touch) {
|
function getTouchCenter(a: Touch, b: Touch) {
|
||||||
@ -76,12 +84,27 @@ function getTouchCenter(a: Touch, b: Touch) {
|
|||||||
let lastClickTime = 0;
|
let lastClickTime = 0;
|
||||||
|
|
||||||
export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
||||||
let captureEvent: MouseEvent | RealTouchEvent | undefined;
|
let captureEvent: MouseEvent | RealTouchEvent | WheelEvent | undefined;
|
||||||
let hasMoved = false;
|
let hasMoved = false;
|
||||||
let hasSwiped = false;
|
let hasSwiped = false;
|
||||||
|
let isZooming = false;
|
||||||
let initialDistance = 0;
|
let initialDistance = 0;
|
||||||
let initialTouchCenter = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
let wheelZoom = options.initialZoom ?? 1;
|
||||||
|
let initialDragOffset = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
};
|
||||||
|
let isDragCanceled = {
|
||||||
|
x: false,
|
||||||
|
y: false,
|
||||||
|
};
|
||||||
|
let initialTouchCenter = {
|
||||||
|
x: window.innerWidth / 2,
|
||||||
|
y: window.innerHeight / 2,
|
||||||
|
};
|
||||||
let initialSwipeAxis: TSwipeAxis | undefined;
|
let initialSwipeAxis: TSwipeAxis | undefined;
|
||||||
|
const minZoom = options.minZoom ?? 1;
|
||||||
|
const maxZoom = options.maxZoom ?? 4;
|
||||||
|
|
||||||
function onCapture(e: MouseEvent | RealTouchEvent) {
|
function onCapture(e: MouseEvent | RealTouchEvent) {
|
||||||
if (options.excludedClosestSelector && (
|
if (options.excludedClosestSelector && (
|
||||||
@ -94,7 +117,7 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
|||||||
captureEvent = e;
|
captureEvent = e;
|
||||||
|
|
||||||
if (e.type === 'mousedown') {
|
if (e.type === 'mousedown') {
|
||||||
if (options.onDrag) {
|
if (!options.withNativeDrag && options.onDrag) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,9 +169,10 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
|||||||
(captureEvent.target as HTMLElement).removeEventListener('touchmove', onMove);
|
(captureEvent.target as HTMLElement).removeEventListener('touchmove', onMove);
|
||||||
|
|
||||||
if (IS_IOS && options.selectorToPreventScroll) {
|
if (IS_IOS && options.selectorToPreventScroll) {
|
||||||
Array.from(document.querySelectorAll<HTMLElement>(options.selectorToPreventScroll)).forEach((scrollable) => {
|
Array.from(document.querySelectorAll<HTMLElement>(options.selectorToPreventScroll))
|
||||||
scrollable.style.overflow = '';
|
.forEach((scrollable) => {
|
||||||
});
|
scrollable.style.overflow = '';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e) {
|
if (e) {
|
||||||
@ -172,9 +196,22 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
|||||||
|
|
||||||
hasMoved = false;
|
hasMoved = false;
|
||||||
hasSwiped = false;
|
hasSwiped = false;
|
||||||
|
isZooming = false;
|
||||||
initialDistance = 0;
|
initialDistance = 0;
|
||||||
|
wheelZoom = clamp(wheelZoom, minZoom, maxZoom);
|
||||||
initialSwipeAxis = undefined;
|
initialSwipeAxis = undefined;
|
||||||
initialTouchCenter = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
initialDragOffset = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
};
|
||||||
|
isDragCanceled = {
|
||||||
|
x: false,
|
||||||
|
y: false,
|
||||||
|
};
|
||||||
|
initialTouchCenter = {
|
||||||
|
x: window.innerWidth / 2,
|
||||||
|
y: window.innerHeight / 2,
|
||||||
|
};
|
||||||
captureEvent = undefined;
|
captureEvent = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,7 +255,10 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
|||||||
let shouldPreventScroll = false;
|
let shouldPreventScroll = false;
|
||||||
|
|
||||||
if (options.onDrag) {
|
if (options.onDrag) {
|
||||||
options.onDrag(e, captureEvent, { dragOffsetX, dragOffsetY });
|
options.onDrag(e, captureEvent, {
|
||||||
|
dragOffsetX,
|
||||||
|
dragOffsetY,
|
||||||
|
});
|
||||||
shouldPreventScroll = true;
|
shouldPreventScroll = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,9 +268,10 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (IS_IOS && shouldPreventScroll && options.selectorToPreventScroll) {
|
if (IS_IOS && shouldPreventScroll && options.selectorToPreventScroll) {
|
||||||
Array.from(document.querySelectorAll<HTMLElement>(options.selectorToPreventScroll)).forEach((scrollable) => {
|
Array.from(document.querySelectorAll<HTMLElement>(options.selectorToPreventScroll))
|
||||||
scrollable.style.overflow = 'hidden';
|
.forEach((scrollable) => {
|
||||||
});
|
scrollable.style.overflow = 'hidden';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -276,12 +317,71 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
|||||||
return processSwipe(e, axis, dragOffsetX, dragOffsetY, options.onSwipe!);
|
return processSwipe(e, axis, dragOffsetX, dragOffsetY, options.onSwipe!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const releaseWheel = debounce(onRelease, 100, false);
|
||||||
|
|
||||||
|
function onWheel(e: WheelEvent) {
|
||||||
|
if (!options.onZoom && !options.onDrag) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!hasMoved) {
|
||||||
|
onCapture(e);
|
||||||
|
hasMoved = true;
|
||||||
|
initialTouchCenter = {
|
||||||
|
x: e.x,
|
||||||
|
y: e.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { doubleTapZoom = 3 } = options;
|
||||||
|
if (options.onDoubleClick && Object.is(e.deltaX, -0) && Object.is(e.deltaY, -0) && e.ctrlKey) {
|
||||||
|
wheelZoom = wheelZoom > 1 ? 1 : doubleTapZoom;
|
||||||
|
options.onDoubleClick(e, { centerX: e.pageX, centerY: e.pageY });
|
||||||
|
hasMoved = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const metaKeyPressed = e.metaKey || e.ctrlKey || e.shiftKey;
|
||||||
|
if (options.onZoom && metaKeyPressed) {
|
||||||
|
isZooming = true;
|
||||||
|
const dragOffsetX = e.x - initialTouchCenter.x;
|
||||||
|
const dragOffsetY = e.y - initialTouchCenter.y;
|
||||||
|
const delta = clamp(e.deltaY, -25, 25);
|
||||||
|
wheelZoom -= delta * 0.01;
|
||||||
|
wheelZoom = clamp(wheelZoom, minZoom * 0.5, maxZoom * 3);
|
||||||
|
options.onZoom(e, {
|
||||||
|
zoom: round(wheelZoom, 2),
|
||||||
|
initialCenterX: initialTouchCenter.x,
|
||||||
|
initialCenterY: initialTouchCenter.y,
|
||||||
|
dragOffsetX,
|
||||||
|
dragOffsetY,
|
||||||
|
currentCenterX: e.x,
|
||||||
|
currentCenterY: e.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.onDrag && !metaKeyPressed && !isZooming) {
|
||||||
|
// Ignore wheel inertia if drag is canceled in this direction
|
||||||
|
if (!isDragCanceled.x || Math.sign(initialDragOffset.x) === Math.sign(e.deltaX)) {
|
||||||
|
initialDragOffset.x -= e.deltaX;
|
||||||
|
}
|
||||||
|
if (!isDragCanceled.y || Math.sign(initialDragOffset.y) === Math.sign(e.deltaY)) {
|
||||||
|
initialDragOffset.y -= e.deltaY;
|
||||||
|
}
|
||||||
|
const { x, y } = initialDragOffset;
|
||||||
|
options.onDrag(e, captureEvent!, {
|
||||||
|
dragOffsetX: x,
|
||||||
|
dragOffsetY: y,
|
||||||
|
}, (dx, dy) => {
|
||||||
|
isDragCanceled = { x: dx, y: dy };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
releaseWheel(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.addEventListener('wheel', onWheel);
|
||||||
element.addEventListener('mousedown', onCapture);
|
element.addEventListener('mousedown', onCapture);
|
||||||
element.addEventListener('touchstart', onCapture, { passive: !options.isNotPassive });
|
element.addEventListener('touchstart', onCapture, { passive: !options.isNotPassive });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
onRelease();
|
onRelease();
|
||||||
|
element.removeEventListener('wheel', onWheel);
|
||||||
element.removeEventListener('touchstart', onCapture);
|
element.removeEventListener('touchstart', onCapture);
|
||||||
element.removeEventListener('mousedown', onCapture);
|
element.removeEventListener('mousedown', onCapture);
|
||||||
};
|
};
|
||||||
|
|||||||
3
src/util/math.ts
Normal file
3
src/util/math.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const clamp = (num: number, min: number, max: number) => (Math.min(max, Math.max(min, num)));
|
||||||
|
export const isBetween = (num: number, min: number, max: number) => (num >= min && num <= max);
|
||||||
|
export const round = (num: number, decimals: number = 0) => Math.round(num * 10 ** decimals) / 10 ** decimals;
|
||||||
Loading…
x
Reference in New Issue
Block a user