Media Viewer: Support trackpad swipe gestures on desktop (#1910)
This commit is contained in:
parent
ca70e2c0eb
commit
4cf79d22ae
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
99
src/util/lethargy.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user