Media Viewer: Zoom and pan with track pad and mouse wheel (#1829)

This commit is contained in:
Alexander Zinchuk 2022-05-31 20:58:43 +04:00
parent 2dfac887cd
commit 7997dff459
18 changed files with 499 additions and 823 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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}
/>
)}

View File

@ -35,4 +35,8 @@
&--active {
z-index: 1;
}
&--moving {
cursor: move;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[]) {

View File

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

View File

@ -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({

View File

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