Media Viewer: Support trackpad swipe gestures on desktop (#1910)

This commit is contained in:
Alexander Zinchuk 2022-07-08 14:59:57 +02:00
parent ca70e2c0eb
commit 4cf79d22ae
4 changed files with 327 additions and 180 deletions

View File

@ -8,7 +8,7 @@ import type {
import { ApiMediaFormat } from '../../api/types';
import { MediaViewerOrigin } from '../../types';
import { IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../util/environment';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import useBlurSync from '../../hooks/useBlurSync';
import useMedia from '../../hooks/useMedia';
import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
@ -228,7 +228,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
message && calculateMediaViewerDimensions(dimensions!, hasFooter),
!IS_SINGLE_COLUMN_LAYOUT && !isProtected,
)}
{isVideo && ((!isActive && IS_TOUCH_ENV) ? renderVideoPreview(
{isVideo && (!isActive ? renderVideoPreview(
bestImageData,
message && calculateMediaViewerDimensions(dimensions!, hasFooter, true),
!IS_SINGLE_COLUMN_LAYOUT && !isProtected,

View File

@ -7,7 +7,6 @@ import type { MediaViewerOrigin } from '../../types';
import type { RealTouchEvent } from '../../util/captureEvents';
import { animateNumber, timingFunctions } from '../../util/animation';
import arePropsShallowEqual from '../../util/arePropsShallowEqual';
import buildClassName from '../../util/buildClassName';
import { captureEvents, IOS_SCREEN_EDGE_THRESHOLD } from '../../util/captureEvents';
import { IS_IOS, IS_TOUCH_ENV } from '../../util/environment';
@ -96,6 +95,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
const lastTransformRef = useRef<Transform>({ x: 0, y: 0, scale: 1 });
const swipeDirectionRef = useRef<SwipeDirection | undefined>(undefined);
const isActiveRef = useRef(true);
const isReleasedRef = useRef(false);
const [activeMessageId, setActiveMessageId] = useState<number | undefined>(messageId);
const prevZoomLevelChange = usePrevious(zoomLevelChange);
const hasZoomChanged = prevZoomLevelChange !== undefined && prevZoomLevelChange !== zoomLevelChange;
@ -246,6 +246,135 @@ const MediaViewerSlides: FC<OwnProps> = ({
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 absX = Math.abs(transformRef.current.x);
const absY = Math.abs(transformRef.current.y);
const {
scale,
x,
y,
} = transformRef.current;
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 mId = getMessageId(activeMessageId, 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 (mId && 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
transformRef.current.x += offset;
setActiveMessageId(mId);
selectMessageDebounced(mId);
}
// Then we always return to the original position
cancelAnimation = animateNumber({
from: transformRef.current.x,
to: 0,
duration: ANIMATION_DURATION,
timing: easeOutCubic,
onUpdate: (value) => setTransform({
y: 0,
x: value,
scale: transformRef.current.scale,
}),
});
};
const cleanup = captureEvents(containerRef.current, {
isNotPassive: true,
withNativeDrag: true,
@ -263,7 +392,8 @@ const MediaViewerSlides: FC<OwnProps> = ({
}
}
lastGestureTime = Date.now();
if (arePropsShallowEqual(transformRef.current, { x: 0, y: 0, scale: 1 })) {
const { x, y, scale } = transformRef.current;
if (x === 0 && y === 0 && scale === 1) {
if (!activeSlideRef.current) return;
content = activeSlideRef.current.querySelector('img, video');
if (!content) return;
@ -275,7 +405,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
dragOffsetX,
dragOffsetY,
}, cancelDrag) => {
if (checkIfControlTarget(event)) return;
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];
@ -298,7 +428,8 @@ const MediaViewerSlides: FC<OwnProps> = ({
x,
y,
} = transformRef.current;
const h = 10;
const threshold = 10;
const tolerance = 1.5;
// If user is inactive but is still touching the screen
// we reset last gesture time
@ -323,33 +454,46 @@ const MediaViewerSlides: FC<OwnProps> = ({
}
return;
}
if (['wheel', 'mousemove'].includes(event.type)) 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) > h || (absOffsetX > h && absOffsetY < h)) {
|| Math.abs(x) > threshold || absOffsetX / absOffsetY > tolerance) {
swipeDirectionRef.current = SwipeDirection.Horizontal;
isActiveRef.current = false;
const limit = windowWidth + SLIDES_GAP;
const x1 = clamp(dragOffsetX, -limit, limit);
setTransform({
x: clamp(dragOffsetX, -limit, limit),
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) > h || (absOffsetY > h && absOffsetX < h)) {
|| Math.abs(y) > threshold || absOffsetY / absOffsetX > tolerance) {
swipeDirectionRef.current = SwipeDirection.Vertical;
const limit = windowHeight;
const y1 = clamp(dragOffsetY, -limit, limit);
setTransform({
x: 0,
y: clamp(dragOffsetY, -limit, limit),
y: y1,
scale,
});
if (event.type === 'wheel' && Math.abs(y1) > SWIPE_Y_THRESHOLD * 2) {
onRelease(event);
isReleasedRef.current = true;
}
}
},
onZoom: (e, {
@ -419,14 +563,10 @@ const MediaViewerSlides: FC<OwnProps> = ({
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);
const {
scale,
x,
y,
} = transformRef.current;
if (scale === 1) {
if (x !== 0 || y !== 0) return;
lastTransform = calculateOffsetBoundaries({
@ -456,133 +596,13 @@ const MediaViewerSlides: FC<OwnProps> = ({
},
});
},
onRelease: (e) => {
if (e.type === 'mouseup') {
setIsMouseDown(false);
}
const absX = Math.abs(transformRef.current.x);
const absY = Math.abs(transformRef.current.y);
const {
scale,
x,
y,
} = transformRef.current;
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 undefined;
}
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 undefined;
}
lastTransform = {
x,
y,
scale,
};
if (e.type !== 'wheel' && absY >= SWIPE_Y_THRESHOLD) return onClose();
// 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 undefined;
}
// Get horizontal swipe direction
const direction = x < 0 ? 1 : -1;
const mId = getMessageId(activeMessageId, 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 (mId && 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
transformRef.current.x += offset;
setActiveMessageId(mId);
selectMessageDebounced(mId);
}
// Then we always return to the original position
cancelAnimation = animateNumber({
from: transformRef.current.x,
to: 0,
duration: ANIMATION_DURATION,
timing: easeOutCubic,
onUpdate: (value) => setTransform({
y: 0,
x: value,
scale: transformRef.current.scale,
}),
});
return undefined;
},
onRelease,
});
document.addEventListener('keydown', handleKeyDown, false);
return () => {
cleanup();
document.removeEventListener('keydown', handleKeyDown, false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
onClose,
setTransform,

View File

@ -1,4 +1,5 @@
import { IS_IOS } from './environment';
import { Lethargy } from './lethargy';
import { clamp, round } from './math';
import { debounce } from './schedulers';
@ -10,8 +11,8 @@ export enum SwipeDirection {
}
interface CaptureOptions {
onCapture?: (e: MouseEvent | TouchEvent) => void;
onRelease?: (e: MouseEvent | TouchEvent) => void;
onCapture?: (e: MouseEvent | TouchEvent | WheelEvent) => void;
onRelease?: (e: MouseEvent | TouchEvent | WheelEvent) => void;
onDrag?: (
e: MouseEvent | TouchEvent | WheelEvent,
captureEvent: MouseEvent | TouchEvent | WheelEvent,
@ -41,7 +42,7 @@ interface CaptureOptions {
currentCenterY: number;
}) => void;
onClick?: (e: MouseEvent | TouchEvent) => void;
onDoubleClick?: (e: MouseEvent | RealTouchEvent, params: { centerX: number; centerY: number }) => void;
onDoubleClick?: (e: MouseEvent | RealTouchEvent | WheelEvent, params: { centerX: number; centerY: number }) => void;
excludedClosestSelector?: string;
selectorToPreventScroll?: string;
withNativeDrag?: boolean;
@ -68,6 +69,8 @@ type TSwipeAxis =
export const IOS_SCREEN_EDGE_THRESHOLD = 20;
const MOVED_THRESHOLD = 15;
const SWIPE_THRESHOLD = 50;
const RELEASE_WHEEL_ZOOM_DELAY = 150;
const RELEASE_WHEEL_DRAG_DELAY = 150;
function getDistance(a: Touch, b?: Touch) {
if (!b) return 0;
@ -82,6 +85,12 @@ function getTouchCenter(a: Touch, b: Touch) {
}
let lastClickTime = 0;
const lethargy = new Lethargy({
stability: 5,
sensitivity: 25,
tolerance: 0.6,
delay: 150,
});
export function captureEvents(element: HTMLElement, options: CaptureOptions) {
let captureEvent: MouseEvent | RealTouchEvent | WheelEvent | undefined;
@ -317,7 +326,55 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
return processSwipe(e, axis, dragOffsetX, dragOffsetY, options.onSwipe!);
}
const releaseWheel = debounce(onRelease, 100, false);
const releaseWheelDrag = debounce(onRelease, RELEASE_WHEEL_DRAG_DELAY, false);
const releaseWheelZoom = debounce(onRelease, RELEASE_WHEEL_ZOOM_DELAY, false);
function onWheelCapture(e: WheelEvent) {
if (hasMoved) return;
onCapture(e);
hasMoved = true;
initialTouchCenter = { x: e.x, y: e.y };
}
function onWheelZoom(e: WheelEvent) {
if (!options.onZoom) return;
onWheelCapture(e);
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,
});
releaseWheelZoom(e);
}
function onWheelDrag(e: WheelEvent) {
if (!options.onDrag) return;
onWheelCapture(e);
// 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 };
});
releaseWheelDrag(e);
}
function onWheel(e: WheelEvent) {
if (!options.onZoom && !options.onDrag) return;
@ -329,56 +386,27 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
}
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) {
onWheelCapture(e);
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 (metaKeyPressed) {
onWheelZoom(e);
}
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 (!metaKeyPressed && !isZooming) {
// Check if this event produced by user scroll and not by inertia
const isUserEvent = lethargy.check(e);
if (wheelZoom !== 1) {
onWheelDrag(e);
} else if (isUserEvent) {
onWheelDrag(e);
}
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);

99
src/util/lethargy.ts Normal file
View File

@ -0,0 +1,99 @@
/**
* Lethargy help distinguish between scroll events initiated by the user, and those by inertial scrolling.
* Lethargy does not have external dependencies.
*
* @param stability - Specifies the length of the rolling average.
* In effect, the larger the value, the smoother the curve will be.
* This attempts to prevent anomalies from firing 'real' events. Valid values are all positive integers,
* but in most cases, you would need to stay between 5 and around 30.
*
* @param sensitivity - Specifies the minimum value for wheelDelta for it to register as a valid scroll event.
* Because the tail of the curve have low wheelDelta values,
* this will stop them from registering as valid scroll events.
* The unofficial standard wheelDelta is 120, so valid values are positive integers below 120.
*
* @param tolerance - Prevent small fluctuations from affecting results.
* Valid values are decimals from 0, but should ideally be between 0.05 and 0.3.
*
* Based on https://github.com/d4nyll/lethargy
*/
export type LethargyConfig = {
stability?: number;
sensitivity?: number;
tolerance?: number;
delay?: number;
};
export class Lethargy {
stability: number;
sensitivity: number;
tolerance: number;
delay: number;
lastUpDeltas: Array<number>;
lastDownDeltas: Array<number>;
deltasTimestamp: Array<number>;
constructor({
stability = 8,
sensitivity = 100,
tolerance = 1.1,
delay = 150,
}: LethargyConfig = {}) {
this.stability = stability;
this.sensitivity = sensitivity;
this.tolerance = tolerance;
this.delay = delay;
this.lastUpDeltas = new Array(this.stability * 2).fill(0);
this.lastDownDeltas = new Array(this.stability * 2).fill(0);
this.deltasTimestamp = new Array(this.stability * 2).fill(0);
}
check(e: any) {
let lastDelta;
e = e.originalEvent || e;
if (e.wheelDelta !== undefined) {
lastDelta = e.wheelDelta;
} else if (e.deltaY !== undefined) {
lastDelta = e.deltaY * -40;
} else if (e.detail !== undefined || e.detail === 0) {
lastDelta = e.detail * -40;
}
this.deltasTimestamp.push(Date.now());
this.deltasTimestamp.shift();
if (lastDelta > 0) {
this.lastUpDeltas.push(lastDelta);
this.lastUpDeltas.shift();
return this.isInertia(1);
} else {
this.lastDownDeltas.push(lastDelta);
this.lastDownDeltas.shift();
return this.isInertia(-1);
}
}
isInertia(direction: number) {
const lastDeltas = direction === -1 ? this.lastDownDeltas : this.lastUpDeltas;
if (lastDeltas[0] === undefined) return direction;
if (
this.deltasTimestamp[this.stability * 2 - 2] + this.delay > Date.now()
&& lastDeltas[0] === lastDeltas[this.stability * 2 - 1]
) {
return false;
}
const lastDeltasOld = lastDeltas.slice(0, this.stability);
const lastDeltasNew = lastDeltas.slice(this.stability, this.stability * 2);
const oldSum = lastDeltasOld.reduce((t, s) => t + s);
const newSum = lastDeltasNew.reduce((t, s) => t + s);
const oldAverage = oldSum / lastDeltasOld.length;
const newAverage = newSum / lastDeltasNew.length;
return Math.abs(oldAverage) < Math.abs(newAverage * this.tolerance)
&& this.sensitivity < Math.abs(newAverage);
}
}