From ca19360c3268d7ea6653d4a0afa57fc76458da15 Mon Sep 17 00:00:00 2001 From: Xiaoji Chen Date: Tue, 21 May 2024 06:51:15 -0700 Subject: [PATCH] v3 --- .eslintrc.cjs | 2 + .gitignore | 1 + .ocularrc.js | 10 +- README.md | 2 +- docs/api-reference/event-manager.md | 210 +++------ docs/api-reference/event.md | 21 - docs/api-reference/pan.md | 38 ++ docs/api-reference/pinch.md | 36 ++ docs/api-reference/press.md | 32 ++ docs/api-reference/rotate.md | 33 ++ docs/api-reference/swipe.md | 36 ++ docs/api-reference/tap.md | 37 ++ docs/api-reference/types.md | 77 ++++ docs/get-started.md | 93 ++-- docs/table-of-contents.json | 9 +- docs/upgrade-guide.md | 37 ++ docs/whats-new.md | 7 +- examples/image-viewer/app.ts | 87 ++++ examples/image-viewer/index.html | 17 + examples/image-viewer/package.json | 15 + examples/image-viewer/style.css | 16 + examples/image-viewer/transform.ts | 58 +++ examples/image-viewer/tsconfig.json | 8 + examples/main/app.tsx | 54 ++- examples/main/constants.ts | 15 +- examples/main/index.html | 11 +- examples/main/package.json | 22 +- examples/main/webpack.config.js | 77 ---- examples/vite.config.local.js | 24 ++ examples/webpack.config.local.js | 84 ---- index.html | 2 +- package.json | 11 +- src/constants.ts | 129 ------ src/event-manager.ts | 255 ++++------- src/hammerjs/README.md | 11 + src/hammerjs/index.ts | 25 ++ src/hammerjs/input/compute-input-data.ts | 79 ++++ .../input/compute-interval-input-data.ts | 44 ++ src/hammerjs/input/get-angle.ts | 21 + src/hammerjs/input/get-center.ts | 30 ++ src/hammerjs/input/get-delta-xy.ts | 34 ++ src/hammerjs/input/get-direction.ts | 16 + src/hammerjs/input/get-distance.ts | 21 + src/hammerjs/input/get-rotation.ts | 10 + src/hammerjs/input/get-scale.ts | 10 + src/hammerjs/input/get-velocity.ts | 11 + src/hammerjs/input/input-consts.ts | 21 + src/hammerjs/input/input-handler.ts | 36 ++ src/hammerjs/input/input.ts | 64 +++ src/hammerjs/input/simple-clone-input-data.ts | 26 ++ src/hammerjs/input/types.ts | 101 +++++ src/hammerjs/inputs/pointerevent.ts | 78 ++++ src/hammerjs/manager.ts | 397 ++++++++++++++++++ src/hammerjs/recognizer/recognizer-state.ts | 9 + src/hammerjs/recognizer/recognizer.ts | 286 +++++++++++++ src/hammerjs/recognizer/state-str.ts | 17 + src/hammerjs/recognizers/attribute.ts | 47 +++ src/hammerjs/recognizers/pan.ts | 115 +++++ src/hammerjs/recognizers/pinch.ts | 65 +++ src/hammerjs/recognizers/press.ts | 104 +++++ src/hammerjs/recognizers/rotate.ts | 57 +++ src/hammerjs/recognizers/swipe.ts | 91 ++++ src/hammerjs/recognizers/tap.ts | 152 +++++++ .../touchaction/clean-touch-actions.ts | 41 ++ .../touchaction/touchaction-Consts.ts | 7 + src/hammerjs/touchaction/touchaction.ts | 53 +++ src/hammerjs/utils/event-listeners.ts | 33 ++ src/hammerjs/utils/get-window-for-element.ts | 7 + src/hammerjs/utils/has-parent.ts | 13 + src/hammerjs/utils/prefixed.ts | 18 + src/hammerjs/utils/split-str.ts | 7 + src/hammerjs/utils/unique-id.ts | 7 + src/index.ts | 27 +- src/inputs/contextmenu-input.ts | 10 +- src/inputs/input.ts | 8 +- src/inputs/key-input.ts | 31 +- src/inputs/move-input.ts | 106 +++-- src/inputs/wheel-input.ts | 26 +- src/types.ts | 224 +--------- src/utils/event-registrar.ts | 50 ++- src/utils/event-utils.ts | 9 +- src/utils/globals.ts | 26 -- src/utils/hammer-overrides.ts | 81 ---- src/utils/hammer.browser.ts | 9 - src/utils/hammer.ts | 13 - test/{browser.js => browser.ts} | 0 ...-manager.spec.js => event-manager.spec.ts} | 187 +++++---- test/index.js | 23 - test/index.ts | 3 + test/inputs/contextmenu-input.spec.js | 68 --- test/inputs/{index.js => index.ts} | 1 - .../{key-input.spec.js => key-input.spec.ts} | 41 +- ...{move-input.spec.js => move-input.spec.ts} | 68 ++- ...heel-input.spec.js => wheel-input.spec.ts} | 45 +- test/{test-utils/index.js => node.ts} | 9 +- test/test-utils/dom.ts | 23 + test/test-utils/event.js | 60 --- test/test-utils/manager.js | 61 --- test/test-utils/{spy.js => spy.ts} | 46 +- ...istrar.spec.js => event-registrar.spec.ts} | 37 +- ...vent-utils.spec.js => event-utils.spec.ts} | 6 +- test/utils/{global.spec.js => global.spec.ts} | 3 +- test/utils/{index.js => index.ts} | 0 tsconfig.build.json | 11 + tsconfig.json | 6 +- yarn.lock | 14 +- 106 files changed, 3417 insertions(+), 1645 deletions(-) delete mode 100644 docs/api-reference/event.md create mode 100644 docs/api-reference/pan.md create mode 100644 docs/api-reference/pinch.md create mode 100644 docs/api-reference/press.md create mode 100644 docs/api-reference/rotate.md create mode 100644 docs/api-reference/swipe.md create mode 100644 docs/api-reference/tap.md create mode 100644 docs/api-reference/types.md create mode 100644 examples/image-viewer/app.ts create mode 100644 examples/image-viewer/index.html create mode 100644 examples/image-viewer/package.json create mode 100644 examples/image-viewer/style.css create mode 100644 examples/image-viewer/transform.ts create mode 100644 examples/image-viewer/tsconfig.json delete mode 100644 examples/main/webpack.config.js create mode 100644 examples/vite.config.local.js delete mode 100644 examples/webpack.config.local.js delete mode 100644 src/constants.ts create mode 100644 src/hammerjs/README.md create mode 100644 src/hammerjs/index.ts create mode 100644 src/hammerjs/input/compute-input-data.ts create mode 100644 src/hammerjs/input/compute-interval-input-data.ts create mode 100644 src/hammerjs/input/get-angle.ts create mode 100644 src/hammerjs/input/get-center.ts create mode 100644 src/hammerjs/input/get-delta-xy.ts create mode 100644 src/hammerjs/input/get-direction.ts create mode 100644 src/hammerjs/input/get-distance.ts create mode 100644 src/hammerjs/input/get-rotation.ts create mode 100644 src/hammerjs/input/get-scale.ts create mode 100644 src/hammerjs/input/get-velocity.ts create mode 100644 src/hammerjs/input/input-consts.ts create mode 100644 src/hammerjs/input/input-handler.ts create mode 100644 src/hammerjs/input/input.ts create mode 100644 src/hammerjs/input/simple-clone-input-data.ts create mode 100644 src/hammerjs/input/types.ts create mode 100644 src/hammerjs/inputs/pointerevent.ts create mode 100644 src/hammerjs/manager.ts create mode 100644 src/hammerjs/recognizer/recognizer-state.ts create mode 100644 src/hammerjs/recognizer/recognizer.ts create mode 100644 src/hammerjs/recognizer/state-str.ts create mode 100644 src/hammerjs/recognizers/attribute.ts create mode 100644 src/hammerjs/recognizers/pan.ts create mode 100644 src/hammerjs/recognizers/pinch.ts create mode 100644 src/hammerjs/recognizers/press.ts create mode 100644 src/hammerjs/recognizers/rotate.ts create mode 100644 src/hammerjs/recognizers/swipe.ts create mode 100644 src/hammerjs/recognizers/tap.ts create mode 100644 src/hammerjs/touchaction/clean-touch-actions.ts create mode 100644 src/hammerjs/touchaction/touchaction-Consts.ts create mode 100644 src/hammerjs/touchaction/touchaction.ts create mode 100644 src/hammerjs/utils/event-listeners.ts create mode 100644 src/hammerjs/utils/get-window-for-element.ts create mode 100644 src/hammerjs/utils/has-parent.ts create mode 100644 src/hammerjs/utils/prefixed.ts create mode 100644 src/hammerjs/utils/split-str.ts create mode 100644 src/hammerjs/utils/unique-id.ts delete mode 100644 src/utils/hammer-overrides.ts delete mode 100644 src/utils/hammer.browser.ts delete mode 100644 src/utils/hammer.ts rename test/{browser.js => browser.ts} (100%) rename test/{event-manager.spec.js => event-manager.spec.ts} (61%) delete mode 100644 test/index.js create mode 100644 test/index.ts delete mode 100644 test/inputs/contextmenu-input.spec.js rename test/inputs/{index.js => index.ts} (97%) rename test/inputs/{key-input.spec.js => key-input.spec.ts} (77%) rename test/inputs/{move-input.spec.js => move-input.spec.ts} (74%) rename test/inputs/{wheel-input.spec.js => wheel-input.spec.ts} (75%) rename test/{test-utils/index.js => node.ts} (89%) create mode 100644 test/test-utils/dom.ts delete mode 100644 test/test-utils/event.js delete mode 100644 test/test-utils/manager.js rename test/test-utils/{spy.js => spy.ts} (75%) rename test/utils/{event-registrar.spec.js => event-registrar.spec.ts} (84%) rename test/utils/{event-utils.spec.js => event-utils.spec.ts} (88%) rename test/utils/{global.spec.js => global.spec.ts} (88%) rename test/utils/{index.js => index.ts} (100%) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 3146f4b..ae40d85 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -26,6 +26,8 @@ module.exports = getESLintConfig({ files: ['**/*.ts', '**/*.tsx', '**/*.d.ts'], rules: { indent: 0, + 'max-statements': 1, + complexity: 1, '@typescript-eslint/ban-ts-comment': 0, '@typescript-eslint/explicit-module-boundary-types': 0, '@typescript-eslint/no-unsafe-assignment': 0, diff --git a/.gitignore b/.gitignore index 50576aa..7f7ad39 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ yarn-error.log .idea .reify-cache .nyc_output +dist.min.js diff --git a/.ocularrc.js b/.ocularrc.js index 14882ba..9f7dd47 100644 --- a/.ocularrc.js +++ b/.ocularrc.js @@ -1,10 +1,18 @@ +/** @typedef {import('ocular-dev-tools').OcularConfig} OcularConfig */ import {resolve} from 'path'; +/** @type OcularConfig */ export default { lint: { paths: ['src', 'examples', 'test', 'docs'] }, + bundle: { + globalName: 'mjolnir', + target: ['chrome110', 'firefox110', 'safari15'], + format: 'umd' + }, + typescript: { project: 'tsconfig.build.json' }, @@ -16,7 +24,7 @@ export default { }, entry: { - test: 'test/index.js', + test: 'test/node.ts', 'test-browser': 'index.html', size: ['test/size.js'] } diff --git a/README.md b/README.md index d331886..0cafb27 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,4 @@ mjolnir.js is a JavaScript event and gesture handling module. -Please refer to the extensive documentation on the [website](https://uber-web.github.io/mjolnir.js) +Please refer to the API documentation on the [website](https://visgl.github.io/mjolnir.js) diff --git a/docs/api-reference/event-manager.md b/docs/api-reference/event-manager.md index 6007028..28581ce 100644 --- a/docs/api-reference/event-manager.md +++ b/docs/api-reference/event-manager.md @@ -1,83 +1,60 @@ # EventManager -Provides a unified API for subscribing to events about both basic input events (e.g. 'mousemove', 'touchstart', 'wheel') and gestural input (e.g. 'click', 'tap', 'panstart'). +Provides a unified API for subscribing to events about both basic input events (e.g. 'pointermove', 'keydown', 'wheel') and gestural input (e.g. 'click', 'tap', 'panstart'). -## Usage +## Constructor -```js -import {EventManager} from 'mjolnir.js'; - -const eventManager = new EventManager(document.getElementById('container')); -function onClick(event) {} -function onPinch(event) {} +Creates a new `EventManager` instance. -eventManager.on({ - click: onClick, - pinch: onPinch -}); +```ts +import {EventManager} from 'mjolnir.js'; -// ... -eventManager.destroy(); +const eventManager = new EventManager(target, options); ``` -**Note:** While EventManager supports mouse and touch events, we recommend the use of [Pointer Events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events) when possible for the broadest input device compatibility. +* `target` (HTMLElement) - DOM element on which event handlers will be registered. +* `options` (object) - Options + - `events` (object) - A map from event names to their handler functions, to register on init. + - `recognizers` (RecognizerTuple[]) - Gesture recognizers. See [Recognize Gestures](#recognize-gestures) section below for usage. + - `touchAction` (string) - Allow browser default touch actions. See [touch-action CSS property](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action). Use 'compute' to automatically set as the least restrictive value to support the recognizers. Default `'compute'`. + - `tabIndex` (number) - The [tabindex](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex) of the root element. Default `0`. + - `cssProps` (object) - Optional CSS properties to apply to the tarfet element. Default `{userSelect: 'none', touchCallout: 'none'}`. ## Methods -### constructor - -Creates a new `EventManager` instance. - -`new EventManager(element, {events, recognizers})` - -- `element` {DOM Element, optional} - DOM element on which event handlers will be registered. Default `null`. -- `options` {Object, optional} - Options - - `events` {Object} - A map from event names to their handler functions, to register on init. - - `recognizers` - {Object} Gesture recognizers from Hammer.js to register, as an Array in [Hammer.Recognizer format](http://hammerjs.github.io/api/#hammermanager). If not provided, a default set of recognizers will be used. See "Gesture Events" section below for more details. - - `recognizerOptions` - {Object} Override the default options of `recognizers`. Keys are recognizer names and values are recognizer options. For a list of default recognizers, see "Gesture Events" section below. - - `rightButton` - {Boolean} Recognizes click and drag from pressing the right mouse button. Default `false`. If turned on, the context menu will be disabled. - - `touchAction` - {String} Allow browser default touch actions. Default `none`. See [hammer.js doc](http://hammerjs.github.io/touch-action/). - - `tabIndex` - {Number} The [tabindex](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex) of the root element. Default `0`. - ### destroy Tears down internal event management implementations. -`eventManager.destroy()` - -Note: It is important to call `destroy` when done since `EventManager` adds event listeners to `window`. - -### setElement - -Set the DOM element on which event handlers will be registered. If element has been set, events will be unregistered from the previous element. - -`eventManager.setElement(element)` +```ts +eventManager.destroy(); +``` -- `element` {DOM Element, optional} - DOM element on which event handlers will be registered. +*Note: It is recommended to call `destroy` when done since `EventManager` adds event listeners to `window`.* ### on Register an event handler function to be called on `event`. -```js +```ts eventManager.on(event, handler, options); eventManager.on(eventMap, options); ``` -- `event` {String} - An event name -- `handler` {Function} - The function to be called on `event`. -- `eventMap` {Object} - A map from event names to their handler functions -- `options` {Object, optional} - - `srcElement` {Node} - The source element of this event. If provided, only events that are targeting this element or its decendants will invoke the handler. If ignored, default to the root element of the event manager. Events are propagated up the DOM tree. - - `priority` {Number} - Handlers targeting the same `srcElement` will be executed by their priorities (higher numbers first). Handlers with the same priority will be executed in the order of registration. Default `0`. +- `event` (string) - An event name +- `handler` ((event: MjolnirEvent) => void) - The function to be called on `event`. +- `eventMap` (object) - A map from event names to their handler functions +- `options` (object, optional) + + `srcElement` (HTMLElement) - The source element of this event. If provided, only events that are targeting this element or its decendants will invoke the handler. If ignored, default to the root element of the event manager. Events are propagated up the DOM tree. + + `priority` (number) - Handlers targeting the same `srcElement` will be executed by their priorities (higher numbers first). Handlers with the same priority will be executed in the order of registration. Default `0`. -** Note: Unlike the DOM event system, developers are responsible of deregistering event handlers when `srcElement` is removed. ** +*Note: Unlike the DOM event system, developers are responsible of deregistering event handlers when `srcElement` is removed.* ### once Register a one-time event handler function to be called on `event`. The handler is removed once it has been called. -```js +```ts eventManager.once(event, handler, options); eventManager.once(eventMap, options); ``` @@ -88,7 +65,7 @@ Expects the same arguments as [on](#on). Register an event handler function to be called on `event`. This handler does not ask the event to be recognized from user input; rather, it "intercepts" the event if some other handler is getting it. -```js +```ts eventManager.watch(event, handler, options); eventManager.watch(eventMap, options); ``` @@ -97,13 +74,13 @@ Expects the same arguments as [on](#on). For example, we want a child element to block any `dblclick` event from bubbling up to root. The root may or may not be actually listening to `dblclick`. If the root did not register a handler, and we use -```js +```ts eventManager.on('dblClick', evt => evt.stopPropagation(), {srcElement: }); ``` -It will enable the `DoubleTapRecognizer`. Recognizers for gestures add additional overhead, and may cause subtle behavioral changes. In this case, recognizing `dblclick` events will cause the `click` events to be fired with a small delay. Since we only want to be notified _if_ a `dblclick` event is fired, it is safer to use: +It will enable the double tap recognizer. Recognizers for gestures add additional overhead, and may cause subtle behavioral changes. In this case, recognizing `dblclick` events will cause the `click` events to be fired with a small delay. Since we only want to be notified _if_ a `dblclick` event is fired, it is safer to use: -```js +```ts eventManager.watch('dblClick', evt => evt.stopPropagation(), {srcElement: }); ``` @@ -114,104 +91,47 @@ eventManager.watch('dblClick', evt => evt.stopPropagation(), {srcElement: void) - The function to be called on `event`. +- `eventMap` (object) - A map from event names to their handler functions -## Supported Events and Gestures +## Events and Gestures ### Basic input events -Keyboard events are fired when focus is on the EventManager's target element or its decendants, unless typing into a text input. - - `'keydown'` - `'keyup'` - -Mouse event and pointer event names are interchangeable. - -- `'mousedown'` | `'pointerdown'` -- `'mousemove'` | `'pointermove'` -- `'mouseup'` | `'pointerup'` -- `'mouseover'` | `'pointerover'` -- `'mouseout'` | `'pointerout'` -- `'mouseleave'` | `'pointerleave'` +- `'pointerdown'` +- `'pointermove'` +- `'pointerup'` +- `'pointerover'` +- `'pointerout'` +- `'pointerleave'` - `'wheel'` - `'contextmenu'` -### Gesture events - -The following events are generated with [hammer.js](http://hammerjs.github.io/)recognizers. You may fine-tune the behavior of these events by supplying `recognizerOptions` to the `EventManager` constructor. - -- The following events are controlled by the `rotate` ([Hammer.Rotate](https://hammerjs.github.io/recognizer-rotate/)) recognizer: - - `'rotate'` - - `'rotatestart'` - - `'rotatemove'` - - `'rotateend'` - - `'rotatecancel'` -- The following events are controlled by the `pinch` ([Hammer.Pinch](https://hammerjs.github.io/recognizer-pinch/)) recognizer: - - `'pinch'` - - `'pinchin'` - - `'pinchout'` - - `'pinchstart'` - - `'pinchmove'` - - `'pinchend'` - - `'pinchcancel'` -- The following events are controlled by the `swipe` ([Hammer.Swipe](https://hammerjs.github.io/recognizer-swipe/)) recognizer: - - `'swipe'` - - `'swipeleft'` - - `'swiperight'` - - `'swipeup'` - - `'swipedown'` -- The following events are controlled by the `tripan` ([Hammer.Pan](https://hammerjs.github.io/recognizer-pan/)) recognizer (3-finger pan): - - `'tripan'` - - `'tripanstart'` - - `'tripanmove'` - - `'tripanup'` - - `'tripandown'` - - `'tripanleft'` - - `'tripanright'` - - `'tripanend'` - - `'tripancancel'` -- The following events are controlled by the `pan` ([Hammer.Pan](https://hammerjs.github.io/recognizer-pan/)) recognizer: - - `'pan'` - - `'panstart'` - - `'panmove'` - - `'panup'` - - `'pandown'` - - `'panleft'` - - `'panright'` - - `'panend'` - - `'pancancel'` -- The following events are controlled by the `Press` ([Hammer.Pan](https://hammerjs.github.io/recognizer-press/)) recognizer: - - `'press'` -- The following events are controlled by the `doubletap` ([Hammer.Pan](https://hammerjs.github.io/recognizer-tap/)) recognizer: - - `'doubletap'` - - `'dblclick'` - alias of `doubletap` -- The following events are controlled by the `tap` ([Hammer.Pan](https://hammerjs.github.io/recognizer-tap/)) recognizer: - - `'tap'` - a single click. Not fired if double clicking. - - `'click'` - alias of `tap` -- The following events are controlled by the `anytap` ([Hammer.Pan](https://hammerjs.github.io/recognizer-tap/)) recognizer: - - `'anytap'` - like `click`, but fired twice if double clicking. - - `'anyclick'` - alias of `anytap` - -## Event handling shims - -`EventManager` currently uses Hammer.js for gesture and touch support, but Hammer.js does not support all input event types out of the box. Therefore, `EventManager` employs the following modules to shim the missing functionality: - -### KeyInput - -Handles keyboard events. - -### MoveInput - -Handles pointer/touch/mouse move events while no button pressed, and leave events (for when the cursor leaves the DOM element registered with `EventManager`). - -### WheelInput - -Handles mouse wheel events and trackpad events that emulate mouse wheel events. Note that this module is stateful: it tracks time elapsed between events in order to determine the magnitude/scroll distance of an event. - -## Remarks - -- Current implementation delegates touch and gesture event registration and handling to Hammer.js. Includes shims for handling event not supported by Hammer.js, such as keyboard input, mouse move, and wheel input. This dependency structure may change in the future. - -- Hammer.js unsafely references `window` and `document`, and so will fail in environments without these constructs (e.g. Node). To mitigate this, Hammer.js modules are conditionally `require()`d, and replaced with mocks in non-browser environments. +Remarks: +- Keyboard events are fired when focus is on the EventManager's target element or its decendants, unless typing into a text input. + +### Recognize gestures + +To emit gesture events from user input, the application should pass a list of recognizers to the `EventManager` constructor. +Each item in the `recognizers` list can be either a recognizer instance, or an array in the following form: + +- `recognizer` - a recognizer instance +- `recognizeWith` (string | string[]) - Allow another gesture to be recognized simultaneously with this one. For example an interaction can trigger pinch and rotate at the same time. +- `requireFailure` (string | string[]) - Another recognizer is mutually exclusive with this one. For example an interaction could be singletap or doubletap; pan-horizontal or pan-vertical; but never both. + +The following recognizers are available for use: + +- [Pan](./pan.md) +- [Pinch](./pinch.md) +- [Press](./press.md) +- [Rotate](./rotate.md) +- [Swipe](./swipe.md) +- [Tap](./tap.md) + + +## Source + +https://github.com/visgl/mjolnir.js/blob/master/src/event-manager.ts \ No newline at end of file diff --git a/docs/api-reference/event.md b/docs/api-reference/event.md deleted file mode 100644 index 7539d22..0000000 --- a/docs/api-reference/event.md +++ /dev/null @@ -1,21 +0,0 @@ -# Event - -Event handlers subscribed via [`EventManager.on()`](/docs/api-reference/event-manager.md#on) will be called with one parameter. This event parameter always has the following properties: - -- `type` (string) - The event type to which the event handler is subscribed, e.g. `'click'` or `'pointermove'` -- `center` (Object `{x, y}`) - The center of the event location (e.g. the centroid of a touch) relative to the viewport (basically, [`clientX/Y`](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/clientX)) -- `offsetCenter` (Object `{x, y}`) - The center of the event location (e.g. the centroid of a touch) -- `target` (Object) - The target of the event, as specified by the original `srcEvent` -- `srcEvent` (Object) - The original event object dispatched by the browser to the JS runtime -- `preventDefault` (Function) - Equivalent to `srcEvent.preventDefault`. -- `stopPropagation` (Function) - Do not invoke handlers registered for any ancestors in the DOM tree. -- `stopImmediatePropagation` (Function) - Do not invoke any other handlers registered for the same element or its ancestors. - -Additionally, event objects for different event types contain a subset of the following properties: - -- `key` (number) - The keycode of the keyboard event -- `leftButton` (boolean) - Flag indicating whether the left button is involved during the event -- `middleButton` (boolean) - Flag indicating whether the middle button is involved during the event -- `rightButton` (boolean) - Flag indicating whether the right button is involved during the event -- `pointerType` (string) - A string indicating the type of input (e.g. `'mouse'`, `'touch'`, `'pointer'`) -- `delta` (number) - The scroll magnitude/distance of a wheel event diff --git a/docs/api-reference/pan.md b/docs/api-reference/pan.md new file mode 100644 index 0000000..a8ee4fa --- /dev/null +++ b/docs/api-reference/pan.md @@ -0,0 +1,38 @@ +# Pan + +Recognized when the pointer is down and moved in the allowed direction. + +## Constructor + +```ts +import {EventManager, Pan, InputDirection} from 'mjolnir.js'; + +const eventManager = new EventManager({ + // ... + recognizers: [ + new Pan({direction: InputDirection.Horizontal}) + ] +}); +``` + +* `options` (object, optional) - Options + - `event` (string) - Name of the event. Default `'pan'`. + - `pointers` (number) - Required pointers. Default `1`. + - `threshold` (number) - Minimal pan distance required before recognizing. Default `10`. + - `direction` {InputDirection} - Direction of the panning. Default `InputDirection.All`. + +## Events + +- pan, together with all of below +- panstart +- panmove +- panend +- pancancel +- panleft +- panright +- panup +- pandown + +## Source + +https://github.com/visgl/mjolnir.js/blob/master/src/hammerjs/recognizers/pan.ts diff --git a/docs/api-reference/pinch.md b/docs/api-reference/pinch.md new file mode 100644 index 0000000..e9a4c3d --- /dev/null +++ b/docs/api-reference/pinch.md @@ -0,0 +1,36 @@ +# Pinch + +Recognized when two or more pointers are moving toward (zoom-in) or away from each other (zoom-out). + +## Constructor + +```ts +import {EventManager, Pinch} from 'mjolnir.js'; + +const eventManager = new EventManager({ + // ... + recognizers: [ + new Pinch({pointers: 2}) + ] +}); +``` + +* `options` (object, optional) - Options + - `event` (string) - Name of the event. Default `'pinch'`. + - `pointers` (number) - Required pointers, with a minimal of 2. Default `2`. + - `threshold` (number) - Minimal scale before recognizing. Default `0`. + +## Events + +- pinch, together with all of below +- pinchstart +- pinchmove +- pinchend +- pinchcancel +- pinchin +- pinchout + + +## Source + +https://github.com/visgl/mjolnir.js/blob/master/src/hammerjs/recognizers/pinch.ts diff --git a/docs/api-reference/press.md b/docs/api-reference/press.md new file mode 100644 index 0000000..7f00276 --- /dev/null +++ b/docs/api-reference/press.md @@ -0,0 +1,32 @@ +# Press + +Recognized when the pointer is down for some time without any movement. + +## Constructor + +```ts +import {EventManager, Press} from 'mjolnir.js'; + +const eventManager = new EventManager({ + // ... + recognizers: [ + new Press({time: 500}) + ] +}); +``` + +* `options` (object, optional) - Options + - `event` (string) - Name of the event. Default `'press'`. + - `pointers` (number) - Required pointers. Default `1`. + - `threshold` (number) - Minimal movement that is allowed while pressing. Default `9`. + - `time` (number) - Minimal press time in ms. Default `251`. + +## Events + +- press +- pressup + + +## Source + +https://github.com/visgl/mjolnir.js/blob/master/src/hammerjs/recognizers/press.ts diff --git a/docs/api-reference/rotate.md b/docs/api-reference/rotate.md new file mode 100644 index 0000000..45c01f5 --- /dev/null +++ b/docs/api-reference/rotate.md @@ -0,0 +1,33 @@ +# Rotate + +Recognized when two or more pointer are moving in a circular motion. + +## Constructor + +```ts +import {EventManager, Rotate} from 'mjolnir.js'; + +const eventManager = new EventManager({ + // ... + recognizers: [ + new Rotate({pointers: 2}) + ] +}); +``` + +* `options` (object, optional) - Options + - `event` (string) - Name of the event. Default `'rotate'`. + - `pointers` (number) - Required pointers, with a minimal of 2. Default `2`. + - `threshold` (number) - Minimal rotation before recognizing. Default `0`. + +## Events + +- rotate, together with all of below +- rotatestart +- rotatemove +- rotateend +- rotatecancel + +## Source + +https://github.com/visgl/mjolnir.js/blob/master/src/hammerjs/recognizers/rotate.ts diff --git a/docs/api-reference/swipe.md b/docs/api-reference/swipe.md new file mode 100644 index 0000000..8875bc9 --- /dev/null +++ b/docs/api-reference/swipe.md @@ -0,0 +1,36 @@ +# Swipe + +Recognized when the pointer is moving fast (velocity), with enough distance in the allowed direction. + +## Constructor + +```ts +import {EventManager, InputDirection, Swipe} from 'mjolnir.js'; + +const eventManager = new EventManager({ + // ... + recognizers: [ + new Swipe({direction: InputDirection.Horizontal}) + ] +}); +``` + +* `options` (object, optional) - Options + - `event` (string) - Name of the event. Default `'swipe'`. + - `pointers` (number) - Required pointers. Default `1`. + - `threshold` (number) - Minimal distance required before recognizing. Default `10`. + - `direction` {InputDirection} - Direction of the panning. Default `InputDirection.All`. + - `velocity` (number) - Minimal velocity required before recognizing, unit is in px per ms. Default `0.3`. + +## Events + +- swipe, together with all of below +- swipeleft +- swiperight +- swipeup +- swipedown + + +## Source + +https://github.com/visgl/mjolnir.js/blob/master/src/hammerjs/recognizers/swipe.ts diff --git a/docs/api-reference/tap.md b/docs/api-reference/tap.md new file mode 100644 index 0000000..f74dbdb --- /dev/null +++ b/docs/api-reference/tap.md @@ -0,0 +1,37 @@ +# Tap + +Recognized when the pointer is doing a small tap/click. Multiple taps are recognized if they occur between the given interval and position. The eventData from the emitted event contains the property `tapCount`, which contains the amount of multi-taps being recognized. + +If an Tap recognizer has a failing requirement, it waits the interval time before emitting the event. This is because if you want to only trigger a doubletap, the recognizer needs to see if any other taps are coming in. Use [requireFailure](./event-manager.md#recognize-gesture) to distinguish single tap events from double tap. + + +## Constructor + +```ts +import {EventManager, Tap} from 'mjolnir.js'; + +const eventManager = new EventManager({ + // ... + recognizers: [ + new Tap({event: 'doubletap', pointers: 2}), + [new Tap({event: 'singletap'}), null, 'doubletap'] + ] +}); +``` + +* `options` (object, optional) - Options + - `event` (string) - Name of the event. Default `'tap'`. + - `pointers` (number) - Required pointers. Default `1`. + - `taps` (number) - Amount of taps required. Default `1`. + - `interval` (number) - Maximum time in ms between multiple taps. Default `300`. + - `time` (number) - Maximum press time in ms. Default `250`. + - `threshold` (number) - While doing a tap some small movement is allowed. Default `2`. + - `posThreshold` (number) - The maximum position difference between multiple taps. Default `10`. + +## Events + +- tap + +## Source + +https://github.com/visgl/mjolnir.js/blob/master/src/hammerjs/recognizers/tap.ts diff --git a/docs/api-reference/types.md b/docs/api-reference/types.md new file mode 100644 index 0000000..b1fe366 --- /dev/null +++ b/docs/api-reference/types.md @@ -0,0 +1,77 @@ +# Types + +## InputDirection Enum + +- None +- Left +- Right +- Up +- Down +- Horizontal +- Vertical +- All + +## InputEvent Enum + +- Start +- Move +- End +- Cancel + +## MjolnirEvent + +- `type` (string) - The event type to which the event handler is subscribed, e.g. `'click'` or `'pointermove'` +- `center` (Point) - The center of the event location (e.g. the centroid of a touch) relative to the browser's viewport (basically, [`clientX/Y`](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/clientX)) +- `offsetCenter` (Point) - The center of the event location (e.g. the centroid of a touch) relative to the root element +- `target` (TargetElement) - The immediate target of the event, as specified by the original `srcEvent` +- `rootElement` (HTMLElement) - The root element of the `EventManager` +- `srcEvent` (Event) - The original event object dispatched by the browser to the JS runtime +- `preventDefault` (() => void) - Equivalent to `srcEvent.preventDefault`. +- `stopPropagation` (() => void) - Do not invoke handlers registered for any ancestors in the DOM tree. +- `stopImmediatePropagation` (() => void) - Do not invoke any other handlers registered for the same element or its ancestors. + + +### MjolnirPointerEvent + +Emitted by `pointer*` events. Extends `MjolnirEvent` with the following fields: + +- `leftButton` (boolean) - Flag indicating whether the left mouse button is involved during the event +- `middleButton` (boolean) - Flag indicating whether the middle mouse button is involved during the event +- `rightButton` (boolean) - Flag indicating whether the right mouse button is involved during the event +- `pointerType` (string) - A string indicating the type of input (e.g. `'mouse'`, `'touch'`, `'pen'`) + + +### MjolnirGestureEvent + +Emitted by recognizers (`Pan`, `Rotate` etc.). Extends `MjolnirEvent` with the following fields: + +- `eventType` (InputEvent) - type of this event (start, move, end) in the gesture lifecycle +- `timeStamp` (number) - Timestamp of the event +- `deltaTime` (number) - Total time since the first input +- `deltaX` (number) - Movement along the X axis +- `deltaY` (number) - Movement along the Y axis +- `angle` (number) - Angle moved, in degrees +- `distance` (number) - Distance moved +- `scale` (number) - Scaling that has been done with multi-touch. 1 on a single touch. +- `rotation` (number) - Rotation (in degrees) that has been done with multi-touch. 0 on a single touch. +- `direction` (InputDirection) - Direction moved. +- `offsetDirection` (InputDirection) - Direction moved from its starting point. +- `velocity` (number) - Highest velocityX/Y value. +- `velocityX` (number) - Velocity along the X axis, in px/ms +- `velocityY` (number) - Velocity along the Y axis, in px/ms +- `leftButton` (boolean) - Flag indicating whether the left mouse button is involved during the event +- `middleButton` (boolean) - Flag indicating whether the middle mouse button is involved during the event +- `rightButton` (boolean) - Flag indicating whether the right mouse button is involved during the event + + +### MjolnirWheelEvent + +Emitted by the `wheel` event. Extends `MjolnirEvent` with the following fields: + +- `delta` (number) - The scroll magnitude/distance of a wheel event + +### MjolnirKeyEvent + +Emitted by the `key*` events. Extends `MjolnirEvent` with the following fields: + +- `key` (string) - The [key value](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values) associated with the keyboard event. diff --git a/docs/get-started.md b/docs/get-started.md index 4f4d44f..65d5521 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -6,73 +6,76 @@ npm install mjolnir.js ``` -# Usage +or -```js -import {EventManager} from 'mjolnir.js'; +```html + +``` + +# Using with NPM -const eventManager = new EventManager(document.getElementById('container')); -function onClick(event) {} -function onPinch(event) {} +```ts +import {EventManager, Pinch, Pan} from 'mjolnir.js'; -eventManager.on({ - click: onClick, - pinch: onPinch +const eventManager = new EventManager(document.getElementById('container'), { + recognizers: [new Pinch(), new Pan()], + events: { + pinch: (event) => { + // do something + } + } }); -// ... +// when done eventManager.destroy(); ``` -## Using with React - -The `EventManager` can be initialized with an empty root: +## Using with Script Tag ```js -import {EventManager} from 'mjolnir.js'; +const {EventManager, Pinch, Pan} = mjolnir; + +const eventManager = new EventManager(document.getElementById('container'), { + recognizers: [new Pinch(), new Pan()], + events: { + pinch: (event) => { + // do something + } + } +}); -const eventManager = new EventManager(); -// Events can be registered now, but they will have no effect until -// the event manager is attached to a DOM element -eventManager.on('dblclick', onDblClick); +// when done +eventManager.destroy(); ``` -We may set the root element later to a DOM node that's rendered by React: -```jsx -import React, {useRef, useEffect} from 'react'; +## Using with React -function App() { - const ref = useRef(null); - useEffect(() => { - // did mount - eventManager.setElement(ref.current); - // unmount - return () => eventManager.setElement(null); - }, []); - return ( -
- -
- ); -} -``` +```tsx +import React, {useRef, useEffect} from 'react'; +import {EventManager, Pinch, Pan} from 'mjolnir.js'; -Or add/remove event listeners when a React component is rendered: +function App() { + const ref = useRef(); -```js -function Child() { - const ref = useRef(null); useEffect(() => { // did mount - eventManager.on('panstart', onDragChild, ref.current); - // unmount - return () => eventManager.off('panstart', onDragChild); + const eventManager = new EventManager(ref.current, { + recognizers: [new Pinch(), new Pan()], + events: { + pinch: (event) => { + // do something + } + } + }); + + // unmounting + return () => eventManager.destroy(); }, []); - return
Child node
; + return
; } ``` -Note that React's event chain is independent from that of mjolnir.js'. Therefore, a `click` event handler registered with mjolnir.js cannot be blocked by calling `stopPropagation` on a React `onClick` event. +*Note that React's event chain is independent from that of mjolnir.js'. Therefore, a `click` event handler registered with mjolnir.js cannot be blocked by calling `stopPropagation` on a React `onClick` event.* diff --git a/docs/table-of-contents.json b/docs/table-of-contents.json index 09af01f..70d7995 100644 --- a/docs/table-of-contents.json +++ b/docs/table-of-contents.json @@ -6,6 +6,7 @@ "entries": [ {"entry": "docs"}, {"entry": "docs/get-started"}, + {"entry": "docs/whats-new"}, {"entry": "docs/upgrade-guide"} ] }, @@ -13,7 +14,13 @@ "title": "API Reference", "entries": [ {"entry": "docs/api-reference/event-manager"}, - {"entry": "docs/api-reference/event"} + {"entry": "docs/api-reference/pan"}, + {"entry": "docs/api-reference/pinch"}, + {"entry": "docs/api-reference/press"}, + {"entry": "docs/api-reference/rotate"}, + {"entry": "docs/api-reference/swipe"}, + {"entry": "docs/api-reference/tap"}, + {"entry": "docs/api-reference/types"} ] } ] diff --git a/docs/upgrade-guide.md b/docs/upgrade-guide.md index 114b750..ecae0f7 100644 --- a/docs/upgrade-guide.md +++ b/docs/upgrade-guide.md @@ -1,5 +1,42 @@ # Upgrade Guide +## From 2.x to 3.0 + +- `EventManager` no longer comes with a default set of recognizers. Specify `options.recognizers` to emit gesture events. +- `EventManager`'s `recognizers` is now a different format. Each element in the array should be either a recognizer instance or an array of [recognizer, recognizeWith, requireFailure]. +- `EventManager`'s `recognizerOptions` is removed. +- Element must be supplied when constructing `EventManager` and cannot be reassigned. To change the event target, destroy the existing event manager instance and construct a new one. +- Hammer.js is no longer a dependency. Due to the lack of maintenance on the legacy hammerjs project, mjolnir.js has ported it to TypeScript and incorporated it into the code base. To configure recognizers (Pan, Pinch etc.), directly import them from `mjolnir.js`. For details, see the documentation of each recognizer. + +Before: + +```ts title="v2" +import Hammer from 'hammer.js'; +import {EventManager} from 'mjolnir.js'; + +new EventManager(document.body, { + recognizers: [ + [Hammer.Pan, {threshold: 4, direction: Hammer.DIRECTION_HORIZONTAL}], + [Hammer.Tap, {event: 'doubletap', pointers: 2}], + [Hammer.Tap, {event: 'singletap'}, null, 'doubletap'] + ] +}); +``` + +After: + +```ts title="v3" +import {EventManager, Pan, Tap, InputDirection} from 'mjolnir.js'; + +new EventManager(document.body, { + recognizers: [ + new Pan({threshold: 4, direction: InputDirection.Horizontal}), + new Tap({event: 'doubletap', pointers: 2}), + [new Tap({event: 'singletap'}), null, 'doubletap'] + ] +}); +``` + ## From 1.x to 2.0 - The `legacyBlockScroll` option to `EventManager` is removed. Use `eventManager.on('wheel', evt => evt.preventDefault())` to block scrolling. diff --git a/docs/whats-new.md b/docs/whats-new.md index 40fed9b..9b2848a 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,3 +1,8 @@ # What's New -## v1.0 Initial version +## v3.0 + +- ES module +- Improved TypeScript definitions +- Ready to use with script tag +- Hammer.js is no longer a dependency due to the lack of maintenance. It has been ported to TypeScript and incorporated into mjolnir.js' code base. This will allow us to better address bugs and security issues moving forward. diff --git a/examples/image-viewer/app.ts b/examples/image-viewer/app.ts new file mode 100644 index 0000000..691b50d --- /dev/null +++ b/examples/image-viewer/app.ts @@ -0,0 +1,87 @@ +import {EventManager, Pinch, Pan, MjolnirGestureEvent, MjolnirWheelEvent} from 'mjolnir.js'; +import {Transform, Point} from './transform'; + +import './style.css'; + +export function renderToDOM(container: HTMLElement) { + const border = document.createElement('div'); + border.className = 'image-viewer'; + + const img = document.createElement('img'); + img.src = 'https://upload.wikimedia.org/wikipedia/commons/7/75/Planisph%C3%A6ri_c%C5%93leste.jpg'; + img.style.maxWidth = '400px'; + + border.appendChild(img); + + const currState = new Transform(); + + // Uncommitted changes + let startState: Transform; + let startCenter: Point; + + img.onload = () => { + currState.width = img.width; + currState.height = img.height; + currState.centerX = border.clientWidth / 2 - img.width / 2; + currState.centerY = border.clientHeight / 2 - img.height / 2; + updateTransform(); + }; + + function updateTransform() { + img.style.transform = currState.toCSSTransform(); + } + + function onPinchStart(e: MjolnirGestureEvent) { + startState = currState.clone(); + startCenter = currState.unproject(e.offsetCenter); + } + + function onPinchMove(e: MjolnirGestureEvent) { + currState.scale = startState.scale * e.scale; + currState.angle = startState.angle + e.rotation; + const center = currState.project(startCenter); + currState.centerX += e.offsetCenter.x - center.x; + currState.centerY += e.offsetCenter.y - center.y; + updateTransform(); + } + + function onPanStart(e: MjolnirGestureEvent) { + startState = currState.clone(); + startState.centerX -= e.deltaX; + startState.centerY -= e.deltaY; + } + + function onPanMove(e: MjolnirGestureEvent) { + currState.centerX = startState.centerX + e.deltaX; + currState.centerY = startState.centerY + e.deltaY; + updateTransform(); + } + + function onWheel(e: MjolnirWheelEvent) { + e.preventDefault(); + startCenter = currState.unproject(e.offsetCenter); + currState.scale *= e.delta > 0 ? 1.1 : 1 / 1.1; + const center = currState.project(startCenter); + currState.centerX += e.offsetCenter.x - center.x; + currState.centerY += e.offsetCenter.y - center.y; + updateTransform(); + } + + const manager = new EventManager(border, { + recognizers: [new Pinch(), new Pan()], + events: { + pinchstart: onPinchStart, + pinchmove: onPinchMove, + panstart: onPanStart, + panmove: onPanMove, + wheel: onWheel + } + }); + + container.appendChild(border); + + return () => { + container.removeChild(border); + manager.destroy(); + }; +} diff --git a/examples/image-viewer/index.html b/examples/image-viewer/index.html new file mode 100644 index 0000000..3b2cec7 --- /dev/null +++ b/examples/image-viewer/index.html @@ -0,0 +1,17 @@ + + + + + mjolnir.js + + + +
+ + + diff --git a/examples/image-viewer/package.json b/examples/image-viewer/package.json new file mode 100644 index 0000000..0d6e94d --- /dev/null +++ b/examples/image-viewer/package.json @@ -0,0 +1,15 @@ +{ + "name": "example-image-viewer", + "type": "module", + "scripts": { + "start": "vite --open", + "start-local": "vite --config ../vite.config.local.js" + }, + "dependencies": { + "mjolnir.js": "^2.1.1" + }, + "devDependencies": { + "typescript": "^4.0.0", + "vite": "^5.2.0" + } +} diff --git a/examples/image-viewer/style.css b/examples/image-viewer/style.css new file mode 100644 index 0000000..f3c1a7f --- /dev/null +++ b/examples/image-viewer/style.css @@ -0,0 +1,16 @@ +body { + margin: 0; +} +* { + box-sizing: border-box; +} +.image-viewer { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +} +.image-viewer img { + pointer-events: none; + position: absolute; +} \ No newline at end of file diff --git a/examples/image-viewer/transform.ts b/examples/image-viewer/transform.ts new file mode 100644 index 0000000..83f1778 --- /dev/null +++ b/examples/image-viewer/transform.ts @@ -0,0 +1,58 @@ +export type Point = { + x: number; + y: number; +}; + +export class Transform { + width: number = 0; + height: number = 0; + scale: number = 1; + angle: number = 0; + centerX: number = 0; + centerY: number = 0; + + clone(): Transform { + const t = new Transform(); + t.width = this.width; + t.height = this.height; + t.scale = this.scale; + t.angle = this.angle; + t.centerX = this.centerX; + t.centerY = this.centerY; + return t; + } + + /** From image-space coordinates to screen-space coordinates */ + project({x, y}: Point): Point { + x -= this.width / 2; + y -= this.height / 2; + const p1 = rotate({x, y}, this.angle); + p1.x = p1.x * this.scale + this.centerX + this.width / 2; + p1.y = p1.y * this.scale + this.centerY + this.height / 2; + + return p1; + } + + /** From screen-space coordinates to image-space coordinates */ + unproject({x, y}: Point): Point { + x = (x - this.centerX - this.width / 2) / this.scale; + y = (y - this.centerY - this.height / 2) / this.scale; + const p1 = rotate({x, y}, -this.angle); + p1.x += this.width / 2; + p1.y += this.height / 2; + + return p1; + } + + /** Convert to CSS transform prop value */ + toCSSTransform(): string { + return `translate(${this.centerX}px, ${this.centerY}px) scale(${this.scale}) rotate(${this.angle}deg)`; + } +} + +function rotate(p: Point, angle: number): Point { + const r = (angle * Math.PI) / 180; + const x = p.x * Math.cos(r) - p.y * Math.sin(r); + const y = p.x * Math.sin(r) + p.y * Math.cos(r); + return {x, y}; +} diff --git a/examples/image-viewer/tsconfig.json b/examples/image-viewer/tsconfig.json new file mode 100644 index 0000000..8b206d1 --- /dev/null +++ b/examples/image-viewer/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "es2020", + "allowSyntheticDefaultImports": true, + "moduleResolution": "node", + "sourceMap": true + } +} diff --git a/examples/main/app.tsx b/examples/main/app.tsx index 2b3977b..6296383 100644 --- a/examples/main/app.tsx +++ b/examples/main/app.tsx @@ -24,12 +24,13 @@ import {render} from 'react-dom'; import {EventManager, MjolnirEvent} from 'mjolnir.js'; import './style.css'; -import {EVENTS, INITIAL_OPTIONS} from './constants'; +import {RECOGNIZERS, EVENTS, INITIAL_OPTIONS} from './constants'; export default function App() { const rootRef = useRef(); const redBoxRef = useRef(); + const [eventManager, setEventManager] = useState(null); const [options, setOptions] = useState<{[eventName: string]: boolean}>(INITIAL_OPTIONS); const [eventLog, setEventLog] = useState([]); @@ -44,48 +45,40 @@ export default function App() { }); }, []); - const eventManager = useMemo(() => { - const eventListeners = {}; - for (const eventName of EVENTS) { - if (INITIAL_OPTIONS[eventName]) { - eventListeners[eventName] = handleEvent; - } - } - - return new EventManager(null, { - events: eventListeners - }); - }, []); - useEffect(() => { - eventManager.setElement(rootRef.current); + const eventManager = new EventManager(rootRef.current, { + recognizers: RECOGNIZERS + }); + setEventManager(eventManager); for (const eventName of EVENTS) { if (INITIAL_OPTIONS[eventName]) { + eventManager.on(eventName, handleEvent); eventManager.on(eventName, handleEvent, {srcElement: redBoxRef.current}); } } return () => { - for (const eventName of EVENTS) { - eventManager.off(eventName, handleEvent); - } + eventManager.destroy(); }; }, []); - const updateOption = useCallback((evt: React.ChangeEvent) => { - const {name, checked} = evt.target; - if (checked) { - eventManager.on(name, handleEvent); - eventManager.on(name, handleEvent, {srcElement: redBoxRef.current}); - } else { - eventManager.off(name, handleEvent); - } - setOptions(curr => ({...curr, [name]: checked})); - }, []); + const updateOption = useCallback( + (evt: React.ChangeEvent) => { + const {name, checked} = evt.target; + if (checked) { + eventManager.on(name, handleEvent); + eventManager.on(name, handleEvent, {srcElement: redBoxRef.current}); + } else { + eventManager.off(name, handleEvent); + } + setOptions(curr => ({...curr, [name]: checked})); + }, + [eventManager] + ); return ( -
+
evt.preventDefault()}>
@@ -95,6 +88,7 @@ export default function App() {
{EVENTS.map(eventName => ( , container); } diff --git a/examples/main/constants.ts b/examples/main/constants.ts index 3be15bf..5b364dd 100644 --- a/examples/main/constants.ts +++ b/examples/main/constants.ts @@ -17,18 +17,29 @@ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. +import {Pan, Rotate, Pinch, Swipe, Press, Tap, RecognizerTuple} from 'mjolnir.js'; + +export const RECOGNIZERS: RecognizerTuple[] = [ + [Rotate, {enable: false}, ['doublepan']], + [Pinch, {enable: false}, ['doublepan', 'rotate']], + [Swipe, {enable: false}], + [Pan, {threshold: 1, enable: false}, ['pinch'], ['swipe']], + [Press, {enable: false}], + [Tap, {event: 'dblclick', taps: 2, enable: false}], + [Tap, {event: 'anyclick', enable: false}, ['dblclick']], + [Tap, {event: 'click', enable: false}, ['anyclick'], ['dblclick']] +]; export const EVENTS = [ 'click', 'anyclick', - 'contextmenu', + 'dblclick', 'pointerdown', 'pointermove', 'pointerup', 'pointerover', 'pointerout', 'pointerleave', - 'doubletap', 'pinchin', 'pinchout', 'pinchstart', diff --git a/examples/main/index.html b/examples/main/index.html index b306caf..e0709c9 100644 --- a/examples/main/index.html +++ b/examples/main/index.html @@ -3,16 +3,15 @@ mjolnir.js - -
- - diff --git a/examples/main/package.json b/examples/main/package.json index c1047c0..ef5f14a 100644 --- a/examples/main/package.json +++ b/examples/main/package.json @@ -1,7 +1,9 @@ { + "name": "example-event-listeners", + "type": "module", "scripts": { - "start": "webpack-dev-server --progress --hot --port 3000 --open", - "start-local": "webpack-dev-server --env local --progress --hot --port 3000 --open" + "start": "vite --open", + "start-local": "vite --config ../vite.config.local.js" }, "dependencies": { "@types/react": "^16.0.0", @@ -10,21 +12,7 @@ "react-dom": "^16.8.0" }, "devDependencies": { - "@babel/cli": "^7.0.0", - "@babel/core": "^7.4.0", - "@babel/preset-react": "^7.0.0", - "babel-loader": "^8.0.5", - "css-loader": "^2.1.1", - "style-loader": "^0.23.1", - "ts-loader": "^9.0.0", "typescript": "^4.0.0", - "webpack": "^5.65.0", - "webpack-cli": "^4.9.0", - "webpack-dev-server": "^4.7.0" - }, - "babel": { - "presets": [ - "@babel/react" - ] + "vite": "^5.2.0" } } diff --git a/examples/main/webpack.config.js b/examples/main/webpack.config.js deleted file mode 100644 index bf49c82..0000000 --- a/examples/main/webpack.config.js +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2017 Uber Technologies, Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -// NOTE: To use this example standalone (e.g. outside of mjolnir.js repo) -// delete the local development overrides at the bottom of this file - -// avoid destructuring for older Node version support -const resolve = require('path').resolve; - -const config = { - mode: 'development', - - devServer: { - static: '.' - }, - - entry: { - app: './app.tsx' - }, - - output: { - library: 'App' - }, - - devtool: 'source-map', - - module: { - rules: [ - { - test: /\.(ts|js)x?$/, - include: [resolve('.')], - exclude: [/node_modules/], - use: [ - { - loader: 'babel-loader', - options: { - presets: ['@babel/env', '@babel/react'] - } - }, - { - loader: 'ts-loader' - } - ] - }, - { - test: /\.css$/, - use: ['style-loader', 'css-loader'] - } - ] - }, - - resolve: { - extensions: ['.ts', '.tsx', '.js', '.json'], - alias: {} - } -}; - -// Enables bundling against src in this repo rather than the installed version -module.exports = env => - env && env.local ? require('../webpack.config.local')(config)(env) : config; diff --git a/examples/vite.config.local.js b/examples/vite.config.local.js new file mode 100644 index 0000000..4be5afa --- /dev/null +++ b/examples/vite.config.local.js @@ -0,0 +1,24 @@ +import {defineConfig} from 'vite'; +import {getOcularConfig} from 'ocular-dev-tools'; +import {join} from 'path'; +import {fileURLToPath} from 'url'; + +const rootDir = join(fileURLToPath(import.meta.url), '../..'); + +/** https://vitejs.dev/config/ */ +export default defineConfig(async () => { + const {aliases} = await getOcularConfig({root: rootDir}); + + return { + resolve: { + alias: aliases + }, + server: { + open: true, + port: 8080 + }, + optimizeDeps: { + esbuildOptions: {target: 'es2020'} + } + }; +}); diff --git a/examples/webpack.config.local.js b/examples/webpack.config.local.js deleted file mode 100644 index 2c222c3..0000000 --- a/examples/webpack.config.local.js +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) 2017 Uber Technologies, Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -// This file contains webpack configuration settings that allow -// examples to be built against the mjolnir.js source code in this repo instead -// of building against their installed version of mjolnir.js. -// -// This enables using the examples to debug the main mjolnir.js library source -// without publishing or npm linking, with conveniences such hot reloading etc. - -const {resolve} = require('path'); - -const LIB_DIR = resolve(__dirname, '..'); -const SRC_DIR = resolve(LIB_DIR, './src'); - -// Support for hot reloading changes to the mjolnir.js library: -const LOCAL_DEVELOPMENT_CONFIG = { - // suppress warnings about bundle size - devServer: { - stats: { - warnings: false - } - }, - - resolve: { - extensions: ['.ts', '.tsx', '.js', '.json'], - alias: { - // Imports the mjolnir.js library from the src directory in this repo - 'mjolnir.js': SRC_DIR, - './utils/hammer': resolve(SRC_DIR, './utils/hammer.browser') - } - }, - module: { - rules: [ - { - test: /\.(ts|js)x?$/, - include: [SRC_DIR], - exclude: [/node_modules/], - use: [ - { - loader: 'babel-loader', - options: { - presets: ['@babel/env', '@babel/react'] - } - }, - { - loader: 'ts-loader' - } - ] - } - ] - } -}; - -function addLocalDevSettings(config) { - Object.assign(config.resolve.alias, LOCAL_DEVELOPMENT_CONFIG.resolve.alias); - config.module.rules = config.module.rules.concat(LOCAL_DEVELOPMENT_CONFIG.module.rules); - return config; -} - -module.exports = baseConfig => env => { - const config = baseConfig; - if (env && env.local) { - addLocalDevSettings(config); - } - return config; -}; diff --git a/index.html b/index.html index 3818cbd..584c6b3 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,6 @@ mjolnir.js unit tests - + diff --git a/package.json b/package.json index b8a669e..9c02eeb 100644 --- a/package.json +++ b/package.json @@ -26,20 +26,17 @@ "types": "./dist/index.d.ts" } }, - "browser": { - "./src/utils/hammer.js": "./src/utils/hammer.browser.js", - "./dist/es5/utils/hammer.js": "./dist/es5/utils/hammer.browser.js", - "./dist/esm/utils/hammer.js": "./dist/esm/utils/hammer.browser.js" - }, "files": [ "src", - "dist" + "dist", + "dist.min.js" ], "scripts": { "typecheck": "tsc -p tsconfig.json --noEmit", "bootstrap": "yarn && ocular-bootstrap", "start": "(cd examples/main && (path-exists node_modules || npm i) && npm run start-local)", "build": "ocular-clean && ocular-build", + "build-bundle": "ocular-bundle ./src/index.ts", "lint": "ocular-lint", "cover": "ocular-test cover", "publish-prod": "ocular-publish prod", @@ -50,8 +47,6 @@ "test" ], "dependencies": { - "@types/hammerjs": "^2.0.41", - "hammerjs": "^2.0.8" }, "devDependencies": { "jsdom": "^16.0.0", diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index 8d9ede3..0000000 --- a/src/constants.ts +++ /dev/null @@ -1,129 +0,0 @@ -import Hammer from './utils/hammer'; -import {RecognizerTuple} from './types'; - -// This module contains constants that must be conditionally required -// due to `window`/`document` references downstream. -export const RECOGNIZERS: RecognizerTuple[] = Hammer - ? [ - [Hammer.Pan, {event: 'tripan', pointers: 3, threshold: 0, enable: false}], - [Hammer.Rotate, {enable: false}], - [Hammer.Pinch, {enable: false}], - [Hammer.Swipe, {enable: false}], - [Hammer.Pan, {threshold: 0, enable: false}], - [Hammer.Press, {enable: false}], - [Hammer.Tap, {event: 'doubletap', taps: 2, enable: false}], - // TODO - rename to 'tap' and 'singletap' in the next major release - [Hammer.Tap, {event: 'anytap', enable: false}], - [Hammer.Tap, {enable: false}] - ] - : null; - -// Recognize the following gestures even if a given recognizer succeeds -export const RECOGNIZER_COMPATIBLE_MAP = { - tripan: ['rotate', 'pinch', 'pan'], - rotate: ['pinch'], - pinch: ['pan'], - pan: ['press', 'doubletap', 'anytap', 'tap'], - doubletap: ['anytap'], - anytap: ['tap'] -} as const; - -// Recognize the folling gestures only if a given recognizer fails -export const RECOGNIZER_FALLBACK_MAP = { - doubletap: ['tap'] -} as const; - -/** - * Only one set of basic input events will be fired by Hammer.js: - * either pointer, touch, or mouse, depending on system support. - * In order to enable an application to be agnostic of system support, - * alias basic input events into "classes" of events: down, move, and up. - * See `_onBasicInput()` for usage of these aliases. - */ -export const BASIC_EVENT_ALIASES = { - pointerdown: 'pointerdown', - pointermove: 'pointermove', - pointerup: 'pointerup', - touchstart: 'pointerdown', - touchmove: 'pointermove', - touchend: 'pointerup', - mousedown: 'pointerdown', - mousemove: 'pointermove', - mouseup: 'pointerup' -} as const; - -export const INPUT_EVENT_TYPES = { - KEY_EVENTS: ['keydown', 'keyup'], - MOUSE_EVENTS: ['mousedown', 'mousemove', 'mouseup', 'mouseover', 'mouseout', 'mouseleave'], - WHEEL_EVENTS: [ - // Chrome, Safari - 'wheel', - // IE - 'mousewheel' - ] -} as const; - -/** - * "Gestural" events are those that have semantic meaning beyond the basic input event, - * e.g. a click or tap is a sequence of `down` and `up` events with no `move` event in between. - * Hammer.js handles these with its Recognizer system; - * this block maps event names to the Recognizers required to detect the events. - */ -export const EVENT_RECOGNIZER_MAP = { - tap: 'tap', - anytap: 'anytap', - doubletap: 'doubletap', - press: 'press', - pinch: 'pinch', - pinchin: 'pinch', - pinchout: 'pinch', - pinchstart: 'pinch', - pinchmove: 'pinch', - pinchend: 'pinch', - pinchcancel: 'pinch', - rotate: 'rotate', - rotatestart: 'rotate', - rotatemove: 'rotate', - rotateend: 'rotate', - rotatecancel: 'rotate', - tripan: 'tripan', - tripanstart: 'tripan', - tripanmove: 'tripan', - tripanup: 'tripan', - tripandown: 'tripan', - tripanleft: 'tripan', - tripanright: 'tripan', - tripanend: 'tripan', - tripancancel: 'tripan', - pan: 'pan', - panstart: 'pan', - panmove: 'pan', - panup: 'pan', - pandown: 'pan', - panleft: 'pan', - panright: 'pan', - panend: 'pan', - pancancel: 'pan', - swipe: 'swipe', - swipeleft: 'swipe', - swiperight: 'swipe', - swipeup: 'swipe', - swipedown: 'swipe' -} as const; - -/** - * Map gestural events typically provided by browsers - * that are not reported in 'hammer.input' events - * to corresponding Hammer.js gestures. - */ -export const GESTURE_EVENT_ALIASES = { - click: 'tap', - anyclick: 'anytap', - dblclick: 'doubletap', - mousedown: 'pointerdown', - mousemove: 'pointermove', - mouseup: 'pointerup', - mouseover: 'pointerover', - mouseout: 'pointerout', - mouseleave: 'pointerleave' -} as const; diff --git a/src/event-manager.ts b/src/event-manager.ts index aa9304a..52bbfef 100644 --- a/src/event-manager.ts +++ b/src/event-manager.ts @@ -1,61 +1,45 @@ -import {Manager} from './utils/hammer'; +import {Manager as HammerManager, RecognizerTuple} from './hammerjs'; import type { - HammerManager, - HammerManagerConstructor, MjolnirEventRaw, MjolnirEvent, - RecognizerOptions, - RecognizerTuple, + MjolnirEventHandler, MjolnirEventHandlers } from './types'; -import WheelInput from './inputs/wheel-input'; -import MoveInput from './inputs/move-input'; -import KeyInput from './inputs/key-input'; -import ContextmenuInput from './inputs/contextmenu-input'; +import {WheelInput} from './inputs/wheel-input'; +import {MoveInput} from './inputs/move-input'; +import {KeyInput} from './inputs/key-input'; +import {ContextmenuInput} from './inputs/contextmenu-input'; -import EventRegistrar, {HandlerOptions} from './utils/event-registrar'; - -import { - BASIC_EVENT_ALIASES, - EVENT_RECOGNIZER_MAP, - GESTURE_EVENT_ALIASES, - RECOGNIZERS, - RECOGNIZER_COMPATIBLE_MAP, - RECOGNIZER_FALLBACK_MAP -} from './constants'; +import {EventRegistrar, HandlerOptions} from './utils/event-registrar'; export type EventManagerOptions = { + /** Event listeners */ events?: MjolnirEventHandlers; + /** Gesture recognizers */ recognizers?: RecognizerTuple[]; - recognizerOptions?: {[type: string]: RecognizerOptions}; - Manager?: HammerManagerConstructor; - touchAction?: string; + /** Touch action to set on the target element. + * Use 'compute' to automatically set as the least restrictive value to support the recognizers. + * https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action + * @default 'compute' + */ + touchAction?: 'none' | 'compute' | 'manipulation' | 'pan-x' | 'pan-y' | 'pan-x pan-y'; + /** Tab index of the target element */ tabIndex?: number; -}; - -const DEFAULT_OPTIONS: EventManagerOptions = { - // event handlers - events: null, - // custom recognizers - recognizers: null, - recognizerOptions: {}, - // Manager class - Manager, - // allow browser default touch action - // https://github.com/uber/react-map-gl/issues/506 - touchAction: 'none', - tabIndex: 0 + /** + * Optional CSS properties to be applied to the target element. + */ + cssProps?: Partial; }; // Unified API for subscribing to events about both // basic input events (e.g. 'mousemove', 'touchstart', 'wheel') // and gestural input (e.g. 'click', 'tap', 'panstart'). // Delegates gesture related event registration and handling to Hammer.js. -export default class EventManager { +export class EventManager { + private element: HTMLElement | null; private manager: HammerManager; - private element: HTMLElement; - private options: EventManagerOptions; + private options: Required; private events: Map; // Custom handlers @@ -64,64 +48,23 @@ export default class EventManager { private contextmenuInput: ContextmenuInput; private keyInput: KeyInput; - constructor(element: HTMLElement = null, options: EventManagerOptions) { - this.options = {...DEFAULT_OPTIONS, ...options}; + constructor(element: HTMLElement | null = null, options: EventManagerOptions = {}) { + this.options = { + recognizers: [], + events: {}, + touchAction: 'compute', + tabIndex: 0, + cssProps: {}, + ...options + }; this.events = new Map(); - - this.setElement(element); - - // Register all passed events. - const {events} = this.options; - if (events) { - this.on(events); - } - } - - getElement(): HTMLElement { - return this.element; - } - - setElement(element: HTMLElement): void { - if (this.element) { - // unregister all events - this.destroy(); - } this.element = element; - if (!element) { - return; - } - const {options} = this; - const ManagerClass = options.Manager; - - this.manager = new ManagerClass(element, { - touchAction: options.touchAction, - recognizers: options.recognizers || RECOGNIZERS - }).on('hammer.input', this._onBasicInput); - - if (!options.recognizers) { - // Set default recognize withs - // http://hammerjs.github.io/recognize-with/ - Object.keys(RECOGNIZER_COMPATIBLE_MAP).forEach(name => { - const recognizer = this.manager.get(name); - if (recognizer) { - RECOGNIZER_COMPATIBLE_MAP[name].forEach(otherName => { - recognizer.recognizeWith(otherName); - }); - } - }); - } + if (!element) return; - // Set recognizer options - for (const recognizerName in options.recognizerOptions) { - const recognizer = this.manager.get(recognizerName); - if (recognizer) { - const recognizerOption = options.recognizerOptions[recognizerName]; - // `enable` is managed by the event registrations - delete recognizerOption.enable; - recognizer.set(recognizerOption); - } - } + this.manager = new HammerManager(element, this.options); + + this.manager.on('hammer.input', this._onBasicInput); // Handle events not handled by Hammer.js: // - mouse wheel @@ -140,34 +83,24 @@ export default class EventManager { enable: false }); - // Register all existing events - for (const [eventAlias, eventRegistrar] of this.events) { - if (!eventRegistrar.isEmpty()) { - // Enable recognizer for this event. - this._toggleRecognizer(eventRegistrar.recognizerName, true); - this.manager.on(eventAlias, eventRegistrar.handleEvent); - } - } + // Register all passed events. + this.on(this.options.events); + } + + getElement(): HTMLElement | null { + return this.element; } // Tear down internal event management implementations. destroy(): void { - if (this.element) { - // wheelInput etc. are created in setElement() and therefore - // cannot exist if there is no element - this.wheelInput.destroy(); - this.moveInput.destroy(); - this.keyInput.destroy(); - this.contextmenuInput.destroy(); - this.manager.destroy(); - - this.wheelInput = null; - this.moveInput = null; - this.keyInput = null; - this.contextmenuInput = null; - this.manager = null; - this.element = null; - } + // manager etc. cannot exist if there is no element + if (!this.element) return; + + this.wheelInput.destroy(); + this.moveInput.destroy(); + this.keyInput.destroy(); + this.contextmenuInput.destroy(); + this.manager.destroy(); } /** Register multiple event handlers */ @@ -179,7 +112,7 @@ export default class EventManager { ): void; /** Register an event handler function to be called on `event` */ - on(event, handler, opts?: any) { + on(event: any, handler: any, opts?: any) { this._addEventHandler(event, handler, opts, false); } @@ -229,38 +162,14 @@ export default class EventManager { return; } const recognizer = manager.get(name); - // @ts-ignore - if (recognizer && recognizer.options.enable !== enabled) { + if (recognizer) { recognizer.set({enable: enabled}); - - const fallbackRecognizers: string[] = RECOGNIZER_FALLBACK_MAP[name]; - if (fallbackRecognizers && !this.options.recognizers) { - // Set default require failures - // http://hammerjs.github.io/require-failure/ - fallbackRecognizers.forEach(otherName => { - const otherRecognizer = manager.get(otherName); - if (enabled) { - // Wait for this recognizer to fail - otherRecognizer.requireFailure(name); - /** - * This seems to be a bug in hammerjs: - * requireFailure() adds both ways - * dropRequireFailure() only drops one way - * https://github.com/hammerjs/hammer.js/blob/master/src/recognizerjs/ - recognizer-constructor.js#L136 - */ - recognizer.dropRequireFailure(otherName); - } else { - // Do not wait for this recognizer to fail - otherRecognizer.dropRequireFailure(name); - } - }); - } + manager.touchAction.update(); } - this.wheelInput.enableEventType(name, enabled); - this.moveInput.enableEventType(name, enabled); - this.keyInput.enableEventType(name, enabled); - this.contextmenuInput.enableEventType(name, enabled); + this.wheelInput?.enableEventType(name, enabled); + this.moveInput?.enableEventType(name, enabled); + this.keyInput?.enableEventType(name, enabled); + this.contextmenuInput?.enableEventType(name, enabled); } /** @@ -268,7 +177,7 @@ export default class EventManager { */ private _addEventHandler( event: string | MjolnirEventHandlers, - handler: (event: MjolnirEvent) => void, + handler: MjolnirEventHandler, opts?: HandlerOptions, once?: boolean, passive?: boolean @@ -277,25 +186,25 @@ export default class EventManager { // @ts-ignore opts = handler; // If `event` is a map, call `on()` for each entry. - for (const eventName in event) { - this._addEventHandler(eventName, event[eventName], opts, once, passive); + for (const [eventName, handler] of Object.entries(event)) { + this._addEventHandler(eventName, handler!, opts, once, passive); } return; } const {manager, events} = this; - // Alias to a recognized gesture as necessary. - const eventAlias: string = GESTURE_EVENT_ALIASES[event] || event; + if (!manager) return; - let eventRegistrar = events.get(eventAlias); + let eventRegistrar = events.get(event); if (!eventRegistrar) { - eventRegistrar = new EventRegistrar(this); - events.set(eventAlias, eventRegistrar); // Enable recognizer for this event. - eventRegistrar.recognizerName = EVENT_RECOGNIZER_MAP[eventAlias] || eventAlias; + const recognizerName = this._getRecognizerName(event) || event; + + eventRegistrar = new EventRegistrar(this, recognizerName); + events.set(event, eventRegistrar); // Listen to the event if (manager) { - manager.on(eventAlias, eventRegistrar.handleEvent); + manager.on(event, eventRegistrar.handleEvent); } } eventRegistrar.add(event, handler, opts, once, passive); @@ -307,29 +216,24 @@ export default class EventManager { /** * Process the event deregistration for a single event + handler. */ - private _removeEventHandler( - event: string | MjolnirEventHandlers, - handler?: (event: MjolnirEvent) => void - ) { + private _removeEventHandler(event: string | MjolnirEventHandlers, handler?: MjolnirEventHandler) { if (typeof event !== 'string') { // If `event` is a map, call `off()` for each entry. - for (const eventName in event) { - this._removeEventHandler(eventName, event[eventName]); + for (const [eventName, handler] of Object.entries(event)) { + this._removeEventHandler(eventName, handler); } return; } const {events} = this; - // Alias to a recognized gesture as necessary. - const eventAlias = GESTURE_EVENT_ALIASES[event] || event; - const eventRegistrar = events.get(eventAlias); + const eventRegistrar = events.get(event); if (!eventRegistrar) { return; } - eventRegistrar.remove(event, handler); + eventRegistrar.remove(event, handler!); if (eventRegistrar.isEmpty()) { const {recognizerName} = eventRegistrar; @@ -347,6 +251,12 @@ export default class EventManager { } } + private _getRecognizerName(event: string): string | undefined { + return this.manager.recognizers.find(recognizer => { + return recognizer.getEventNames().includes(event); + })?.options.event; + } + /** * Handle basic events using the 'hammer.input' Hammer.js API: * Before running Recognizers, Hammer emits a 'hammer.input' event @@ -355,12 +265,7 @@ export default class EventManager { * See constants.BASIC_EVENT_CLASSES basic event class definitions. */ private _onBasicInput = (event: MjolnirEventRaw) => { - const {srcEvent} = event; - const alias = BASIC_EVENT_ALIASES[srcEvent.type]; - if (alias) { - // fire all events aliased to srcEvent.type - this.manager.emit(alias, event); - } + this.manager.emit(event.srcEvent.type, event as any); }; /** @@ -369,6 +274,6 @@ export default class EventManager { */ private _onOtherEvent = (event: MjolnirEventRaw) => { // console.log('onotherevent', event.type, event) - this.manager.emit(event.type, event); + this.manager.emit(event.type, event as any); }; } diff --git a/src/hammerjs/README.md b/src/hammerjs/README.md new file mode 100644 index 0000000..ad34a42 --- /dev/null +++ b/src/hammerjs/README.md @@ -0,0 +1,11 @@ +Code in this directory is adapted from [hammer.js](https://github.com/hammerjs/hammer.js) + +The MIT License (MIT) + +Copyright (C) 2011-2017 by Jorik Tangelder (Eight Media) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/hammerjs/index.ts b/src/hammerjs/index.ts new file mode 100644 index 0000000..5e4741a --- /dev/null +++ b/src/hammerjs/index.ts @@ -0,0 +1,25 @@ +export {InputEvent} from './input/input-consts'; +export {RecognizerState} from './recognizer/recognizer-state'; +export {InputDirection} from './input/input-consts'; + +export {Manager} from './manager'; +export {Input} from './input/input'; +export {TouchAction} from './touchaction/touchaction'; +export {PointerEventInput} from './inputs/pointerevent'; + +export {Recognizer} from './recognizer/recognizer'; +export {AttrRecognizer} from './recognizers/attribute'; +export {TapRecognizer as Tap} from './recognizers/tap'; +export {PanRecognizer as Pan} from './recognizers/pan'; +export {SwipeRecognizer as Swipe} from './recognizers/swipe'; +export {PinchRecognizer as Pinch} from './recognizers/pinch'; +export {RotateRecognizer as Rotate} from './recognizers/rotate'; +export {PressRecognizer as Press} from './recognizers/press'; + +export type {HammerEvent, RecognizerTuple, ManagerOptions} from './manager'; +export type {TapRecognizerOptions} from './recognizers/tap'; +export type {PanRecognizerOptions} from './recognizers/pan'; +export type {SwipeRecognizerOptions} from './recognizers/swipe'; +export type {PinchRecognizerOptions} from './recognizers/pinch'; +export type {RotateRecognizerOptions} from './recognizers/rotate'; +export type {PressRecognizerOptions} from './recognizers/press'; diff --git a/src/hammerjs/input/compute-input-data.ts b/src/hammerjs/input/compute-input-data.ts new file mode 100644 index 0000000..0800eab --- /dev/null +++ b/src/hammerjs/input/compute-input-data.ts @@ -0,0 +1,79 @@ +import hasParent from '../utils/has-parent'; +import {simpleCloneInputData} from './simple-clone-input-data'; +import {getCenter} from './get-center'; +import {getPointDistance} from './get-distance'; +import {getPointAngle} from './get-angle'; +import {getDirection} from './get-direction'; +import {computeDeltaXY} from './get-delta-xy'; +import {getVelocity} from './get-velocity'; +import {getScale} from './get-scale'; +import {getRotation} from './get-rotation'; +import {computeIntervalInputData} from './compute-interval-input-data'; + +import type {Manager} from '../manager'; +import type {RawInput, HammerInput} from './types'; + +/** + * extend the data with some usable properties like scale, rotate, velocity etc + */ +export function computeInputData(manager: Manager, input: RawInput): HammerInput { + const {session} = manager; + const {pointers} = input; + const {length: pointersLength} = pointers; + + // store the first input to calculate the distance and direction + if (!session.firstInput) { + session.firstInput = simpleCloneInputData(input); + } + + // to compute scale and rotation we need to store the multiple touches + if (pointersLength > 1 && !session.firstMultiple) { + session.firstMultiple = simpleCloneInputData(input); + } else if (pointersLength === 1) { + session.firstMultiple = false; + } + + const {firstInput, firstMultiple} = session; + const offsetCenter = firstMultiple ? firstMultiple.center : firstInput.center; + + const center = (input.center = getCenter(pointers)); + input.timeStamp = Date.now(); + input.deltaTime = input.timeStamp - firstInput.timeStamp; + + input.angle = getPointAngle(offsetCenter, center); + input.distance = getPointDistance(offsetCenter, center); + + const {deltaX, deltaY} = computeDeltaXY(session, input); + input.deltaX = deltaX; + input.deltaY = deltaY; + input.offsetDirection = getDirection(input.deltaX, input.deltaY); + + const overallVelocity = getVelocity(input.deltaTime, input.deltaX, input.deltaY); + input.overallVelocityX = overallVelocity.x; + input.overallVelocityY = overallVelocity.y; + input.overallVelocity = + Math.abs(overallVelocity.x) > Math.abs(overallVelocity.y) + ? overallVelocity.x + : overallVelocity.y; + + input.scale = firstMultiple ? getScale(firstMultiple.pointers, pointers) : 1; + input.rotation = firstMultiple ? getRotation(firstMultiple.pointers, pointers) : 0; + + input.maxPointers = !session.prevInput + ? input.pointers.length + : input.pointers.length > session.prevInput.maxPointers + ? input.pointers.length + : session.prevInput.maxPointers; + + // find the correct target + let target = manager.element!; + if (hasParent(input.srcEvent.target as HTMLElement, target)) { + target = input.srcEvent.target as HTMLElement; + } + input.target = target; + + computeIntervalInputData(session, input as HammerInput); + + // All the optional fields have been populated + return input as HammerInput; +} diff --git a/src/hammerjs/input/compute-interval-input-data.ts b/src/hammerjs/input/compute-interval-input-data.ts new file mode 100644 index 0000000..ba57ab3 --- /dev/null +++ b/src/hammerjs/input/compute-interval-input-data.ts @@ -0,0 +1,44 @@ +import {InputEvent, COMPUTE_INTERVAL} from './input-consts'; +import {getVelocity} from './get-velocity'; +import {getDirection} from './get-direction'; + +import type {Session, HammerInput} from './types'; + +/** + * velocity is calculated every x ms + */ +export function computeIntervalInputData(session: Session, input: HammerInput) { + const last = session.lastInterval || input; + const deltaTime = input.timeStamp - last.timeStamp; + let velocity; + let velocityX; + let velocityY; + let direction; + + if ( + input.eventType !== InputEvent.Cancel && + (deltaTime > COMPUTE_INTERVAL || last.velocity === undefined) + ) { + const deltaX = input.deltaX - last.deltaX; + const deltaY = input.deltaY - last.deltaY; + + const v = getVelocity(deltaTime, deltaX, deltaY); + velocityX = v.x; + velocityY = v.y; + velocity = Math.abs(v.x) > Math.abs(v.y) ? v.x : v.y; + direction = getDirection(deltaX, deltaY); + + session.lastInterval = input; + } else { + // use latest velocity info if it doesn't overtake a minimum period + velocity = last.velocity; + velocityX = last.velocityX; + velocityY = last.velocityY; + direction = last.direction; + } + + input.velocity = velocity; + input.velocityX = velocityX; + input.velocityY = velocityY; + input.direction = direction; +} diff --git a/src/hammerjs/input/get-angle.ts b/src/hammerjs/input/get-angle.ts new file mode 100644 index 0000000..8ca44e6 --- /dev/null +++ b/src/hammerjs/input/get-angle.ts @@ -0,0 +1,21 @@ +import {Point, PointerEventLike} from './types'; + +/** + * calculate the angle between two coordinates + * @returns angle in degrees + */ +export function getPointAngle(p1: Point, p2: Point) { + const x: number = p2.x - p1.x; + const y: number = p2.y - p1.y; + return (Math.atan2(y, x) * 180) / Math.PI; +} + +/** + * calculate the angle between two pointer events + * @returns angle in degrees + */ +export function getEventAngle(p1: PointerEventLike, p2: PointerEventLike) { + const x: number = p2.clientX - p1.clientX; + const y: number = p2.clientY - p1.clientY; + return (Math.atan2(y, x) * 180) / Math.PI; +} diff --git a/src/hammerjs/input/get-center.ts b/src/hammerjs/input/get-center.ts new file mode 100644 index 0000000..da1f3ae --- /dev/null +++ b/src/hammerjs/input/get-center.ts @@ -0,0 +1,30 @@ +import type {Point, PointerEventLike} from './types'; + +/** + * get the center of all the pointers + */ +export function getCenter(pointers: PointerEventLike[]): Point { + const pointersLength = pointers.length; + + // no need to loop when only one touch + if (pointersLength === 1) { + return { + x: Math.round(pointers[0].clientX), + y: Math.round(pointers[0].clientY) + }; + } + + let x = 0; + let y = 0; + let i = 0; + while (i < pointersLength) { + x += pointers[i].clientX; + y += pointers[i].clientY; + i++; + } + + return { + x: Math.round(x / pointersLength), + y: Math.round(y / pointersLength) + }; +} diff --git a/src/hammerjs/input/get-delta-xy.ts b/src/hammerjs/input/get-delta-xy.ts new file mode 100644 index 0000000..b5017f7 --- /dev/null +++ b/src/hammerjs/input/get-delta-xy.ts @@ -0,0 +1,34 @@ +import {InputEvent} from './input-consts'; +import type {RawInput, Session} from './types'; + +/** Populates input.deltaX, input.deltaY */ +export function computeDeltaXY( + session: Session, + input: RawInput +): { + deltaX: number; + deltaY: number; +} { + // getCenter is called before computeDeltaXY + const center = input.center!; + let offset = session.offsetDelta; + let prevDelta = session.prevDelta; + const prevInput = session.prevInput; + + if (input.eventType === InputEvent.Start || prevInput?.eventType === InputEvent.End) { + prevDelta = session.prevDelta = { + x: prevInput?.deltaX || 0, + y: prevInput?.deltaY || 0 + }; + + offset = session.offsetDelta = { + x: center.x, + y: center.y + }; + } + + return { + deltaX: prevDelta!.x + (center.x - offset!.x), + deltaY: prevDelta!.y + (center.y - offset!.y) + }; +} diff --git a/src/hammerjs/input/get-direction.ts b/src/hammerjs/input/get-direction.ts new file mode 100644 index 0000000..55a016d --- /dev/null +++ b/src/hammerjs/input/get-direction.ts @@ -0,0 +1,16 @@ +import {InputDirection} from './input-consts'; + +/** + * get the direction between two points + * @returns direction + */ +export function getDirection(dx: number, dy: number): InputDirection { + if (dx === dy) { + return InputDirection.None; + } + + if (Math.abs(dx) >= Math.abs(dy)) { + return dx < 0 ? InputDirection.Left : InputDirection.Right; + } + return dy < 0 ? InputDirection.Up : InputDirection.Down; +} diff --git a/src/hammerjs/input/get-distance.ts b/src/hammerjs/input/get-distance.ts new file mode 100644 index 0000000..59d7b28 --- /dev/null +++ b/src/hammerjs/input/get-distance.ts @@ -0,0 +1,21 @@ +import type {Point, PointerEventLike} from './types'; + +/** + * calculate the absolute distance between two points + * @returns distance + */ +export function getPointDistance(p1: Point, p2: Point): number { + const x = p2.x - p1.x; + const y = p2.y - p1.y; + return Math.sqrt(x * x + y * y); +} + +/** + * calculate the absolute distance between two pointer events + * @returns distance + */ +export function getEventDistance(p1: PointerEventLike, p2: PointerEventLike): number { + const x = p2.clientX - p1.clientX; + const y = p2.clientY - p1.clientY; + return Math.sqrt(x * x + y * y); +} diff --git a/src/hammerjs/input/get-rotation.ts b/src/hammerjs/input/get-rotation.ts new file mode 100644 index 0000000..3b8ac19 --- /dev/null +++ b/src/hammerjs/input/get-rotation.ts @@ -0,0 +1,10 @@ +import {getEventAngle} from './get-angle'; +import {PointerEventLike} from './types'; + +/** + * calculate the rotation degrees between two pointer sets + * @returns rotation in degrees + */ +export function getRotation(start: PointerEventLike[], end: PointerEventLike[]): number { + return getEventAngle(end[1], end[0]) - getEventAngle(start[1], start[0]); +} diff --git a/src/hammerjs/input/get-scale.ts b/src/hammerjs/input/get-scale.ts new file mode 100644 index 0000000..ed82671 --- /dev/null +++ b/src/hammerjs/input/get-scale.ts @@ -0,0 +1,10 @@ +import {getEventDistance} from './get-distance'; +import type {PointerEventLike} from './types'; + +/** + * calculate the scale factor between two pointersets + * no scale is 1, and goes down to 0 when pinched together, and bigger when pinched out + */ +export function getScale(start: PointerEventLike[], end: PointerEventLike[]): number { + return getEventDistance(end[0], end[1]) / getEventDistance(start[0], start[1]); +} diff --git a/src/hammerjs/input/get-velocity.ts b/src/hammerjs/input/get-velocity.ts new file mode 100644 index 0000000..25ce938 --- /dev/null +++ b/src/hammerjs/input/get-velocity.ts @@ -0,0 +1,11 @@ +import type {Vector} from './types'; + +/** + * calculate the velocity between two points. unit is in px per ms. + */ +export function getVelocity(deltaTime: number, x: number, y: number): Vector { + return { + x: x / deltaTime || 0, + y: y / deltaTime || 0 + }; +} diff --git a/src/hammerjs/input/input-consts.ts b/src/hammerjs/input/input-consts.ts new file mode 100644 index 0000000..4256de7 --- /dev/null +++ b/src/hammerjs/input/input-consts.ts @@ -0,0 +1,21 @@ +export const MOBILE_REGEX = /mobile|tablet|ip(ad|hone|od)|android/i; + +export const COMPUTE_INTERVAL = 25; + +export enum InputEvent { + Start = 1, + Move = 2, + End = 4, + Cancel = 8 +} + +export enum InputDirection { + None = 0, + Left = 1, + Right = 2, + Up = 4, + Down = 8, + Horizontal = 3, + Vertical = 12, + All = 15 +} diff --git a/src/hammerjs/input/input-handler.ts b/src/hammerjs/input/input-handler.ts new file mode 100644 index 0000000..443535b --- /dev/null +++ b/src/hammerjs/input/input-handler.ts @@ -0,0 +1,36 @@ +import {InputEvent} from './input-consts'; +import {computeInputData} from './compute-input-data'; + +import type {Manager} from '../manager'; +import type {RawInput} from './types'; + +/** + * handle input events + */ +export function inputHandler(manager: Manager, eventType: InputEvent, input: RawInput) { + const pointersLen = input.pointers.length; + const changedPointersLen = input.changedPointers.length; + const isFirst = eventType & InputEvent.Start && pointersLen - changedPointersLen === 0; + const isFinal = + eventType & (InputEvent.End | InputEvent.Cancel) && pointersLen - changedPointersLen === 0; + + input.isFirst = Boolean(isFirst); + input.isFinal = Boolean(isFinal); + + if (isFirst) { + manager.session = {}; + } + + // source event is the normalized value of the domEvents + // like 'touchstart, mouseup, pointerdown' + input.eventType = eventType; + + // compute scale, rotation etc + const processedInput = computeInputData(manager, input); + + // emit secret event + manager.emit('hammer.input', processedInput); + + manager.recognize(processedInput); + manager.session.prevInput = processedInput; +} diff --git a/src/hammerjs/input/input.ts b/src/hammerjs/input/input.ts new file mode 100644 index 0000000..a722448 --- /dev/null +++ b/src/hammerjs/input/input.ts @@ -0,0 +1,64 @@ +import {addEventListeners, removeEventListeners} from '../utils/event-listeners'; +import {getWindowForElement} from '../utils/get-window-for-element'; +import {inputHandler} from './input-handler'; + +import {InputEvent} from './input-consts'; +import type {RawInput} from './types'; +import type {Manager} from '../manager'; + +/** + * create new input type manager + */ +export abstract class Input { + manager: Manager; + element: HTMLElement; + target: EventTarget; + + evEl: string = ''; + evWin: string = ''; + evTarget: string = ''; + + constructor(manager: Manager) { + this.manager = manager; + this.element = manager.element!; + this.target = manager.options.inputTarget || manager.element!; + } + + /** smaller wrapper around the handler, for the scope and the enabled state of the manager, + * so when disabled the input events are completely bypassed. + */ + protected domHandler = (ev: Event) => { + if (this.manager.options.enable) { + this.handler(ev); + } + }; + + protected callback(eventType: InputEvent, input: RawInput) { + inputHandler(this.manager, eventType, input); + } + + /** + * should handle the inputEvent data and trigger the callback + */ + abstract handler(ev: Event): void; + + // eslint-disable @typescript-eslint/unbound-method + /** + * bind the events + */ + init() { + addEventListeners(this.element, this.evEl, this.domHandler); + addEventListeners(this.target, this.evTarget, this.domHandler); + addEventListeners(getWindowForElement(this.element), this.evWin, this.domHandler); + } + + /** + * unbind the events + */ + destroy() { + removeEventListeners(this.element, this.evEl, this.domHandler); + removeEventListeners(this.target, this.evTarget, this.domHandler); + removeEventListeners(getWindowForElement(this.element), this.evWin, this.domHandler); + } + // eslint-enable @typescript-eslint/unbound-method +} diff --git a/src/hammerjs/input/simple-clone-input-data.ts b/src/hammerjs/input/simple-clone-input-data.ts new file mode 100644 index 0000000..102afa1 --- /dev/null +++ b/src/hammerjs/input/simple-clone-input-data.ts @@ -0,0 +1,26 @@ +import {getCenter} from './get-center'; +import type {RawInput, PointerEventLike, SimpleInput} from './types'; + +/** + * create a simple clone from the input used for storage of firstInput and firstMultiple + */ +export function simpleCloneInputData(input: RawInput): SimpleInput { + // make a simple copy of the pointers because we will get a reference if we don't + const pointers: PointerEventLike[] = []; + let i = 0; + while (i < input.pointers.length) { + pointers[i] = { + clientX: Math.round(input.pointers[i].clientX), + clientY: Math.round(input.pointers[i].clientY) + }; + i++; + } + + return { + timeStamp: Date.now(), + pointers, + center: getCenter(pointers), + deltaX: input.deltaX, + deltaY: input.deltaY + }; +} diff --git a/src/hammerjs/input/types.ts b/src/hammerjs/input/types.ts new file mode 100644 index 0000000..acbd3e2 --- /dev/null +++ b/src/hammerjs/input/types.ts @@ -0,0 +1,101 @@ +import {InputEvent, InputDirection} from './input-consts'; +import type {Recognizer} from '../recognizer/recognizer'; + +export type Point = {x: number; y: number}; +export type Vector = {x: number; y: number}; +export type PointerEventLike = {clientX: number; clientY: number}; + +export type DeviceInputEvent = MouseEvent | PointerEvent; + +/** + * Transitional type generated by Input classes for processing */ +export type RawInput = { + pointers: DeviceInputEvent[]; + changedPointers: DeviceInputEvent[]; + pointerType: string; + srcEvent: DeviceInputEvent; + eventType: InputEvent; + + // Populated during processing + /** Timestamp of the event. */ + timeStamp?: number; + /** Total time since the first input. */ + deltaTime?: number; + + /** Center position for multi-touch, or the position of the single pointer. */ + center?: Point; + /** Movement along the X axis. */ + deltaX?: number; + /** Movement along the Y axis. */ + deltaY?: number; + /** Angle moved, in degrees */ + angle?: number; + /** Distance moved */ + distance?: number; + /** Scaling that has been done with multi-touch. 1 on a single touch. */ + scale?: number; + /** Rotation (in degrees) that has been done with multi-touch. 0 on a single touch. */ + rotation?: number; + + /** Direction moved. */ + direction?: InputDirection; + /** Direction moved from its starting point. */ + offsetDirection?: InputDirection; + + /** Highest velocityX/Y value. */ + velocity?: number; + /** Velocity along the X axis, in px/ms */ + velocityX?: number; + /** Velocity along the Y axis, in px/ms */ + velocityY?: number; + + overallVelocity?: number; + overallVelocityX?: number; + overallVelocityY?: number; + + maxPointers?: number; + target?: HTMLElement; + + /** Internal flag */ + additionalEvent?: string; + /** Internal flag */ + isFirst?: boolean; + /** Internal flag */ + isFinal?: boolean; +}; + +/** + * Simplified HammerInput object retained in memory to help event processing */ +export type SimpleInput = { + pointers: PointerEventLike[]; + timeStamp: number; + center: Point; + deltaX?: number; + deltaY?: number; +}; + +/** + * Information about an input session (pointers down-move-up) + */ +export type Session = { + pointerEvents?: PointerEvent[]; + stopped?: number; + curRecognizer?: Recognizer | null; + offsetDelta?: Vector; + prevDelta?: Vector; + + firstInput?: SimpleInput; + firstMultiple?: SimpleInput | false; + + prevInput?: HammerInput; + lastInterval?: HammerInput; + + prevented?: boolean; +}; + +/** + * Emitted input event */ +export type HammerInput = Omit, 'isFirst' | 'isFinal'> & { + /** Number of consecutive taps recognized. Populated if emitted by TapRecognizer */ + tapCount?: number; +}; diff --git a/src/hammerjs/inputs/pointerevent.ts b/src/hammerjs/inputs/pointerevent.ts new file mode 100644 index 0000000..c0e0a33 --- /dev/null +++ b/src/hammerjs/inputs/pointerevent.ts @@ -0,0 +1,78 @@ +import {InputEvent} from '../input/input-consts'; +import {Input} from '../input/input'; +import type {Manager} from '../manager'; + +const POINTER_INPUT_MAP = { + pointerdown: InputEvent.Start, + pointermove: InputEvent.Move, + pointerup: InputEvent.End, + pointercancel: InputEvent.Cancel, + pointerout: InputEvent.Cancel +} as const; + +const POINTER_ELEMENT_EVENTS = 'pointerdown'; +const POINTER_WINDOW_EVENTS = 'pointermove pointerup pointercancel'; + +/** + * Pointer events input + */ +export class PointerEventInput extends Input { + store: PointerEvent[]; + + constructor(manager: Manager) { + super(manager); + this.evEl = POINTER_ELEMENT_EVENTS; + this.evWin = POINTER_WINDOW_EVENTS; + + this.store = this.manager.session.pointerEvents = []; + this.init(); + } + + /** + * handle mouse events + */ + handler(ev: PointerEvent) { + const {store} = this; + let removePointer = false; + + // @ts-ignore + const eventType = POINTER_INPUT_MAP[ev.type]; + const pointerType = ev.pointerType; + + const isTouch = pointerType === 'touch'; + + // get index of the event in the store + let storeIndex = store.findIndex(e => e.pointerId === ev.pointerId); + + // start and mouse must be down + if (eventType & InputEvent.Start && (ev.buttons || isTouch)) { + if (storeIndex < 0) { + store.push(ev); + storeIndex = store.length - 1; + } + } else if (eventType & (InputEvent.End | InputEvent.Cancel)) { + removePointer = true; + } + + // it not found, so the pointer hasn't been down (so it's probably a hover) + if (storeIndex < 0) { + return; + } + + // update the event in the store + store[storeIndex] = ev; + + this.callback(eventType, { + pointers: store, + changedPointers: [ev], + eventType, + pointerType, + srcEvent: ev + }); + + if (removePointer) { + // remove from the store + store.splice(storeIndex, 1); + } + } +} diff --git a/src/hammerjs/manager.ts b/src/hammerjs/manager.ts new file mode 100644 index 0000000..2e5767a --- /dev/null +++ b/src/hammerjs/manager.ts @@ -0,0 +1,397 @@ +import {TouchAction} from './touchaction/touchaction'; +import {PointerEventInput} from './inputs/pointerevent'; +import {splitStr} from './utils/split-str'; +import {prefixed} from './utils/prefixed'; +import {RecognizerState} from './recognizer/recognizer-state'; + +import type {Input} from './input/input'; +import type {Recognizer} from './recognizer/recognizer'; +import type {Session, HammerInput} from './input/types'; + +const STOP = 1; +const FORCED_STOP = 2; + +export type RecognizerTuple = + | Recognizer + | [ + recognizer: Recognizer, + /** Allow another gesture to be recognized simultaneously with this one. + * For example an interaction can trigger pinch and rotate at the same time. */ + recognizeWith?: string | string[], + /** Another recognizer is mutually exclusive with this one. + * For example an interaction could be singletap or doubletap; pan-horizontal or pan-vertical; but never both. */ + requireFailure?: string | string[] + ]; + +export type ManagerOptions = { + /** + * The recognizers that are being used. + */ + recognizers: RecognizerTuple[]; + + /** + * The value for the touchAction property/fallback. + * When set to `compute` it will magically set the correct value based on the added recognizers. + * @default compute + */ + touchAction?: string; + + /** + * @default true + */ + enable?: boolean; + + /** + * EXPERIMENTAL FEATURE -- can be removed/changed + * Change the parent input target element. + * If Null, then it is being set the to main element. + * @default null + */ + inputTarget?: null | EventTarget; + + /** + * force an input class + * @default null + */ + inputClass?: null | Function; + + /** + * Some CSS properties can be used to improve the working of Hammer. + * Add them to this method and they will be set when creating a new Manager. + */ + cssProps?: Partial; +}; + +export type HammerEvent = HammerInput & { + type: string; + preventDefault: () => void; +}; +export type EventHandler = (event: HammerEvent) => void; + +const defaultOptions: Required = { + recognizers: [], + touchAction: 'compute', + enable: true, + inputTarget: null, + inputClass: null, + cssProps: { + /** + * Disables text selection to improve the dragging gesture. Mainly for desktop browsers. + */ + userSelect: 'none', + /** + * (Webkit) Disable default dragging behavior + */ + // @ts-ignore + userDrag: 'none', + /** + * (iOS only) Disables the default callout shown when you touch and hold a touch target. + * When you touch and hold a touch target such as a link, Safari displays + * a callout containing information about the link. This property allows you to disable that callout. + */ + // @ts-ignore + touchCallout: 'none', + /** + * (iOS only) Sets the color of the highlight that appears over a link while it's being tapped. + */ + // @ts-ignore + tapHighlightColor: 'rgba(0,0,0,0)' + } +}; + +/** + * Manager + */ +export class Manager { + options: Required; + + element: HTMLElement | null; + touchAction: TouchAction; + oldCssProps: {[prop: string]: any}; + session: Session; + recognizers: Recognizer[]; + input: Input; + handlers: {[event: string]: EventHandler[]}; + + constructor(element: HTMLElement, options: ManagerOptions) { + this.options = { + ...defaultOptions, + ...options, + cssProps: {...defaultOptions.cssProps, ...options.cssProps}, + inputTarget: options.inputTarget || element + }; + + this.handlers = {}; + this.session = {}; + this.recognizers = []; + this.oldCssProps = {}; + + this.element = element; + this.input = new PointerEventInput(this); + this.touchAction = new TouchAction(this, this.options.touchAction); + + this.toggleCssProps(true); + + for (const item of this.options.recognizers) { + const itemArray = Array.isArray(item) ? item : [item]; + const recognizer = itemArray[0]; + this.add(recognizer); + if (itemArray[2]) { + recognizer.recognizeWith(itemArray[2]); + } + if (itemArray[3]) { + recognizer.requireFailure(itemArray[3]); + } + } + } + + /** + * set options + */ + set(options: Partial) { + Object.assign(this.options, options); + + // Options that need a little more setup + if (options.touchAction) { + this.touchAction.update(); + } + if (options.inputTarget) { + // Clean up existing event listeners and reinitialize + this.input.destroy(); + this.input.target = options.inputTarget; + this.input.init(); + } + return this; + } + + /** + * stop recognizing for this session. + * This session will be discarded, when a new [input]start event is fired. + * When forced, the recognizer cycle is stopped immediately. + */ + stop(force?: boolean) { + this.session.stopped = force ? FORCED_STOP : STOP; + } + + /** + * run the recognizers! + * called by the inputHandler function on every movement of the pointers (touches) + * it walks through all the recognizers and tries to detect the gesture that is being made + */ + recognize(inputData: HammerInput) { + const {session} = this; + if (session.stopped) { + return; + } + + // run the touch-action polyfill + if (this.session.prevented) { + inputData.srcEvent.preventDefault(); + } + + let recognizer; + const {recognizers} = this; + + // this holds the recognizer that is being recognized. + // so the recognizer's state needs to be BEGAN, CHANGED, ENDED or RECOGNIZED + // if no recognizer is detecting a thing, it is set to `null` + let {curRecognizer} = session; + + // reset when the last recognizer is recognized + // or when we're in a new session + if (!curRecognizer || (curRecognizer && curRecognizer.state & RecognizerState.Recognized)) { + curRecognizer = session.curRecognizer = null; + } + + let i = 0; + while (i < recognizers.length) { + recognizer = recognizers[i]; + + // find out if we are allowed try to recognize the input for this one. + // 1. allow if the session is NOT forced stopped (see the .stop() method) + // 2. allow if we still haven't recognized a gesture in this session, or the this recognizer is the one + // that is being recognized. + // 3. allow if the recognizer is allowed to run simultaneous with the current recognized recognizer. + // this can be setup with the `recognizeWith()` method on the recognizer. + if ( + session.stopped !== FORCED_STOP && // 1 + (!curRecognizer || + recognizer === curRecognizer || // 2 + recognizer.canRecognizeWith(curRecognizer)) + ) { + // 3 + recognizer.recognize(inputData); + } else { + recognizer.reset(); + } + + // if the recognizer has been recognizing the input as a valid gesture, we want to store this one as the + // current active recognizer. but only if we don't already have an active recognizer + if ( + !curRecognizer && + recognizer.state & (RecognizerState.Began | RecognizerState.Changed | RecognizerState.Ended) + ) { + curRecognizer = session.curRecognizer = recognizer; + } + i++; + } + } + + /** + * get a recognizer by its event name. + */ + get(recognizer: Recognizer | string): Recognizer | null { + if (typeof recognizer === 'string') { + const {recognizers} = this; + for (let i = 0; i < recognizers.length; i++) { + if (recognizers[i].options.event === recognizer) { + return recognizers[i]; + } + } + return null; + } + return recognizer; + } + + /** + * add a recognizer to the manager + * existing recognizers with the same event name will be removed + */ + add(recognizer: Recognizer | Recognizer[]) { + if (Array.isArray(recognizer)) { + for (const item of recognizer) { + this.add(item); + } + return this; + } + + // remove existing + const existing = this.get(recognizer.options.event); + if (existing) { + this.remove(existing); + } + + this.recognizers.push(recognizer); + recognizer.manager = this; + + this.touchAction.update(); + return recognizer; + } + + /** + * remove a recognizer by name or instance + */ + remove(recognizerOrName: Recognizer | string | (Recognizer | string)[]) { + if (Array.isArray(recognizerOrName)) { + for (const item of recognizerOrName) { + this.remove(item); + } + return this; + } + + const recognizer = this.get(recognizerOrName); + + // let's make sure this recognizer exists + if (recognizer) { + const {recognizers} = this; + const index = recognizers.indexOf(recognizer); + + if (index !== -1) { + recognizers.splice(index, 1); + this.touchAction.update(); + } + } + + return this; + } + + /** + * bind event + */ + on(events: string, handler: EventHandler) { + if (!events || !handler) { + return; + } + const {handlers} = this; + for (const event of splitStr(events)) { + handlers[event] = handlers[event] || []; + handlers[event].push(handler); + } + } + + /** + * unbind event, leave hander blank to remove all handlers + */ + off(events: string, handler?: EventHandler) { + if (!events) { + return; + } + + const {handlers} = this; + for (const event of splitStr(events)) { + if (!handler) { + delete handlers[event]; + } else if (handlers[event]) { + handlers[event].splice(handlers[event].indexOf(handler), 1); + } + } + } + + /** + * emit event to the listeners + */ + emit(event: string, data: HammerInput) { + // no handlers, so skip it all + const handlers = this.handlers[event] && this.handlers[event].slice(); + if (!handlers || !handlers.length) { + return; + } + + const evt = data as HammerEvent; + evt.type = event; + evt.preventDefault = function () { + data.srcEvent.preventDefault(); + }; + + let i = 0; + while (i < handlers.length) { + handlers[i](evt); + i++; + } + } + + /** + * destroy the manager and unbinds all events + * it doesn't unbind dom events, that is the user own responsibility + */ + destroy() { + this.toggleCssProps(false); + + this.handlers = {}; + this.session = {}; + this.input.destroy(); + this.element = null; + } + + /** + * add/remove the css properties as defined in manager.options.cssProps + */ + private toggleCssProps(add: boolean) { + const {element} = this; + if (!element) { + return; + } + for (const [name, value] of Object.entries(this.options.cssProps)) { + const prop = prefixed(element.style, name) as any; + if (add) { + this.oldCssProps[prop] = element.style[prop]; + element.style[prop] = value as any; + } else { + element.style[prop] = this.oldCssProps[prop] || ''; + } + } + if (!add) { + this.oldCssProps = {}; + } + } +} diff --git a/src/hammerjs/recognizer/recognizer-state.ts b/src/hammerjs/recognizer/recognizer-state.ts new file mode 100644 index 0000000..a2f2502 --- /dev/null +++ b/src/hammerjs/recognizer/recognizer-state.ts @@ -0,0 +1,9 @@ +export enum RecognizerState { + Possible = 1, + Began = 2, + Changed = 4, + Ended = 8, + Recognized = 8, // eslint-disable-line + Cancelled = 16, + Failed = 32 +} diff --git a/src/hammerjs/recognizer/recognizer.ts b/src/hammerjs/recognizer/recognizer.ts new file mode 100644 index 0000000..f348d09 --- /dev/null +++ b/src/hammerjs/recognizer/recognizer.ts @@ -0,0 +1,286 @@ +import {RecognizerState} from './recognizer-state'; +import {uniqueId} from '../utils/unique-id'; +import {stateStr} from './state-str'; + +import type {Manager} from '../manager'; +import type {HammerInput} from '../input/types'; + +export type RecognizerOptions = { + /** Name of the event */ + event: string; + /** Enable this recognizer */ + enable: boolean; +}; + +/** + * Recognizer flow explained; * + * All recognizers have the initial state of POSSIBLE when a input session starts. + * The definition of a input session is from the first input until the last input, with all it's movement in it. * + * Example session for mouse-input: mousedown -> mousemove -> mouseup + * + * On each recognizing cycle (see Manager.recognize) the .recognize() method is executed + * which determines with state it should be. + * + * If the recognizer has the state FAILED, CANCELLED or RECOGNIZED (equals ENDED), it is reset to + * POSSIBLE to give it another change on the next cycle. + * + * Possible + * | + * +-----+---------------+ + * | | + * +-----+-----+ | + * | | | + * Failed Cancelled | + * +-------+------+ + * | | + * Recognized Began + * | + * Changed + * | + * Ended/Recognized + */ + +/** + * Recognizer + * Every recognizer needs to extend from this class. + */ +export abstract class Recognizer { + id: number; + state: RecognizerState; + manager!: Manager; + + readonly options: OptionsT; + + protected simultaneous: {[id: string]: Recognizer}; + protected requireFail: Recognizer[]; + + constructor(options: OptionsT) { + this.options = options; + + this.id = uniqueId(); + + this.state = RecognizerState.Possible; + this.simultaneous = {}; + this.requireFail = []; + } + + /** + * set options + */ + set(options: Partial) { + Object.assign(this.options, options); + + // also update the touchAction, in case something changed about the directions/enabled state + this.manager.touchAction.update(); + return this; + } + + /** + * recognize simultaneous with an other recognizer. + */ + recognizeWith(recognizerOrName: Recognizer | string | (Recognizer | string)[]) { + if (Array.isArray(recognizerOrName)) { + for (const item of recognizerOrName) { + this.recognizeWith(item); + } + return this; + } + + const {simultaneous} = this; + const otherRecognizer = this.manager.get(recognizerOrName) ?? (recognizerOrName as Recognizer); + if (!simultaneous[otherRecognizer.id]) { + simultaneous[otherRecognizer.id] = otherRecognizer; + otherRecognizer.recognizeWith(this); + } + return this; + } + + /** + * drop the simultaneous link. it doesnt remove the link on the other recognizer. + */ + dropRecognizeWith(recognizerOrName: Recognizer | string | (Recognizer | string)[]) { + if (Array.isArray(recognizerOrName)) { + for (const item of recognizerOrName) { + this.dropRecognizeWith(item); + } + return this; + } + + const otherRecognizer = this.manager.get(recognizerOrName) ?? (recognizerOrName as Recognizer); + delete this.simultaneous[otherRecognizer.id]; + return this; + } + + /** + * recognizer can only run when an other is failing + */ + requireFailure(recognizerOrName: Recognizer | string | (Recognizer | string)[]) { + if (Array.isArray(recognizerOrName)) { + for (const item of recognizerOrName) { + this.requireFailure(item); + } + return this; + } + + const {requireFail} = this; + const otherRecognizer = this.manager.get(recognizerOrName) ?? (recognizerOrName as Recognizer); + if (requireFail.indexOf(otherRecognizer) === -1) { + requireFail.push(otherRecognizer); + otherRecognizer.requireFailure(this); + } + return this; + } + + /** + * drop the requireFailure link. it does not remove the link on the other recognizer. + */ + dropRequireFailure(recognizerOrName: Recognizer | string | (Recognizer | string)[]) { + if (Array.isArray(recognizerOrName)) { + for (const item of recognizerOrName) { + this.dropRequireFailure(item); + } + return this; + } + + const otherRecognizer = this.manager.get(recognizerOrName) ?? (recognizerOrName as Recognizer); + const index = this.requireFail.indexOf(otherRecognizer); + if (index > -1) { + this.requireFail.splice(index, 1); + } + return this; + } + + /** + * has require failures boolean + */ + hasRequireFailures(): boolean { + return Boolean(this.requireFail.find(recognier => recognier.options.enable)); + } + + /** + * if the recognizer can recognize simultaneous with an other recognizer + */ + canRecognizeWith(otherRecognizer: Recognizer): boolean { + return Boolean(this.simultaneous[otherRecognizer.id]); + } + + /** + * You should use `tryEmit` instead of `emit` directly to check + * that all the needed recognizers has failed before emitting. + */ + protected emit(input?: HammerInput) { + // Some recognizers override emit() with their own logic + if (!input) return; + + const {state} = this; + + // 'panstart' and 'panmove' + if (state < RecognizerState.Ended) { + this.manager.emit(this.options.event + stateStr(state), input); + } + + // simple 'eventName' events + this.manager.emit(this.options.event, input); + + // additional event(panleft, panright, pinchin, pinchout...) + if (input.additionalEvent) { + this.manager.emit(input.additionalEvent, input); + } + + // panend and pancancel + if (state >= RecognizerState.Ended) { + this.manager.emit(this.options.event + stateStr(state), input); + } + } + + /** + * Check that all the require failure recognizers has failed, + * if true, it emits a gesture event, + * otherwise, setup the state to FAILED. + */ + protected tryEmit(input?: HammerInput) { + if (this.canEmit()) { + this.emit(input); + } else { + // it's failing anyway + this.state = RecognizerState.Failed; + } + } + + /** + * can we emit? + */ + protected canEmit(): boolean { + let i = 0; + while (i < this.requireFail.length) { + if (!(this.requireFail[i].state & (RecognizerState.Failed | RecognizerState.Possible))) { + return false; + } + i++; + } + return true; + } + + /** + * update the recognizer + */ + recognize(inputData: HammerInput) { + // make a new copy of the inputData + // so we can change the inputData without messing up the other recognizers + const inputDataClone = {...inputData}; + + // is is enabled and allow recognizing? + if (!this.options.enable) { + this.reset(); + this.state = RecognizerState.Failed; + return; + } + + // reset when we've reached the end + if ( + this.state & + (RecognizerState.Recognized | RecognizerState.Cancelled | RecognizerState.Failed) + ) { + this.state = RecognizerState.Possible; + } + + this.state = this.process(inputDataClone); + + // the recognizer has recognized a gesture + // so trigger an event + if ( + this.state & + (RecognizerState.Began | + RecognizerState.Changed | + RecognizerState.Ended | + RecognizerState.Cancelled) + ) { + this.tryEmit(inputDataClone); + } + } + + /** + * return the state of the recognizer + * the actual recognizing happens in this method + */ + + abstract process(inputData: HammerInput): RecognizerState; + + /** + * return the preferred touch-action + */ + abstract getTouchAction(): string[]; + + /** + * return the event names that are emitted by this recognizer + */ + getEventNames(): string[] { + return [this.options.event]; + } + + /** + * called when the gesture isn't allowed to recognize + * like when another is being recognized or it is disabled + */ + reset(): void {} +} diff --git a/src/hammerjs/recognizer/state-str.ts b/src/hammerjs/recognizer/state-str.ts new file mode 100644 index 0000000..3358c85 --- /dev/null +++ b/src/hammerjs/recognizer/state-str.ts @@ -0,0 +1,17 @@ +import {RecognizerState} from './recognizer-state'; + +/** + * get a usable string, used as event postfix + */ +export function stateStr(state: RecognizerState) { + if (state & RecognizerState.Cancelled) { + return 'cancel'; + } else if (state & RecognizerState.Ended) { + return 'end'; + } else if (state & RecognizerState.Changed) { + return 'move'; + } else if (state & RecognizerState.Began) { + return 'start'; + } + return ''; +} diff --git a/src/hammerjs/recognizers/attribute.ts b/src/hammerjs/recognizers/attribute.ts new file mode 100644 index 0000000..09eba8f --- /dev/null +++ b/src/hammerjs/recognizers/attribute.ts @@ -0,0 +1,47 @@ +import {Recognizer, RecognizerOptions} from '../recognizer/recognizer'; +import {RecognizerState} from '../recognizer/recognizer-state'; +import {InputEvent} from '../input/input-consts'; +import type {HammerInput} from '../input/types'; + +type AttrRecognizerOptions = RecognizerOptions & { + pointers: number; +}; + +/** + * This recognizer is just used as a base for the simple attribute recognizers. + */ +export abstract class AttrRecognizer< + OptionsT extends AttrRecognizerOptions +> extends Recognizer { + /** + * Used to check if it the recognizer receives valid input, like input.distance > 10. + */ + attrTest(input: HammerInput): boolean { + const optionPointers = this.options.pointers; + return optionPointers === 0 || input.pointers.length === optionPointers; + } + + /** + * Process the input and return the state for the recognizer + */ + process(input: HammerInput) { + const {state} = this; + const {eventType} = input; + + const isRecognized = state & (RecognizerState.Began | RecognizerState.Changed); + const isValid = this.attrTest(input); + + // on cancel input and we've recognized before, return STATE_CANCELLED + if (isRecognized && (eventType & InputEvent.Cancel || !isValid)) { + return state | RecognizerState.Cancelled; + } else if (isRecognized || isValid) { + if (eventType & InputEvent.End) { + return state | RecognizerState.Ended; + } else if (!(state & RecognizerState.Began)) { + return RecognizerState.Began; + } + return state | RecognizerState.Changed; + } + return RecognizerState.Failed; + } +} diff --git a/src/hammerjs/recognizers/pan.ts b/src/hammerjs/recognizers/pan.ts new file mode 100644 index 0000000..417872b --- /dev/null +++ b/src/hammerjs/recognizers/pan.ts @@ -0,0 +1,115 @@ +import {AttrRecognizer} from './attribute'; +import {InputDirection} from '../input/input-consts'; +import {RecognizerState} from '../recognizer/recognizer-state'; +import {TOUCH_ACTION_PAN_X, TOUCH_ACTION_PAN_Y} from '../touchaction/touchaction-Consts'; +import type {HammerInput} from '../input/types'; + +export type PanRecognizerOptions = { + /** Name of the event. + * @default 'pan' + */ + event?: string; + /** Enable this event. + * @default true + */ + enable?: boolean; + /** Required number of pointers. 0 for all pointers. + * @default 1 + */ + pointers?: number; + /** Required direction of panning. + * @default InputDirection.All + */ + direction?: InputDirection; + /** Minimal pan distance required before recognizing. + * @default 10 + */ + threshold?: number; +}; + +const EVENT_NAMES = ['', 'start', 'move', 'end', 'cancel', 'up', 'down', 'left', 'right'] as const; + +/** + * Pan + * Recognized when the pointer is down and moved in the allowed direction. + */ +export class PanRecognizer extends AttrRecognizer> { + pX: number | null; + pY: number | null; + + constructor(options: PanRecognizerOptions = {}) { + super({ + enable: true, + pointers: 1, + event: 'pan', + threshold: 10, + direction: InputDirection.All, + ...options + }); + this.pX = null; + this.pY = null; + } + + getTouchAction() { + const { + options: {direction} + } = this; + const actions = []; + if (direction & InputDirection.Horizontal) { + actions.push(TOUCH_ACTION_PAN_Y); + } + if (direction & InputDirection.Vertical) { + actions.push(TOUCH_ACTION_PAN_X); + } + return actions; + } + + getEventNames(): string[] { + return EVENT_NAMES.map(suffix => this.options.event + suffix); + } + + directionTest(input: HammerInput): boolean { + const {options} = this; + let hasMoved = true; + let {distance} = input; + let {direction} = input; + const x = input.deltaX; + const y = input.deltaY; + + // lock to axis? + if (!(direction & options.direction)) { + if (options.direction & InputDirection.Horizontal) { + direction = + x === 0 ? InputDirection.None : x < 0 ? InputDirection.Left : InputDirection.Right; + hasMoved = x !== this.pX; + distance = Math.abs(input.deltaX); + } else { + direction = y === 0 ? InputDirection.None : y < 0 ? InputDirection.Up : InputDirection.Down; + hasMoved = y !== this.pY; + distance = Math.abs(input.deltaY); + } + } + input.direction = direction; + return hasMoved && distance > options.threshold && Boolean(direction & options.direction); + } + + attrTest(input: HammerInput): boolean { + return ( + super.attrTest(input) && + (Boolean(this.state & RecognizerState.Began) || + (!(this.state & RecognizerState.Began) && this.directionTest(input))) + ); + } + + emit(input: HammerInput) { + this.pX = input.deltaX; + this.pY = input.deltaY; + + const direction = InputDirection[input.direction].toLowerCase(); + + if (direction) { + input.additionalEvent = this.options.event + direction; + } + super.emit(input); + } +} diff --git a/src/hammerjs/recognizers/pinch.ts b/src/hammerjs/recognizers/pinch.ts new file mode 100644 index 0000000..1b285f0 --- /dev/null +++ b/src/hammerjs/recognizers/pinch.ts @@ -0,0 +1,65 @@ +import {AttrRecognizer} from './attribute'; +import {TOUCH_ACTION_NONE} from '../touchaction/touchaction-Consts'; +import {RecognizerState} from '../recognizer/recognizer-state'; +import type {HammerInput} from '../input/types'; + +export type PinchRecognizerOptions = { + /** Name of the event. + * @default 'pinch' + */ + event?: string; + /** Enable this event. + * @default true + */ + enable?: boolean; + /** Required number of pointers, with a minimum of 2. + * @default 2 + */ + pointers?: number; + /** Minimal scale before recognizing. + * @default 0 + */ + threshold?: number; +}; + +const EVENT_NAMES = ['', 'start', 'move', 'end', 'cancel', 'in', 'out'] as const; + +/** + * Pinch + * Recognized when two or more pointers are moving toward (zoom-in) or away from each other (zoom-out). + */ +export class PinchRecognizer extends AttrRecognizer> { + constructor(options: PinchRecognizerOptions = {}) { + super({ + enable: true, + event: 'pinch', + threshold: 0, + pointers: 2, + ...options + }); + } + + getTouchAction() { + return [TOUCH_ACTION_NONE]; + } + + getEventNames(): string[] { + return EVENT_NAMES.map(suffix => this.options.event + suffix); + } + + attrTest(input: HammerInput): boolean { + return ( + super.attrTest(input) && + (Math.abs(input.scale - 1) > this.options.threshold || + Boolean(this.state & RecognizerState.Began)) + ); + } + + emit(input: HammerInput) { + if (input.scale !== 1) { + const inOut = input.scale < 1 ? 'in' : 'out'; + input.additionalEvent = this.options.event + inOut; + } + super.emit(input); + } +} diff --git a/src/hammerjs/recognizers/press.ts b/src/hammerjs/recognizers/press.ts new file mode 100644 index 0000000..d8f5598 --- /dev/null +++ b/src/hammerjs/recognizers/press.ts @@ -0,0 +1,104 @@ +/* global setTimeout, clearTimeout */ +import {Recognizer} from '../recognizer/recognizer'; +import {RecognizerState} from '../recognizer/recognizer-state'; +import {TOUCH_ACTION_AUTO} from '../touchaction/touchaction-Consts'; +import {InputEvent} from '../input/input-consts'; +import {HammerInput} from '../input/types'; + +export type PressRecognizerOptions = { + /** Name of the event. + * @default 'press' + */ + event?: string; + /** Enable this event. + * @default true + */ + enable?: boolean; + /** Required number of pointers. + * @default 1 + */ + pointers?: number; + /** Minimal press time in ms. + * @default 251 + */ + time?: number; + /** Minimal movement that is allowed while pressing. + * @default 9 + */ + threshold?: number; +}; + +const EVENT_NAMES = ['', 'up'] as const; + +/** + * Press + * Recognized when the pointer is down for x ms without any movement. + */ +export class PressRecognizer extends Recognizer> { + private _timer: any = null; + private _input: HammerInput | null = null; + + constructor(options: PressRecognizerOptions = {}) { + super({ + enable: true, + event: 'press', + pointers: 1, + time: 251, + threshold: 9, + ...options + }); + } + + getTouchAction() { + return [TOUCH_ACTION_AUTO]; + } + + getEventNames(): string[] { + return EVENT_NAMES.map(suffix => this.options.event + suffix); + } + + process(input: HammerInput) { + const {options} = this; + const validPointers = input.pointers.length === options.pointers; + const validMovement = input.distance < options.threshold; + const validTime = input.deltaTime > options.time; + + this._input = input; + + // we only allow little movement + // and we've reached an end event, so a tap is possible + if ( + !validMovement || + !validPointers || + (input.eventType & (InputEvent.End | InputEvent.Cancel) && !validTime) + ) { + this.reset(); + } else if (input.eventType & InputEvent.Start) { + this.reset(); + this._timer = setTimeout(() => { + this.state = RecognizerState.Recognized; + this.tryEmit(); + }, options.time); + } else if (input.eventType & InputEvent.End) { + return RecognizerState.Recognized; + } + return RecognizerState.Failed; + } + + reset() { + clearTimeout(this._timer); + } + + emit(input?: HammerInput) { + if (this.state !== RecognizerState.Recognized) { + return; + } + + if (input && input.eventType & InputEvent.End) { + this.manager.emit(`${this.options.event}up`, input); + } else { + this._input!.timeStamp = Date.now(); + this.manager.emit(this.options.event, this._input!); + } + } +} diff --git a/src/hammerjs/recognizers/rotate.ts b/src/hammerjs/recognizers/rotate.ts new file mode 100644 index 0000000..f126918 --- /dev/null +++ b/src/hammerjs/recognizers/rotate.ts @@ -0,0 +1,57 @@ +import {AttrRecognizer} from './attribute'; +import {TOUCH_ACTION_NONE} from '../touchaction/touchaction-Consts'; +import {RecognizerState} from '../recognizer/recognizer-state'; +import type {HammerInput} from '../input/types'; + +export type RotateRecognizerOptions = { + /** Name of the event. + * @default 'rotate' + */ + event?: string; + /** Enable this event. + * @default true + */ + enable?: boolean; + /** Required number of pointers, with a minimum of 2. + * @default 2 + */ + pointers?: number; + /** Minimal rotation before recognizing. + * @default 0 + */ + threshold?: number; +}; + +const EVENT_NAMES = ['', 'start', 'move', 'end', 'cancel'] as const; + +/** + * Rotate + * Recognized when two or more pointer are moving in a circular motion. + */ +export class RotateRecognizer extends AttrRecognizer> { + constructor(options: RotateRecognizerOptions = {}) { + super({ + enable: true, + event: 'rotate', + threshold: 0, + pointers: 2, + ...options + }); + } + + getTouchAction() { + return [TOUCH_ACTION_NONE]; + } + + getEventNames(): string[] { + return EVENT_NAMES.map(suffix => this.options.event + suffix); + } + + attrTest(input: HammerInput): boolean { + return ( + super.attrTest(input) && + (Math.abs(input.rotation) > this.options.threshold || + Boolean(this.state & RecognizerState.Began)) + ); + } +} diff --git a/src/hammerjs/recognizers/swipe.ts b/src/hammerjs/recognizers/swipe.ts new file mode 100644 index 0000000..3e0e5f1 --- /dev/null +++ b/src/hammerjs/recognizers/swipe.ts @@ -0,0 +1,91 @@ +import {AttrRecognizer} from './attribute'; +import {InputDirection} from '../input/input-consts'; +import {PanRecognizer} from './pan'; +import {InputEvent} from '../input/input-consts'; +import type {HammerInput} from '../input/types'; + +export type SwipeRecognizerOptions = { + /** Name of the event. + * @default 'swipe' + */ + event?: string; + /** Enable this event. + * @default true + */ + enable?: boolean; + /** Required number of pointers. + * @default 1 + */ + pointers?: number; + /** Direction of the panning. + * @default InputDirection.All + */ + direction?: InputDirection; + /** Minimal distance required before recognizing. + * @default 10 + */ + threshold?: number; + /** Minimal velocity required before recognizing, in px/ms + * @default 0.3 + */ + velocity?: number; +}; + +const EVENT_NAMES = ['', 'up', 'down', 'left', 'right'] as const; + +/** + * Swipe + * Recognized when the pointer is moving fast (velocity), with enough distance in the allowed direction. + */ +export class SwipeRecognizer extends AttrRecognizer> { + constructor(options: SwipeRecognizerOptions = {}) { + super({ + enable: true, + event: 'swipe', + threshold: 10, + velocity: 0.3, + direction: InputDirection.All, + pointers: 1, + ...options + }); + } + + getTouchAction() { + return PanRecognizer.prototype.getTouchAction.call(this); + } + + getEventNames(): string[] { + return EVENT_NAMES.map(suffix => this.options.event + suffix); + } + + attrTest(input: HammerInput): boolean { + const {direction} = this.options; + let velocity = 0; + + if (direction & InputDirection.All) { + velocity = input.overallVelocity; + } else if (direction & InputDirection.Horizontal) { + velocity = input.overallVelocityX; + } else if (direction & InputDirection.Vertical) { + velocity = input.overallVelocityY; + } + + return ( + super.attrTest(input) && + Boolean(direction & input.offsetDirection) && + input.distance > this.options.threshold && + input.maxPointers === this.options.pointers && + Math.abs(velocity) > this.options.velocity && + Boolean(input.eventType & InputEvent.End) + ); + } + + emit(input: HammerInput) { + const direction = InputDirection[input.offsetDirection].toLowerCase(); + if (direction) { + this.manager.emit(this.options.event + direction, input); + } + + this.manager.emit(this.options.event, input); + } +} diff --git a/src/hammerjs/recognizers/tap.ts b/src/hammerjs/recognizers/tap.ts new file mode 100644 index 0000000..b094efd --- /dev/null +++ b/src/hammerjs/recognizers/tap.ts @@ -0,0 +1,152 @@ +/* global setTimeout, clearTimeout */ +import {Recognizer} from '../recognizer/recognizer'; +import {TOUCH_ACTION_MANIPULATION} from '../touchaction/touchaction-Consts'; +import {InputEvent} from '../input/input-consts'; +import {RecognizerState} from '../recognizer/recognizer-state'; +import {getPointDistance} from '../input/get-distance'; +import type {Point, HammerInput} from '../input/types'; + +export type TapRecognizerOptions = { + /** Name of the event. + * @default 'tap' + */ + event?: string; + /** Enable this event. + * @default true + */ + enable?: boolean; + /** Required pointers. + * @default 1 + */ + pointers?: number; + /** Required number of taps in succession. + * @default 1 + */ + taps?: number; + /** Maximum time in ms between multiple taps. + * @default 300 + */ + interval?: number; + /** Maximum press time in ms. + * @default 250 + */ + time?: number; + /** While doing a tap some small movement is allowed. + * @default 9 + */ + threshold?: number; + /** The maximum position difference between multiple taps. + * @default 10 + */ + posThreshold?: number; +}; + +/** + * A tap is recognized when the pointer is doing a small tap/click. Multiple taps are recognized if they occur + * between the given interval and position. The delay option can be used to recognize multi-taps without firing + * a single tap. + * + * The eventData from the emitted event contains the property `tapCount`, which contains the amount of + * multi-taps being recognized. + */ +export class TapRecognizer extends Recognizer> { + /** previous time for tap counting */ + private pTime: number | null = null; + /** previous center for tap counting */ + private pCenter: Point | null = null; + + private _timer: any = null; + private _input: HammerInput | null = null; + + private count: number = 0; + + constructor(options: TapRecognizerOptions = {}) { + super({ + enable: true, + event: 'tap', + pointers: 1, + taps: 1, + interval: 300, + time: 250, + threshold: 9, + posThreshold: 10, + ...options + }); + } + + getTouchAction() { + return [TOUCH_ACTION_MANIPULATION]; + } + + process(input: HammerInput) { + const {options} = this; + + const validPointers = input.pointers.length === options.pointers; + const validMovement = input.distance < options.threshold; + const validTouchTime = input.deltaTime < options.time; + + this.reset(); + + if (input.eventType & InputEvent.Start && this.count === 0) { + return this.failTimeout(); + } + + // we only allow little movement + // and we've reached an end event, so a tap is possible + if (validMovement && validTouchTime && validPointers) { + if (input.eventType !== InputEvent.End) { + return this.failTimeout(); + } + + const validInterval = this.pTime ? input.timeStamp - this.pTime < options.interval : true; + const validMultiTap = + !this.pCenter || getPointDistance(this.pCenter, input.center) < options.posThreshold; + + this.pTime = input.timeStamp; + this.pCenter = input.center; + + if (!validMultiTap || !validInterval) { + this.count = 1; + } else { + this.count += 1; + } + + this._input = input; + + // if tap count matches we have recognized it, + // else it has began recognizing... + const tapCount = this.count % options.taps; + if (tapCount === 0) { + // no failing requirements, immediately trigger the tap event + // or wait as long as the multitap interval to trigger + if (!this.hasRequireFailures()) { + return RecognizerState.Recognized; + } + this._timer = setTimeout(() => { + this.state = RecognizerState.Recognized; + this.tryEmit(this._input!); + }, options.interval); + return RecognizerState.Began; + } + } + return RecognizerState.Failed; + } + + failTimeout() { + this._timer = setTimeout(() => { + this.state = RecognizerState.Failed; + }, this.options.interval); + return RecognizerState.Failed; + } + + reset() { + clearTimeout(this._timer); + } + + emit(input: HammerInput) { + if (this.state === RecognizerState.Recognized) { + input.tapCount = this.count; + this.manager.emit(this.options.event, input); + } + } +} diff --git a/src/hammerjs/touchaction/clean-touch-actions.ts b/src/hammerjs/touchaction/clean-touch-actions.ts new file mode 100644 index 0000000..bf2a0ce --- /dev/null +++ b/src/hammerjs/touchaction/clean-touch-actions.ts @@ -0,0 +1,41 @@ +import { + TOUCH_ACTION_NONE, + TOUCH_ACTION_PAN_X, + TOUCH_ACTION_PAN_Y, + TOUCH_ACTION_MANIPULATION, + TOUCH_ACTION_AUTO +} from './touchaction-Consts'; + +/** + * when the touchActions are collected they are not a valid value, so we need to clean things up. * + * @returns valid touchAction + */ +export default function cleanTouchActions(actions: string): string { + // none + if (actions.includes(TOUCH_ACTION_NONE)) { + return TOUCH_ACTION_NONE; + } + + const hasPanX = actions.includes(TOUCH_ACTION_PAN_X); + const hasPanY = actions.includes(TOUCH_ACTION_PAN_Y); + + // if both pan-x and pan-y are set (different recognizers + // for different directions, e.g. horizontal pan but vertical swipe?) + // we need none (as otherwise with pan-x pan-y combined none of these + // recognizers will work, since the browser would handle all panning + if (hasPanX && hasPanY) { + return TOUCH_ACTION_NONE; + } + + // pan-x OR pan-y + if (hasPanX || hasPanY) { + return hasPanX ? TOUCH_ACTION_PAN_X : TOUCH_ACTION_PAN_Y; + } + + // manipulation + if (actions.includes(TOUCH_ACTION_MANIPULATION)) { + return TOUCH_ACTION_MANIPULATION; + } + + return TOUCH_ACTION_AUTO; +} diff --git a/src/hammerjs/touchaction/touchaction-Consts.ts b/src/hammerjs/touchaction/touchaction-Consts.ts new file mode 100644 index 0000000..126c333 --- /dev/null +++ b/src/hammerjs/touchaction/touchaction-Consts.ts @@ -0,0 +1,7 @@ +// magical touchAction value +export const TOUCH_ACTION_COMPUTE = 'compute'; +export const TOUCH_ACTION_AUTO = 'auto'; +export const TOUCH_ACTION_MANIPULATION = 'manipulation'; // not implemented +export const TOUCH_ACTION_NONE = 'none'; +export const TOUCH_ACTION_PAN_X = 'pan-x'; +export const TOUCH_ACTION_PAN_Y = 'pan-y'; diff --git a/src/hammerjs/touchaction/touchaction.ts b/src/hammerjs/touchaction/touchaction.ts new file mode 100644 index 0000000..71cc09b --- /dev/null +++ b/src/hammerjs/touchaction/touchaction.ts @@ -0,0 +1,53 @@ +import {TOUCH_ACTION_COMPUTE} from './touchaction-Consts'; +import cleanTouchActions from './clean-touch-actions'; + +import type {Manager} from '../manager'; + +/** + * Touch Action + * sets the touchAction property or uses the js alternative + */ +export class TouchAction { + manager: Manager; + actions: string = ''; + + constructor(manager: Manager, value: string) { + this.manager = manager; + this.set(value); + } + + /** + * set the touchAction value on the element or enable the polyfill + */ + set(value: string) { + // find out the touch-action by the event handlers + if (value === TOUCH_ACTION_COMPUTE) { + value = this.compute(); + } + + if (this.manager.element) { + this.manager.element.style.touchAction = value; + this.actions = value; + } + } + + /** + * just re-set the touchAction value + */ + update() { + this.set(this.manager.options.touchAction); + } + + /** + * compute the value for the touchAction property based on the recognizer's settings + */ + compute(): string { + let actions: string[] = []; + for (const recognizer of this.manager.recognizers) { + if (recognizer.options.enable) { + actions = actions.concat(recognizer.getTouchAction()); + } + } + return cleanTouchActions(actions.join(' ')); + } +} diff --git a/src/hammerjs/utils/event-listeners.ts b/src/hammerjs/utils/event-listeners.ts new file mode 100644 index 0000000..633ebf1 --- /dev/null +++ b/src/hammerjs/utils/event-listeners.ts @@ -0,0 +1,33 @@ +import {splitStr} from './split-str'; + +/** + * addEventListener with multiple events at once + */ +export function addEventListeners( + target: EventTarget | null, + types: string, + handler: EventListener +) { + if (!target) { + return; + } + for (const type of splitStr(types)) { + target.addEventListener(type, handler, false); + } +} + +/** + * removeEventListener with multiple events at once + */ +export function removeEventListeners( + target: EventTarget | null, + types: string, + handler: EventListener +) { + if (!target) { + return; + } + for (const type of splitStr(types)) { + target.removeEventListener(type, handler, false); + } +} diff --git a/src/hammerjs/utils/get-window-for-element.ts b/src/hammerjs/utils/get-window-for-element.ts new file mode 100644 index 0000000..fadecd6 --- /dev/null +++ b/src/hammerjs/utils/get-window-for-element.ts @@ -0,0 +1,7 @@ +/** + * get the window object of an element + */ +export function getWindowForElement(element: HTMLElement): Window | null { + const doc = element.ownerDocument || (element as unknown as Document); + return doc.defaultView; +} diff --git a/src/hammerjs/utils/has-parent.ts b/src/hammerjs/utils/has-parent.ts new file mode 100644 index 0000000..189ca6a --- /dev/null +++ b/src/hammerjs/utils/has-parent.ts @@ -0,0 +1,13 @@ +/** + * find if a node is in the given parent + */ +export default function hasParent(node: HTMLElement, parent: HTMLElement): boolean { + let ancester: Node | null = node; + while (ancester) { + if (ancester === parent) { + return true; + } + ancester = node.parentNode; + } + return false; +} diff --git a/src/hammerjs/utils/prefixed.ts b/src/hammerjs/utils/prefixed.ts new file mode 100644 index 0000000..672e312 --- /dev/null +++ b/src/hammerjs/utils/prefixed.ts @@ -0,0 +1,18 @@ +const VENDOR_PREFIXES = ['', 'webkit', 'Moz', 'MS', 'ms', 'o']; + +/** + * get the prefixed property + * @returns prefixed property name + */ +export function prefixed(obj: Record, property: string): string | undefined { + const camelProp = property[0].toUpperCase() + property.slice(1); + + for (const prefix of VENDOR_PREFIXES) { + const prop = prefix ? prefix + camelProp : property; + + if (prop in obj) { + return prop; + } + } + return undefined; +} diff --git a/src/hammerjs/utils/split-str.ts b/src/hammerjs/utils/split-str.ts new file mode 100644 index 0000000..7a527dc --- /dev/null +++ b/src/hammerjs/utils/split-str.ts @@ -0,0 +1,7 @@ +/** + * split string on whitespace + * @returns {Array} words + */ +export function splitStr(str: string): string[] { + return str.trim().split(/\s+/g); +} diff --git a/src/hammerjs/utils/unique-id.ts b/src/hammerjs/utils/unique-id.ts new file mode 100644 index 0000000..418faa4 --- /dev/null +++ b/src/hammerjs/utils/unique-id.ts @@ -0,0 +1,7 @@ +/** + * get a unique id + */ +let _uniqueId = 1; +export function uniqueId(): number { + return _uniqueId++; +} diff --git a/src/index.ts b/src/index.ts index ea6b76c..4f901f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,15 @@ -export {default as EventManager} from './event-manager'; +export {EventManager} from './event-manager'; +export { + Recognizer, + Pan, + Rotate, + Pinch, + Swipe, + Press, + Tap, + InputDirection, + InputEvent +} from './hammerjs'; // types export type {EventManagerOptions} from './event-manager'; @@ -7,7 +18,15 @@ export type { MjolnirGestureEvent, MjolnirKeyEvent, MjolnirWheelEvent, - MjolnirPointerEvent, - RecognizerOptions, - RecognizerTuple + MjolnirPointerEvent } from './types'; + +export type { + PanRecognizerOptions, + RotateRecognizerOptions, + PinchRecognizerOptions, + SwipeRecognizerOptions, + PressRecognizerOptions, + TapRecognizerOptions, + RecognizerTuple +} from './hammerjs'; diff --git a/src/inputs/contextmenu-input.ts b/src/inputs/contextmenu-input.ts index a544a3a..2195345 100644 --- a/src/inputs/contextmenu-input.ts +++ b/src/inputs/contextmenu-input.ts @@ -1,9 +1,7 @@ import type {MjolnirPointerEventRaw} from '../types'; -import Input, {InputOptions} from './input'; +import {Input, InputOptions} from './input'; -const EVENT_TYPE = 'contextmenu'; - -export default class ContextmenuInput extends Input { +export class ContextmenuInput extends Input { constructor( element: HTMLElement, callback: (event: MjolnirPointerEventRaw) => void, @@ -23,7 +21,7 @@ export default class ContextmenuInput extends Input { +export class Input { element: HTMLElement; options: Options; callback: (e: EventType) => void; @@ -13,7 +12,6 @@ export default class Input void, options: Options) { this.element = element; this.callback = callback; - - this.options = {enable: true, ...options}; + this.options = options; } } diff --git a/src/inputs/key-input.ts b/src/inputs/key-input.ts index c773b30..671d8de 100644 --- a/src/inputs/key-input.ts +++ b/src/inputs/key-input.ts @@ -1,42 +1,33 @@ import type {MjolnirKeyEventRaw} from '../types'; -import Input, {InputOptions} from './input'; +import {Input, InputOptions} from './input'; -import {INPUT_EVENT_TYPES} from '../constants'; - -const {KEY_EVENTS} = INPUT_EVENT_TYPES; -const DOWN_EVENT_TYPE = 'keydown'; -const UP_EVENT_TYPE = 'keyup'; +const KEY_EVENTS = ['keydown', 'keyup'] as const; type KeyInputOptions = InputOptions & { - events?: string[]; tabIndex?: number; }; -export default class KeyInput extends Input { +export class KeyInput extends Input> { enableDownEvent: boolean; enableUpEvent: boolean; - events: string[]; - constructor( element: HTMLElement, callback: (event: MjolnirKeyEventRaw) => void, options: KeyInputOptions ) { - super(element, callback, options); + super(element, callback, {enable: true, tabIndex: 0, ...options}); this.enableDownEvent = this.options.enable; this.enableUpEvent = this.options.enable; - this.events = (this.options.events || []).concat(KEY_EVENTS); - - element.tabIndex = this.options.tabIndex || 0; + element.tabIndex = this.options.tabIndex; element.style.outline = 'none'; - this.events.forEach(event => element.addEventListener(event, this.handleEvent)); + KEY_EVENTS.forEach(event => element.addEventListener(event, this.handleEvent)); } destroy() { - this.events.forEach(event => this.element.removeEventListener(event, this.handleEvent)); + KEY_EVENTS.forEach(event => this.element.removeEventListener(event, this.handleEvent)); } /** @@ -44,10 +35,10 @@ export default class KeyInput extends Input * if the specified event type is among those handled by this input. */ enableEventType(eventType: string, enabled: boolean) { - if (eventType === DOWN_EVENT_TYPE) { + if (eventType === 'keydown') { this.enableDownEvent = enabled; } - if (eventType === UP_EVENT_TYPE) { + if (eventType === 'keyup') { this.enableUpEvent = enabled; } } @@ -64,7 +55,7 @@ export default class KeyInput extends Input if (this.enableDownEvent && event.type === 'keydown') { this.callback({ - type: DOWN_EVENT_TYPE, + type: 'keydown', srcEvent: event, key: event.key, target: event.target as HTMLElement @@ -73,7 +64,7 @@ export default class KeyInput extends Input if (this.enableUpEvent && event.type === 'keyup') { this.callback({ - type: UP_EVENT_TYPE, + type: 'keyup', srcEvent: event, key: event.key, target: event.target as HTMLElement diff --git a/src/inputs/move-input.ts b/src/inputs/move-input.ts index 0b376d5..fca6da1 100644 --- a/src/inputs/move-input.ts +++ b/src/inputs/move-input.ts @@ -1,13 +1,16 @@ import type {MjolnirPointerEventRaw} from '../types'; -import Input, {InputOptions} from './input'; -import {INPUT_EVENT_TYPES} from '../constants'; +import {Input, InputOptions} from './input'; -const {MOUSE_EVENTS} = INPUT_EVENT_TYPES; -const MOVE_EVENT_TYPE = 'pointermove'; -const OVER_EVENT_TYPE = 'pointerover'; -const OUT_EVENT_TYPE = 'pointerout'; -const ENTER_EVENT_TYPE = 'pointerenter'; -const LEAVE_EVENT_TYPE = 'pointerleave'; +const MOUSE_EVENTS = [ + 'mousedown', + 'mousemove', + 'mouseup', + 'mouseover', + 'mouseout', + 'mouseleave' +] as const; + +type MoveEventType = 'pointermove' | 'pointerover' | 'pointerout' | 'pointerenter' | 'pointerleave'; /** * Hammer.js swallows 'move' events (for pointer/touch/mouse) @@ -17,7 +20,7 @@ const LEAVE_EVENT_TYPE = 'pointerleave'; * move events across input types, e.g. storing multiple simultaneous * pointer/touch events, calculating speed/direction, etc. */ -export default class MoveInput extends Input { +export class MoveInput extends Input> { pressed: boolean; enableMoveEvent: boolean; enableEnterEvent: boolean; @@ -25,14 +28,12 @@ export default class MoveInput extends Input void, options: InputOptions ) { - super(element, callback, options); + super(element, callback, {enable: true, ...options}); this.pressed = false; const {enable} = this.options; @@ -43,13 +44,11 @@ export default class MoveInput extends Input element.addEventListener(event, this.handleEvent)); + MOUSE_EVENTS.forEach(event => element.addEventListener(event, this.handleEvent)); } destroy() { - this.events.forEach(event => this.element.removeEventListener(event, this.handleEvent)); + MOUSE_EVENTS.forEach(event => this.element.removeEventListener(event, this.handleEvent)); } /** @@ -57,24 +56,28 @@ export default class MoveInput extends Input { + handleEvent = (event: MouseEvent) => { this.handleOverEvent(event); this.handleOutEvent(event); this.handleEnterEvent(event); @@ -82,39 +85,31 @@ export default class MoveInput extends Input { - events: string[]; - +export class WheelInput extends Input> { constructor( element: HTMLElement, callback: (event: MjolnirWheelEventRaw) => void, options: InputOptions ) { - super(element, callback, options); - - this.events = (this.options.events || []).concat(WHEEL_EVENTS); + super(element, callback, {enable: true, ...options}); - this.events.forEach(event => - element.addEventListener(event, this.handleEvent, passiveSupported ? {passive: false} : false) - ); + element.addEventListener('wheel', this.handleEvent, {passive: false}); } destroy() { - this.events.forEach(event => this.element.removeEventListener(event, this.handleEvent)); + this.element.removeEventListener('wheel', this.handleEvent); } /** @@ -41,7 +31,7 @@ export default class WheelInput extends Input boolean); - event?: string; - interval?: number; - pointers?: number; - posThreshold?: number; - taps?: number | undefined; - threshold?: number; - time?: number; - velocity?: number; -} - -export interface RecognizerStatic { - new (options?: RecognizerOptions): Recognizer; -} - -export type RecognizerTuple = - | [RecognizerStatic] - | [RecognizerStatic, RecognizerOptions] - | [RecognizerStatic, RecognizerOptions, string | string[]] - | [ - RecognizerStatic, - RecognizerOptions, - string | string[], - (string | Recognizer) | (string | Recognizer)[] - ]; - -export interface HammerOptions { - // cssProps?: CssProps; - domEvents?: boolean; - enable?: boolean | ((manager: HammerManager) => boolean); - preset?: RecognizerTuple[]; - touchAction?: string; - recognizers?: RecognizerTuple[]; -} - -export interface HammerManager { - // add( recognizer:Recognizer ):Recognizer; - // add( recognizer:Recognizer[] ):HammerManager; - destroy(): void; - emit(event: string, data: any): void; - get(recognizer: Recognizer): Recognizer; - get(recognizer: string): Recognizer; - off(events: string, handler?: (event: HammerInput) => void): HammerManager; - on(events: string, handler: (event: HammerInput) => void): HammerManager; - // recognize( inputData:any ):void; - // remove( recognizer:Recognizer ):HammerManager; - // remove( recognizer:string ):HammerManager; - set(options: HammerOptions): HammerManager; - // stop( force:boolean ):void; -} - -export interface HammerManagerConstructor { - new (element: EventTarget, options?: HammerOptions): HammerManager; -} - -/** A hammerjs gesture event */ -export type HammerInput = { - /** Name of the event. */ - type: - | 'tap' - | 'anytap' - | 'doubletap' - | 'press' - | 'pinch' - | 'pinchin' - | 'pinchout' - | 'pinchstart' - | 'pinchmove' - | 'pinchend' - | 'pinchcancel' - | 'rotate' - | 'rotatestart' - | 'rotatemove' - | 'rotateend' - | 'rotatecancel' - | 'tripan' - | 'tripanstart' - | 'tripanmove' - | 'tripanup' - | 'tripandown' - | 'tripanleft' - | 'tripanright' - | 'tripanend' - | 'tripancancel' - | 'pan' - | 'panstart' - | 'panmove' - | 'panup' - | 'pandown' - | 'panleft' - | 'panright' - | 'panend' - | 'pancancel' - | 'swipe' - | 'swipeleft' - | 'swiperight' - | 'swipeup' - | 'swipedown' - // Aliases - | 'click' - | 'anyclick' - | 'dblclick'; - - /** Movement of the X axis. */ - deltaX: number; - - /** Movement of the Y axis. */ - deltaY: number; - - /** Total time in ms since the first input. */ - deltaTime: number; - - /** Distance moved. */ - distance: number; - - /** Angle moved. */ - angle: number; - - /** Velocity on the X axis, in px/ms. */ - velocityX: number; - - /** Velocity on the Y axis, in px/ms */ - velocityY: number; - - /** Highest velocityX/Y value. */ - velocity: number; - - /** Direction moved. Matches the DIRECTION constants. */ - direction: number; - - /** Direction moved from it's starting point. Matches the DIRECTION constants. */ - offsetDirection: number; - - /** Scaling that has been done when multi-touch. 1 on a single touch. */ - scale: number; - - /** Rotation that has been done when multi-touch. 0 on a single touch. */ - rotation: number; - - /** Center position for multi-touch, or just the single pointer. */ - center: Point; - - /** Source event object, type TouchEvent, MouseEvent or PointerEvent. */ - srcEvent: TouchEvent | MouseEvent | PointerEvent; - - /** Target that received the event. */ - target: HTMLElement; - - /** Primary pointer type, could be touch, mouse, pen or kinect. */ - pointerType: string; - - /** Event type, matches the INPUT constants. */ - eventType: string; - - /** true when the first input. */ - isFirst: boolean; - - /** true when the final (last) input. */ - isFinal: boolean; - - /** Array with all pointers, including the ended pointers (touchend, mouseup). */ - pointers: any[]; - - /** Array with all new/moved/lost pointers. */ - changedPointers: any[]; - - /** Maximum number of pointers detected in the gesture */ - maxPointers: number; - - /** Timestamp of a gesture */ - timeStamp: number; -}; - /* mjolnir.js */ export interface MjolnirEventRaw { @@ -229,9 +35,9 @@ export type MjolnirPointerEventRaw = MjolnirEventRaw & { | 'pointerout' | 'pointerenter' | 'pointerleave'; - pointerType: 'mouse' | 'touch'; + pointerType: 'mouse' | 'pen' | 'touch'; center: Point; - srcEvent: TouchEvent | MouseEvent | PointerEvent; + srcEvent: MouseEvent | PointerEvent; }; export type MjolnirWheelEventRaw = MjolnirEventRaw & { @@ -251,11 +57,13 @@ export type MjolnirKeyEventRaw = MjolnirEventRaw & { export type MjolnirKeyEvent = MjolnirKeyEventRaw & { rootElement: HTMLElement; handled: boolean; + /** Prevents the current event from bubbling up */ stopPropagation: () => void; + /** Prevents any remaining handlers from being called */ stopImmediatePropagation: () => void; }; -export type MjolnirGestureEvent = MjolnirEventWrapper; +export type MjolnirGestureEvent = MjolnirEventWrapper; export type MjolnirPointerEvent = MjolnirEventWrapper; export type MjolnirWheelEvent = MjolnirEventWrapper; @@ -265,8 +73,16 @@ export type MjolnirEvent = | MjolnirWheelEvent | MjolnirKeyEvent; -export type MjolnirEventHandlers = { - [type in MjolnirGestureEvent['type']]?: (event: MjolnirGestureEvent) => void; -} & {[type in MjolnirPointerEvent['type']]?: (event: MjolnirPointerEvent) => void} & { - [type in MjolnirWheelEvent['type']]?: (event: MjolnirWheelEvent) => void; -} & {[type in MjolnirKeyEvent['type']]?: (event: MjolnirKeyEvent) => void}; +export type MjolnirEventHandler = (event: EventT) => void; + +export type MjolnirEventHandlers = Partial< + { + [type in MjolnirGestureEvent['type']]: (event: MjolnirGestureEvent) => void; + } & { + [type in MjolnirPointerEvent['type']]: (event: MjolnirPointerEvent) => void; + } & { + [type in MjolnirWheelEvent['type']]: (event: MjolnirWheelEvent) => void; + } & { + [type in MjolnirKeyEvent['type']]: (event: MjolnirKeyEvent) => void; + } +>; diff --git a/src/utils/event-registrar.ts b/src/utils/event-registrar.ts index f77ce33..96205d7 100644 --- a/src/utils/event-registrar.ts +++ b/src/utils/event-registrar.ts @@ -1,9 +1,21 @@ -import type EventManager from '../event-manager'; +import type {EventManager} from '../event-manager'; import {whichButtons, getOffsetPosition} from './event-utils'; -import type {MjolnirEventRaw, MjolnirEventWrapper, MjolnirEvent} from '../types'; +import type { + MjolnirEventRaw, + MjolnirEventWrapper, + MjolnirEvent, + MjolnirEventHandler +} from '../types'; export type HandlerOptions = { + /** Optional element from which the event is originated from. + * @default 'root' + */ srcElement?: 'root' | HTMLElement; + /** Handler with higher priority will be called first. + * Handler with the same priority will be called in the order of registration. + * @default 0 + */ priority?: number; }; @@ -12,22 +24,25 @@ type EventHandler = { handler: (event: MjolnirEvent) => void; once?: boolean; passive?: boolean; -} & HandlerOptions; + srcElement: 'root' | HTMLElement; + priority: number; +}; -const DEFAULT_OPTIONS: HandlerOptions = { +const DEFAULT_OPTIONS: Required = { srcElement: 'root', priority: 0 }; -export default class EventRegistrar { +export class EventRegistrar { eventManager: EventManager; recognizerName: string; handlers: EventHandler[]; handlersByElement: Map<'root' | HTMLElement, EventHandler[]>; _active: boolean; - constructor(eventManager: EventManager) { + constructor(eventManager: EventManager, recognizerName: string) { this.eventManager = eventManager; + this.recognizerName = recognizerName; this.handlers = []; // Element -> handler map this.handlersByElement = new Map(); @@ -42,21 +57,13 @@ export default class EventRegistrar { add( type: string, - handler: (event: MjolnirEvent) => void, - options: HTMLElement | HandlerOptions, + handler: MjolnirEventHandler, + options?: HandlerOptions, once: boolean = false, passive: boolean = false ) { const {handlers, handlersByElement} = this; - let opts: HandlerOptions = DEFAULT_OPTIONS; - - if (typeof options === 'string' || (options && (options as HTMLElement).addEventListener)) { - // is DOM element, backward compatibility - // @ts-ignore - opts = {...DEFAULT_OPTIONS, srcElement: options}; - } else if (options) { - opts = {...DEFAULT_OPTIONS, ...options}; - } + const opts: Required = {...DEFAULT_OPTIONS, ...options}; let entries = handlersByElement.get(opts.srcElement); if (!entries) { @@ -90,7 +97,7 @@ export default class EventRegistrar { entries.splice(insertPosition + 1, 0, entry); } - remove(type: string, handler: (event: MjolnirEvent) => void) { + remove(type: string, handler: MjolnirEventHandler) { const {handlers, handlersByElement} = this; for (let i = handlers.length - 1; i >= 0; i--) { @@ -98,7 +105,7 @@ export default class EventRegistrar { if (entry.type === type && entry.handler === handler) { handlers.splice(i, 1); - const entries = handlersByElement.get(entry.srcElement); + const entries = handlersByElement.get(entry.srcElement)!; entries.splice(entries.indexOf(entry), 1); if (entries.length === 0) { handlersByElement.delete(entry.srcElement); @@ -154,9 +161,9 @@ export default class EventRegistrar { for (let i = 0; i < entries.length; i++) { const {type, handler, once} = entries[i]; + // @ts-ignore handler({ ...event, - // @ts-ignore type, stopPropagation, stopImmediatePropagation @@ -182,10 +189,11 @@ export default class EventRegistrar { _normalizeEvent(event: T): MjolnirEventWrapper { const rootElement = this.eventManager.getElement(); + // @ts-ignore return { ...event, ...whichButtons(event), - ...getOffsetPosition(event, rootElement), + ...getOffsetPosition(event, rootElement!), preventDefault: () => { event.srcEvent.preventDefault(); }, diff --git a/src/utils/event-utils.ts b/src/utils/event-utils.ts index 3d2e01f..983bb2a 100644 --- a/src/utils/event-utils.ts +++ b/src/utils/event-utils.ts @@ -1,4 +1,5 @@ -import type {MjolnirEventRaw, HammerInput, Point} from '../types'; +import type {MjolnirEventRaw, Point} from '../types'; +import type {HammerEvent} from '../hammerjs'; /* Constants */ const DOWN_EVENT = 1; @@ -29,7 +30,7 @@ export function whichButtons(event: MjolnirEventRaw): { leftButton: boolean; middleButton: boolean; rightButton: boolean; -} { +} | null { const eventType = MOUSE_EVENTS[event.srcEvent.type]; if (!eventType) { // Not a mouse evet @@ -63,8 +64,8 @@ export function getOffsetPosition( ): { center: Point; offsetCenter: Point; -} { - const center = (event as HammerInput).center; +} | null { + const center = (event as HammerEvent).center; // `center` is a hammer.js event property if (!center) { diff --git a/src/utils/globals.ts b/src/utils/globals.ts index 599e88d..e147158 100644 --- a/src/utils/globals.ts +++ b/src/utils/globals.ts @@ -10,29 +10,3 @@ const global_ = typeof global !== 'undefined' ? global : window; const document_ = typeof document !== 'undefined' ? document : {}; export {window_ as window, global_ as global, document_ as document}; - -/* - * Detect whether passive option is supported by the current browser. - * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener - #Safely_detecting_option_support - */ -let passiveSupported = false; - -/* eslint-disable accessor-pairs, no-empty */ -try { - const options = { - // This function will be called when the browser - // attempts to access the passive property. - get passive() { - passiveSupported = true; - return true; - } - }; - - window_.addEventListener('test', null, options); - window_.removeEventListener('test', null); -} catch (err) { - passiveSupported = false; -} - -export {passiveSupported}; diff --git a/src/utils/hammer-overrides.ts b/src/utils/hammer-overrides.ts deleted file mode 100644 index 9d48b8b..0000000 --- a/src/utils/hammer-overrides.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * This file contains overrides the default - * hammer.js functions to add our own utility - */ -/* eslint-disable */ - -/* Hammer.js constants */ -const INPUT_START = 1; -const INPUT_MOVE = 2; -const INPUT_END = 4; -const MOUSE_INPUT_MAP = { - mousedown: INPUT_START, - mousemove: INPUT_MOVE, - mouseup: INPUT_END -}; - -/** - * Helper function that returns true if any element in an array meets given criteria. - * Because older browsers do not support `Array.prototype.some` - * @params array {Array} - * @params predict {Function} - */ -function some(array, predict) { - for (let i = 0; i < array.length; i++) { - if (predict(array[i])) { - return true; - } - } - return false; -} - -/* eslint-disable no-invalid-this */ -export function enhancePointerEventInput(PointerEventInput) { - const oldHandler = PointerEventInput.prototype.handler; - - // overrides PointerEventInput.handler to accept right mouse button - PointerEventInput.prototype.handler = function handler(ev) { - const store = this.store; - - // Allow non-left mouse buttons through - if (ev.button > 0 && ev.type === 'pointerdown') { - if (!some(store, e => e.pointerId === ev.pointerId)) { - store.push(ev); - } - } - - oldHandler.call(this, ev); - }; -} - -// overrides MouseInput.handler to accept right mouse button -export function enhanceMouseInput(MouseInput) { - MouseInput.prototype.handler = function handler(ev) { - let eventType = MOUSE_INPUT_MAP[ev.type]; - - // on start we want to have the mouse button down - if (eventType & INPUT_START && ev.button >= 0) { - this.pressed = true; - } - - if (eventType & INPUT_MOVE && ev.buttons === 0) { - eventType = INPUT_END; - } - - // mouse must be down - if (!this.pressed) { - return; - } - - if (eventType & INPUT_END) { - this.pressed = false; - } - - this.callback(this.manager, eventType, { - pointers: [ev], - changedPointers: [ev], - pointerType: 'mouse', - srcEvent: ev - }); - }; -} diff --git a/src/utils/hammer.browser.ts b/src/utils/hammer.browser.ts deleted file mode 100644 index 264b255..0000000 --- a/src/utils/hammer.browser.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as hammerjs from 'hammerjs'; -import {enhancePointerEventInput, enhanceMouseInput} from './hammer-overrides'; - -enhancePointerEventInput(hammerjs.PointerEventInput); -enhanceMouseInput(hammerjs.MouseInput); - -export const Manager = hammerjs.Manager; - -export default hammerjs; diff --git a/src/utils/hammer.ts b/src/utils/hammer.ts deleted file mode 100644 index c4f914e..0000000 --- a/src/utils/hammer.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Hammer.Manager mock for use in environments without `document` / `window`. -class HammerManagerMock { - get = () => null; - set = () => this; - on = () => this; - off = () => this; - destroy = () => this; - emit = () => this; -} - -export const Manager = HammerManagerMock; - -export default null; diff --git a/test/browser.js b/test/browser.ts similarity index 100% rename from test/browser.js rename to test/browser.ts diff --git a/test/event-manager.spec.js b/test/event-manager.spec.ts similarity index 61% rename from test/event-manager.spec.js rename to test/event-manager.spec.ts index de9a04a..bcd5bb5 100644 --- a/test/event-manager.spec.js +++ b/test/event-manager.spec.ts @@ -19,45 +19,46 @@ // THE SOFTWARE. import test from 'tape-promise/tape'; -import EventManager from 'mjolnir.js/event-manager'; -import {spy, createEventRegistrarMock, HammerManagerMock} from './test-utils'; +import {EventManager, Tap, Pan} from 'mjolnir.js'; +import {spy} from './test-utils/spy'; +import {createEventTarget} from './test-utils/dom'; test('eventManager#constructor', t => { - const eventRegistrar = createEventRegistrarMock(); - let eventManager = new EventManager(eventRegistrar); - const onSpy = spy(); - const origOn = EventManager.prototype.on; - EventManager.prototype.on = onSpy; + const root = createEventTarget(); + let eventManager = new EventManager(root); t.ok(eventManager, 'EventManager created'); t.ok(eventManager.manager, 'Hammer.Manager created'); t.ok(eventManager.wheelInput, 'WheelInput created'); t.ok(eventManager.moveInput, 'MoveInput created'); t.ok(eventManager.keyInput, 'MoveInput created'); - t.notOk(onSpy.called, 'on() not called if options.events not passed'); + t.notOk(eventManager.events.size, 'No events are registered'); + eventManager.destroy(); - eventManager = new EventManager(eventRegistrar, { + eventManager = new EventManager(root, { events: {foo: () => {}}, - Manager: HammerManagerMock, - recognizerOptions: { - tap: { - threshold: 10 - } - } + recognizers: [new Tap()] }); - t.ok(onSpy.called, 'on() is called if options.events is passed'); - EventManager.prototype.on = origOn; + t.ok(eventManager.events.size, 'No events are registered'); + eventManager.destroy(); // construct without element - eventManager = new EventManager(); + eventManager = new EventManager(null, { + recognizers: [new Tap()] + }); t.ok(eventManager, 'EventManager created'); t.notOk(eventManager.manager, 'Hammer.Manager should not be created'); + t.doesNotThrow(() => eventManager.on('tap', () => {}), 'eventManager.on() does not throw'); + t.doesNotThrow(() => eventManager.off('tap', () => {}), 'eventManager.off() does not throw'); + eventManager.destroy(); + root.remove(); t.end(); }); test('eventManager#destroy', t => { - const eventManager = new EventManager(createEventRegistrarMock()); + const root = createEventTarget(); + const eventManager = new EventManager(root); const {manager, moveInput, wheelInput, keyInput} = eventManager; spy(manager, 'destroy'); @@ -71,50 +72,31 @@ test('eventManager#destroy', t => { t.equal(wheelInput.destroy.callCount, 1, 'WheelInput.destroy() should be called once'); t.equal(keyInput.destroy.callCount, 1, 'KeyInput.destroy() should be called once'); - eventManager.destroy(); - t.pass('EventManager does not throw error on destroyed twice'); + t.doesNotThrow( + () => eventManager.destroy(), + 'EventManager does not throw error on destroyed twice' + ); const emptyEventManager = new EventManager(); emptyEventManager.destroy(); - t.pass('EventManager without elements can be destroyed'); - - t.end(); -}); - -test('eventManager#setElement', t => { - const events = { - foo: () => {} - }; - spy(events, 'foo'); - const eventManager = new EventManager(null, { - Manager: HammerManagerMock, - events - }); - spy(eventManager, 'destroy'); - - eventManager.setElement(createEventRegistrarMock()); - t.ok(eventManager.manager, 'Hammer.Manager created'); - t.equal(eventManager.destroy.callCount, 0, 'Manager.destroy() should not be called'); - eventManager.manager.emit('foo', {type: 'foo', srcEvent: {}}); - t.equal(events.foo.callCount, 1, 'event is transfered'); - - const oldManager = eventManager.manager; - eventManager.setElement(createEventRegistrarMock()); - t.ok(eventManager.manager, 'Hammer.Manager created'); - t.notEqual(eventManager.manager, oldManager, 'manager has changed'); - t.equal(eventManager.destroy.callCount, 1, 'Manager.destroy() should be called once'); - eventManager.manager.emit('foo', {type: 'foo', srcEvent: {}}); - t.equal(events.foo.callCount, 2, 'event is transfered'); + t.doesNotThrow( + () => emptyEventManager.destroy(), + 'EventManager without elements can be destroyed' + ); + root.remove(); t.end(); }); test('eventManager#on', t => { - const eventManager = new EventManager(createEventRegistrarMock()); + const root = createEventTarget(); + const eventManager = new EventManager(root, { + recognizers: [new Tap({event: 'click'}), new Tap({event: 'dblclick', taps: 2})] + }); const toggleRecSpy = spy(eventManager, '_toggleRecognizer'); eventManager.on('dblclick', () => {}); - t.ok(eventManager.events.get('doubletap'), 'event doubletap is registered'); + t.ok(eventManager.events.get('dblclick'), 'event dblclick is registered'); t.equal( toggleRecSpy.callCount, 1, @@ -131,25 +113,36 @@ test('eventManager#on', t => { 2, '_toggleRecognizer should be called once for each entry in an event:handler map' ); + + eventManager.destroy(); + root.remove(); t.end(); }); test('eventManager#watch', t => { - const eventManager = new EventManager(createEventRegistrarMock()); + const root = createEventTarget(); + const eventManager = new EventManager(root, { + recognizers: [new Tap({event: 'click'}), new Tap({event: 'dblclick', taps: 2})] + }); const toggleRecSpy = spy(eventManager, '_toggleRecognizer'); eventManager.watch('dblclick', () => {}); t.equal(toggleRecSpy.callCount, 0, '_toggleRecognizer should not be called for passive handler'); + eventManager.destroy(); + root.remove(); t.end(); }); test('eventManager#once', t => { - const eventManager = new EventManager(createEventRegistrarMock()); + const root = createEventTarget(); + const eventManager = new EventManager(root, { + recognizers: [new Tap({event: 'click'}), new Tap({event: 'dblclick', taps: 2})] + }); const toggleRecSpy = spy(eventManager, '_toggleRecognizer'); eventManager.once('dblclick', () => {}); - t.ok(eventManager.events.get('doubletap'), 'event doubletap is registered'); + t.ok(eventManager.events.get('dblclick'), 'event doubletap is registered'); t.equal( toggleRecSpy.callCount, 1, @@ -166,33 +159,52 @@ test('eventManager#once', t => { 2, '_toggleRecognizer should be called once for each entry in an event:handler map' ); + + eventManager.destroy(); + root.remove(); t.end(); }); test('eventManager#off', t => { - const eventManager = new EventManager(createEventRegistrarMock()); + const root = createEventTarget(); + const eventManager = new EventManager(root, { + recognizers: [new Tap({event: 'click'}), new Tap({event: 'dblclick', taps: 2}), new Pan()] + }); const handler1 = () => {}; const handler2 = () => {}; eventManager.on('click', handler1); - eventManager.on('tap', handler2); + eventManager.on('click', handler2); eventManager.on('dblclick', handler1); eventManager.on('panstart', handler1); eventManager.on('panmove', handler2); const toggleRecSpy = spy(eventManager, '_toggleRecognizer'); - eventManager.off('foo', () => {}); + eventManager.off('foo', handler1); t.equal( toggleRecSpy.callCount, 0, '_toggleRecognizer should not be called on an unrecognized event' ); + eventManager.off('panstart', handler1); + t.equal( + toggleRecSpy.callCount, + 0, + '_toggleRecognizer should not be called on an event that still has handlers' + ); + eventManager.off('panmove', handler2); + t.equal( + toggleRecSpy.callCount, + 1, + '_toggleRecognizer should be called on an event that no longer has handlers' + ); + + toggleRecSpy.reset(); eventManager.off({ - tap: handler1, - panstart: handler1, + click: handler1, dblclick: handler1 }); t.equal( @@ -200,28 +212,37 @@ test('eventManager#off', t => { 1, '_toggleRecognizer should be called once for each event that has no more handlers' ); + toggleRecSpy.reset(); + eventManager.off({ + click: handler2 + }); + t.equal( + toggleRecSpy.callCount, + 1, + '_toggleRecognizer should be called once for each event that has no more handlers' + ); + + eventManager.destroy(); + root.remove(); t.end(); }); test('eventManager#eventHandling', t => { - const eventRegistrar = createEventRegistrarMock(); + const root = createEventTarget(); const eventMock = {type: 'foo'}; - const eventManager = new EventManager(eventRegistrar, { - Manager: HammerManagerMock - }); + const eventManager = new EventManager(root); const emitSpy = spy(eventManager.manager, 'emit'); eventManager._onOtherEvent(eventMock); t.ok(emitSpy.called, 'manager.emit() should be called from _onOtherEvent()...'); - // TODO - fix spy - // t.ok(emitSpy.calledWith(eventMock.type, eventMock), - // '...and should be called with correct params'); + eventManager.destroy(); + root.remove(); t.end(); }); test('eventManager#normalizeEvent', t => { - const eventRegistrar = createEventRegistrarMock(); + const root = createEventTarget(); const eventMock = { type: 'foo', center: {x: 0, y: 0}, @@ -231,9 +252,7 @@ test('eventManager#normalizeEvent', t => { target: {} } }; - const eventManager = new EventManager(eventRegistrar, { - Manager: HammerManagerMock - }); + const eventManager = new EventManager(root); let normalizedEvent; @@ -243,16 +262,18 @@ test('eventManager#normalizeEvent', t => { eventManager._onOtherEvent(eventMock); - t.is(normalizedEvent.rootElement, eventRegistrar, 'rootElement is set'); + t.is(normalizedEvent.rootElement, root, 'rootElement is set'); t.ok(normalizedEvent.center, 'center is populated'); t.ok(normalizedEvent.offsetCenter, 'offsetCenter is populated'); t.is(normalizedEvent.handled, false, 'event marked as not handled'); + eventManager.destroy(); + root.remove(); t.end(); }); test('eventManager#propagation', t => { - const rootNode = createEventRegistrarMock({ + const rootNode = createEventTarget({ id: 'root', children: [ { @@ -262,14 +283,14 @@ test('eventManager#propagation', t => { {id: 'child-1'} ] }); - const eventManager = new EventManager(rootNode, { - Manager: HammerManagerMock - }); + const childNode = rootNode.children[0] as HTMLDivElement; + const grandchildNodes = Array.from(childNode.children) as HTMLDivElement[]; + const eventManager = new EventManager(rootNode); const handlerCalls = []; const fooHandler = - (message, stopPropagation = false) => + (message: string, stopPropagation = false) => evt => { handlerCalls.push(message); if (stopPropagation) { @@ -278,12 +299,12 @@ test('eventManager#propagation', t => { }; // Should not be called (propagation stopped) - eventManager.on('foo', fooHandler('foo@root'), rootNode); + eventManager.on('foo', fooHandler('foo@root'), {srcElement: rootNode}); // Should be called - eventManager.on('foo', fooHandler('foo@child-0', true), rootNode.find('child-0')); - eventManager.on('foo', fooHandler('foo@grandchild-00'), rootNode.find('grandchild-00')); + eventManager.on('foo', fooHandler('foo@child-0', true), {srcElement: childNode}); + eventManager.on('foo', fooHandler('foo@grandchild-00'), {srcElement: grandchildNodes[0]}); // Should not be called (not on propagation path) - eventManager.on('foo', fooHandler('foo@grandchild-01'), rootNode.find('grandchild-01')); + eventManager.on('foo', fooHandler('foo@grandchild-01'), {srcElement: grandchildNodes[1]}); eventManager.on( { @@ -292,13 +313,13 @@ test('eventManager#propagation', t => { // Should not be called (wrong event type) bar: fooHandler('bar@child-0') }, - rootNode.find('child-0') + {srcElement: childNode} ); const eventMock = { type: 'foo', srcEvent: { - target: rootNode.find('grandchild-00') + target: grandchildNodes[0] } }; eventManager._onOtherEvent(eventMock); diff --git a/test/index.js b/test/index.js deleted file mode 100644 index acdc76e..0000000 --- a/test/index.js +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) 2017 Uber Technologies, Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import './event-manager.spec'; -import './inputs'; -import './utils'; diff --git a/test/index.ts b/test/index.ts new file mode 100644 index 0000000..6435f43 --- /dev/null +++ b/test/index.ts @@ -0,0 +1,3 @@ +import './event-manager.spec'; +import './inputs'; +import './utils'; diff --git a/test/inputs/contextmenu-input.spec.js b/test/inputs/contextmenu-input.spec.js deleted file mode 100644 index bc4501d..0000000 --- a/test/inputs/contextmenu-input.spec.js +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) 2017 Uber Technologies, Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import test from 'tape-promise/tape'; -import ContextmenuInput from 'mjolnir.js/inputs/contextmenu-input'; -import {spy, createEventRegistrarMock} from '../test-utils'; - -test('contextmenuInput#constructor', t => { - const eventRegistrar = createEventRegistrarMock(); - let contextmenuInput = new ContextmenuInput(eventRegistrar); - t.ok(contextmenuInput, 'ContextmenuInput created without optional params'); - - const addELSpy = spy(eventRegistrar, 'addEventListener'); - contextmenuInput = new ContextmenuInput(eventRegistrar, { - rightButton: true - }); - t.equal(addELSpy.callCount, 1, 'should call addEventListener once'); - t.end(); -}); - -test('contextmenuInput#destroy', t => { - const eventRegistrar = createEventRegistrarMock(); - const removeELSpy = spy(eventRegistrar, 'removeEventListener'); - const contextmenuInput = new ContextmenuInput(eventRegistrar); - contextmenuInput.destroy(); - t.equal(removeELSpy.callCount, 1, 'should call removeEventListener once'); - t.end(); -}); - -test('contextmenuInput#handleEvent', t => { - const eventRegistrar = createEventRegistrarMock(); - const callbackSpy = spy(); - const contextmenuEventMock = { - type: 'foo', - preventDefault: () => {}, - clientX: 123, - clientY: 456, - target: eventRegistrar - }; - const contextmenuInput = new ContextmenuInput(eventRegistrar, callbackSpy); - contextmenuInput.handleEvent(contextmenuEventMock); - - t.is(callbackSpy.callCount, 1, 'callback should be called once'); - - contextmenuInput.enableEventType('contextmenu'); - contextmenuInput.handleEvent(contextmenuEventMock); - - t.is(callbackSpy.callCount, 1, 'callback should not be called on disabled input'); - - t.end(); -}); diff --git a/test/inputs/index.js b/test/inputs/index.ts similarity index 97% rename from test/inputs/index.js rename to test/inputs/index.ts index 3a4b1e5..e9fbf40 100644 --- a/test/inputs/index.js +++ b/test/inputs/index.ts @@ -21,4 +21,3 @@ import './key-input.spec'; import './move-input.spec'; import './wheel-input.spec'; -import './contextmenu-input.spec'; diff --git a/test/inputs/key-input.spec.js b/test/inputs/key-input.spec.ts similarity index 77% rename from test/inputs/key-input.spec.js rename to test/inputs/key-input.spec.ts index 00304ed..b9c175f 100644 --- a/test/inputs/key-input.spec.js +++ b/test/inputs/key-input.spec.ts @@ -19,53 +19,55 @@ // THE SOFTWARE. import test from 'tape-promise/tape'; -import KeyInput from 'mjolnir.js/inputs/key-input'; -import {spy, createEventRegistrarMock} from '../test-utils'; +import {KeyInput} from 'mjolnir.js/inputs/key-input'; +import {spy} from '../test-utils/spy'; +import {createEventTarget} from '../test-utils/dom'; test('keyInput#constructor', t => { - const eventRegistrar = createEventRegistrarMock(); - let keyInput = new KeyInput(eventRegistrar); - t.ok(keyInput, 'KeyInput created without optional params'); + const element = createEventTarget(); - const events = ['foo', 'bar']; const numKeyEvents = 2; // KEY_EVENTS.length - const addELSpy = spy(eventRegistrar, 'addEventListener'); - keyInput = new KeyInput(eventRegistrar, () => {}, {events}); + const addELSpy = spy(element, 'addEventListener'); + const keyInput = new KeyInput(element, () => {}, {}); + t.ok(keyInput, 'KeyInput created without optional params'); t.equal( addELSpy.callCount, - events.length + numKeyEvents, + numKeyEvents, 'should call addEventListener once for each passed event:handler pair' ); + + element.remove(); t.end(); }); test('keyInput#destroy', t => { - const eventRegistrar = createEventRegistrarMock(); - const events = ['foo', 'bar']; + const element = createEventTarget(); const numKeyEvents = 2; // KEY_EVENTS.length - const removeELSpy = spy(eventRegistrar, 'removeEventListener'); - const keyInput = new KeyInput(eventRegistrar, () => {}, {events}); + const removeELSpy = spy(element, 'removeEventListener'); + const keyInput = new KeyInput(element, () => {}, {}); keyInput.destroy(); t.equal( removeELSpy.callCount, - events.length + numKeyEvents, + numKeyEvents, 'should call removeEventListener once for each passed event:handler pair' ); + + element.remove(); t.end(); }); /* eslint-disable max-statements */ test('keyInput#enableEventType', t => { - const eventRegistrar = createEventRegistrarMock(); + const element = createEventTarget(); const keyDownMock = { type: 'keydown', key: 'a', - target: eventRegistrar + target: element }; const keyUpMock = { type: 'keyup', key: 'a', - target: eventRegistrar + target: element }; const keyUpMock2 = { type: 'keyup', @@ -73,7 +75,7 @@ test('keyInput#enableEventType', t => { }; let callbackSpy = spy(); - let keyInput = new KeyInput(eventRegistrar, callbackSpy, {enable: true}); + let keyInput = new KeyInput(element, callbackSpy, {enable: true}); keyInput.enableEventType('keydown', false); keyInput.handleEvent(keyDownMock); @@ -84,7 +86,7 @@ test('keyInput#enableEventType', t => { t.ok(callbackSpy.called, 'callback should be called on key down when enabled...'); callbackSpy = spy(); - keyInput = new KeyInput(eventRegistrar, callbackSpy, {enable: true}); + keyInput = new KeyInput(element, callbackSpy, {enable: true}); keyInput.enableEventType('keyup', false); keyInput.handleEvent(keyUpMock); @@ -108,5 +110,6 @@ test('keyInput#enableEventType', t => { keyInput.handleEvent(keyUpMock2); t.notOk(callbackSpy.called, 'callback should not be called when typing into a text box'); + element.remove(); t.end(); }); diff --git a/test/inputs/move-input.spec.js b/test/inputs/move-input.spec.ts similarity index 74% rename from test/inputs/move-input.spec.js rename to test/inputs/move-input.spec.ts index e5e91ee..a83526e 100644 --- a/test/inputs/move-input.spec.js +++ b/test/inputs/move-input.spec.ts @@ -19,66 +19,65 @@ // THE SOFTWARE. import test from 'tape-promise/tape'; -import MoveInput from 'mjolnir.js/inputs/move-input'; -import {spy, createEventRegistrarMock} from '../test-utils'; +import {MoveInput} from 'mjolnir.js/inputs/move-input'; +import {spy} from '../test-utils/spy'; +import {createEventTarget} from '../test-utils/dom'; test('moveInput#constructor', t => { - const eventRegistrar = createEventRegistrarMock(); - let moveInput = new MoveInput(eventRegistrar); - t.ok(moveInput, 'MoveInput created without optional params'); - - const events = ['foo', 'bar']; + const element = createEventTarget(); const numMouseEvents = 6; // MOUSE_EVENTS.length - const addELSpy = spy(eventRegistrar, 'addEventListener'); - moveInput = new MoveInput(eventRegistrar, () => {}, {events}); + const addELSpy = spy(element, 'addEventListener'); + const moveInput = new MoveInput(element, () => {}, {}); + t.ok(moveInput, 'MoveInput created without optional params'); t.equal( addELSpy.callCount, - events.length + numMouseEvents, + numMouseEvents, 'should call addEventListener once for each passed event:handler pair' ); t.end(); }); test('moveInput#destroy', t => { - const eventRegistrar = createEventRegistrarMock(); - const events = ['foo', 'bar']; + const element = createEventTarget(); const numMouseEvents = 6; // MOUSE_EVENTS.length - const removeELSpy = spy(eventRegistrar, 'removeEventListener'); - const moveInput = new MoveInput(eventRegistrar, () => {}, {events}); + const removeELSpy = spy(element, 'removeEventListener'); + const moveInput = new MoveInput(element, () => {}, {}); moveInput.destroy(); t.equal( removeELSpy.callCount, - events.length + numMouseEvents, + numMouseEvents, 'should call removeEventListener once for each passed event:handler pair' ); + + element.remove(); t.end(); }); test('moveInput#handleEvent', t => { - const eventRegistrar = createEventRegistrarMock(); + const element = createEventTarget(); const callbackSpy = spy(); const mouseDownMock = { type: 'mousedown', button: 0, - target: eventRegistrar + target: element }; const mouseDragMock = { type: 'mousemove', button: 0, buttons: 1, - target: eventRegistrar + target: element }; const mouseHoverMock = { type: 'mousemove', button: 0, buttons: 0, - target: eventRegistrar + target: element }; const mouseUpMock = { type: 'mouseup', - target: eventRegistrar + target: element }; - const moveInput = new MoveInput(eventRegistrar, callbackSpy, { + const moveInput = new MoveInput(element, callbackSpy, { enable: true }); @@ -91,18 +90,12 @@ test('moveInput#handleEvent', t => { moveInput.handleEvent(mouseHoverMock); t.ok(callbackSpy.called, 'callback should be called on mouse hover'); - // TODO - fix spy - // t.deepEqual(callbackSpy.calls[0].arguments[0], { - // type: mouseHoverMock.type, - // srcEvent: mouseHoverMock, - // pointerType: 'mouse', - // target: eventRegistrar - // }, '...and should be called with correct params'); + element.remove(); t.end(); }); test('moveInput#enableEventType', t => { - const eventRegistrar = createEventRegistrarMock(); + const element = createEventTarget(); let callbackSpy; let moveInput; @@ -112,11 +105,11 @@ test('moveInput#enableEventType', t => { type: 'mousemove', button: 0, buttons: 0, - target: eventRegistrar + target: element }; callbackSpy = spy(); - moveInput = new MoveInput(eventRegistrar, callbackSpy, {enable: true}); + moveInput = new MoveInput(element, callbackSpy, {enable: true}); moveInput.enableEventType('pointermove', false); moveInput.handleEvent(mouseHoverMock); @@ -132,11 +125,11 @@ test('moveInput#enableEventType', t => { t.test('pointerleave', assert => { const mouseLeaveMock = { type: 'mouseleave', - target: eventRegistrar + target: element }; callbackSpy = spy(); - moveInput = new MoveInput(eventRegistrar, callbackSpy, {enable: true}); + moveInput = new MoveInput(element, callbackSpy, {enable: true}); moveInput.enableEventType('pointerleave', false); moveInput.handleEvent(mouseLeaveMock); @@ -152,11 +145,11 @@ test('moveInput#enableEventType', t => { t.test('pointerover', assert => { const mouseOverMock = { type: 'mouseover', - target: eventRegistrar + target: element }; callbackSpy = spy(); - moveInput = new MoveInput(eventRegistrar, callbackSpy, {enable: true}); + moveInput = new MoveInput(element, callbackSpy, {enable: true}); moveInput.enableEventType('pointerover', false); moveInput.handleEvent(mouseOverMock); @@ -172,11 +165,11 @@ test('moveInput#enableEventType', t => { t.test('pointerout', assert => { const mouseOutMock = { type: 'mouseout', - target: eventRegistrar + target: element }; callbackSpy = spy(); - moveInput = new MoveInput(eventRegistrar, callbackSpy, {enable: true}); + moveInput = new MoveInput(element, callbackSpy, {enable: true}); moveInput.enableEventType('pointerout', false); moveInput.handleEvent(mouseOutMock); @@ -189,5 +182,6 @@ test('moveInput#enableEventType', t => { assert.end(); }); + element.remove(); t.end(); }); diff --git a/test/inputs/wheel-input.spec.js b/test/inputs/wheel-input.spec.ts similarity index 75% rename from test/inputs/wheel-input.spec.js rename to test/inputs/wheel-input.spec.ts index e39391d..891ccaf 100644 --- a/test/inputs/wheel-input.spec.js +++ b/test/inputs/wheel-input.spec.ts @@ -19,44 +19,46 @@ // THE SOFTWARE. import test from 'tape-promise/tape'; -import WheelInput from 'mjolnir.js/inputs/wheel-input'; -import {spy, createEventRegistrarMock} from '../test-utils'; +import {WheelInput} from 'mjolnir.js/inputs/wheel-input'; +import {spy} from '../test-utils/spy'; +import {createEventTarget} from '../test-utils/dom'; test('wheelInput#constructor', t => { - const eventRegistrar = createEventRegistrarMock(); - let wheelInput = new WheelInput(eventRegistrar); + const element = createEventTarget(); + const numWheelEvents = 1; // WHEEL_EVENTS.length + const addELSpy = spy(element, 'addEventListener'); + const wheelInput = new WheelInput(element, () => {}, {}); t.ok(wheelInput, 'WheelInput created without optional params'); - - const events = ['foo', 'bar']; - const numWheelEvents = 2; // WHEEL_EVENTS.length - const addELSpy = spy(eventRegistrar, 'addEventListener'); - wheelInput = new WheelInput(eventRegistrar, () => {}, {events}); t.equal( addELSpy.callCount, - events.length + numWheelEvents, + numWheelEvents, 'should call addEventListener once for each passed event:handler pair' ); + + element.remove(); t.end(); }); test('wheelInput#destroy', t => { - const eventRegistrar = createEventRegistrarMock(); - const events = ['foo', 'bar']; - const numWheelEvents = 2; // WHEEL_EVENTS.length - const removeELSpy = spy(eventRegistrar, 'removeEventListener'); - const wheelInput = new WheelInput(eventRegistrar, () => {}, {events}); + const element = createEventTarget(); + const numWheelEvents = 1; // WHEEL_EVENTS.length + const removeELSpy = spy(element, 'removeEventListener'); + const wheelInput = new WheelInput(element, () => {}, {}); wheelInput.destroy(); t.equal( removeELSpy.callCount, - events.length + numWheelEvents, + numWheelEvents, 'should call removeEventListener once for each passed event:handler pair' ); + + element.remove(); t.end(); }); test('moveInput#enableEventType', t => { + const element = createEventTarget(); const WHEEL_EVENT_TYPES = ['wheel']; // wheel-input.EVENT_TYPE - const wheelInput = new WheelInput(createEventRegistrarMock(), null, { + const wheelInput = new WheelInput(element, null, { enable: false }); wheelInput.enableEventType('foo', true); @@ -70,11 +72,13 @@ test('moveInput#enableEventType', t => { }), 'should enable for all supported events' ); + + element.remove(); t.end(); }); test('wheelInput#handleEvent', t => { - const eventRegistrar = createEventRegistrarMock(); + const element = createEventTarget(); const wheelEventMock = { type: 'foo', @@ -82,13 +86,13 @@ test('wheelInput#handleEvent', t => { deltaY: 1, clientX: 123, clientY: 456, - target: eventRegistrar + target: element }; let callbackParams = null; const callback = evt => (callbackParams = evt); - const wheelInput = new WheelInput(eventRegistrar, callback, { + const wheelInput = new WheelInput(element, callback, { enable: false }); @@ -107,5 +111,6 @@ test('wheelInput#handleEvent', t => { wheelInput.handleEvent(wheelEventMock); t.is(callbackParams.delta, -0.25, 'callback contains the correct delta'); + element.remove(); t.end(); }); diff --git a/test/test-utils/index.js b/test/node.ts similarity index 89% rename from test/test-utils/index.js rename to test/node.ts index be63915..1534a0e 100644 --- a/test/test-utils/index.js +++ b/test/node.ts @@ -18,6 +18,9 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -export * from './event'; -export {default as spy} from './spy'; -export * from './manager'; +import {JSDOM} from 'jsdom'; + +const dom = new JSDOM(''); +globalThis.document = dom.window.document; + +import('./index'); diff --git a/test/test-utils/dom.ts b/test/test-utils/dom.ts new file mode 100644 index 0000000..9ddc609 --- /dev/null +++ b/test/test-utils/dom.ts @@ -0,0 +1,23 @@ +type ElementOptions = { + id?: string; + children?: ElementOptions[]; +}; + +/** Generate an event root element for testing */ +export function createEventTarget( + opts: ElementOptions = {}, + parent: HTMLElement = document.body +): HTMLDivElement { + const {id, children} = opts; + const el = document.createElement('div'); + if (id) { + el.id = id; + } + if (children) { + for (const child of children) { + createEventTarget(child, el); + } + } + parent.appendChild(el); + return el; +} diff --git a/test/test-utils/event.js b/test/test-utils/event.js deleted file mode 100644 index 112aaa4..0000000 --- a/test/test-utils/event.js +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) 2017 Uber Technologies, Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -class NodeMock { - constructor({id, children = []}, parentNode = null) { - this.id = id; - this.style = {}; - this.parentNode = parentNode; - this.children = children.map(child => new NodeMock(child, this)); - } - - contains(otherNode) { - if (this === otherNode) { - return true; - } - return this.children.some(child => child.contains(otherNode)); - } - - find(id) { - if (this.id === id) { - return this; - } - for (let i = 0; i < this.children.length; i++) { - const node = this.children[i].find(id); - if (node) { - return node; - } - } - return undefined; - } - - addEventListener() {} - - removeEventListener() {} - - getBoundingClientRect() { - return {left: 0, top: 0, width: 1, height: 1}; - } -} - -export function createEventRegistrarMock(tree = {id: ''}) { - return new NodeMock(tree); -} diff --git a/test/test-utils/manager.js b/test/test-utils/manager.js deleted file mode 100644 index 0df80e2..0000000 --- a/test/test-utils/manager.js +++ /dev/null @@ -1,61 +0,0 @@ -const noop = () => {}; - -// Hammer.Manager mock for use in environments without `document` / `window`. -export class HammerManagerMock { - constructor(element) { - this.handlers = {}; - } - - get() { - return { - options: {}, - set: noop, - recognizeWith: noop, - dropRecognizeWith: noop, - requireFailure: noop, - dropRequireFailure: noop - }; - } - - set() { - return this; - } - - on(event, handler) { - const {handlers} = this; - - handlers[event] = handlers[event] || []; - handlers[event].push(handler); - - return this; - } - - off(event, handler) { - const {handlers} = this; - const handlersArray = handlers[event]; - - if (!handler) { - delete handlers[event]; - } else if (handlersArray) { - for (let i = handlersArray.length - 1; i >= 0; i--) { - if (handlersArray[i] === handler) { - handlersArray.splice(i, 1); - } - } - } - return this; - } - - destroy() { - return this; - } - - emit(event, data) { - const handlersArray = this.handlers[event] && this.handlers[event].slice(); - if (!handlersArray || !handlersArray.length) { - return; - } - - handlersArray.forEach(handler => handler(data)); - } -} diff --git a/test/test-utils/spy.js b/test/test-utils/spy.ts similarity index 75% rename from test/test-utils/spy.js rename to test/test-utils/spy.ts index 00834b5..89da954 100644 --- a/test/test-utils/spy.js +++ b/test/test-utils/spy.ts @@ -18,51 +18,47 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -// Inspired by https://github.com/popomore/spy -export default function (obj, func) { - let methodName; +export type SpyFunction = Function & { + callCount: number; + called: boolean; + reset: () => void; + restore: () => void; +}; - if (!obj && !func) { - func = function mock() {}; - obj = {}; - methodName = 'spy'; - } else if (typeof obj === 'function' && !func) { - func = obj; - obj = {}; - methodName = `${func.name}-spy`; - } else { - methodName = func; - func = obj[methodName]; +// Inspired by https://github.com/popomore/spy +export function spy(obj?: object, methodName?: string): SpyFunction { + if (!obj || !methodName) { + obj = { + noop: () => {} + }; + methodName = 'noop'; } + const func = obj[methodName] as Function | SpyFunction; + // will not wrap more than once - if (func.func !== undefined) { + if ('restore' in func) { return func; } - function spy(...args) { + // @ts-ignore + const spy: SpyFunction = function (...args) { spy.callCount++; spy.called = true; /* eslint-disable no-invalid-this */ return func.apply(this, args); - } + }; + spy.callCount = 0; + spy.called = false; spy.reset = () => { spy.callCount = 0; spy.called = false; }; - spy.restore = () => { obj[methodName] = func; }; - spy.obj = obj; - spy.methodName = methodName; - spy.func = func; - spy.method = func; - - spy.reset(); - obj[methodName] = spy; return spy; } diff --git a/test/utils/event-registrar.spec.js b/test/utils/event-registrar.spec.ts similarity index 84% rename from test/utils/event-registrar.spec.js rename to test/utils/event-registrar.spec.ts index 8d87907..e8fcd89 100644 --- a/test/utils/event-registrar.spec.js +++ b/test/utils/event-registrar.spec.ts @@ -19,12 +19,12 @@ // THE SOFTWARE. import test from 'tape-promise/tape'; -import EventRegistrar from 'mjolnir.js/utils/event-registrar'; -import {createEventRegistrarMock} from '../test-utils'; +import {EventRegistrar} from 'mjolnir.js/utils/event-registrar'; +import {createEventTarget} from '../test-utils/dom'; /* eslint-disable max-statements */ test('EventRegistrar#add, remove', t => { - const eventRegistrar = new EventRegistrar(); + const eventRegistrar = new EventRegistrar(null, 'test'); const handler1 = () => {}; const handler2 = () => {}; const handler3 = () => {}; @@ -49,7 +49,7 @@ test('EventRegistrar#add, remove', t => { 'event elements map is updated' ); - eventRegistrar.add('click', handler2, 'child-0'); + eventRegistrar.add('click', handler2, {srcElement: 'child-0'}); t.is(eventRegistrar.handlers.length, 2, 'event handler is added'); t.deepEquals( @@ -99,7 +99,7 @@ test('EventRegistrar#add, remove', t => { }); test('EventRegistrar#normalizeEvent', t => { - const elementMock = createEventRegistrarMock(); + const root = createEventTarget(); const eventMock = { type: 'foo', center: {x: 0, y: 0}, @@ -111,14 +111,14 @@ test('EventRegistrar#normalizeEvent', t => { }; let normalizedEvent; - const eventRegistrar = new EventRegistrar({getElement: () => elementMock}); + const eventRegistrar = new EventRegistrar({getElement: () => root}); eventRegistrar.add('foo', evt => { normalizedEvent = evt; }); eventRegistrar.handleEvent(eventMock); - t.is(normalizedEvent.rootElement, elementMock, 'rootElement is set'); + t.is(normalizedEvent.rootElement, root, 'rootElement is set'); t.ok(normalizedEvent.center, 'center is populated'); t.ok(normalizedEvent.offsetCenter, 'offsetCenter is populated'); t.is(normalizedEvent.handled, false, 'event marked as not handled'); @@ -133,7 +133,7 @@ test('EventRegistrar#normalizeEvent', t => { }); test('EventRegistrar#propagation', t => { - const rootNode = createEventRegistrarMock({ + const rootNode = createEventTarget({ id: 'root', children: [ { @@ -143,6 +143,8 @@ test('EventRegistrar#propagation', t => { {id: 'child-1'} ] }); + const childNode = rootNode.children[0] as HTMLDivElement; + const grandchildNodes = Array.from(childNode.children) as HTMLDivElement[]; const eventRegistrar = new EventRegistrar({getElement: () => rootNode}); t.doesNotThrow( @@ -159,7 +161,7 @@ test('EventRegistrar#propagation', t => { const handlerCalls = []; const fooHandler = - (message, stopPropagation = false, stopImmediatePropagation = false) => + (message: string, stopPropagation = false, stopImmediatePropagation = false) => evt => { handlerCalls.push(message); if (stopPropagation) { @@ -174,16 +176,21 @@ test('EventRegistrar#propagation', t => { eventRegistrar.add('foo', fooHandler('foo@root', false, true), 'root', true); eventRegistrar.add('foo', fooHandler('foo@root:2')); // Should be called - eventRegistrar.add('foo', fooHandler('foo@child-0', true), rootNode.find('child-0')); - eventRegistrar.add('foo', fooHandler('foo@grandchild-00'), rootNode.find('grandchild-00'), true); - eventRegistrar.add('foo', fooHandler('foo@child-0:2'), rootNode.find('child-0')); + eventRegistrar.add('foo', fooHandler('foo@child-0', true), {srcElement: childNode}); + eventRegistrar.add( + 'foo', + fooHandler('foo@grandchild-00'), + {srcElement: grandchildNodes[0]}, + true + ); + eventRegistrar.add('foo', fooHandler('foo@child-0:2'), {srcElement: childNode}); // Should not be called (not on propagation path) - eventRegistrar.add('foo', fooHandler('foo@grandchild-01'), rootNode.find('grandchild-01')); + eventRegistrar.add('foo', fooHandler('foo@grandchild-01'), {srcElement: grandchildNodes[1]}); eventRegistrar.handleEvent({ type: 'foo', srcEvent: { - target: rootNode.find('grandchild-00') + target: grandchildNodes[0] } }); t.deepEquals( @@ -196,7 +203,7 @@ test('EventRegistrar#propagation', t => { eventRegistrar.handleEvent({ type: 'foo', srcEvent: { - target: rootNode.find('grandchild-00') + target: grandchildNodes[0] } }); t.deepEquals( diff --git a/test/utils/event-utils.spec.js b/test/utils/event-utils.spec.ts similarity index 88% rename from test/utils/event-utils.spec.js rename to test/utils/event-utils.spec.ts index 4e44762..d6fa0fa 100644 --- a/test/utils/event-utils.spec.js +++ b/test/utils/event-utils.spec.ts @@ -72,9 +72,9 @@ test('EventUtils#whichButtons', t => { ]; for (const testCase of TESTS) { - t.is(whichButtons(testCase).leftButton, testCase.leftButton, 'returns left button flag'); - t.is(whichButtons(testCase).middleButton, testCase.middleButton, 'returns middle button flag'); - t.is(whichButtons(testCase).rightButton, testCase.rightButton, 'returns right button flag'); + t.is(whichButtons(testCase)?.leftButton, testCase.leftButton, 'returns left button flag'); + t.is(whichButtons(testCase)?.middleButton, testCase.middleButton, 'returns middle button flag'); + t.is(whichButtons(testCase)?.rightButton, testCase.rightButton, 'returns right button flag'); } t.end(); diff --git a/test/utils/global.spec.js b/test/utils/global.spec.ts similarity index 88% rename from test/utils/global.spec.js rename to test/utils/global.spec.ts index 2e3ca64..63f90c6 100644 --- a/test/utils/global.spec.js +++ b/test/utils/global.spec.ts @@ -19,14 +19,13 @@ // THE SOFTWARE. import test from 'tape-promise/tape'; -import {global, window, document, userAgent, passiveSupported} from 'mjolnir.js/utils/globals'; +import {global, window, document, userAgent} from 'mjolnir.js/utils/globals'; test('globals', t => { t.ok(global, 'global is an object'); t.ok(window, 'window is an object'); t.ok(document, 'document is an object'); t.is(typeof userAgent, 'string', 'userAgent is a string'); - t.is(typeof passiveSupported, 'boolean', 'passiveSupported is a boolean'); t.end(); }); diff --git a/test/utils/index.js b/test/utils/index.ts similarity index 100% rename from test/utils/index.js rename to test/utils/index.ts diff --git a/tsconfig.build.json b/tsconfig.build.json index 590b0a0..91c405a 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,12 +2,23 @@ "compilerOptions": { "target": "es2020", "moduleResolution": "node", + "strictNullChecks": true, "allowSyntheticDefaultImports": true, "module": "ES2020", "declaration": true, "sourceMap": true, "outDir": "./dist" }, + "plugins": [ + { + "transform": "ocular-dev-tools/ts-transform-append-extension", + "after": true + }, + { + "transform": "ocular-dev-tools/ts-transform-append-extension", + "afterDeclarations": true + } + ], "include":[ "src/**/*" ] diff --git a/tsconfig.json b/tsconfig.json index 68430a9..b5c7932 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,10 +10,14 @@ "compilerOptions": { "target": "es2020", "jsx": "react", + "strictNullChecks": true, "allowSyntheticDefaultImports": true, "resolveJsonModule": true, "moduleResolution": "node", - "module": "ES2020" + "module": "ES2020", + "paths": { + "mjolnir.js": ["./src"] + } }, "include":[ "src/**/*", diff --git a/yarn.lock b/yarn.lock index 1830ea0..6a3bee1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2020,11 +2020,6 @@ "@tufjs/canonical-json" "2.0.0" minimatch "^9.0.4" -"@types/hammerjs@^2.0.41": - version "2.0.41" - resolved "https://registry.yarnpkg.com/@types/hammerjs/-/hammerjs-2.0.41.tgz#f6ecf57d1b12d2befcce00e928a6a097c22980aa" - integrity sha512-ewXv/ceBaJprikMcxCmWU1FKyMAQ2X7a9Gtmzw8fcg2kIePI1crERDM818W+XYrxqdBBOdlf2rm137bU+BltCA== - "@types/istanbul-lib-coverage@^2.0.1": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" @@ -2738,7 +2733,7 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== -buffer@^5.5.0: +buffer@^5.2.1, buffer@^5.5.0: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== @@ -4715,11 +4710,6 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== -hammerjs@^2.0.8: - version "2.0.8" - resolved "https://registry.yarnpkg.com/hammerjs/-/hammerjs-2.0.8.tgz#04ef77862cff2bb79d30f7692095930222bf60f1" - integrity sha1-BO93hiz/K7edMPdpIJWTAiK/YPE= - handlebars@^4.7.7: version "4.7.8" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9" @@ -8420,7 +8410,7 @@ through2@^2.0.0: readable-stream "~2.3.6" xtend "~4.0.1" -through@2, "through@>=2.2.7 <3", through@^2.3.4, through@^2.3.6: +through@2, "through@>=2.2.7 <3", through@^2.3.4, through@^2.3.6, through@^2.3.8: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=