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 { ApiMediaFormat } from '../../api/types';
|
||||||
import { MediaViewerOrigin } from '../../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 useBlurSync from '../../hooks/useBlurSync';
|
||||||
import useMedia from '../../hooks/useMedia';
|
import useMedia from '../../hooks/useMedia';
|
||||||
import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
|
import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
|
||||||
@ -228,7 +228,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
|
|||||||
message && calculateMediaViewerDimensions(dimensions!, hasFooter),
|
message && calculateMediaViewerDimensions(dimensions!, hasFooter),
|
||||||
!IS_SINGLE_COLUMN_LAYOUT && !isProtected,
|
!IS_SINGLE_COLUMN_LAYOUT && !isProtected,
|
||||||
)}
|
)}
|
||||||
{isVideo && ((!isActive && IS_TOUCH_ENV) ? renderVideoPreview(
|
{isVideo && (!isActive ? renderVideoPreview(
|
||||||
bestImageData,
|
bestImageData,
|
||||||
message && calculateMediaViewerDimensions(dimensions!, hasFooter, true),
|
message && calculateMediaViewerDimensions(dimensions!, hasFooter, true),
|
||||||
!IS_SINGLE_COLUMN_LAYOUT && !isProtected,
|
!IS_SINGLE_COLUMN_LAYOUT && !isProtected,
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import type { MediaViewerOrigin } from '../../types';
|
|||||||
import type { RealTouchEvent } from '../../util/captureEvents';
|
import type { RealTouchEvent } from '../../util/captureEvents';
|
||||||
|
|
||||||
import { animateNumber, timingFunctions } from '../../util/animation';
|
import { animateNumber, timingFunctions } from '../../util/animation';
|
||||||
import arePropsShallowEqual from '../../util/arePropsShallowEqual';
|
|
||||||
import buildClassName from '../../util/buildClassName';
|
import buildClassName from '../../util/buildClassName';
|
||||||
import { captureEvents, IOS_SCREEN_EDGE_THRESHOLD } from '../../util/captureEvents';
|
import { captureEvents, IOS_SCREEN_EDGE_THRESHOLD } from '../../util/captureEvents';
|
||||||
import { IS_IOS, IS_TOUCH_ENV } from '../../util/environment';
|
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 lastTransformRef = useRef<Transform>({ x: 0, y: 0, scale: 1 });
|
||||||
const swipeDirectionRef = useRef<SwipeDirection | undefined>(undefined);
|
const swipeDirectionRef = useRef<SwipeDirection | undefined>(undefined);
|
||||||
const isActiveRef = useRef(true);
|
const isActiveRef = useRef(true);
|
||||||
|
const isReleasedRef = useRef(false);
|
||||||
const [activeMessageId, setActiveMessageId] = useState<number | undefined>(messageId);
|
const [activeMessageId, setActiveMessageId] = useState<number | undefined>(messageId);
|
||||||
const prevZoomLevelChange = usePrevious(zoomLevelChange);
|
const prevZoomLevelChange = usePrevious(zoomLevelChange);
|
||||||
const hasZoomChanged = prevZoomLevelChange !== undefined && prevZoomLevelChange !== zoomLevelChange;
|
const hasZoomChanged = prevZoomLevelChange !== undefined && prevZoomLevelChange !== zoomLevelChange;
|
||||||
@ -246,6 +246,135 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
return [{ x, y, scale }, inBoundsX, inBoundsY];
|
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, {
|
const cleanup = captureEvents(containerRef.current, {
|
||||||
isNotPassive: true,
|
isNotPassive: true,
|
||||||
withNativeDrag: true,
|
withNativeDrag: true,
|
||||||
@ -263,7 +392,8 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
lastGestureTime = Date.now();
|
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;
|
if (!activeSlideRef.current) return;
|
||||||
content = activeSlideRef.current.querySelector('img, video');
|
content = activeSlideRef.current.querySelector('img, video');
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
@ -275,7 +405,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
dragOffsetX,
|
dragOffsetX,
|
||||||
dragOffsetY,
|
dragOffsetY,
|
||||||
}, cancelDrag) => {
|
}, cancelDrag) => {
|
||||||
if (checkIfControlTarget(event)) return;
|
if (isReleasedRef.current || checkIfControlTarget(event)) return;
|
||||||
// Avoid conflicts with swipe-to-back gestures
|
// Avoid conflicts with swipe-to-back gestures
|
||||||
if (IS_IOS && captureEvent.type === 'touchstart') {
|
if (IS_IOS && captureEvent.type === 'touchstart') {
|
||||||
const { pageX } = (captureEvent as RealTouchEvent).touches[0];
|
const { pageX } = (captureEvent as RealTouchEvent).touches[0];
|
||||||
@ -298,7 +428,8 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
} = transformRef.current;
|
} = transformRef.current;
|
||||||
const h = 10;
|
const threshold = 10;
|
||||||
|
const tolerance = 1.5;
|
||||||
|
|
||||||
// If user is inactive but is still touching the screen
|
// If user is inactive but is still touching the screen
|
||||||
// we reset last gesture time
|
// we reset last gesture time
|
||||||
@ -323,33 +454,46 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (['wheel', 'mousemove'].includes(event.type)) return;
|
if (event.type === 'mousemove') return;
|
||||||
if (swipeDirectionRef.current !== SwipeDirection.Vertical) {
|
if (swipeDirectionRef.current !== SwipeDirection.Vertical) {
|
||||||
// If user is swiping horizontally or horizontal shift is dominant
|
// If user is swiping horizontally or horizontal shift is dominant
|
||||||
// we change only horizontal position
|
// we change only horizontal position
|
||||||
if (swipeDirectionRef.current === SwipeDirection.Horizontal
|
if (swipeDirectionRef.current === SwipeDirection.Horizontal
|
||||||
|| Math.abs(x) > h || (absOffsetX > h && absOffsetY < h)) {
|
|| Math.abs(x) > threshold || absOffsetX / absOffsetY > tolerance) {
|
||||||
swipeDirectionRef.current = SwipeDirection.Horizontal;
|
swipeDirectionRef.current = SwipeDirection.Horizontal;
|
||||||
isActiveRef.current = false;
|
isActiveRef.current = false;
|
||||||
const limit = windowWidth + SLIDES_GAP;
|
const limit = windowWidth + SLIDES_GAP;
|
||||||
|
const x1 = clamp(dragOffsetX, -limit, limit);
|
||||||
setTransform({
|
setTransform({
|
||||||
x: clamp(dragOffsetX, -limit, limit),
|
x: x1,
|
||||||
y: 0,
|
y: 0,
|
||||||
scale,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If vertical shift is dominant we change only vertical position
|
// If vertical shift is dominant we change only vertical position
|
||||||
if (swipeDirectionRef.current === SwipeDirection.Vertical
|
if (swipeDirectionRef.current === SwipeDirection.Vertical
|
||||||
|| Math.abs(y) > h || (absOffsetY > h && absOffsetX < h)) {
|
|| Math.abs(y) > threshold || absOffsetY / absOffsetX > tolerance) {
|
||||||
swipeDirectionRef.current = SwipeDirection.Vertical;
|
swipeDirectionRef.current = SwipeDirection.Vertical;
|
||||||
const limit = windowHeight;
|
const limit = windowHeight;
|
||||||
|
const y1 = clamp(dragOffsetY, -limit, limit);
|
||||||
setTransform({
|
setTransform({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: clamp(dragOffsetY, -limit, limit),
|
y: y1,
|
||||||
scale,
|
scale,
|
||||||
});
|
});
|
||||||
|
if (event.type === 'wheel' && Math.abs(y1) > SWIPE_Y_THRESHOLD * 2) {
|
||||||
|
onRelease(event);
|
||||||
|
isReleasedRef.current = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onZoom: (e, {
|
onZoom: (e, {
|
||||||
@ -419,14 +563,10 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!IS_TOUCH_ENV && e.type !== 'wheel') 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
|
// 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 scaleOffsetX = (centerX - DOUBLE_TAP_ZOOM * centerX);
|
||||||
const scaleOffsetY = (centerY - DOUBLE_TAP_ZOOM * centerY);
|
const scaleOffsetY = (centerY - DOUBLE_TAP_ZOOM * centerY);
|
||||||
const {
|
|
||||||
scale,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
} = transformRef.current;
|
|
||||||
if (scale === 1) {
|
if (scale === 1) {
|
||||||
if (x !== 0 || y !== 0) return;
|
if (x !== 0 || y !== 0) return;
|
||||||
lastTransform = calculateOffsetBoundaries({
|
lastTransform = calculateOffsetBoundaries({
|
||||||
@ -456,133 +596,13 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onRelease: (e) => {
|
onRelease,
|
||||||
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;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', handleKeyDown, false);
|
document.addEventListener('keydown', handleKeyDown, false);
|
||||||
return () => {
|
return () => {
|
||||||
cleanup();
|
cleanup();
|
||||||
document.removeEventListener('keydown', handleKeyDown, false);
|
document.removeEventListener('keydown', handleKeyDown, false);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [
|
}, [
|
||||||
onClose,
|
onClose,
|
||||||
setTransform,
|
setTransform,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { IS_IOS } from './environment';
|
import { IS_IOS } from './environment';
|
||||||
|
import { Lethargy } from './lethargy';
|
||||||
import { clamp, round } from './math';
|
import { clamp, round } from './math';
|
||||||
import { debounce } from './schedulers';
|
import { debounce } from './schedulers';
|
||||||
|
|
||||||
@ -10,8 +11,8 @@ export enum SwipeDirection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CaptureOptions {
|
interface CaptureOptions {
|
||||||
onCapture?: (e: MouseEvent | TouchEvent) => void;
|
onCapture?: (e: MouseEvent | TouchEvent | WheelEvent) => void;
|
||||||
onRelease?: (e: MouseEvent | TouchEvent) => void;
|
onRelease?: (e: MouseEvent | TouchEvent | WheelEvent) => void;
|
||||||
onDrag?: (
|
onDrag?: (
|
||||||
e: MouseEvent | TouchEvent | WheelEvent,
|
e: MouseEvent | TouchEvent | WheelEvent,
|
||||||
captureEvent: MouseEvent | TouchEvent | WheelEvent,
|
captureEvent: MouseEvent | TouchEvent | WheelEvent,
|
||||||
@ -41,7 +42,7 @@ interface CaptureOptions {
|
|||||||
currentCenterY: number;
|
currentCenterY: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
onClick?: (e: MouseEvent | TouchEvent) => 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;
|
excludedClosestSelector?: string;
|
||||||
selectorToPreventScroll?: string;
|
selectorToPreventScroll?: string;
|
||||||
withNativeDrag?: boolean;
|
withNativeDrag?: boolean;
|
||||||
@ -68,6 +69,8 @@ type TSwipeAxis =
|
|||||||
export const IOS_SCREEN_EDGE_THRESHOLD = 20;
|
export const IOS_SCREEN_EDGE_THRESHOLD = 20;
|
||||||
const MOVED_THRESHOLD = 15;
|
const MOVED_THRESHOLD = 15;
|
||||||
const SWIPE_THRESHOLD = 50;
|
const SWIPE_THRESHOLD = 50;
|
||||||
|
const RELEASE_WHEEL_ZOOM_DELAY = 150;
|
||||||
|
const RELEASE_WHEEL_DRAG_DELAY = 150;
|
||||||
|
|
||||||
function getDistance(a: Touch, b?: Touch) {
|
function getDistance(a: Touch, b?: Touch) {
|
||||||
if (!b) return 0;
|
if (!b) return 0;
|
||||||
@ -82,6 +85,12 @@ function getTouchCenter(a: Touch, b: Touch) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let lastClickTime = 0;
|
let lastClickTime = 0;
|
||||||
|
const lethargy = new Lethargy({
|
||||||
|
stability: 5,
|
||||||
|
sensitivity: 25,
|
||||||
|
tolerance: 0.6,
|
||||||
|
delay: 150,
|
||||||
|
});
|
||||||
|
|
||||||
export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
||||||
let captureEvent: MouseEvent | RealTouchEvent | WheelEvent | undefined;
|
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!);
|
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) {
|
function onWheel(e: WheelEvent) {
|
||||||
if (!options.onZoom && !options.onDrag) return;
|
if (!options.onZoom && !options.onDrag) return;
|
||||||
@ -329,56 +386,27 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
|
|||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!hasMoved) {
|
|
||||||
onCapture(e);
|
|
||||||
hasMoved = true;
|
|
||||||
initialTouchCenter = {
|
|
||||||
x: e.x,
|
|
||||||
y: e.y,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const { doubleTapZoom = 3 } = options;
|
const { doubleTapZoom = 3 } = options;
|
||||||
if (options.onDoubleClick && Object.is(e.deltaX, -0) && Object.is(e.deltaY, -0) && e.ctrlKey) {
|
if (options.onDoubleClick && Object.is(e.deltaX, -0) && Object.is(e.deltaY, -0) && e.ctrlKey) {
|
||||||
|
onWheelCapture(e);
|
||||||
wheelZoom = wheelZoom > 1 ? 1 : doubleTapZoom;
|
wheelZoom = wheelZoom > 1 ? 1 : doubleTapZoom;
|
||||||
options.onDoubleClick(e, { centerX: e.pageX, centerY: e.pageY });
|
options.onDoubleClick(e, { centerX: e.pageX, centerY: e.pageY });
|
||||||
hasMoved = false;
|
hasMoved = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const metaKeyPressed = e.metaKey || e.ctrlKey || e.shiftKey;
|
const metaKeyPressed = e.metaKey || e.ctrlKey || e.shiftKey;
|
||||||
if (options.onZoom && metaKeyPressed) {
|
if (metaKeyPressed) {
|
||||||
isZooming = true;
|
onWheelZoom(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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (options.onDrag && !metaKeyPressed && !isZooming) {
|
if (!metaKeyPressed && !isZooming) {
|
||||||
// Ignore wheel inertia if drag is canceled in this direction
|
// Check if this event produced by user scroll and not by inertia
|
||||||
if (!isDragCanceled.x || Math.sign(initialDragOffset.x) === Math.sign(e.deltaX)) {
|
const isUserEvent = lethargy.check(e);
|
||||||
initialDragOffset.x -= e.deltaX;
|
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);
|
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