/* * Copyright (c) 2015 NAVER Corp. * egjs projects are licensed under the MIT license */ /* eslint-disable @typescript-eslint/no-empty-function */ import { $, isCssPropsFromAxes, setCssProps, revertCssProps, useDirection, getDirection, } from "../utils"; import { IS_IOS_SAFARI, IOS_EDGE_THRESHOLD, DIRECTION_NONE, DIRECTION_VERTICAL, DIRECTION_HORIZONTAL, MOUSE_LEFT, ANY, } from "../const"; import { ActiveEvent, ElementType, InputEventType } from "../types"; import { convertInputType, getAddEventOptions, InputType, InputTypeObserver, toAxis, } from "./InputType"; export interface PanInputOption { inputType?: string[]; inputKey?: string[]; inputButton?: string[]; scale?: number[]; thresholdAngle?: number; threshold?: number; preventClickOnDrag?: boolean; preventDefaultOnDrag?: boolean; iOSEdgeSwipeThreshold?: number; releaseOnScroll?: boolean; touchAction?: string; } // get user's direction export const getDirectionByAngle = ( angle: number, thresholdAngle: number ): number => { if (thresholdAngle < 0 || thresholdAngle > 90) { return DIRECTION_NONE; } const toAngle = Math.abs(angle); return toAngle > thresholdAngle && toAngle < 180 - thresholdAngle ? DIRECTION_VERTICAL : DIRECTION_HORIZONTAL; }; /** * @typedef {Object} PanInputOption The option object of the eg.Axes.PanInput module. * @ko eg.Axes.PanInput 모듈의 옵션 객체 * @param {String[]} [inputType=["touch", "mouse", "pointer"]] Types of input devices * - touch: Touch screen * - mouse: Mouse * - pointer: Mouse and touch 입력 장치 종류 * - touch: 터치 입력 장치 * - mouse: 마우스 * - pointer: 마우스 및 터치 * @param {String[]} [inputKey=["any"]] List of key combinations to allow input * - any: any key * - shift: shift key * - ctrl: ctrl key and pinch gesture on the trackpad * - alt: alt key * - meta: meta key * - none: none of these keys are pressed 입력을 허용할 키 조합 목록 * - any: 아무 키 * - shift: shift 키 * - ctrl: ctrl 키 및 트랙패드의 pinch 제스쳐 * - alt: alt 키 * - meta: meta 키 * - none: 아무 키도 눌리지 않은 상태 * @param {String[]} [inputButton=["left"]] List of buttons to allow input * - left: Left mouse button and normal touch * - middle: Mouse wheel press * - right: Right mouse button 입력을 허용할 버튼 목록 * - left: 마우스 왼쪽 버튼 * - middle: 마우스 휠 눌림 * - right: 마우스 오른쪽 버튼 * @param {Number[]} [scale] Coordinate scale that a user can move사용자의 동작으로 이동하는 좌표의 배율 * @param {Number} [scale[0]=1] horizontal axis scale 수평축 배율 * @param {Number} [scale[1]=1] vertical axis scale 수직축 배율 * @param {Number} [thresholdAngle=45] The threshold value that determines whether user action is horizontal or vertical (0~90) 사용자의 동작이 가로 방향인지 세로 방향인지 판단하는 기준 각도(0~90) * @param {Number} [threshold=0] Minimal pan distance required before recognizing 사용자의 Pan 동작을 인식하기 위해산 최소한의 거리 * @param {Boolean} [preventClickOnDrag=false] Whether to cancel the {@link https://developer.mozilla.org/en/docs/Web/API/Element/click_event click} event when the user finishes dragging more than 1 pixel 사용자가 1픽셀 이상 드래그를 마쳤을 때 {@link https://developer.mozilla.org/ko/docs/Web/API/Element/click_event click} 이벤트 취소 여부 * @param {Boolean} [preventDefaultOnDrag=false] Whether to use the {@link https://developer.mozilla.org/ko/docs/Web/API/Event/preventDefault preventDefault} when the user starts dragging 사용자가 드래그를 시작할 때 {@link https://developer.mozilla.org/ko/docs/Web/API/Event/preventDefault preventDefault} 실행 여부 * @param {Number} [iOSEdgeSwipeThreshold=30] Area (px) that can go to the next page when swiping the right edge in iOS safari iOS Safari에서 오른쪽 엣지를 스와이프 하는 경우 다음 페이지로 넘어갈 수 있는 영역(px) * @param {String} [touchAction=null] Value that overrides the element's "touch-action" css property. If set to null, it is automatically set to prevent scrolling in the direction of the connected axis. 엘리먼트의 "touch-action" CSS 속성을 덮어쓰는 값. 만약 null로 설정된 경우, 연결된 축 방향으로의 스크롤을 방지하게끔 자동으로 설정된다. **/ /** * A module that passes the amount of change to eg.Axes when the mouse or touchscreen is down and moved. use less than two axes. * @ko 마우스나 터치 스크린을 누르고 움직일때의 변화량을 eg.Axes에 전달하는 모듈. 두개 이하의 축을 사용한다. * * @example * ```js * const pan = new eg.Axes.PanInput("#area", { * inputType: ["touch"], * scale: [1, 1.3], * }); * * // Connect the 'something2' axis to the mouse or touchscreen x position when the mouse or touchscreen is down and moved. * // Connect the 'somethingN' axis to the mouse or touchscreen y position when the mouse or touchscreen is down and moved. * axes.connect(["something2", "somethingN"], pan); // or axes.connect("something2 somethingN", pan); * * // Connect only one 'something1' axis to the mouse or touchscreen x position when the mouse or touchscreen is down and moved. * axes.connect(["something1"], pan); // or axes.connect("something1", pan); * * // Connect only one 'something2' axis to the mouse or touchscreen y position when the mouse or touchscreen is down and moved. * axes.connect(["", "something2"], pan); // or axes.connect(" something2", pan); * ``` * @param {String|HTMLElement|Ref|jQuery} element An element to use the eg.Axes.PanInput module eg.Axes.PanInput 모듈을 사용할 엘리먼트 * @param {PanInputOption} [options={}] The option object of the eg.Axes.PanInput moduleeg.Axes.PanInput 모듈의 옵션 객체 */ export class PanInput implements InputType { public options: PanInputOption; public axes: string[] = []; public element: HTMLElement = null; protected _observer: InputTypeObserver; protected _direction: number; protected _enabled = false; protected _activeEvent: ActiveEvent = null; private _originalCssProps: { [key: string]: string }; private _atRightEdge = false; private _rightEdgeTimer = 0; private _dragged = false; private _isOverThreshold = false; /** * */ public constructor(el: ElementType, options?: PanInputOption) { this.element = $(el); this.options = { inputType: ["touch", "mouse", "pointer"], inputKey: [ANY], inputButton: [MOUSE_LEFT], scale: [1, 1], thresholdAngle: 45, threshold: 0, preventClickOnDrag: false, preventDefaultOnDrag: false, iOSEdgeSwipeThreshold: IOS_EDGE_THRESHOLD, releaseOnScroll: false, touchAction: null, ...options, }; this._onPanstart = this._onPanstart.bind(this); this._onPanmove = this._onPanmove.bind(this); this._onPanend = this._onPanend.bind(this); } public mapAxes(axes: string[]) { this._direction = getDirection(!!axes[0], !!axes[1]); this.axes = axes; } public connect(observer: InputTypeObserver): InputType { if (this._activeEvent) { this._detachElementEvent(); this._detachWindowEvent(this._activeEvent); } this._attachElementEvent(observer); return this; } public disconnect() { this._detachElementEvent(); this._detachWindowEvent(this._activeEvent); this._direction = DIRECTION_NONE; return this; } /** * Destroys elements, properties, and events used in a module. * @ko 모듈에 사용한 엘리먼트와 속성, 이벤트를 해제한다. */ public destroy() { this.disconnect(); this.element = null; } /** * Enables input devices * @ko 입력 장치를 사용할 수 있게 한다 * @return {PanInput} An instance of a module itself 모듈 자신의 인스턴스 */ public enable() { const activeEvent = convertInputType(this.options.inputType); if (!activeEvent) { throw new Error("PanInput cannot be enabled if there is no available input event."); } else if (!this._enabled) { this._enabled = true; this._originalCssProps = setCssProps( this.element, this.options, this._direction ); } return this; } /** * Disables input devices * @ko 입력 장치를 사용할 수 없게 한다. * @return {PanInput} An instance of a module itself 모듈 자신의 인스턴스 */ public disable() { if (this._enabled) { this._enabled = false; if (!isCssPropsFromAxes(this._originalCssProps)) { revertCssProps(this.element, this._originalCssProps); } } return this; } /** * Returns whether to use an input device * @ko 입력 장치 사용 여부를 반환한다. * @return {Boolean} Whether to use an input device 입력장치 사용여부 */ public isEnabled() { return this._enabled; } /** * Releases current user input. * @ko 사용자의 입력을 강제로 중단시킨다. * @return {PanInput} An instance of a module itself 모듈 자신의 인스턴스 */ public release() { const activeEvent = this._activeEvent; const prevEvent = activeEvent.prevEvent; activeEvent.onRelease(); this._observer.release(this, prevEvent, [0, 0]); this._detachWindowEvent(activeEvent); return this; } protected _onPanstart(event: InputEventType) { const { inputKey, inputButton, preventDefaultOnDrag } = this.options; const activeEvent = this._activeEvent; const panEvent = activeEvent.onEventStart(event, inputKey, inputButton); if ( !panEvent || !this._enabled || activeEvent.getTouches(event, inputButton) > 1 ) { return; } if (panEvent.srcEvent.cancelable !== false) { const edgeThreshold = this.options.iOSEdgeSwipeThreshold; this._dragged = false; this._isOverThreshold = false; this._observer.hold(this, panEvent); this._atRightEdge = IS_IOS_SAFARI && panEvent.center.x > window.innerWidth - edgeThreshold; this._attachWindowEvent(activeEvent); (preventDefaultOnDrag && panEvent.srcEvent.type !== "touchstart") && panEvent.srcEvent.preventDefault(); activeEvent.prevEvent = panEvent; } } protected _onPanmove(event: InputEventType) { const { iOSEdgeSwipeThreshold, preventClickOnDrag, releaseOnScroll, inputKey, inputButton, threshold, thresholdAngle, } = this.options; const activeEvent = this._activeEvent; const panEvent = activeEvent.onEventMove(event, inputKey, inputButton); const touches = activeEvent.getTouches(event, inputButton); if ( touches === 0 || (releaseOnScroll && panEvent && !panEvent.srcEvent.cancelable) ) { this._onPanend(event); return; } if (!panEvent || !this._enabled || touches > 1) { return; } const userDirection = getDirectionByAngle(panEvent.angle, thresholdAngle); const useHorizontal = useDirection( DIRECTION_HORIZONTAL, this._direction, userDirection ); const useVertical = useDirection( DIRECTION_VERTICAL, this._direction, userDirection ); if (activeEvent.prevEvent && IS_IOS_SAFARI) { const swipeLeftToRight = panEvent.center.x < 0; if (swipeLeftToRight) { // iOS swipe left => right this.release(); return; } else if (this._atRightEdge) { clearTimeout(this._rightEdgeTimer); // - is right to left const swipeRightToLeft = panEvent.deltaX < -iOSEdgeSwipeThreshold; if (swipeRightToLeft) { this._atRightEdge = false; } else { // iOS swipe right => left this._rightEdgeTimer = window.setTimeout(() => this.release(), 100); } } } const distance = this._getDistance( [panEvent.deltaX, panEvent.deltaY], [useHorizontal, useVertical] ); const offset = this._getOffset( [panEvent.offsetX, panEvent.offsetY], [useHorizontal, useVertical] ); const prevent = offset.some((v) => v !== 0); if (prevent) { if (panEvent.srcEvent.cancelable !== false) { panEvent.srcEvent.preventDefault(); } panEvent.srcEvent.stopPropagation(); } panEvent.preventSystemEvent = prevent; if (prevent && (this._isOverThreshold || distance >= threshold)) { this._dragged = preventClickOnDrag; this._isOverThreshold = true; this._observer.change(this, panEvent, toAxis(this.axes, offset)); } activeEvent.prevEvent = panEvent; } protected _onPanend(event: InputEventType) { const inputButton = this.options.inputButton; const activeEvent = this._activeEvent; activeEvent.onEventEnd(event); if (!this._enabled || activeEvent.getTouches(event, inputButton) !== 0) { return; } this._detachWindowEvent(activeEvent); clearTimeout(this._rightEdgeTimer); const prevEvent = activeEvent.prevEvent; const velocity = this._isOverThreshold ? this._getOffset( [ Math.abs(prevEvent.velocityX) * prevEvent.directionX, Math.abs(prevEvent.velocityY) * prevEvent.directionY, ], [ useDirection(DIRECTION_HORIZONTAL, this._direction), useDirection(DIRECTION_VERTICAL, this._direction), ] ) : [0, 0]; activeEvent.onRelease(); this._observer.release(this, prevEvent, velocity); } protected _attachWindowEvent(activeEvent: ActiveEvent) { activeEvent?.move.forEach((event) => { window.addEventListener(event, this._onPanmove, getAddEventOptions(event)); }); activeEvent?.end.forEach((event) => { window.addEventListener(event, this._onPanend, getAddEventOptions(event)); }); } protected _detachWindowEvent(activeEvent: ActiveEvent) { activeEvent?.move.forEach((event) => { window.removeEventListener(event, this._onPanmove); }); activeEvent?.end.forEach((event) => { window.removeEventListener(event, this._onPanend); }); } protected _getOffset(properties: number[], direction: boolean[]): number[] { const scale = this.options.scale; return [ direction[0] ? properties[0] * scale[0] : 0, direction[1] ? properties[1] * scale[1] : 0, ]; } private _getDistance(delta: number[], direction: boolean[]): number { return Math.sqrt( Number(direction[0]) * Math.pow(delta[0], 2) + Number(direction[1]) * Math.pow(delta[1], 2) ); } private _attachElementEvent(observer: InputTypeObserver) { const activeEvent = convertInputType(this.options.inputType); const element = this.element; if (!activeEvent) { return; } if (!element) { throw new Error("Element to connect input does not exist."); } this._observer = observer; this.enable(); this._activeEvent = activeEvent; element.addEventListener("click", this._preventClickWhenDragged, true); activeEvent.start.forEach((event) => { element.addEventListener(event, this._onPanstart); }); // adding event listener to element prevents invalid behavior in iOS Safari activeEvent.move.forEach((event) => { element.addEventListener(event, this._voidFunction); }); } private _detachElementEvent() { const activeEvent = this._activeEvent; const element = this.element; if (element) { element.removeEventListener("click", this._preventClickWhenDragged, true); activeEvent?.start.forEach((event) => { element.removeEventListener(event, this._onPanstart); }); activeEvent?.move.forEach((event) => { element.removeEventListener(event, this._voidFunction); }); } this.disable(); this._observer = null; } private _preventClickWhenDragged = (e: PointerEvent | MouseEvent) => { if (this._dragged) { e.preventDefault(); e.stopPropagation(); } this._dragged = false; }; private _voidFunction = () => {}; }