diff --git a/src/components/mediaViewer/MediaViewerContent.tsx b/src/components/mediaViewer/MediaViewerContent.tsx index 443e0897b..f6d33f0de 100644 --- a/src/components/mediaViewer/MediaViewerContent.tsx +++ b/src/components/mediaViewer/MediaViewerContent.tsx @@ -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 = (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, diff --git a/src/components/mediaViewer/MediaViewerSlides.tsx b/src/components/mediaViewer/MediaViewerSlides.tsx index 36d8b0cbb..d70fcbac0 100644 --- a/src/components/mediaViewer/MediaViewerSlides.tsx +++ b/src/components/mediaViewer/MediaViewerSlides.tsx @@ -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 = ({ const lastTransformRef = useRef({ x: 0, y: 0, scale: 1 }); const swipeDirectionRef = useRef(undefined); const isActiveRef = useRef(true); + const isReleasedRef = useRef(false); const [activeMessageId, setActiveMessageId] = useState(messageId); const prevZoomLevelChange = usePrevious(zoomLevelChange); const hasZoomChanged = prevZoomLevelChange !== undefined && prevZoomLevelChange !== zoomLevelChange; @@ -246,6 +246,135 @@ const MediaViewerSlides: FC = ({ 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 = ({ } } 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 = ({ 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 = ({ 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 = ({ } 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 = ({ 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 = ({ }, }); }, - 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, diff --git a/src/util/captureEvents.ts b/src/util/captureEvents.ts index 36be0c80f..edd9aa45f 100644 --- a/src/util/captureEvents.ts +++ b/src/util/captureEvents.ts @@ -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); diff --git a/src/util/lethargy.ts b/src/util/lethargy.ts new file mode 100644 index 000000000..c50198953 --- /dev/null +++ b/src/util/lethargy.ts @@ -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; + + lastDownDeltas: Array; + + deltasTimestamp: Array; + + 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); + } +}