TelegramPWA/src/components/mediaViewer/MediaViewerSlides.tsx
2025-06-04 20:36:48 +02:00

811 lines
28 KiB
TypeScript

import type { FC } from '../../lib/teact/teact';
import React, {
memo, useEffect, useLayoutEffect, useMemo, useRef, useSignal, useState,
} from '../../lib/teact/teact';
import type { MediaViewerOrigin, ThreadId } from '../../types';
import type { RealTouchEvent } from '../../util/captureEvents';
import type { MediaViewerItem } from './helpers/getViewableMedia';
import { animateNumber, timingFunctions } from '../../util/animation';
import { IS_IOS, IS_TOUCH_ENV } from '../../util/browser/windowEnvironment';
import buildClassName from '../../util/buildClassName';
import {
captureEvents,
IOS_SCREEN_EDGE_THRESHOLD,
SWIPE_DIRECTION_THRESHOLD,
SWIPE_DIRECTION_TOLERANCE,
} from '../../util/captureEvents';
import { clamp, isBetween, round } from '../../util/math';
import { debounce } from '../../util/schedulers';
import useTimeout from '../../hooks/schedulers/useTimeout';
import useDebouncedCallback from '../../hooks/useDebouncedCallback';
import useDerivedState from '../../hooks/useDerivedState';
import useHistoryBack from '../../hooks/useHistoryBack';
import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang';
import { useSignalRef } from '../../hooks/useSignalRef';
import { useFullscreenStatus } from '../../hooks/window/useFullscreen';
import useWindowSize from '../../hooks/window/useWindowSize';
import useControlsSignal from './hooks/useControlsSignal';
import useZoomChange from './hooks/useZoomChangeSignal';
import MediaViewerContent from './MediaViewerContent';
import './MediaViewerSlides.scss';
const { easeOutCubic, easeOutQuart } = timingFunctions;
type OwnProps = {
item?: MediaViewerItem;
isLoadingMoreMedia?: boolean;
isSynced?: boolean;
isVideo?: boolean;
isGif?: boolean;
isPhoto?: boolean;
isOpen?: boolean;
chatId?: string;
threadId?: ThreadId;
avatarOwnerId?: string;
origin?: MediaViewerOrigin;
withAnimation?: boolean;
isHidden?: boolean;
hasFooter?: boolean;
getNextItem: (from: MediaViewerItem, direction: number) => MediaViewerItem | undefined;
selectItem: (item: MediaViewerItem) => void;
loadMoreItemsIfNeeded: (item: MediaViewerItem) => void;
onFooterClick: (e: React.MouseEvent<HTMLDivElement>) => void;
handleSponsoredClick: (isFromMedia?: boolean) => void;
onClose: () => void;
};
const SWIPE_X_THRESHOLD = 50;
const SWIPE_Y_THRESHOLD = 50;
const SLIDES_GAP = IS_TOUCH_ENV ? 40 : 0;
const ANIMATION_DURATION = 350;
const DEBOUNCE_SELECT_MEDIA = 350;
const DEBOUNCE_SWIPE = 500;
const DEBOUNCE_ACTIVE = 800;
const DOUBLE_TAP_ZOOM = 3;
const CLICK_Y_THRESHOLD = 80;
const HEADER_HEIGHT = 60;
const MAX_ZOOM = 4;
const MIN_ZOOM = 1;
let cancelAnimation: ReturnType<typeof animateNumber> | undefined;
let cancelZoomAnimation: ReturnType<typeof animateNumber> | undefined;
type Transform = {
x: number;
y: number;
scale: number;
};
enum SwipeDirection {
Horizontal,
Vertical,
}
const MediaViewerSlides: FC<OwnProps> = ({
item,
isVideo,
isGif,
isOpen,
withAnimation,
isHidden,
isLoadingMoreMedia,
isSynced,
loadMoreItemsIfNeeded,
getNextItem,
selectItem,
onClose,
onFooterClick,
handleSponsoredClick,
}) => {
const containerRef = useRef<HTMLDivElement>();
const activeSlideRef = useRef<HTMLDivElement>();
const leftSlideRef = useRef<HTMLDivElement>();
const rightSlideRef = useRef<HTMLDivElement>();
const lastTransformRef = useRef<Transform>({ x: 0, y: 0, scale: 1 });
const swipeDirectionRef = useRef<SwipeDirection | undefined>(undefined);
const initialContentRectRef = useRef<DOMRect | undefined>(undefined);
const isReleasedRef = useRef(false);
const [isActive, setIsActive] = useState(true);
const [getZoomChange] = useZoomChange();
const prevZoomChangeRef = useRef(getZoomChange());
const isFullscreen = useFullscreenStatus();
const [isMouseDown, setIsMouseDown] = useState(false);
const [getTransform, setTransform] = useSignal<Transform>({ x: 0, y: 0, scale: 1 });
const transformRef = useSignalRef(getTransform);
const [getActiveItem, setActiveItem] = useSignal<MediaViewerItem | undefined>(item);
const activeItemRef = useSignalRef(getActiveItem);
const isScaled = useDerivedState(() => getTransform().scale !== 1, [getTransform]);
const activeItem = useDerivedState(getActiveItem);
const { height: windowHeight, width: windowWidth, isResizing } = useWindowSize();
const [getControlsVisible, setControlsVisible, lockControls] = useControlsSignal();
const lang = useOldLang();
useHistoryBack({
isActive: isOpen,
onBack: onClose,
shouldBeReplaced: true,
});
const selectItemDebounced = useDebouncedCallback(selectItem, [selectItem], DEBOUNCE_SELECT_MEDIA, true);
const clearSwipeDirectionDebounced = useDebouncedCallback(() => {
swipeDirectionRef.current = undefined;
}, [], DEBOUNCE_SWIPE, true);
const setIsActiveDebounced = useDebouncedCallback((value) => setIsActive(value), [], DEBOUNCE_ACTIVE, true);
const shouldCloseOnVideo = Boolean(isGif && !IS_IOS);
const clickXThreshold = IS_TOUCH_ENV ? 40 : windowWidth / 10;
const handleControlsVisibility = useLastCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (!IS_TOUCH_ENV) return;
const isFooter = windowHeight - e.pageY < CLICK_Y_THRESHOLD;
if (!isFooter && e.pageX < clickXThreshold) return;
if (!isFooter && e.pageX > windowWidth - clickXThreshold) return;
setControlsVisible(!getControlsVisible());
});
useTimeout(() => setControlsVisible(true), ANIMATION_DURATION);
useEffect(() => {
const { scale, x, y } = transformRef.current;
// Only update active media if slide is in default position
if (x === 0 && y === 0 && scale === 1) {
setActiveItem(item);
}
}, [item, setActiveItem, transformRef]);
useEffect(() => {
if (!isSynced || !activeItem || isLoadingMoreMedia) return;
loadMoreItemsIfNeeded(activeItem);
}, [activeItem, loadMoreItemsIfNeeded, isSynced, isLoadingMoreMedia]);
useLayoutEffect(() => {
const { x, y, scale } = getTransform();
lockControls(scale !== 1);
if (leftSlideRef.current) {
leftSlideRef.current.style.transform = getTransformStyle(-windowWidth + x - SLIDES_GAP);
}
if (activeSlideRef.current) {
activeSlideRef.current.style.transform = getTransformStyle(x, y, scale);
}
if (rightSlideRef.current) {
rightSlideRef.current.style.transform = getTransformStyle(windowWidth + x + SLIDES_GAP);
}
}, [getTransform, lockControls, windowWidth]);
useEffect(() => {
if (!containerRef.current || activeItemRef.current === undefined || isHidden || isFullscreen) {
return undefined;
}
let lastTransform = lastTransformRef.current;
const lastDragOffset = {
x: 0,
y: 0,
};
const lastZoomCenter = {
x: 0,
y: 0,
};
const panDelta = {
x: 0,
y: 0,
};
let lastGestureTime = Date.now();
let content: HTMLElement | null;
const setLastGestureTime = debounce(() => {
lastGestureTime = Date.now();
}, 500, false, true);
const changeSlide = (direction: number) => {
const cActiveItem = activeItemRef.current;
if (cActiveItem === undefined) return false;
const nextItem = getNextItem(cActiveItem, direction);
if (nextItem !== undefined) {
const offset = (windowWidth + SLIDES_GAP) * direction;
const transform = transformRef.current;
const x = transform.x + offset;
setIsActive(false);
setActiveItem(nextItem);
selectItemDebounced(nextItem);
setIsActiveDebounced(true);
lastTransform = { x: 0, y: 0, scale: 1 };
if (!withAnimation) {
setTransform(lastTransform);
return true;
}
cancelAnimation = animateNumber({
from: x,
to: 0,
duration: ANIMATION_DURATION,
timing: easeOutCubic,
onUpdate: (value) => setTransform({
y: 0,
x: value,
scale: 1,
}),
});
return true;
}
return false;
};
const changeSlideOnClick = (e: MouseEvent): [boolean, boolean] => {
const { scale } = transformRef.current;
if (scale !== 1) return [false, false];
if ((e.target as HTMLElement).closest('div.VideoPlayerControls')) {
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) => {
const { scale } = transformRef.current;
if (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] => {
const initialContentRect = initialContentRectRef.current;
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 onRelease = (e: MouseEvent | RealTouchEvent | WheelEvent) => {
// This allows to prevent onRelease triggered by debounced wheel event
// after onRelease was triggered manually in onDrag
if (isReleasedRef.current) {
isReleasedRef.current = false;
return;
}
if (e.type === 'mouseup') {
setIsMouseDown(false);
}
const transform = transformRef.current;
const { y, scale } = transform;
let x = transform.x;
const absX = Math.abs(x);
const absY = Math.abs(y);
clearSwipeDirectionDebounced();
setIsActiveDebounced(true);
// If scale is less than 1 we need to bounce back
if (scale < 1) {
lastTransform = { x: 0, y: 0, scale: 1 };
cancelAnimation = animateNumber({
from: [x, y, scale],
to: [0, 0, 1],
duration: ANIMATION_DURATION,
timing: easeOutCubic,
onUpdate: (value) => setTransform({
x: value[0],
y: value[1],
scale: value[2],
}),
});
return;
}
if (scale > 1) {
// Get current content boundaries
const s1 = Math.min(scale, MAX_ZOOM);
const scaleFactor = s1 / scale;
// Calculate new position based on the last zoom center to keep the zoom center
// at the same position when bouncing back from max zoom
let x1 = x * scaleFactor + (lastZoomCenter.x - scaleFactor * lastZoomCenter.x);
let y1 = y * scaleFactor + (lastZoomCenter.y - scaleFactor * lastZoomCenter.y);
// Arbitrary pan velocity coefficient
const k = 0.15;
// If scale didn't change, we need to add inertia to pan gesture
if (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);
// Add extra distance based on gesture velocity and last pan delta
x1 -= Math.abs(lastDragOffset.x) * Vx * k * panDelta.x;
y1 -= Math.abs(lastDragOffset.y) * Vy * k * panDelta.y;
}
[lastTransform] = calculateOffsetBoundaries({ x: x1, y: y1, scale: s1 }, HEADER_HEIGHT);
cancelAnimation = animateNumber({
from: [x, y, scale],
to: [lastTransform.x, lastTransform.y, lastTransform.scale],
duration: ANIMATION_DURATION,
timing: easeOutCubic,
onUpdate: (value) => setTransform({
x: value[0],
y: value[1],
scale: value[2],
}),
});
return;
}
lastTransform = {
x,
y,
scale,
};
if (absY >= SWIPE_Y_THRESHOLD) {
onClose();
return;
}
// Bounce back if vertical swipe is below threshold
if (absY > 0) {
cancelAnimation = animateNumber({
from: y,
to: 0,
duration: ANIMATION_DURATION,
timing: easeOutCubic,
onUpdate: (value) => setTransform({
x: 0,
y: value,
scale,
}),
});
return;
}
// Get horizontal swipe direction
const direction = x < 0 ? 1 : -1;
const cActiveItem = activeItemRef.current;
const nextItem = cActiveItem && getNextItem(cActiveItem, x < 0 ? 1 : -1);
// Get the direction of the last pan gesture.
// Could be different from the total horizontal swipe direction
// if user starts a swipe in one direction and then changes the direction
// we need to cancel slide transition
const dirX = panDelta.x < 0 ? -1 : 1;
if (nextItem !== undefined && absX >= SWIPE_X_THRESHOLD && direction === dirX) {
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
x += offset;
setActiveItem(nextItem);
selectItemDebounced(nextItem);
}
// Then we always return to the original position
cancelAnimation = animateNumber({
from: x,
to: 0,
duration: ANIMATION_DURATION,
timing: easeOutCubic,
onUpdate: (value) => setTransform({
y: 0,
x: value,
scale: scale ?? 1,
}),
});
};
const cleanup = captureEvents(containerRef.current, {
isNotPassive: true,
withNativeDrag: true,
excludedClosestSelector: '.MediaViewerFooter, .ZoomControls, .VideoPlayerControls',
minZoom: MIN_ZOOM,
maxZoom: MAX_ZOOM,
doubleTapZoom: DOUBLE_TAP_ZOOM,
withWheelDrag: true,
onCapture: (e) => {
if (checkIfControlTarget(e)) return;
const { x, y, scale } = transformRef.current;
if (e.type === 'mousedown') {
setIsMouseDown(true);
if (scale !== 1) {
e.preventDefault();
return;
}
}
lastGestureTime = Date.now();
if (x === 0 && y === 0 && scale === 1) {
if (!activeSlideRef.current) return;
content = activeSlideRef.current.querySelector('img, video');
if (!content) return;
// Store initial content rect, without transformations
initialContentRectRef.current = content.getBoundingClientRect();
}
},
onDrag: (event, captureEvent, {
dragOffsetX,
dragOffsetY,
}, cancelDrag) => {
if (isReleasedRef.current || checkIfControlTarget(event)) return;
// Avoid conflicts with swipe-to-back gestures
if (IS_IOS && captureEvent.type === 'touchstart') {
const { pageX } = (captureEvent as RealTouchEvent).touches[0];
if (pageX <= IOS_SCREEN_EDGE_THRESHOLD || pageX >= windowWidth - IOS_SCREEN_EDGE_THRESHOLD) {
return;
}
}
if (cancelAnimation) {
cancelAnimation();
cancelAnimation = undefined;
}
panDelta.x = lastDragOffset.x - dragOffsetX;
panDelta.y = lastDragOffset.y - dragOffsetY;
lastDragOffset.x = dragOffsetX;
lastDragOffset.y = dragOffsetY;
const absOffsetX = Math.abs(dragOffsetX);
const absOffsetY = Math.abs(dragOffsetY);
const { x, y, scale } = transformRef.current;
// If user is inactive but is still touching the screen
// we reset last gesture time
setLastGestureTime();
// If image is scaled we just need to pan it
if (scale !== 1) {
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: x1,
y: y1,
scale,
});
}
return;
}
if (event.type === 'mousemove') return;
if (swipeDirectionRef.current !== SwipeDirection.Vertical) {
// If user is swiping horizontally or horizontal shift is dominant
// we change only horizontal position
if (swipeDirectionRef.current === SwipeDirection.Horizontal
|| Math.abs(x) > SWIPE_DIRECTION_THRESHOLD || absOffsetX / absOffsetY > SWIPE_DIRECTION_TOLERANCE) {
swipeDirectionRef.current = SwipeDirection.Horizontal;
setIsActive(false);
const limit = windowWidth + SLIDES_GAP;
const x1 = clamp(dragOffsetX, -limit, limit);
setTransform({
x: x1,
y: 0,
scale,
});
// We know that at this point onRelease will trigger slide change,
// We can trigger onRelease directly instead of waiting for the debounced callback
// to avoid a delay
if (event.type === 'wheel' && Math.abs(x1) > SWIPE_X_THRESHOLD * 2) {
onRelease(event);
isReleasedRef.current = true;
}
return;
}
}
// If vertical shift is dominant we change only vertical position
if (swipeDirectionRef.current === SwipeDirection.Vertical
|| Math.abs(y) > SWIPE_DIRECTION_THRESHOLD || absOffsetY / absOffsetX > SWIPE_DIRECTION_TOLERANCE) {
swipeDirectionRef.current = SwipeDirection.Vertical;
const limit = windowHeight;
const y1 = clamp(dragOffsetY, -limit, limit);
setTransform({
x: 0,
y: y1,
scale,
});
if (event.type === 'wheel' && Math.abs(y1) > SWIPE_Y_THRESHOLD * 2) {
onRelease(event);
isReleasedRef.current = true;
}
}
},
onZoom: (e, {
zoom,
zoomFactor,
initialCenterX,
initialCenterY,
dragOffsetX,
dragOffsetY,
currentCenterX,
currentCenterY,
}) => {
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));
// 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);
const [transform] = calculateOffsetBoundaries({
x: lastTransform.x + scaleOffsetX + dragOffsetX,
y: lastTransform.y + scaleOffsetY + dragOffsetY,
scale,
});
setTransform(transform);
},
onClick(e) {
setIsMouseDown(false);
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,
}) {
const [isInThreshold] = changeSlideOnClick(e as MouseEvent);
if (isInThreshold) {
e.preventDefault();
e.stopPropagation();
return;
}
if (!IS_TOUCH_ENV && e.type !== 'wheel') return;
const { x, y, scale } = transformRef.current;
// 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);
if (scale === 1) {
if (x !== 0 || y !== 0) return;
lastTransform = calculateOffsetBoundaries({
x: scaleOffsetX,
y: scaleOffsetY,
scale: DOUBLE_TAP_ZOOM,
})[0];
} else {
lastTransform = {
x: 0,
y: 0,
scale: 1,
};
}
cancelAnimation = animateNumber({
from: [x, y, scale],
to: [lastTransform.x, lastTransform.y, lastTransform.scale],
duration: ANIMATION_DURATION,
timing: easeOutCubic,
onUpdate: (value) => {
const transform = {
x: value[0],
y: value[1],
scale: value[2],
};
setTransform(transform);
},
});
},
onRelease,
});
document.addEventListener('keydown', handleKeyDown, false);
return () => {
cleanup();
document.removeEventListener('keydown', handleKeyDown, false);
};
},
[
activeItemRef,
clearSwipeDirectionDebounced,
clickXThreshold,
getNextItem,
isFullscreen,
isHidden,
onClose,
selectItemDebounced,
setActiveItem,
setIsActiveDebounced,
setTransform,
shouldCloseOnVideo,
transformRef,
windowHeight,
windowWidth,
withAnimation,
]);
useEffect(() => {
const zoomChange = getZoomChange();
const hasZoomChanged = prevZoomChangeRef.current !== undefined
&& prevZoomChangeRef.current !== zoomChange;
if (!containerRef.current || !hasZoomChanged || isHidden || isFullscreen) return;
prevZoomChangeRef.current = zoomChange;
const { scale } = transformRef.current;
const dir = zoomChange > 0 ? -1 : +1;
const minZoom = MIN_ZOOM * 0.6;
const maxZoom = MAX_ZOOM * 3;
let steps = 100;
let prevValue = 0;
if (scale <= minZoom && dir > 0) return;
if (scale >= maxZoom && dir < 0) return;
if (scale === 1 && dir > 0) steps = 20;
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);
},
});
}, [getZoomChange, isHidden, isFullscreen, transformRef]);
const [prevItem, nextItem] = useMemo(() => {
if (activeItem === undefined) return [undefined, undefined];
return [getNextItem(activeItem, -1), getNextItem(activeItem, 1)];
}, [activeItem, getNextItem]);
if (activeItem === undefined) return undefined;
const hasPrev = prevItem !== undefined;
const hasNext = nextItem !== undefined;
const isMoving = isMouseDown && isScaled;
return (
<div className="MediaViewerSlides" ref={containerRef}>
<div className="MediaViewerSlide" ref={leftSlideRef}>
{hasPrev && !isScaled && !isResizing && (
<MediaViewerContent
withAnimation={withAnimation}
isMoving={isMoving}
item={prevItem}
onClose={onClose}
onFooterClick={onFooterClick}
handleSponsoredClick={handleSponsoredClick}
/>
)}
</div>
<div
className={buildClassName(
'MediaViewerSlide',
'MediaViewerSlide--active',
isMoving && 'MediaViewerSlide--moving',
)}
onClick={handleControlsVisibility}
ref={activeSlideRef}
>
<MediaViewerContent
item={activeItem}
withAnimation={withAnimation}
isActive={isActive}
isMoving={isMoving}
onClose={onClose}
onFooterClick={onFooterClick}
handleSponsoredClick={handleSponsoredClick}
/>
</div>
<div className="MediaViewerSlide" ref={rightSlideRef}>
{hasNext && !isScaled && !isResizing && (
<MediaViewerContent
withAnimation={withAnimation}
isMoving={isMoving}
item={nextItem}
onClose={onClose}
onFooterClick={onFooterClick}
handleSponsoredClick={handleSponsoredClick}
/>
)}
</div>
{hasPrev && !isScaled && !IS_TOUCH_ENV && (
<button
type="button"
className={`navigation prev ${isVideo && !isGif && 'inline'}`}
aria-label={lang('AccDescrPrevious')}
dir={lang.isRtl ? 'rtl' : undefined}
/>
)}
{hasNext && !isScaled && !IS_TOUCH_ENV && (
<button
type="button"
className={`navigation next ${isVideo && !isGif && 'inline'}`}
aria-label={lang('Next')}
dir={lang.isRtl ? 'rtl' : undefined}
/>
)}
</div>
);
};
export default memo(MediaViewerSlides);
function getTransformStyle(x = 0, y = 0, scale = 1) {
return `translate3d(${x.toFixed(3)}px, ${y.toFixed(3)}px, 0px) scale(${scale.toFixed(3)})`;
}
function checkIfInsideSelector(element: HTMLElement, selector: string) {
if (!element) return false;
if (element.matches(selector)) return true;
return Boolean(element.closest(selector));
}
function checkIfControlTarget(e: TouchEvent | MouseEvent) {
const target = e.target as HTMLElement;
if (checkIfInsideSelector(target, '.VideoPlayerControls')) {
if (checkIfInsideSelector(
target,
'.play, .fullscreen, .volume, .volume-slider, .playback-rate, .playback-rate-menu, .SeekLine',
)) {
return true;
}
e.preventDefault();
return true;
}
return false;
}