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;
|
||||
z-index: var(--z-media-viewer-head);
|
||||
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));
|
||||
|
||||
& > .Transition {
|
||||
|
||||
@ -2,7 +2,6 @@ import type { FC } from '../../lib/teact/teact';
|
||||
import React, {
|
||||
memo, useCallback, useEffect, useMemo, useRef, useState,
|
||||
} from '../../lib/teact/teact';
|
||||
import { getActions, withGlobal } from '../../global';
|
||||
|
||||
import type {
|
||||
ApiChat, ApiDimensions, ApiMessage, ApiUser,
|
||||
@ -10,17 +9,7 @@ import type {
|
||||
import { ApiMediaFormat } 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 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 { getActions, withGlobal } from '../../global';
|
||||
import {
|
||||
getChatAvatarHash,
|
||||
getChatMediaMessageIds,
|
||||
@ -52,22 +41,30 @@ import {
|
||||
} from '../../global/selectors';
|
||||
import { stopCurrentAudio } from '../../util/audioPlayer';
|
||||
import captureEscKeyListener from '../../util/captureEscKeyListener';
|
||||
import { captureEvents } from '../../util/captureEvents';
|
||||
import windowSize from '../../util/windowSize';
|
||||
import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
|
||||
import { ANIMATION_END_DELAY } from '../../config';
|
||||
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 { 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 ShowTransition from '../ui/ShowTransition';
|
||||
import Transition from '../ui/Transition';
|
||||
import MediaViewerActions from './MediaViewerActions';
|
||||
import MediaViewerSlides from './MediaViewerSlides';
|
||||
import PanZoom from './PanZoom';
|
||||
import SenderInfo from './SenderInfo';
|
||||
import SlideTransition from './SlideTransition';
|
||||
import ZoomControls from './ZoomControls';
|
||||
import ReportModal from '../common/ReportModal';
|
||||
|
||||
import './MediaViewer.scss';
|
||||
|
||||
@ -137,8 +134,6 @@ const MediaViewer: FC<StateProps> = ({
|
||||
}, [singleMessageId, chatMessages, collectionIds, isFromSharedMedia]);
|
||||
|
||||
const selectedMediaMessageIndex = messageId ? messageIds.indexOf(messageId) : -1;
|
||||
const isFirst = selectedMediaMessageIndex === 0 || selectedMediaMessageIndex === -1;
|
||||
const isLast = selectedMediaMessageIndex === messageIds.length - 1 || selectedMediaMessageIndex === -1;
|
||||
|
||||
/* Animation */
|
||||
const animationKey = useRef<number>();
|
||||
@ -146,19 +141,12 @@ const MediaViewer: FC<StateProps> = ({
|
||||
if (isOpen && (!prevSenderId || prevSenderId !== senderId || !animationKey.current)) {
|
||||
animationKey.current = selectedMediaMessageIndex;
|
||||
}
|
||||
const slideAnimation = animationLevel >= 1 && !IS_TOUCH_ENV ? 'mv-slide' : 'none';
|
||||
const headerAnimation = animationLevel === 2 ? 'slide-fade' : 'none';
|
||||
const isGhostAnimation = animationLevel === 2;
|
||||
|
||||
/* Controls */
|
||||
const [isReportModalOpen, openReportModal, closeReportModal] = useFlag();
|
||||
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 [zoomLevelChange, setZoomLevelChange] = useState<number>(1);
|
||||
|
||||
/* Media data */
|
||||
function getMediaHash(isFull?: boolean) {
|
||||
@ -271,54 +259,8 @@ const MediaViewer: FC<StateProps> = ({
|
||||
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(() => {
|
||||
closeMediaViewer();
|
||||
closeZoom();
|
||||
}, [closeMediaViewer]);
|
||||
|
||||
const handleFooterClick = useCallback(() => {
|
||||
@ -339,7 +281,6 @@ const MediaViewer: FC<StateProps> = ({
|
||||
fromChatId: chatId,
|
||||
messageIds: [messageId],
|
||||
});
|
||||
closeZoom();
|
||||
}, [openForwardMenu, chatId, messageId]);
|
||||
|
||||
const selectMessage = useCallback((id?: number) => openMediaViewer({
|
||||
@ -352,12 +293,8 @@ const MediaViewer: FC<StateProps> = ({
|
||||
}), [chatId, openMediaViewer, origin, threadId]);
|
||||
|
||||
useEffect(() => (isOpen ? captureEscKeyListener(() => {
|
||||
if (isZoomed) {
|
||||
closeZoom();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}) : undefined), [close, isOpen, isZoomed]);
|
||||
close();
|
||||
}) : undefined), [close, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVideo && !isGif) {
|
||||
@ -387,16 +324,6 @@ const MediaViewer: FC<StateProps> = ({
|
||||
return undefined;
|
||||
}, [messageIds]);
|
||||
|
||||
const nextMessageId = getMessageId(messageId, 1);
|
||||
const previousMessageId = getMessageId(messageId, -1);
|
||||
|
||||
const handlePan = useCallback((x: number, y: number) => {
|
||||
setPanDelta({
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const lang = useLang();
|
||||
|
||||
useHistoryBack({
|
||||
@ -404,48 +331,6 @@ const MediaViewer: FC<StateProps> = ({
|
||||
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() {
|
||||
return isAvatar ? (
|
||||
<SenderInfo
|
||||
@ -463,11 +348,7 @@ const MediaViewer: FC<StateProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<ShowTransition
|
||||
id="MediaViewer"
|
||||
className={isZoomed ? 'zoomed' : ''}
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<ShowTransition id="MediaViewer" isOpen={isOpen}>
|
||||
<div className="media-viewer-head" dir={lang.isRtl ? 'rtl' : undefined}>
|
||||
{IS_SINGLE_COLUMN_LAYOUT && (
|
||||
<Button
|
||||
@ -487,14 +368,14 @@ const MediaViewer: FC<StateProps> = ({
|
||||
<MediaViewerActions
|
||||
mediaData={fullMediaBlobUrl || previewBlobUrl}
|
||||
isVideo={isVideo}
|
||||
isZoomed={isZoomed}
|
||||
message={message}
|
||||
fileName={fileName}
|
||||
canReport={canReport}
|
||||
onReport={openReportModal}
|
||||
onCloseMediaViewer={close}
|
||||
onForward={handleForward}
|
||||
onZoomToggle={handleZoomToggle}
|
||||
zoomLevelChange={zoomLevelChange}
|
||||
setZoomLevelChange={setZoomLevelChange}
|
||||
isAvatar={isAvatar}
|
||||
/>
|
||||
<ReportModal
|
||||
@ -505,63 +386,25 @@ const MediaViewer: FC<StateProps> = ({
|
||||
chatId={avatarOwner?.id}
|
||||
/>
|
||||
</div>
|
||||
<PanZoom
|
||||
noWrap={!canPanZoomWrap}
|
||||
canPan={isZoomed}
|
||||
panDeltaX={panDelta.x}
|
||||
panDeltaY={panDelta.y}
|
||||
zoomLevel={zoomLevel}
|
||||
onPan={handlePan}
|
||||
>
|
||||
<SlideTransition
|
||||
activeKey={selectedMediaMessageIndex}
|
||||
name={slideAnimation}
|
||||
>
|
||||
{(isActive: boolean) => (
|
||||
<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}
|
||||
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}
|
||||
<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}
|
||||
zoomLevelChange={zoomLevelChange}
|
||||
isActive
|
||||
isVideo={isVideo}
|
||||
animationLevel={animationLevel}
|
||||
onClose={close}
|
||||
selectMessage={selectMessage}
|
||||
onFooterClick={handleFooterClick}
|
||||
/>
|
||||
</ShowTransition>
|
||||
);
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
|
||||
.Button {
|
||||
margin-inline-start: 0.25rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,4 +18,8 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.Button {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ type StateProps = {
|
||||
type OwnProps = {
|
||||
mediaData?: string;
|
||||
isVideo: boolean;
|
||||
isZoomed: boolean;
|
||||
zoomLevelChange: number;
|
||||
message?: ApiMessage;
|
||||
fileName?: string;
|
||||
isAvatar?: boolean;
|
||||
@ -37,13 +37,12 @@ type OwnProps = {
|
||||
onReport: NoneToVoidFunction;
|
||||
onCloseMediaViewer: NoneToVoidFunction;
|
||||
onForward: NoneToVoidFunction;
|
||||
onZoomToggle: NoneToVoidFunction;
|
||||
setZoomLevelChange: (change: number) => void;
|
||||
};
|
||||
|
||||
const MediaViewerActions: FC<OwnProps & StateProps> = ({
|
||||
mediaData,
|
||||
isVideo,
|
||||
isZoomed,
|
||||
message,
|
||||
fileName,
|
||||
isAvatar,
|
||||
@ -52,8 +51,9 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
|
||||
canReport,
|
||||
onReport,
|
||||
onCloseMediaViewer,
|
||||
zoomLevelChange,
|
||||
setZoomLevelChange,
|
||||
onForward,
|
||||
onZoomToggle,
|
||||
}) => {
|
||||
const {
|
||||
downloadMessageMedia,
|
||||
@ -73,6 +73,16 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
|
||||
}
|
||||
}, [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 MenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => {
|
||||
@ -190,10 +200,19 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
|
||||
round
|
||||
size="smaller"
|
||||
color="translucent-white"
|
||||
ariaLabel={isZoomed ? 'Zoom Out' : 'Zoom In'}
|
||||
onClick={onZoomToggle}
|
||||
ariaLabel={lang('MediaZoomOut')}
|
||||
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>
|
||||
{canReport && (
|
||||
<Button
|
||||
|
||||
@ -228,7 +228,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
|
||||
<MediaViewerFooter
|
||||
text={textParts}
|
||||
onClick={onFooterClick}
|
||||
isHidden={isFooterHidden && IS_TOUCH_ENV}
|
||||
isHidden={isFooterHidden}
|
||||
isForVideo={isVideo && !isGif}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -35,4 +35,8 @@
|
||||
&--active {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&--moving {
|
||||
cursor: move;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,21 +4,29 @@ import React, {
|
||||
} from '../../lib/teact/teact';
|
||||
|
||||
import type { MediaViewerOrigin } from '../../types';
|
||||
import type { RealTouchEvent } from '../../util/captureEvents';
|
||||
|
||||
import useForceUpdate from '../../hooks/useForceUpdate';
|
||||
import { animateNumber, timingFunctions } from '../../util/animation';
|
||||
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 { IS_IOS, IS_TOUCH_ENV } from '../../util/environment';
|
||||
import { clamp, isBetween, round } from '../../util/math';
|
||||
import { debounce } from '../../util/schedulers';
|
||||
import useTimeout from '../../hooks/useTimeout';
|
||||
|
||||
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 './MediaViewerSlides.scss';
|
||||
|
||||
const { easeOutCubic, easeOutQuart } = timingFunctions;
|
||||
|
||||
type OwnProps = {
|
||||
messageId?: number;
|
||||
getMessageId: (fromId?: number, direction?: number) => number | undefined;
|
||||
@ -33,26 +41,27 @@ type OwnProps = {
|
||||
avatarOwnerId?: string;
|
||||
profilePhotoIndex?: number;
|
||||
origin?: MediaViewerOrigin;
|
||||
isZoomed?: boolean;
|
||||
animationLevel: 0 | 1 | 2;
|
||||
onClose: () => void;
|
||||
hasFooter?: boolean;
|
||||
onFooterClick: () => void;
|
||||
zoomLevelChange: number;
|
||||
};
|
||||
|
||||
const SWIPE_X_THRESHOLD = 50;
|
||||
const SWIPE_Y_THRESHOLD = 50;
|
||||
const SLIDES_GAP = 40;
|
||||
const SLIDES_GAP = IS_TOUCH_ENV ? 40 : 0;
|
||||
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;
|
||||
const CLICK_X_THRESHOLD = 40;
|
||||
const CLICK_Y_THRESHOLD = 80;
|
||||
const HEADER_HEIGHT = 60;
|
||||
const MAX_ZOOM = 4;
|
||||
const MIN_ZOOM = 1;
|
||||
let cancelAnimation: Function | undefined;
|
||||
let cancelZoomAnimation: Function | undefined;
|
||||
|
||||
type Transform = {
|
||||
x: number;
|
||||
@ -75,6 +84,8 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
isOpen,
|
||||
isActive,
|
||||
hasFooter,
|
||||
zoomLevelChange,
|
||||
animationLevel,
|
||||
...rest
|
||||
}) => {
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
@ -82,16 +93,19 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const activeSlideRef = useRef<HTMLDivElement>(null);
|
||||
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 isActiveRef = useRef(true);
|
||||
const [activeMessageId, setActiveMessageId] = useState<number | undefined>(messageId);
|
||||
const prevZoomLevelChange = usePrevious(zoomLevelChange);
|
||||
const hasZoomChanged = prevZoomLevelChange !== undefined && prevZoomLevelChange !== zoomLevelChange;
|
||||
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 {
|
||||
isZoomed,
|
||||
onClose,
|
||||
} = rest;
|
||||
const lang = useLang();
|
||||
|
||||
const setTransform = useCallback((value: Transform) => {
|
||||
transformRef.current = value;
|
||||
@ -107,21 +121,24 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
forceUpdate();
|
||||
}, [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>) => {
|
||||
if (!IS_TOUCH_ENV) return;
|
||||
const isFooter = window.innerHeight - e.pageY < CLICK_Y_THRESHOLD;
|
||||
if (!isFooter && e.pageX < CLICK_X_THRESHOLD) return;
|
||||
if (!isFooter && e.pageX > window.innerWidth - CLICK_X_THRESHOLD) return;
|
||||
const isFooter = windowHeight - e.pageY < CLICK_Y_THRESHOLD;
|
||||
if (!isFooter && e.pageX < clickXThreshold) return;
|
||||
if (!isFooter && e.pageX > windowWidth - clickXThreshold) return;
|
||||
setIsFooterHidden(!isFooterHidden);
|
||||
}, [isFooterHidden]);
|
||||
}, [clickXThreshold, isFooterHidden, windowHeight, windowWidth]);
|
||||
|
||||
useTimeout(() => setIsFooterHidden(false), ANIMATION_DURATION - 150);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_TOUCH_ENV || !containerRef.current || isZoomed || !activeMessageId) {
|
||||
if (!containerRef.current || !activeMessageId) {
|
||||
return undefined;
|
||||
}
|
||||
let lastTransform = { x: 0, y: 0, scale: 1 };
|
||||
let lastTransform = lastTransformRef.current;
|
||||
const lastDragOffset = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@ -141,46 +158,110 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
lastGestureTime = Date.now();
|
||||
}, 500, false, true);
|
||||
|
||||
const changeSlide = (e: MouseEvent) => {
|
||||
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 changeSlide = (direction: number) => {
|
||||
const mId = getMessageId(activeMessageId, direction);
|
||||
if (mId) {
|
||||
const offset = (window.innerWidth + SLIDES_GAP) * direction;
|
||||
const offset = (windowWidth + SLIDES_GAP) * direction;
|
||||
transformRef.current.x += offset;
|
||||
isActiveRef.current = false;
|
||||
setActiveMessageId(mId);
|
||||
selectMessageDebounced(mId);
|
||||
setIsActiveDebounced(true);
|
||||
lastTransform = { x: 0, y: 0, scale: 1 };
|
||||
if (animationLevel === 0) {
|
||||
setTransform(lastTransform);
|
||||
return true;
|
||||
}
|
||||
cancelAnimation = animateNumber({
|
||||
from: transformRef.current.x,
|
||||
to: 0,
|
||||
duration: ANIMATION_DURATION,
|
||||
timing: timingFunctions.easeOutCubic,
|
||||
timing: easeOutCubic,
|
||||
onUpdate: (value) => setTransform({
|
||||
y: 0,
|
||||
x: value,
|
||||
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,
|
||||
excludedClosestSelector: '.MediaViewerFooter',
|
||||
withNativeDrag: true,
|
||||
excludedClosestSelector: '.MediaViewerFooter, .ZoomControls',
|
||||
minZoom: MIN_ZOOM,
|
||||
maxZoom: MAX_ZOOM,
|
||||
doubleTapZoom: DOUBLE_TAP_ZOOM,
|
||||
onCapture: (e) => {
|
||||
if (checkIfControlTarget(e)) return;
|
||||
if (e.type === 'mousedown') {
|
||||
setIsMouseDown(true);
|
||||
if (transformRef.current.scale !== 1) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
lastGestureTime = Date.now();
|
||||
if (arePropsShallowEqual(transformRef.current, { x: 0, y: 0, scale: 1 })) {
|
||||
if (!activeSlideRef.current) return;
|
||||
@ -193,12 +274,12 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
onDrag: (event, captureEvent, {
|
||||
dragOffsetX,
|
||||
dragOffsetY,
|
||||
}) => {
|
||||
}, cancelDrag) => {
|
||||
if (checkIfControlTarget(event)) return;
|
||||
// Avoid conflicts with swipe-to-back gestures
|
||||
if (IS_IOS) {
|
||||
if (IS_IOS && captureEvent.type === 'touchstart') {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -225,15 +306,24 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
|
||||
// If image is scaled we just need to pan it
|
||||
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) {
|
||||
setTransform({
|
||||
x: lastTransform.x + dragOffsetX,
|
||||
y: lastTransform.y + dragOffsetY,
|
||||
x: x1,
|
||||
y: y1,
|
||||
scale,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (['wheel', 'mousemove'].includes(event.type)) return;
|
||||
if (swipeDirectionRef.current !== SwipeDirection.Vertical) {
|
||||
// If user is swiping horizontally or horizontal shift is dominant
|
||||
// we change only horizontal position
|
||||
@ -241,8 +331,9 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
|| Math.abs(x) > h || (absOffsetX > h && absOffsetY < h)) {
|
||||
swipeDirectionRef.current = SwipeDirection.Horizontal;
|
||||
isActiveRef.current = false;
|
||||
const limit = windowWidth + SLIDES_GAP;
|
||||
setTransform({
|
||||
x: dragOffsetX,
|
||||
x: clamp(dragOffsetX, -limit, limit),
|
||||
y: 0,
|
||||
scale,
|
||||
});
|
||||
@ -253,14 +344,16 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
if (swipeDirectionRef.current === SwipeDirection.Vertical
|
||||
|| Math.abs(y) > h || (absOffsetY > h && absOffsetX < h)) {
|
||||
swipeDirectionRef.current = SwipeDirection.Vertical;
|
||||
const limit = windowHeight;
|
||||
setTransform({
|
||||
x: 0,
|
||||
y: dragOffsetY,
|
||||
y: clamp(dragOffsetY, -limit, limit),
|
||||
scale,
|
||||
});
|
||||
}
|
||||
},
|
||||
onZoom: (e, {
|
||||
zoom,
|
||||
zoomFactor,
|
||||
initialCenterX,
|
||||
initialCenterY,
|
||||
@ -269,45 +362,63 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
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));
|
||||
if (cancelAnimation) cancelAnimation();
|
||||
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 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 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
|
||||
const scaleOffsetX = (scaledCenterX - scaleFactor * scaledCenterX);
|
||||
const scaleOffsetY = (scaledCenterY - scaleFactor * scaledCenterY);
|
||||
|
||||
setTransform({
|
||||
const [transform] = calculateOffsetBoundaries({
|
||||
x: lastTransform.x + scaleOffsetX + dragOffsetX,
|
||||
y: lastTransform.y + scaleOffsetY + dragOffsetY,
|
||||
scale,
|
||||
});
|
||||
|
||||
setTransform(transform);
|
||||
},
|
||||
onClick(e) {
|
||||
if (changeSlide(e as MouseEvent)) {
|
||||
const [isInThreshold, hasNextSlide] = changeSlideOnClick(e as MouseEvent);
|
||||
if (isInThreshold) {
|
||||
e.preventDefault();
|
||||
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, {
|
||||
centerX,
|
||||
centerY,
|
||||
}) {
|
||||
if (changeSlide(e as MouseEvent)) {
|
||||
const [isInThreshold] = changeSlideOnClick(e as MouseEvent);
|
||||
if (isInThreshold) {
|
||||
e.preventDefault();
|
||||
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
|
||||
const scaleOffsetX = (centerX - DOUBLE_TAP_ZOOM * centerX);
|
||||
const scaleOffsetY = (centerY - DOUBLE_TAP_ZOOM * centerY);
|
||||
@ -317,12 +428,12 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
y,
|
||||
} = transformRef.current;
|
||||
if (scale === 1) {
|
||||
if (x !== 0 || y !== 0) return undefined;
|
||||
lastTransform = {
|
||||
if (x !== 0 || y !== 0) return;
|
||||
lastTransform = calculateOffsetBoundaries({
|
||||
x: scaleOffsetX,
|
||||
y: scaleOffsetY,
|
||||
scale: DOUBLE_TAP_ZOOM,
|
||||
};
|
||||
})[0];
|
||||
} else {
|
||||
lastTransform = {
|
||||
x: 0,
|
||||
@ -330,19 +441,25 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
scale: 1,
|
||||
};
|
||||
}
|
||||
return animateNumber({
|
||||
cancelAnimation = 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],
|
||||
}),
|
||||
timing: easeOutCubic,
|
||||
onUpdate: (value) => {
|
||||
const transform = {
|
||||
x: value[0],
|
||||
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 absY = Math.abs(transformRef.current.y);
|
||||
const {
|
||||
@ -357,29 +474,21 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
// If scale is less than 1 we need to bounce back
|
||||
if (scale < 1) {
|
||||
lastTransform = { x: 0, y: 0, scale: 1 };
|
||||
return animateNumber({
|
||||
cancelAnimation = animateNumber({
|
||||
from: [x, y, scale],
|
||||
to: [0, 0, 1],
|
||||
duration: ANIMATION_DURATION,
|
||||
timing: timingFunctions.easeOutCubic,
|
||||
timing: easeOutCubic,
|
||||
onUpdate: (value) => setTransform({
|
||||
x: value[0],
|
||||
y: value[1],
|
||||
scale: value[2],
|
||||
}),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
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;
|
||||
|
||||
@ -392,7 +501,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
const k = 0.15;
|
||||
|
||||
// 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
|
||||
const Vx = Math.abs(lastDragOffset.x) / (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;
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
[lastTransform] = calculateOffsetBoundaries({ x: x1, y: y1, scale: s1 }, HEADER_HEIGHT);
|
||||
cancelAnimation = animateNumber({
|
||||
from: [x, y, scale],
|
||||
to: [x1, y1, s1],
|
||||
to: [lastTransform.x, lastTransform.y, lastTransform.scale],
|
||||
duration: ANIMATION_DURATION,
|
||||
timing: timingFunctions.easeOutCubic,
|
||||
timing: easeOutCubic,
|
||||
onUpdate: (value) => setTransform({
|
||||
x: value[0],
|
||||
y: value[1],
|
||||
@ -443,20 +530,21 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
y,
|
||||
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
|
||||
if (absY > 0) {
|
||||
return animateNumber({
|
||||
cancelAnimation = animateNumber({
|
||||
from: y,
|
||||
to: 0,
|
||||
duration: ANIMATION_DURATION,
|
||||
timing: timingFunctions.easeOutCubic,
|
||||
timing: easeOutCubic,
|
||||
onUpdate: (value) => setTransform({
|
||||
x: 0,
|
||||
y: value,
|
||||
scale,
|
||||
}),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
// Get horizontal swipe direction
|
||||
const direction = x < 0 ? 1 : -1;
|
||||
@ -467,7 +555,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
// 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;
|
||||
const offset = (windowWidth + 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;
|
||||
@ -479,7 +567,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
from: transformRef.current.x,
|
||||
to: 0,
|
||||
duration: ANIMATION_DURATION,
|
||||
timing: timingFunctions.easeOutCubic,
|
||||
timing: easeOutCubic,
|
||||
onUpdate: (value) => setTransform({
|
||||
y: 0,
|
||||
x: value,
|
||||
@ -489,17 +577,58 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
document.addEventListener('keydown', handleKeyDown, false);
|
||||
return () => {
|
||||
cleanup();
|
||||
document.removeEventListener('keydown', handleKeyDown, false);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isZoomed,
|
||||
onClose,
|
||||
setTransform,
|
||||
getMessageId,
|
||||
activeMessageId,
|
||||
windowWidth,
|
||||
windowHeight,
|
||||
clickXThreshold,
|
||||
shouldCloseOnVideo,
|
||||
selectMessageDebounced,
|
||||
setIsActiveDebounced,
|
||||
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;
|
||||
|
||||
const nextMessageId = getMessageId(activeMessageId, 1);
|
||||
@ -510,18 +639,24 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
|
||||
return (
|
||||
<div className="MediaViewerSlides" ref={containerRef}>
|
||||
{previousMessageId && scale === 1 && (
|
||||
<div className="MediaViewerSlide" style={getAnimationStyle(-window.innerWidth + offsetX - SLIDES_GAP)}>
|
||||
{previousMessageId && scale === 1 && !isResizing && (
|
||||
<div className="MediaViewerSlide" style={getAnimationStyle(-windowWidth + offsetX - SLIDES_GAP)}>
|
||||
<MediaViewerContent
|
||||
/* eslint-disable-next-line react/jsx-props-no-spreading */
|
||||
{...rest}
|
||||
animationLevel={animationLevel}
|
||||
isFooterHidden={isFooterHidden}
|
||||
messageId={previousMessageId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeMessageId && (
|
||||
<div
|
||||
className={`MediaViewerSlide ${isActive ? 'MediaViewerSlide--active' : ''}`}
|
||||
className={buildClassName(
|
||||
'MediaViewerSlide',
|
||||
isActive && 'MediaViewerSlide--active',
|
||||
isMouseDown && scale > 1 && 'MediaViewerSlide--moving',
|
||||
)}
|
||||
onClick={handleToggleFooterVisibility}
|
||||
ref={activeSlideRef}
|
||||
style={getAnimationStyle(offsetX, offsetY, scale)}
|
||||
@ -530,21 +665,40 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
/* eslint-disable-next-line react/jsx-props-no-spreading */
|
||||
{...rest}
|
||||
messageId={activeMessageId}
|
||||
animationLevel={animationLevel}
|
||||
isActive={isActive && isActiveRef.current}
|
||||
setIsFooterHidden={setIsFooterHidden}
|
||||
isFooterHidden={isFooterHidden || isZoomed || scale !== 1}
|
||||
isFooterHidden={isFooterHidden || scale !== 1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{nextMessageId && scale === 1 && (
|
||||
<div className="MediaViewerSlide" style={getAnimationStyle(window.innerWidth + offsetX + SLIDES_GAP)}>
|
||||
{nextMessageId && scale === 1 && !isResizing && (
|
||||
<div className="MediaViewerSlide" style={getAnimationStyle(windowWidth + offsetX + SLIDES_GAP)}>
|
||||
<MediaViewerContent
|
||||
/* eslint-disable-next-line react/jsx-props-no-spreading */
|
||||
{...rest}
|
||||
animationLevel={animationLevel}
|
||||
isFooterHidden={isFooterHidden}
|
||||
messageId={nextMessageId}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
display: flex;
|
||||
align-content: center;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
|
||||
opacity: 0.5;
|
||||
transition: 0.15s opacity;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: 0.15s color;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.Avatar {
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 1rem 0.5rem 0.5rem;
|
||||
padding: 2rem 0.5rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
background: linear-gradient(to top, #000 0%, rgba(0, 0, 0, 0) 100%);
|
||||
transition: opacity 0.3s;
|
||||
@ -95,7 +95,7 @@
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
top: 0;
|
||||
top: 1rem;
|
||||
height: 1rem;
|
||||
touch-action: none;
|
||||
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 { calculateMediaDimensions } from './mediaDimensions';
|
||||
import { clamp } from '../../../../util/math';
|
||||
|
||||
export const AlbumRectPart = {
|
||||
None: 0,
|
||||
@ -67,12 +68,10 @@ function accumulate(list: number[], initValue: number) {
|
||||
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) {
|
||||
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[]) {
|
||||
|
||||
@ -1,27 +1,40 @@
|
||||
import { useEffect, useState } from '../lib/teact/teact';
|
||||
import type { ApiDimensions } from '../api/types';
|
||||
|
||||
import { throttle } from '../util/schedulers';
|
||||
import windowSize from '../util/windowSize';
|
||||
import type { ApiDimensions } from '../api/types';
|
||||
import useDebouncedCallback from './useDebouncedCallback';
|
||||
|
||||
const THROTTLE = 250;
|
||||
|
||||
const useWindowSize = () => {
|
||||
const [size, setSize] = useState<ApiDimensions>(windowSize.get());
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const setIsResizingDebounced = useDebouncedCallback(setIsResizing, [], THROTTLE, true);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = throttle(() => {
|
||||
const throttledSetIsResizing = throttle(() => {
|
||||
setIsResizing(true);
|
||||
}, THROTTLE, true);
|
||||
|
||||
const throttledSetSize = throttle(() => {
|
||||
setSize(windowSize.get());
|
||||
setIsResizingDebounced(false);
|
||||
}, THROTTLE, false);
|
||||
|
||||
const handleResize = () => {
|
||||
throttledSetIsResizing();
|
||||
throttledSetSize();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
}, [setIsResizingDebounced]);
|
||||
|
||||
return size;
|
||||
return { ...size, isResizing };
|
||||
};
|
||||
|
||||
export default useWindowSize;
|
||||
|
||||
@ -45,20 +45,20 @@ export type AnimateNumberProps = {
|
||||
export const timingFunctions = {
|
||||
linear: (t: number) => t,
|
||||
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),
|
||||
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,
|
||||
easeInCubic: (t: number) => t ** 3,
|
||||
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),
|
||||
easeInOutCubic: (t: number) => (t < 0.5 ? 4 * t ** 3 : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1),
|
||||
easeInQuart: (t: number) => t ** 4,
|
||||
easeOutQuart: (t: number) => 1 - (--t) * t ** 3,
|
||||
easeInOutQuart: (t: number) => (t < 0.5 ? 8 * t ** 4 : 1 - 8 * (--t) * t ** 3),
|
||||
easeInQuint: (t: number) => t ** 5,
|
||||
easeOutQuint: (t: number) => 1 + (--t) * t ** 4,
|
||||
easeInOutQuint: (t: number) => (t < 0.5 ? 16 * t ** 5 : 1 + 16 * (--t) * t ** 4),
|
||||
};
|
||||
|
||||
export function animateNumber({
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { IS_IOS } from './environment';
|
||||
import { clamp, round } from './math';
|
||||
import { debounce } from './schedulers';
|
||||
|
||||
export enum SwipeDirection {
|
||||
Up,
|
||||
@ -11,17 +13,20 @@ interface CaptureOptions {
|
||||
onCapture?: (e: MouseEvent | TouchEvent) => void;
|
||||
onRelease?: (e: MouseEvent | TouchEvent) => void;
|
||||
onDrag?: (
|
||||
e: MouseEvent | TouchEvent,
|
||||
captureEvent: MouseEvent | TouchEvent,
|
||||
e: MouseEvent | TouchEvent | WheelEvent,
|
||||
captureEvent: MouseEvent | TouchEvent | WheelEvent,
|
||||
params: {
|
||||
dragOffsetX: number;
|
||||
dragOffsetY: number;
|
||||
},
|
||||
cancelDrag?: (x: boolean, y: boolean) => void,
|
||||
) => void;
|
||||
onSwipe?: (e: Event, direction: SwipeDirection) => boolean;
|
||||
onZoom?: (e: TouchEvent, params: {
|
||||
onZoom?: (e: TouchEvent | WheelEvent, params: {
|
||||
// Absolute zoom level
|
||||
zoom?: number;
|
||||
// Relative zoom factor
|
||||
zoomFactor: number;
|
||||
zoomFactor?: number;
|
||||
|
||||
// center coordinate of the initial pinch
|
||||
initialCenterX: number;
|
||||
@ -39,8 +44,11 @@ interface CaptureOptions {
|
||||
onDoubleClick?: (e: MouseEvent | RealTouchEvent, params: { centerX: number; centerY: number }) => void;
|
||||
excludedClosestSelector?: string;
|
||||
selectorToPreventScroll?: string;
|
||||
withNativeDrag?: boolean;
|
||||
maxZoom?: number;
|
||||
minZoom?: number;
|
||||
doubleTapZoom?: number;
|
||||
initialZoom?: number;
|
||||
isNotPassive?: boolean;
|
||||
withCursor?: boolean;
|
||||
}
|
||||
@ -63,7 +71,7 @@ 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);
|
||||
return Math.hypot((b.pageX - a.pageX), (b.pageY - a.pageY));
|
||||
}
|
||||
|
||||
function getTouchCenter(a: Touch, b: Touch) {
|
||||
@ -76,12 +84,27 @@ function getTouchCenter(a: Touch, b: Touch) {
|
||||
let lastClickTime = 0;
|
||||
|
||||
export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
||||
let captureEvent: MouseEvent | RealTouchEvent | undefined;
|
||||
let captureEvent: MouseEvent | RealTouchEvent | WheelEvent | undefined;
|
||||
let hasMoved = false;
|
||||
let hasSwiped = false;
|
||||
let isZooming = false;
|
||||
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;
|
||||
const minZoom = options.minZoom ?? 1;
|
||||
const maxZoom = options.maxZoom ?? 4;
|
||||
|
||||
function onCapture(e: MouseEvent | RealTouchEvent) {
|
||||
if (options.excludedClosestSelector && (
|
||||
@ -94,7 +117,7 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
||||
captureEvent = e;
|
||||
|
||||
if (e.type === 'mousedown') {
|
||||
if (options.onDrag) {
|
||||
if (!options.withNativeDrag && options.onDrag) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
@ -146,9 +169,10 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
||||
(captureEvent.target as HTMLElement).removeEventListener('touchmove', onMove);
|
||||
|
||||
if (IS_IOS && options.selectorToPreventScroll) {
|
||||
Array.from(document.querySelectorAll<HTMLElement>(options.selectorToPreventScroll)).forEach((scrollable) => {
|
||||
scrollable.style.overflow = '';
|
||||
});
|
||||
Array.from(document.querySelectorAll<HTMLElement>(options.selectorToPreventScroll))
|
||||
.forEach((scrollable) => {
|
||||
scrollable.style.overflow = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (e) {
|
||||
@ -172,9 +196,22 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
||||
|
||||
hasMoved = false;
|
||||
hasSwiped = false;
|
||||
isZooming = false;
|
||||
initialDistance = 0;
|
||||
wheelZoom = clamp(wheelZoom, minZoom, maxZoom);
|
||||
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;
|
||||
}
|
||||
|
||||
@ -218,7 +255,10 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
||||
let shouldPreventScroll = false;
|
||||
|
||||
if (options.onDrag) {
|
||||
options.onDrag(e, captureEvent, { dragOffsetX, dragOffsetY });
|
||||
options.onDrag(e, captureEvent, {
|
||||
dragOffsetX,
|
||||
dragOffsetY,
|
||||
});
|
||||
shouldPreventScroll = true;
|
||||
}
|
||||
|
||||
@ -228,9 +268,10 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
||||
}
|
||||
|
||||
if (IS_IOS && shouldPreventScroll && options.selectorToPreventScroll) {
|
||||
Array.from(document.querySelectorAll<HTMLElement>(options.selectorToPreventScroll)).forEach((scrollable) => {
|
||||
scrollable.style.overflow = 'hidden';
|
||||
});
|
||||
Array.from(document.querySelectorAll<HTMLElement>(options.selectorToPreventScroll))
|
||||
.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!);
|
||||
}
|
||||
|
||||
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('touchstart', onCapture, { passive: !options.isNotPassive });
|
||||
|
||||
return () => {
|
||||
onRelease();
|
||||
|
||||
element.removeEventListener('wheel', onWheel);
|
||||
element.removeEventListener('touchstart', 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