449 lines
13 KiB
TypeScript
449 lines
13 KiB
TypeScript
import { IS_IOS } from './windowEnvironment';
|
|
import { Lethargy } from './lethargy';
|
|
import { clamp, round } from './math';
|
|
import { debounce } from './schedulers';
|
|
import windowSize from './windowSize';
|
|
|
|
export enum SwipeDirection {
|
|
Up,
|
|
Down,
|
|
Left,
|
|
Right,
|
|
}
|
|
|
|
interface CaptureOptions {
|
|
onCapture?: (e: MouseEvent | TouchEvent | WheelEvent) => void;
|
|
onRelease?: (e: MouseEvent | TouchEvent | WheelEvent) => void;
|
|
onDrag?: (
|
|
e: MouseEvent | TouchEvent | WheelEvent,
|
|
captureEvent: MouseEvent | TouchEvent | WheelEvent,
|
|
params: {
|
|
dragOffsetX: number;
|
|
dragOffsetY: number;
|
|
},
|
|
cancelDrag?: (x: boolean, y: boolean) => void,
|
|
) => void;
|
|
onSwipe?: (e: Event, direction: SwipeDirection) => boolean;
|
|
onZoom?: (e: TouchEvent | WheelEvent, params: {
|
|
// Absolute zoom level
|
|
zoom?: number;
|
|
// Relative zoom factor
|
|
zoomFactor?: number;
|
|
|
|
// center coordinate of the initial pinch
|
|
initialCenterX: number;
|
|
initialCenterY: number;
|
|
|
|
// offset of the pinch center (current from initial)
|
|
dragOffsetX: number;
|
|
dragOffsetY: number;
|
|
|
|
// center coordinate of the current pinch
|
|
currentCenterX: number;
|
|
currentCenterY: number;
|
|
}) => void;
|
|
onClick?: (e: MouseEvent | TouchEvent) => void;
|
|
onDoubleClick?: (e: MouseEvent | RealTouchEvent | WheelEvent, params: { centerX: number; centerY: number }) => void;
|
|
excludedClosestSelector?: string;
|
|
selectorToPreventScroll?: string;
|
|
withNativeDrag?: boolean;
|
|
maxZoom?: number;
|
|
minZoom?: number;
|
|
doubleTapZoom?: number;
|
|
initialZoom?: number;
|
|
isNotPassive?: boolean;
|
|
withCursor?: boolean;
|
|
}
|
|
|
|
// https://stackoverflow.com/questions/11287877/how-can-i-get-e-offsetx-on-mobile-ipad
|
|
// Android does not have this value, and iOS has it but as read-only
|
|
export interface RealTouchEvent extends TouchEvent {
|
|
pageX?: number;
|
|
pageY?: number;
|
|
}
|
|
|
|
type TSwipeAxis =
|
|
'x'
|
|
| 'y'
|
|
| undefined;
|
|
|
|
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;
|
|
return Math.hypot((b.pageX - a.pageX), (b.pageY - a.pageY));
|
|
}
|
|
|
|
function getTouchCenter(a: Touch, b: Touch) {
|
|
return {
|
|
x: (a.pageX + b.pageX) / 2,
|
|
y: (a.pageY + b.pageY) / 2,
|
|
};
|
|
}
|
|
|
|
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;
|
|
let hasMoved = false;
|
|
let hasSwiped = false;
|
|
let isZooming = false;
|
|
let initialDistance = 0;
|
|
let wheelZoom = options.initialZoom ?? 1;
|
|
let initialDragOffset = {
|
|
x: 0,
|
|
y: 0,
|
|
};
|
|
let isDragCanceled = {
|
|
x: false,
|
|
y: false,
|
|
};
|
|
const currentWindowSize = windowSize.get();
|
|
let initialTouchCenter = {
|
|
x: currentWindowSize.width / 2,
|
|
y: currentWindowSize.height / 2,
|
|
};
|
|
let initialSwipeAxis: TSwipeAxis | undefined;
|
|
const minZoom = options.minZoom ?? 1;
|
|
const maxZoom = options.maxZoom ?? 4;
|
|
|
|
function onCapture(e: MouseEvent | RealTouchEvent) {
|
|
if (options.excludedClosestSelector && (
|
|
(e.target as HTMLElement).matches(options.excludedClosestSelector)
|
|
|| (e.target as HTMLElement).closest(options.excludedClosestSelector)
|
|
)) {
|
|
return;
|
|
}
|
|
|
|
captureEvent = e;
|
|
|
|
if (e.type === 'mousedown') {
|
|
if (!options.withNativeDrag && options.onDrag) {
|
|
e.preventDefault();
|
|
}
|
|
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onRelease);
|
|
} else if (e.type === 'touchstart') {
|
|
// We need to always listen on `touchstart` target:
|
|
// https://stackoverflow.com/questions/33298828/touch-move-event-dont-fire-after-touch-start-target-is-removed
|
|
const target = e.target as HTMLElement;
|
|
target.addEventListener('touchmove', onMove, { passive: true });
|
|
target.addEventListener('touchend', onRelease);
|
|
target.addEventListener('touchcancel', onRelease);
|
|
|
|
if ('touches' in e) {
|
|
if (e.pageX === undefined) {
|
|
e.pageX = e.touches[0].pageX;
|
|
}
|
|
|
|
if (e.pageY === undefined) {
|
|
e.pageY = e.touches[0].pageY;
|
|
}
|
|
|
|
if (e.touches.length === 2) {
|
|
initialDistance = getDistance(e.touches[0], e.touches[1]);
|
|
initialTouchCenter = getTouchCenter(e.touches[0], e.touches[1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (options.withCursor) {
|
|
document.body.classList.add('cursor-grabbing');
|
|
}
|
|
|
|
if (options.onCapture) {
|
|
options.onCapture(e);
|
|
}
|
|
}
|
|
|
|
function onRelease(e?: MouseEvent | TouchEvent) {
|
|
if (captureEvent) {
|
|
if (options.withCursor) {
|
|
document.body.classList.remove('cursor-grabbing');
|
|
}
|
|
|
|
document.removeEventListener('mouseup', onRelease);
|
|
document.removeEventListener('mousemove', onMove);
|
|
(captureEvent.target as HTMLElement).removeEventListener('touchcancel', onRelease);
|
|
(captureEvent.target as HTMLElement).removeEventListener('touchend', onRelease);
|
|
(captureEvent.target as HTMLElement).removeEventListener('touchmove', onMove);
|
|
|
|
if (IS_IOS && options.selectorToPreventScroll) {
|
|
Array.from(document.querySelectorAll<HTMLElement>(options.selectorToPreventScroll))
|
|
.forEach((scrollable) => {
|
|
scrollable.style.overflow = '';
|
|
});
|
|
}
|
|
|
|
if (e) {
|
|
if (hasMoved) {
|
|
if (options.onRelease) {
|
|
options.onRelease(e);
|
|
}
|
|
} else if (e.type === 'mouseup') {
|
|
if (options.onDoubleClick && Date.now() - lastClickTime < 300) {
|
|
options.onDoubleClick(e, {
|
|
centerX: captureEvent!.pageX!,
|
|
centerY: captureEvent!.pageY!,
|
|
});
|
|
} else if (options.onClick && (!('button' in e) || e.button === 0)) {
|
|
options.onClick(e);
|
|
}
|
|
lastClickTime = Date.now();
|
|
}
|
|
}
|
|
}
|
|
|
|
hasMoved = false;
|
|
hasSwiped = false;
|
|
isZooming = false;
|
|
initialDistance = 0;
|
|
wheelZoom = clamp(wheelZoom, minZoom, maxZoom);
|
|
initialSwipeAxis = undefined;
|
|
initialDragOffset = {
|
|
x: 0,
|
|
y: 0,
|
|
};
|
|
isDragCanceled = {
|
|
x: false,
|
|
y: false,
|
|
};
|
|
const newWindowSize = windowSize.get();
|
|
initialTouchCenter = {
|
|
x: newWindowSize.width / 2,
|
|
y: newWindowSize.height / 2,
|
|
};
|
|
captureEvent = undefined;
|
|
}
|
|
|
|
function onMove(e: MouseEvent | RealTouchEvent) {
|
|
if (captureEvent) {
|
|
if (e.type === 'touchmove' && ('touches' in e)) {
|
|
if (e.pageX === undefined) {
|
|
e.pageX = e.touches[0].pageX;
|
|
}
|
|
|
|
if (e.pageY === undefined) {
|
|
e.pageY = e.touches[0].pageY;
|
|
}
|
|
|
|
if (options.onZoom && initialDistance > 0 && e.touches.length === 2) {
|
|
const endDistance = getDistance(e.touches[0], e.touches[1]);
|
|
const touchCenter = getTouchCenter(e.touches[0], e.touches[1]);
|
|
const dragOffsetX = touchCenter.x - initialTouchCenter.x;
|
|
const dragOffsetY = touchCenter.y - initialTouchCenter.y;
|
|
const zoomFactor = endDistance / initialDistance;
|
|
options.onZoom(e, {
|
|
zoomFactor,
|
|
initialCenterX: initialTouchCenter.x,
|
|
initialCenterY: initialTouchCenter.y,
|
|
dragOffsetX,
|
|
dragOffsetY,
|
|
currentCenterX: touchCenter.x,
|
|
currentCenterY: touchCenter.y,
|
|
});
|
|
if (zoomFactor !== 1) hasMoved = true;
|
|
}
|
|
}
|
|
|
|
const dragOffsetX = e.pageX! - captureEvent.pageX!;
|
|
const dragOffsetY = e.pageY! - captureEvent.pageY!;
|
|
|
|
if (Math.abs(dragOffsetX) >= MOVED_THRESHOLD || Math.abs(dragOffsetY) >= MOVED_THRESHOLD) {
|
|
hasMoved = true;
|
|
}
|
|
|
|
let shouldPreventScroll = false;
|
|
|
|
if (options.onDrag) {
|
|
options.onDrag(e, captureEvent, {
|
|
dragOffsetX,
|
|
dragOffsetY,
|
|
});
|
|
shouldPreventScroll = true;
|
|
}
|
|
|
|
if (options.onSwipe && !hasSwiped) {
|
|
hasSwiped = onSwipe(e, dragOffsetX, dragOffsetY);
|
|
shouldPreventScroll = hasSwiped;
|
|
}
|
|
|
|
if (IS_IOS && shouldPreventScroll && options.selectorToPreventScroll) {
|
|
Array.from(document.querySelectorAll<HTMLElement>(options.selectorToPreventScroll))
|
|
.forEach((scrollable) => {
|
|
scrollable.style.overflow = 'hidden';
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function onSwipe(e: MouseEvent | RealTouchEvent, dragOffsetX: number, dragOffsetY: number) {
|
|
// Avoid conflicts with swipe-to-back gestures
|
|
if (IS_IOS) {
|
|
const x = (e as RealTouchEvent).touches[0].pageX;
|
|
if (x <= IOS_SCREEN_EDGE_THRESHOLD || x >= windowSize.get().width - IOS_SCREEN_EDGE_THRESHOLD) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const xAbs = Math.abs(dragOffsetX);
|
|
const yAbs = Math.abs(dragOffsetY);
|
|
|
|
if (dragOffsetX && dragOffsetY) {
|
|
const ratio = Math.max(xAbs, yAbs) / Math.min(xAbs, yAbs);
|
|
// Diagonal swipe
|
|
if (ratio < 2) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
let axis: TSwipeAxis | undefined;
|
|
if (xAbs >= SWIPE_THRESHOLD) {
|
|
axis = 'x';
|
|
} else if (yAbs >= SWIPE_THRESHOLD) {
|
|
axis = 'y';
|
|
}
|
|
|
|
if (!axis) {
|
|
return false;
|
|
}
|
|
|
|
if (!initialSwipeAxis) {
|
|
initialSwipeAxis = axis;
|
|
} else if (initialSwipeAxis !== axis) {
|
|
// Prevent horizontal swipe after vertical to prioritize scroll
|
|
return false;
|
|
}
|
|
|
|
return processSwipe(e, axis, dragOffsetX, dragOffsetY, options.onSwipe!);
|
|
}
|
|
|
|
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);
|
|
isZooming = true;
|
|
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;
|
|
if (options.excludedClosestSelector && (
|
|
(e.target as HTMLElement).matches(options.excludedClosestSelector)
|
|
|| (e.target as HTMLElement).closest(options.excludedClosestSelector)
|
|
)) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
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 (metaKeyPressed) {
|
|
onWheelZoom(e);
|
|
}
|
|
if (!metaKeyPressed && !isZooming) {
|
|
// Check if this event produced by user scroll and not by inertia
|
|
const isUserEvent = lethargy.check(e);
|
|
if (wheelZoom !== 1 || isUserEvent) {
|
|
onWheelDrag(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
element.addEventListener('wheel', onWheel);
|
|
element.addEventListener('mousedown', onCapture);
|
|
element.addEventListener('touchstart', onCapture, { passive: !options.isNotPassive });
|
|
|
|
return () => {
|
|
onRelease();
|
|
element.removeEventListener('wheel', onWheel);
|
|
element.removeEventListener('touchstart', onCapture);
|
|
element.removeEventListener('mousedown', onCapture);
|
|
};
|
|
}
|
|
|
|
function processSwipe(
|
|
e: Event,
|
|
currentSwipeAxis: TSwipeAxis,
|
|
dragOffsetX: number,
|
|
dragOffsetY: number,
|
|
onSwipe: (e: Event, direction: SwipeDirection) => boolean,
|
|
) {
|
|
if (currentSwipeAxis === 'x') {
|
|
if (dragOffsetX < 0) {
|
|
return onSwipe(e, SwipeDirection.Left);
|
|
} else {
|
|
return onSwipe(e, SwipeDirection.Right);
|
|
}
|
|
} else if (currentSwipeAxis === 'y') {
|
|
if (dragOffsetY < 0) {
|
|
return onSwipe(e, SwipeDirection.Up);
|
|
} else {
|
|
return onSwipe(e, SwipeDirection.Down);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|