tweb/src/components/swipeHandler.ts

503 lines
14 KiB
TypeScript

/*
* https://github.com/morethanwords/tweb
* Copyright (C) 2019-2021 Eduard Kuzmenko
* https://github.com/morethanwords/tweb/blob/master/LICENSE
*/
// * zoom part from WebZ
// * https://github.com/Ajaxy/telegram-tt/blob/069f4f5b2f2c7c22529ccced876842e7f9cb81f4/src/util/captureEvents.ts
import cancelEvent from '../helpers/dom/cancelEvent';
import IS_TOUCH_SUPPORTED from '../environment/touchSupport';
import safeAssign from '../helpers/object/safeAssign';
import contextMenuController from '../helpers/contextMenuController';
import {Middleware} from '../helpers/middleware';
import ListenerSetter, {Listener, ListenerOptions} from '../helpers/listenerSetter';
import {attachContextMenuListener} from '../helpers/dom/attachContextMenuListener';
import pause from '../helpers/schedulers/pause';
import deferredPromise from '../helpers/cancellablePromise';
import clamp from '../helpers/number/clamp';
import debounce from '../helpers/schedulers/debounce';
import {logger} from '../lib/logger';
import isSwipingBackSafari from '../helpers/dom/isSwipingBackSafari';
import windowSize from '../helpers/windowSize';
type E = {
clientX: number,
clientY: number,
target: EventTarget,
button?: number,
type?: string
};
type EE = E | (Exclude<E, 'clientX' | 'clientY'> & {
touches: E[]
});
const getEvent = (e: EE) => {
return 'touches' in e ? e.touches[0] : e;
};
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
};
}
const attachGlobalListenerTo = document;
let RESET_GLOBAL = false;
contextMenuController.addEventListener('toggle', (visible) => {
RESET_GLOBAL = visible;
});
export type SwipeHandlerOptions = {
element: SwipeHandler['element'],
onSwipe: SwipeHandler['onSwipe'],
verifyTouchTarget?: SwipeHandler['verifyTouchTarget'],
onFirstSwipe?: SwipeHandler['onFirstSwipe'],
onReset?: SwipeHandler['onReset'],
onStart?: SwipeHandler['onStart'],
onZoom?: SwipeHandler['onZoom'],
onDrag?: SwipeHandler['onDrag'],
onDoubleClick?: SwipeHandler['onDoubleClick'],
cursor?: SwipeHandler['cursor'],
cancelEvent?: SwipeHandler['cancelEvent'],
listenerOptions?: SwipeHandler['listenerOptions'],
setCursorTo?: HTMLElement,
middleware?: Middleware,
withDelay?: boolean,
minZoom?: number,
maxZoom?: number
};
const TOUCH_MOVE_OPTIONS: ListenerOptions = {passive: false};
const MOUSE_MOVE_OPTIONS: ListenerOptions = false as any;
const WHEEL_OPTIONS: ListenerOptions = {capture: true, passive: false};
export type ZoomDetails = {
zoom?: number;
zoomFactor?: number;
zoomAdd?: number;
initialCenterX: number;
initialCenterY: number;
dragOffsetX: number;
dragOffsetY: number;
currentCenterX: number;
currentCenterY: number;
};
export default class SwipeHandler {
private element: HTMLElement;
private onSwipe: (xDiff: number, yDiff: number, e: EE, cancelDrag?: (x: boolean, y: boolean) => void) => boolean | void;
private verifyTouchTarget: (evt: EE) => boolean | Promise<boolean>;
private onFirstSwipe: (e: EE) => void;
private onReset: (e?: Event) => void;
private onStart: () => void;
private onZoom: (details: ZoomDetails) => void;
private onDrag: (e: EE, captureEvent: E, details: {dragOffsetX: number, dragOffsetY: number}, cancelDrag: (x: boolean, y: boolean) => void) => void;
private onDoubleClick: (details: {centerX: number, centerY: number}) => void;
private cursor: 'grabbing' | 'move' | 'row-resize' | 'col-resize' | 'nesw-resize' | 'nwse-resize' | 'ne-resize' | 'se-resize' | 'sw-resize' | 'nw-resize' | 'n-resize' | 'e-resize' | 's-resize' | 'w-resize' | '';
private cancelEvent: boolean;
private listenerOptions: ListenerOptions;
private setCursorTo: HTMLElement;
private isMouseDown: boolean;
private tempId: number;
private hadMove: boolean;
private eventUp: E;
private xDown: number;
private yDown: number;
private xAdded: number;
private yAdded: number;
private withDelay: boolean;
private listenerSetter: ListenerSetter;
private initialDistance: number;
private initialTouchCenter: ReturnType<typeof getTouchCenter>;
private initialDragOffset: {x: number, y: number};
private isDragCanceled: {x: boolean, y: boolean};
private wheelZoom: number;
private releaseWheelDrag: ReturnType<typeof debounce<(e: Event) => void>>;
private releaseWheelZoom: ReturnType<typeof debounce<(e: Event) => void>>;
private log: ReturnType<typeof logger>;
constructor(options: SwipeHandlerOptions) {
safeAssign(this, options);
this.log = logger('SWIPE-HANDLER');
this.cursor ??= 'grabbing';
this.cancelEvent ??= true;
// this.listenerOptions ??= false as any;
this.listenerOptions ??= TOUCH_MOVE_OPTIONS;
this.setCursorTo ??= this.element;
this.listenerSetter = new ListenerSetter();
this.setListeners();
this.resetValues();
this.tempId = 0;
options.middleware?.onDestroy(() => {
this.reset();
this.removeListeners();
});
this.releaseWheelDrag = debounce(this.reset, 150, false);
this.releaseWheelZoom = debounce(this.reset, 150, false);
}
public setListeners() {
if(!IS_TOUCH_SUPPORTED) {
// @ts-ignore
this.listenerSetter.add(this.element)('mousedown', this.handleStart, this.listenerOptions);
this.listenerSetter.add(attachGlobalListenerTo)('mouseup', this.reset);
if(this.onZoom || this.onDoubleClick) {
this.listenerSetter.add(this.element)('wheel', this.handleWheel, WHEEL_OPTIONS);
}
} else {
if(this.withDelay) {
attachContextMenuListener({
element: this.element,
callback: (e) => {
cancelEvent(e);
// @ts-ignore
this.handleStart(e);
},
listenerSetter: this.listenerSetter,
listenerOptions: this.listenerOptions
});
} else {
// @ts-ignore
this.listenerSetter.add(this.element)('touchstart', this.handleStart, this.listenerOptions);
}
if(this.onDoubleClick) {
this.listenerSetter.add(this.element)('dblclick', (e) => {
this.onDoubleClick({centerX: e.pageX, centerY: e.pageY});
});
}
this.listenerSetter.add(attachGlobalListenerTo)('touchend', this.reset);
}
}
public removeListeners() {
this.log('remove listeners');
this.reset();
this.listenerSetter.removeAll();
}
public setCursor(cursor: SwipeHandler['cursor'] = '') {
this.cursor = cursor;
if(!IS_TOUCH_SUPPORTED && this.hadMove) {
this.setCursorTo.style.setProperty('cursor', this.cursor, 'important');
}
}
public add(x: number, y: number) {
this.xAdded = x;
this.yAdded = y;
this.handleMove({
clientX: this.eventUp.clientX,
clientY: this.eventUp.clientY,
target: this.eventUp.target
});
}
protected resetValues() {
++this.tempId;
this.hadMove = false;
this.xAdded = this.yAdded = 0;
this.xDown =
this.yDown =
this.eventUp =
this.isMouseDown =
undefined;
if(this.onZoom) {
this.initialDistance = 0;
this.initialTouchCenter = {
x: windowSize.width / 2,
y: windowSize.height / 2
};
this.initialDragOffset = {x: 0, y: 0};
this.isDragCanceled = {x: false, y: false};
this.wheelZoom = 1;
}
}
protected reset = (e?: Event) => {
this.log('reset');
/* if(e) {
cancelEvent(e);
} */
if(IS_TOUCH_SUPPORTED) {
this.listenerSetter.removeManual(attachGlobalListenerTo, 'touchmove', this.handleMove, TOUCH_MOVE_OPTIONS);
} else {
this.listenerSetter.removeManual(attachGlobalListenerTo, 'mousemove', this.handleMove, MOUSE_MOVE_OPTIONS);
this.setCursorTo.style.cursor = '';
}
if(this.hadMove) {
this.onReset?.(e);
}
this.releaseWheelDrag?.clearTimeout();
this.releaseWheelZoom?.clearTimeout();
this.resetValues();
};
protected setHadMove(_e: EE) {
if(!this.hadMove) {
this.log('had move');
this.hadMove = true;
this.setCursorTo.style.setProperty('cursor', this.cursor, 'important');
this.onFirstSwipe?.(_e);
}
}
protected dispatchOnSwipe(...args: Parameters<SwipeHandlerOptions['onSwipe']>) {
const onSwipeResult = this.onSwipe(...args);
if(onSwipeResult !== undefined && onSwipeResult) {
this.reset();
}
}
protected handleStart = async(_e: EE) => {
this.log('start');
if(this.isMouseDown) {
const touches = (_e as any as TouchEvent).touches;
if(touches?.length === 2) {
this.initialDistance = getDistance(touches[0], touches[1]);
this.initialTouchCenter = getTouchCenter(touches[0], touches[1]);
}
return;
}
const e = getEvent(_e);
if(![0, 1].includes(Math.max(0, e.button ?? 0))) {
return;
}
if(e.button === 1) {
cancelEvent(_e as any);
}
if(isSwipingBackSafari(_e as any)) {
return;
}
const tempId = ++this.tempId;
const verifyResult = this.verifyTouchTarget?.(_e);
if(verifyResult !== undefined) {
let result: any;
if(verifyResult instanceof Promise) {
// const tempId = this.tempId;
result = await verifyResult;
if(this.tempId !== tempId) {
return;
}
} else {
result = verifyResult;
}
if(!result) {
return this.reset();
}
}
this.isMouseDown = true;
if(this.withDelay && !IS_TOUCH_SUPPORTED) {
const options = {...MOUSE_MOVE_OPTIONS, once: true};
const deferred = deferredPromise<void>();
const cb = () => deferred.resolve();
const listener = this.listenerSetter.add(attachGlobalListenerTo)('mousemove', cb, options) as any as Listener;
await Promise.race([
pause(300),
deferred
]);
deferred.resolve();
this.listenerSetter.remove(listener);
if(this.tempId !== tempId) {
return;
}
}
this.xDown = e.clientX;
this.yDown = e.clientY;
this.eventUp = e;
if(IS_TOUCH_SUPPORTED) {
// @ts-ignore
this.listenerSetter.add(attachGlobalListenerTo)('touchmove', this.handleMove, TOUCH_MOVE_OPTIONS);
} else {
// @ts-ignore
this.listenerSetter.add(attachGlobalListenerTo)('mousemove', this.handleMove, MOUSE_MOVE_OPTIONS);
}
if(this.onStart) {
this.onStart();
// have to initiate move instantly
this.hadMove = true;
this.handleMove(e);
}
};
protected handleMove = (_e: EE) => {
if(this.xDown === undefined || this.yDown === undefined || RESET_GLOBAL) {
this.reset();
return;
}
if(this.cancelEvent) {
cancelEvent(_e as any);
}
if(this.releaseWheelDrag?.isDebounced() || this.releaseWheelZoom?.isDebounced()) {
return;
}
this.log('move');
const e = this.eventUp = getEvent(_e);
const xUp = e.clientX;
const yUp = e.clientY;
const xDiff = xUp - this.xDown + this.xAdded;
const yDiff = yUp - this.yDown + this.yAdded;
if(!this.hadMove) {
if(!xDiff && !yDiff) {
return;
}
this.setHadMove(_e);
}
const touches = (_e as any as TouchEvent).touches;
if(this.onZoom && this.initialDistance > 0 && touches.length === 2) {
const endDistance = getDistance(touches[0], touches[1]);
const touchCenter = getTouchCenter(touches[0], touches[1]);
const dragOffsetX = touchCenter.x - this.initialTouchCenter.x;
const dragOffsetY = touchCenter.y - this.initialTouchCenter.y;
const zoomFactor = endDistance / this.initialDistance;
const details: ZoomDetails = {
zoomFactor,
initialCenterX: this.initialTouchCenter.x,
initialCenterY: this.initialTouchCenter.y,
dragOffsetX,
dragOffsetY,
currentCenterX: touchCenter.x,
currentCenterY: touchCenter.y
};
this.onZoom(details);
}
this.dispatchOnSwipe(xDiff, yDiff, _e);
};
protected handleWheel = (e: WheelEvent) => {
if(!this.hadMove && this.verifyTouchTarget) {
const result = this.verifyTouchTarget(e);
if(result !== undefined && !result) {
this.reset(e);
return;
}
}
cancelEvent(e);
this.log('wheel');
if(this.onDoubleClick && Object.is(e.deltaX, -0) && Object.is(e.deltaY, -0) && e.ctrlKey) {
this.onWheelCapture(e);
this.onDoubleClick({centerX: e.pageX, centerY: e.pageY});
this.reset();
return;
}
const metaKeyPressed = e.metaKey || e.ctrlKey || e.shiftKey;
if(metaKeyPressed) {
// * fix zooming while dragging is in inertia
if(this.releaseWheelDrag?.isDebounced()) {
this.reset();
}
this.onWheelZoom(e);
} else {
this.handleWheelDrag(e);
}
};
protected handleWheelDrag = (e: WheelEvent) => {
this.log('wheel drag');
this.onWheelCapture(e);
// Ignore wheel inertia if drag is canceled in this direction
if(!this.isDragCanceled.x || Math.sign(this.initialDragOffset.x) === Math.sign(e.deltaX)) {
this.initialDragOffset.x -= e.deltaX;
}
if(!this.isDragCanceled.y || Math.sign(this.initialDragOffset.y) === Math.sign(e.deltaY)) {
this.initialDragOffset.y -= e.deltaY;
}
const {x, y} = this.initialDragOffset;
this.releaseWheelDrag(e);
this.dispatchOnSwipe(x, y, e, (dx, dy) => {
this.isDragCanceled = {x: dx, y: dy};
});
};
protected onWheelCapture = (e: WheelEvent) => {
if(this.hadMove) return;
this.log('wheel capture');
this.handleStart(e);
this.setHadMove(e);
this.initialTouchCenter = {x: e.x, y: e.y};
};
protected onWheelZoom = (e: WheelEvent) => {
if(!this.onZoom) return;
this.log('wheel zoom');
this.onWheelCapture(e);
const dragOffsetX = e.x - this.initialTouchCenter.x;
const dragOffsetY = e.y - this.initialTouchCenter.y;
const delta = clamp(e.deltaY, -25, 25);
this.wheelZoom -= delta * 0.01;
const details: ZoomDetails = {
zoomAdd: this.wheelZoom - 1,
initialCenterX: this.initialTouchCenter.x,
initialCenterY: this.initialTouchCenter.y,
dragOffsetX,
dragOffsetY,
currentCenterX: e.x,
currentCenterY: e.y
};
this.onZoom(details);
this.releaseWheelZoom(e);
}
}