diff --git a/src/components/mediaViewer/MediaViewer.scss b/src/components/mediaViewer/MediaViewer.scss index 5070fd261..97f107d2e 100644 --- a/src/components/mediaViewer/MediaViewer.scss +++ b/src/components/mediaViewer/MediaViewer.scss @@ -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 { diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index a0353aa89..d76b7914f 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -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 = ({ }, [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(); @@ -146,19 +141,12 @@ const MediaViewer: FC = ({ 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(false); - const [zoomLevel, setZoomLevel] = useState(1); - const [panDelta, setPanDelta] = useState({ - x: 0, - y: 0, - }); + const [zoomLevelChange, setZoomLevelChange] = useState(1); /* Media data */ function getMediaHash(isFull?: boolean) { @@ -271,54 +259,8 @@ const MediaViewer: FC = ({ 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 = ({ fromChatId: chatId, messageIds: [messageId], }); - closeZoom(); }, [openForwardMenu, chatId, messageId]); const selectMessage = useCallback((id?: number) => openMediaViewer({ @@ -352,12 +293,8 @@ const MediaViewer: FC = ({ }), [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 = ({ 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 = ({ 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('.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 ? ( = ({ } return ( - +
{IS_SINGLE_COLUMN_LAYOUT && (
- - - {(isActive: boolean) => ( - - )} - - - {!isFirst && !IS_TOUCH_ENV && ( - + {canReport && ( - -
-
-
- -
-
-
- ); -}; - -export default memo(ZoomControls); diff --git a/src/components/middle/message/helpers/calculateAlbumLayout.ts b/src/components/middle/message/helpers/calculateAlbumLayout.ts index bf251c4a3..fa12d86eb 100644 --- a/src/components/middle/message/helpers/calculateAlbumLayout.ts +++ b/src/components/middle/message/helpers/calculateAlbumLayout.ts @@ -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[]) { diff --git a/src/hooks/useWindowSize.ts b/src/hooks/useWindowSize.ts index 3238acf94..a7a4c42b4 100644 --- a/src/hooks/useWindowSize.ts +++ b/src/hooks/useWindowSize.ts @@ -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(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; diff --git a/src/util/animation.ts b/src/util/animation.ts index 34cef34a8..c7c21d9b2 100644 --- a/src/util/animation.ts +++ b/src/util/animation.ts @@ -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({ diff --git a/src/util/captureEvents.ts b/src/util/captureEvents.ts index 7ea44413d..dd4dca7fc 100644 --- a/src/util/captureEvents.ts +++ b/src/util/captureEvents.ts @@ -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(options.selectorToPreventScroll)).forEach((scrollable) => { - scrollable.style.overflow = ''; - }); + Array.from(document.querySelectorAll(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(options.selectorToPreventScroll)).forEach((scrollable) => { - scrollable.style.overflow = 'hidden'; - }); + Array.from(document.querySelectorAll(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); }; diff --git a/src/util/math.ts b/src/util/math.ts new file mode 100644 index 000000000..7e07a4ad4 --- /dev/null +++ b/src/util/math.ts @@ -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;